Compare commits

..

50 Commits

Author SHA1 Message Date
Dave Allie
cfe838e03b
Update user guide
Some checks are pending
CI / build (push) Waiting to run
2025-12-20 01:44:39 +11:00
Dave Allie
7484fe478c
Replace cover.jpg 2025-12-20 01:15:20 +11:00
Brendan O'Leary
d41d539435
Add connect to Wifi and File Manager Webserver (#41)
## Summary

- **What is the goal of this PR?**  
Implements wireless EPUB file management via a built-in web server,
enabling users to upload, browse, organize, and delete EPUB files from
any device on the same WiFi network without needing a computer cable
connection.

- **What changes are included?**
- **New Web Server**
([`CrossPointWebServer.cpp`](src/CrossPointWebServer.cpp),
[`CrossPointWebServer.h`](src/CrossPointWebServer.h)):
    - HTTP server on port 80 with a responsive HTML/CSS interface
    - Home page showing device status (version, IP, free memory)
    - File Manager with folder navigation and breadcrumb support
    - EPUB file upload with progress tracking
    - Folder creation and file/folder deletion
    - XSS protection via HTML escaping
- Hidden system folders (`.` prefixed, "System Volume Information",
"XTCache")
  
- **WiFi Screen** ([`WifiScreen.cpp`](src/screens/WifiScreen.cpp),
[`WifiScreen.h`](src/screens/WifiScreen.h)):
    - Network scanning with signal strength indicators
    - Visual indicators for encrypted (`*`) and saved (`+`) networks
- State machine managing: scanning, network selection, password entry,
connecting, save/forget prompts
    - 15-second connection timeout handling
    - Integration with web server (starts on connect, stops on exit)
  
- **WiFi Credential Storage**
([`WifiCredentialStore.cpp`](src/WifiCredentialStore.cpp),
[`WifiCredentialStore.h`](src/WifiCredentialStore.h)):
    - Persistent storage in `/sd/.crosspoint/wifi.bin`
- XOR obfuscation for stored passwords (basic protection against casual
reading)
    - Up to 8 saved networks with add/remove/update operations
  
- **On-Screen Keyboard**
([`OnScreenKeyboard.cpp`](src/screens/OnScreenKeyboard.cpp),
[`OnScreenKeyboard.h`](src/screens/OnScreenKeyboard.h)):
    - Reusable QWERTY keyboard component with shift support
    - Special keys: Shift, Space, Backspace, Done
    - Support for password masking mode
  
- **Settings Screen Integration**
([`SettingsScreen.h`](src/screens/SettingsScreen.h)):
    - Added WiFi action to navigate to the new WiFi screen
  
  - **Documentation** ([`docs/webserver.md`](docs/webserver.md)):
- Comprehensive user guide covering WiFi setup, web interface usage,
file management, troubleshooting, and security notes
    - See this for more screenshots!
- Working "displays the right way in GitHub" on my repo:
https://github.com/olearycrew/crosspoint-reader/blob/feature/connect-to-wifi/docs/webserver.md

**Video demo**


https://github.com/user-attachments/assets/283e32dc-2d9f-4ae2-848e-01f41166a731

## Additional Context

- **Security considerations**: The web server has no
authentication—anyone on the same WiFi network can access files. This is
documented as a limitation, recommending use only on trusted private
networks. Password obfuscation in the credential store is XOR-based, not
cryptographically secure.

- **Memory implications**: The web server and WiFi stack consume
significant memory. The implementation properly cleans up (stops server,
disconnects WiFi, sets `WIFI_OFF` mode) when exiting the WiFi screen to
free resources.

- **Async operations**: Network scanning and connection use async
patterns with FreeRTOS tasks to prevent blocking the UI. The display
task handles rendering on a dedicated thread with mutex protection.

- **Browser compatibility**: The web interface uses standard
HTML5/CSS3/JavaScript and is tested to work with all modern browsers on
desktop and mobile.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-20 01:05:43 +11:00
Dave Allie
cf6fec78dc
Cleanup indexing layout string 2025-12-20 00:33:55 +11:00
Jonas Diemer
10d76dde12
Randomly load Sleep Screen from /sleep/*bmp (if exists). (#71)
Load a random sleep screen. 

Only works for ".bmp" (not ".BMP"), but I think that's OK (we do the
same for EPUBs).
2025-12-20 00:17:26 +11:00
Jonas Diemer
7b5a63d220
Option to short-press power button. (#56)
Some checks are pending
CI / build (push) Waiting to run
Adresses #53 

Please check if we still need the code to "Give the user up to 1000ms to
start holding the power button, and must hold for
SETTINGS.getPowerButtonDuration()" - the power button should be pressed
already when waking up...

Also, decided to return before the delay to wait more to make the
behavior more immediate.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2025-12-19 23:37:34 +11:00
IFAKA
c1d5f5d562
Add NULL checks after fopen() in ZipFile (#68)
## Problem
Three `fopen()` calls in ZipFile.cpp did not check for NULL before using
the file handle. If files cannot be opened, `fseek`/`fread`/`fclose`
receive NULL and crash.

## Fix
Added NULL checks with appropriate error logging and early returns for
all three locations:
- `getDataOffset()`
- `readFileToMemory()`
- `readFileToStream()`

## Testing
- Builds successfully with `pio run`
- Affects: `lib/ZipFile/ZipFile.cpp`
2025-12-19 23:28:43 +11:00
IFAKA
adfeee063f
Handle empty spine in getBookSize() and calculateProgress() (#67)
## Problem
- `getBookSize()` calls `getCumulativeSpineItemSize(getSpineItemsCount()
- 1)` which passes -1 when spine is empty
- `calculateProgress()` then divides by zero when book size is 0

## Fix
- Return 0 from `getBookSize()` if spine is empty
- Return 0 from `calculateProgress()` if book size is 0

## Testing
- Builds successfully with `pio run`
- Affects: `lib/Epub/Epub.cpp`
2025-12-19 23:28:36 +11:00
IFAKA
2d3928ed81
Validate file handle when reading progress.bin (#66)
## Problem
Reading progress.bin used `SD.exists()` then `SD.open()` without
checking if open succeeded. Race conditions or SD errors could cause
file handle to be invalid.

## Fix
- Removed redundant `SD.exists()` check
- Check if file opened successfully before reading
- Verify correct number of bytes were read

## Testing
- Builds successfully with `pio run`
- Affects: `src/activities/reader/EpubReaderActivity.cpp`
2025-12-19 23:27:08 +11:00
IFAKA
48249fbd1e
Check SD card initialization and show error on failure (#65)
## Problem
`SD.begin()` return value was ignored. If the SD card fails to
initialize, the device continues and crashes when trying to load
settings/state.

## Fix
Check return value and display "SD card error" message instead of
proceeding with undefined state.

## Testing
- Builds successfully with `pio run`
- Affects: `src/main.cpp`
2025-12-19 23:24:25 +11:00
IFAKA
1a53dccebd
Fix title truncation crash for short titles (#63)
## Problem
The status bar title truncation loop crashes when the chapter title is
shorter than 8 characters.

```cpp
// title.length() - 8 underflows when length < 8 (size_t is unsigned)
title = title.substr(0, title.length() - 8) + "...";
```

## Fix
Added a length guard to skip truncation for titles that are too short to
truncate safely.

## Testing
- Builds successfully with `pio run`
- Affects: `src/activities/reader/EpubReaderActivity.cpp`
2025-12-19 23:23:43 +11:00
IFAKA
3e28724b62
Add bounds checking for TOC/spine array access (#64)
## Problem
`getSpineIndexForTocIndex()` and `getTocIndexForSpineIndex()` access
`toc[tocIndex]` and `spine[spineIndex]` without validating indices are
within bounds. Malformed EPUBs or edge cases could trigger out-of-bounds
access.

## Fix
Added bounds validation at the start of both functions before accessing
the arrays.

## Testing
- Builds successfully with `pio run`
- Affects: `lib/Epub/Epub.cpp`
2025-12-19 23:23:23 +11:00
Jonas Diemer
d86b3fe134
Bugfix/word spacing indented (#59)
Some checks are pending
CI / build (push) Waiting to run
Simplified the indentation to fix having too large gaps between words
(original calculation was inaccurate).
2025-12-19 08:45:20 +11:00
Dave Allie
1a3d6b125d
Custom sleep screen support with BMP reading (#57)
## Summary

* Builds on top of
https://github.com/daveallie/crosspoint-reader/pull/16 - adresses
https://github.com/daveallie/crosspoint-reader/discussions/14
* This PR adds the ability for the user to supply a custom `sleep.bmp`
image at the root of the SD card that will be shown instead of the
default sleep screen if present.
* Supports:
  * Different BPPs:
    * 1bit
    * 2bit
    * 8bit
    * 24bit
    * 32bit (with alpha-channel ignored)
  * Grayscale rendering

---------

Co-authored-by: Sam Davis <sam@sjd.co>
2025-12-19 08:45:14 +11:00
Dave Allie
b2020f5512
Skip pagebreak blocks when parsing epub file (#58)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* Skip pagebreak blocks when parsing epub file
* These blocks break the flow and often contain the page number in them
which should not interrupt the flow of the content
- Attributes sourced from:
  - https://www.w3.org/TR/epub-ssv-11/#pagebreak
  - https://www.w3.org/TR/dpub-aria-1.1/#doc-pagebreak
2025-12-19 01:11:03 +11:00
Dave Allie
70dc0f018e
Cut release 0.7.0 2025-12-19 00:44:22 +11:00
Jonas Diemer
424594488f
Caching of spine item sizes for faster book loading (saves 1-4 seconds). (#54)
Some checks are pending
CI / build (push) Waiting to run
As discussed in
https://github.com/daveallie/crosspoint-reader/pull/38#issuecomment-3665142427,
#38
2025-12-18 22:49:14 +11:00
Dave Allie
57fdb1c0fb
Rendering "Indexing..." on white screen to avoid partial update 2025-12-18 22:13:24 +11:00
Dave Allie
5e1694748c
Fix font readability by expanding blacks and trimming whites (#55)
## Summary

* Previously, only pure black pixels in the font were marked as black,
this expands the black range, and makes the lightest pixels white

## Additional Context

* Noticed personally it was kind of "thin" and washed out a bit, this
massively helps, should also address concerns raised here:
https://github.com/daveallie/crosspoint-reader/discussions/39
2025-12-18 21:39:13 +11:00
Jonas Diemer
063a1df851
Bugfix for #46: don't look at previous chapters if in chapter 0. (#48)
Some checks are pending
CI / build (push) Waiting to run
Fixes #46
2025-12-18 06:28:06 +11:00
Dave Allie
d429966dd4
Rename Screens to Activities and restructure files (#44)
## Summary

* This PR drastically reshapes the structure of the codebase, moving
from the concept of "Screens" to "Activities", restructing the files and
setting up the concept of subactivities.
* This should help with keep the main file clean and containing all
functional logic in the relevant activity.
* CrossPointState is now also a global singleton which should help with
accessing it from within activities.

## Additional Context

* This is probably going to be a bit disruptive for people with open
PRs, sorry 😞
2025-12-17 23:32:18 +11:00
Jonas Diemer
c78f2a9840
Calculate the progress in the book by file sizes of each chapter. (#38)
## Summary

Addresses #35.

Maybe it could be wise to do some caching of the spine sizes (but
performance isn't too bad).
2025-12-17 23:05:24 +11:00
Dave Allie
11f01d3a41
Add home screen (#42)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* Add home screen
* Sits as new root screen, allows for navigation to settings or file
list
2025-12-17 20:47:43 +11:00
Arthur Tazhitdinov
973d372521
TOC location fix (#25)
## Summary

* Rely on media-type="application/x-dtbncx+xml" to find TOC instead of
hardcoded values

## Additional Context

* Most of my epubs don't have id==ncx for toc file location. I think
this media-type is EPUB standard

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-17 18:49:45 +11:00
Dave Allie
67da8139b3
Use 6x8kB chunks instead of 1x48kB chunk for secondary display buffer (#36)
Some checks are pending
CI / build (push) Waiting to run
## Summary

- When allocating the `bwBuffer` required to restore the RED RAM in the
EPD, we were previously allocating the whole frame buffer in one
contiguous memory chunk (48kB)
- Depending on the state of memory fragmentation at the time of this
call, it may not be possible to allocate all that memory
- Instead, we now allocate 6 blocks of 8kB instead of the full 48kB,
this should mean the display updates are more resilient to different
memory conditions

## Additional Context
2025-12-17 01:39:22 +11:00
Dave Allie
c287aa03a4
Use single buffer mode for EInkDisplay (#34)
## Summary

* Frees up 48kB of statically allocated RAM in exchange for 48kB just
when grayscale rendering is needed

## Additional Context

* Upstream changes:
https://github.com/open-x4-epaper/community-sdk/pull/7
2025-12-17 00:17:49 +11:00
Dave Allie
5d68c8b305
Cut release 0.6.0 2025-12-16 23:15:47 +11:00
Jonas Diemer
def7abbd60
Improve indent (#28)
* add horizontal indent in first line of paragraph in case Extra Paragraph Spacing is OFF

* Treat tabs as whitespace (so they are properly stripped)

* Changed size of indent to 1 em.

* Fixed calculation of space when indenting (avoiding squeezed text).

* Source code formatting
2025-12-16 23:02:32 +11:00
Jonas Diemer
9ad8111ce7
Wrap-around navigation in Settings. (#31) 2025-12-16 22:54:16 +11:00
Arthur Tazhitdinov
57d1939be7
Add Cyrillic range to fonts (#27)
* Enhance TOC parsing and chapter selection logic

- Update .gitignore to include additional paths
- Refactor Epub::parseContentOpf to improve NCX item retrieval
- Modify ContentOpfParser to store media type in ManifestItem
- Implement rebuildVisibleSpineIndices in EpubReaderChapterSelectionScreen for better chapter navigation
- Adjust rendering logic to handle empty chapter lists gracefully

* Refactor TOC parsing logic to streamline cover image and NCX item retrieval

* add cyrillic ranges

* revert

* clang format fix
2025-12-16 22:52:49 +11:00
Jonas Diemer
012992f904
Feature/auto poweroff (#32)
* Automatically deep sleep after 10 minutes of inactivity.

* formatting.
2025-12-16 22:49:31 +11:00
Dave Allie
c262f222de
Parse cover image path from content.opf file (#24)
Some checks are pending
CI / build (push) Waiting to run
2025-12-16 03:15:54 +11:00
Dave Allie
449b3ca161
Fixed light gray text rendering 2025-12-16 02:16:38 +11:00
Dave Allie
6989035ef8
Run CI action on PR as well as push 2025-12-15 23:17:35 +11:00
Dave Allie
108cf57202
Fix formatting 2025-12-15 23:17:23 +11:00
Jonas Diemer
a640fbecf8
Settings Screen and first 2 settings (#18)
* white sleep screen

* quicker pwr button

* no extra spacing between paragraphs

* Added settings class with de/serialization and whiteSleepScreen setting to control inverting the sleep screen

* Added Settings screen for real, made settings a global singleton

* Added setting for extra paragraph spacing.

* fixed typo

* Rework after feedback

* Fixed type from bool to uint8
2025-12-15 23:16:46 +11:00
Dave Allie
7a5719b46d
Upgrade open-x4-sdk to fix white streaks on sleep screen (#21)
https://github.com/open-x4-epaper/community-sdk/pull/6 fixes a power down issue with the display which was causing streaks to appear on the sleep screen
2025-12-15 22:27:27 +11:00
Dave Allie
8c3576e397
Add Github Action to build release firmware on tag (#20)
Some checks are pending
CI / build (push) Waiting to run
2025-12-15 20:00:34 +11:00
Dave Allie
fdb5634ea6
Add cppcheck and formatter to CI (#19)
* Add cppcheck and formatter to CI

* Checkout submodules

* Install matching clang-format version in CI
2025-12-15 19:46:52 +11:00
Dave Allie
5cabba7712
Add Github templates
Added Github templates for PRs and bugs
Also added one for funding, please do not feel obligated to
donate to the project, this is not a for-profit endevour, I
just had someone ask where they could send a few dollars so
I have set it up
2025-12-15 08:16:59 +11:00
Dave Allie
a86d405fb0
Add comparison images 2025-12-14 13:46:15 +11:00
Dave Allie
e4b5dc0e6a
Update contribution instructions 2025-12-14 13:46:03 +11:00
Dave Allie
dfc74f94c2
Cut release 0.5.1 2025-12-13 21:52:48 +11:00
Dave Allie
3518cbb56d
Add user guide 2025-12-13 21:52:03 +11:00
Dave Allie
8994953254
Add chapter selection screen 2025-12-13 21:17:34 +11:00
Dave Allie
ead39fd04b
Return -1 from getTocIndexForSpineIndex if TOC item does not exist 2025-12-13 21:17:22 +11:00
Dave Allie
5a7381a0eb
Cut release 0.5.0 2025-12-13 20:16:12 +11:00
Dave Allie
f69fc90b5c
Show end of book screen when navigating past last page 2025-12-13 20:10:38 +11:00
Dave Allie
5bae283838
Process lines into pages as they are built 2025-12-13 20:10:16 +11:00
Dave Allie
c7a32fe41f
Remove tinyxml2 dependency replace with expat parsers (#9) 2025-12-13 19:36:01 +11:00
102 changed files with 24075 additions and 11489 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: [daveallie]
ko_fi: daveallie

54
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Bug Report
description: Report an issue or unexpected behavior
title: "Short, descriptive title of the issue"
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report this bug! Please fill out the details below.
- type: input
id: version
attributes:
label: Affected Version
description: What version of the project/library are you using? (e.g., v1.2.3, master branch commit SHA)
placeholder: Ex. v1.2.3
validations:
required: true
- type: textarea
id: bug-description
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
placeholder:
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: Clearly list the steps necessary to reproduce the unexpected behavior.
placeholder: |
1. Go to '...'
2. Select '...'
3. Crash
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Log Output/Screenshots
description: If applicable, error messages, or log output to help explain your problem. You can drag and drop images here.
render: shell

9
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,9 @@
## 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).

43
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: CI
'on':
push:
branches: [master]
pull_request:
jobs:
build:
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'
- name: Install PlatformIO Core
run: pip install --upgrade platformio
- name: Install clang-format-21
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 21
sudo apt-get update
sudo apt-get install -y clang-format-21
- name: Run cppcheck
run: pio check --fail-on-defect medium --fail-on-defect high
- name: Run clang-format
run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1)
- name: Build CrossPoint
run: pio run

40
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: Compile Release
on:
push:
tags:
- '*'
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'
- name: Install PlatformIO Core
run: pip install --upgrade platformio
- name: Build CrossPoint
run: pio run -e gh_release
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: CrossPoint-${{ github.ref_name }}
path: |
.pio/build/gh_release/bootloader.bin
.pio/build/gh_release/firmware.bin
.pio/build/gh_release/firmware.elf
.pio/build/gh_release/firmware.map
.pio/build/gh_release/partitions.bin

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
.pio .pio
.idea .idea
.DS_Store .DS_Store
.vscode
lib/EpdFont/fontsrc
*.generated.h

View File

@ -6,7 +6,7 @@ Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.
CrossPoint Reader is a purpose-built firmware designed to be a drop-in, fully open-source replacement for the official CrossPoint Reader is a purpose-built firmware designed to be a drop-in, fully open-source replacement for the official
Xteink firmware. It aims to match or improve upon the standard EPUB reading experience. Xteink firmware. It aims to match or improve upon the standard EPUB reading experience.
![](./docs/cover.jpg) ![](./docs/images/cover.jpg)
## Motivation ## Motivation
@ -23,18 +23,23 @@ CrossPoint Reader aims to:
This project is **not affiliated with Xteink**; it's built as a community project. This project is **not affiliated with Xteink**; it's built as a community project.
## Features ## Features & Usage
- [x] EPUB parsing and rendering - [x] EPUB parsing and rendering
- [ ] Image support within EPUB
- [x] Saved reading position - [x] Saved reading position
- [ ] File explorer with file picker - [x] File explorer with file picker
- [x] Basic EPUB picker from root directory - [x] Basic EPUB picker from root directory
- [x] Support nested folders - [x] Support nested folders
- [ ] EPUB picker with cover art - [ ] EPUB picker with cover art
- [ ] Image support within EPUB - [x] Custom sleep screen
- [ ] Cover sleep screen
- [x] Wifi book upload
- [ ] Wifi OTA updates
- [ ] Configurable font, layout, and display options - [ ] Configurable font, layout, and display options
- [ ] WiFi connectivity - [ ] Screen rotation
- [ ] BLE connectivity
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
## Installing ## Installing
@ -127,6 +132,9 @@ EPUB file will reset the reading progress.
Contributions are very welcome! Contributions are very welcome!
If you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/daveallie/crosspoint-reader/discussions/categories/ideas).
If there's something there you'd like to work on, leave a comment so that we can avoid duplicated effort.
### To submit a contribution: ### To submit a contribution:
1. Fork the repo 1. Fork the repo

106
USER_GUIDE.md Normal file
View File

@ -0,0 +1,106 @@
# CrossPoint User Guide
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:
### Button Layout
| Location | Buttons |
|-----------------|--------------------------------------------|
| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** |
| **Right Side** | **Power**, **Volume Up**, **Volume Down** |
---
## 2. Power & Startup
### 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.
### First Launch
Upon turning the device on for the first time, you will be placed on the **Home** screen.
> **Note:** On subsequent restarts, the firmware will automatically reopen the last book you were reading.
---
## 3. Screens
### 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.
### 3.2 Book Selection (Read)
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.
* **Open Selection:** Press **Confirm** to open a folder or read a selected book.
### 3.3 Reading Screen
See [4. 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.
See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files.
### 3.5 Settings
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
- **White Sleep Screen**: Whether to use the white screen or black (inverted) default sleep screen
- **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.
---
## 4. Reading Mode
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** |
### 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.
---
## 5. Chapter Selection Screen
Accessible by pressing **Confirm** while inside a book.
1. Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to highlight the desired chapter.
2. Press **Confirm** to jump to that chapter.
3. *Alternatively, press **Back** to cancel and return to your current page.*
---
## 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:
* **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.

19
docs/comparison.md Normal file
View File

@ -0,0 +1,19 @@
# CrossPoint vs XTOS
Below is like for like comparison of CrossPoint (version 0.5.1) and XTOS (version 3.1.1). CrossPoint is on the left,
XTOS is on the right. CrossPoint does not currently support all features of XTOS, so this comparison is just of key
features which both firmwares support.
## EPUB reading
![](./images/comparison/reading-1.jpg)
![](./images/comparison/reading-2.jpg)
![](./images/comparison/reading-3.jpg)
## Menus
![](./images/comparison/menu.jpg)
![](./images/comparison/chapter-menu.jpg)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
docs/images/cover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

272
docs/webserver.md Normal file
View File

@ -0,0 +1,272 @@
# Web Server Guide
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload EPUB files from your computer or phone.
## Overview
CrossPoint Reader includes a built-in web server that allows you to:
- Upload EPUB files wirelessly from any device on the same WiFi network
- Browse and manage files on your device's SD card
- Create folders to organize your ebooks
- Delete files and folders
## Prerequisites
- Your CrossPoint Reader device
- A WiFi network
- A computer, phone, or tablet connected to the **same WiFi network**
---
## Step 1: Accessing the WiFi Screen
1. From the main menu or file browser, navigate to the **Settings** screen
2. Select the **WiFi** option
3. The device will automatically start scanning for available networks
---
## Step 2: Connecting to WiFi
### Viewing Available Networks
Once the scan completes, you'll see a list of available WiFi networks with the following indicators:
- **Signal strength bars** (`||||`, `|||`, `||`, `|`) - Shows connection quality
- **`*` symbol** - Indicates the network is password-protected (encrypted)
- **`+` symbol** - Indicates you have previously saved credentials for this network
<img src="./images/wifi/wifi_networks.jpeg" height="500">
### Selecting a Network
1. Use the **Left/Right** (or **Volume Up/Down**) buttons to navigate through the network list
2. Press **Confirm** to select the highlighted network
### Entering Password (for encrypted networks)
If the network requires a password:
1. An on-screen keyboard will appear
2. Use the navigation buttons to select characters
3. Press **Confirm** to enter each character
4. When complete, select the **Done** option on the keyboard
<img src="./images/wifi/wifi_password.jpeg" height="500">
**Note:** If you've previously connected to this network, the saved password will be used automatically.
### Connection Process
The device will display "Connecting..." while establishing the connection. This typically takes 5-10 seconds.
### Saving Credentials
If this is a new network, you'll be prompted to save the password:
- Select **Yes** to save credentials for automatic connection next time (NOTE: These are stored in plaintext on the device's SD card. Do not use this for sensitive networks.)
- Select **No** to connect without saving
---
## Step 3: Connection Success
Once connected, the screen will display:
- **Network name** (SSID)
- **IP Address** (e.g., `192.168.1.102`)
- **Web server URL** (e.g., `http://192.168.1.102/`)
<img src="./images/wifi/wifi_connected.jpeg" height="500">
**Important:** Make note of the IP address - you'll need this to access the web interface from your computer or phone.
---
## Step 4: Accessing the Web Interface
### From a Computer
1. Ensure your computer is connected to the **same WiFi network** as your CrossPoint Reader
2. Open any web browser (Chrome is recommended)
3. Type the IP address shown on your device into the browser's address bar
- Example: `http://192.168.1.102/`
4. Press Enter
### From a Phone or Tablet
1. Ensure your phone/tablet is connected to the **same WiFi network** as your CrossPoint Reader
2. Open your mobile browser (Safari, Chrome, etc.)
3. Type the IP address into the address bar
- Example: `http://192.168.1.102/`
4. Tap Go
---
## Step 5: Using the Web Interface
### Home Page
The home page displays:
- Device status and version information
- WiFi connection status
- Current IP address
- Available memory
Navigation links:
- **Home** - Returns to the status page
- **File Manager** - Access file management features
<img src="./images/wifi/webserver_homepage.png" width="600">
### File Manager
Click **File Manager** to access file management features.
#### Browsing Files
- The file manager displays all files and folders on your SD card
- **Folders** are highlighted in yellow with a 📁 icon
- **EPUB files** are highlighted in green with a 📗 icon
- Click on a folder name to navigate into it
- Use the breadcrumb navigation at the top to go back to parent folders
<img src="./images/wifi/webserver_files.png" width="600">
#### Uploading EPUB Files
1. Click the **+ Add** button in the top-right corner
2. Select **Upload eBook** from the dropdown menu
3. Click **Choose File** and select an `.epub` file from your device
4. Click **Upload**
5. A progress bar will show the upload status
6. The page will automatically refresh when the upload is complete
**Note:** Only `.epub` files are accepted. Other file types will be rejected.
<img src="./images/wifi/webserver_upload.png" width="600">
#### Creating Folders
1. Click the **+ Add** button in the top-right corner
2. Select **New Folder** from the dropdown menu
3. Enter a folder name (letters, numbers, underscores, and hyphens only)
4. Click **Create Folder**
This is useful for organizing your ebooks by genre, author, or series.
#### Deleting Files and Folders
1. Click the **🗑️** (trash) icon next to any file or folder
2. Confirm the deletion in the popup dialog
3. Click **Delete** to permanently remove the item
**Warning:** Deletion is permanent and cannot be undone!
**Note:** Folders must be empty before they can be deleted.
---
## Troubleshooting
### Cannot See the Device on the Network
**Problem:** Browser shows "Cannot connect" or "Site can't be reached"
**Solutions:**
1. Verify both devices are on the **same WiFi network**
- Check your computer/phone WiFi settings
- Confirm the CrossPoint Reader shows "Connected" status
2. Double-check the IP address
- Make sure you typed it correctly
- Include `http://` at the beginning
3. Try disabling VPN if you're using one
4. Some networks have "client isolation" enabled - check with your network administrator
### Connection Drops or Times Out
**Problem:** WiFi connection is unstable
**Solutions:**
1. Move closer to the WiFi router
2. Check signal strength on the device (should be at least `||` or better)
3. Avoid interference from other devices
4. Try a different WiFi network if available
### Upload Fails
**Problem:** File upload doesn't complete or shows an error
**Solutions:**
1. Ensure the file is a valid `.epub` file
2. Check that the SD card has enough free space
3. Try uploading a smaller file first to test
4. Refresh the browser page and try again
### Saved Password Not Working
**Problem:** Device fails to connect with saved credentials
**Solutions:**
1. When connection fails, you'll be prompted to "Forget Network"
2. Select **Yes** to remove the saved password
3. Reconnect and enter the password again
4. Choose to save the new password
---
## Security Notes
- The web server runs on port 80 (standard HTTP)
- **No authentication is required** - anyone on the same network can access the interface
- The web server is only accessible while the WiFi screen shows "Connected"
- The web server automatically stops when you exit the WiFi screen
- For security, only use on trusted private networks
---
## Technical Details
- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
- **Web Server Port:** 80 (HTTP)
- **Maximum Upload Size:** Limited by available SD card space
- **Supported File Format:** `.epub` only
- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
---
## Tips and Best Practices
1. **Organize with folders** - Create folders before uploading to keep your library organized
2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
3. **Upload multiple files** - You can upload files one at a time; the page refreshes after each upload
4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
---
## Exiting WiFi Mode
When you're finished uploading files:
1. Press the **Back** button on your CrossPoint Reader
2. The web server will automatically stop
3. WiFi will disconnect to conserve battery
4. You'll return to the previous screen
Your uploaded files will be immediately available in the file browser!
---
## Related Documentation
- [User Guide](../USER_GUIDE.md) - General device operation
- [README](../README.md) - Project overview and features

View File

@ -1,505 +0,0 @@
/**
* generated by fontconvert.py
* name: babyblue
* size: 8
* mode: 1-bit
*/
#pragma once
#include "EpdFontData.h"
static const uint8_t babyblueBitmaps[3140] = {
0xFF, 0xFF, 0x30, 0xFF, 0xFF, 0xF0, 0x36, 0x1B, 0x0D, 0x9F, 0xFF, 0xF9, 0xB3, 0xFF, 0xFF, 0x36, 0x1B, 0x0D, 0x80,
0x18, 0x18, 0x7E, 0xFF, 0xDB, 0xD8, 0xFE, 0x7F, 0x9B, 0xDB, 0xFF, 0x7E, 0x18, 0x00, 0x71, 0x9F, 0x73, 0x6C, 0x6F,
0x8F, 0xE0, 0xFF, 0x83, 0xF8, 0xFB, 0x1B, 0x63, 0x7C, 0xC7, 0x00, 0x38, 0x3E, 0x1B, 0x0D, 0x87, 0xC3, 0xEF, 0xBF,
0x8E, 0xCF, 0x7F, 0x1E, 0xC0, 0xFF, 0x37, 0xEC, 0xCC, 0xCC, 0xCC, 0xCC, 0x67, 0x20, 0xCE, 0x73, 0x33, 0x33, 0x33,
0x33, 0x6C, 0x80, 0x32, 0xFF, 0xDE, 0xFE, 0x30, 0x18, 0x18, 0x18, 0xFF, 0xFF, 0x18, 0x18, 0x18, 0xBF, 0x00, 0xFF,
0xC0, 0x30, 0x0C, 0x31, 0xC6, 0x18, 0xE3, 0x1C, 0x63, 0x8C, 0x00, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3,
0xC7, 0x7E, 0x3C, 0x19, 0xDF, 0xFD, 0x8C, 0x63, 0x18, 0xC6, 0x3C, 0x7E, 0xE7, 0x83, 0x07, 0x0E, 0x1C, 0x38, 0x70,
0xFE, 0xFF, 0x7E, 0xFF, 0xC3, 0x07, 0x3E, 0x3E, 0x07, 0x03, 0x83, 0xFF, 0x7E, 0x0E, 0x1E, 0x3E, 0x76, 0xE6, 0xFF,
0xFF, 0x06, 0x06, 0x06, 0x06, 0x7F, 0xFF, 0x06, 0x0F, 0xDF, 0xC1, 0x83, 0x87, 0xFD, 0xF0, 0x3E, 0x7F, 0xE3, 0xC0,
0xFC, 0xFE, 0xE7, 0xC3, 0xC7, 0x7E, 0x3C, 0xFF, 0xFF, 0xC0, 0xE0, 0xE0, 0x60, 0x70, 0x30, 0x18, 0x1C, 0x0C, 0x06,
0x00, 0x3C, 0x7E, 0x66, 0x66, 0x7E, 0x7E, 0xE7, 0xC3, 0xC7, 0x7E, 0x3C, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC7, 0x7F,
0x3F, 0x07, 0x3E, 0x7C, 0xB0, 0x03, 0xB0, 0x03, 0xF8, 0x06, 0x3D, 0xF7, 0x8F, 0x0F, 0x87, 0x03, 0xFF, 0xFF, 0x00,
0xFF, 0xFF, 0x81, 0xE1, 0xF0, 0xF1, 0xEF, 0xBC, 0x40, 0x38, 0xFB, 0xBE, 0x30, 0xE3, 0x8E, 0x18, 0x30, 0x00, 0xC0,
0x0F, 0xE0, 0x7F, 0xC3, 0x83, 0x9D, 0xE7, 0xEF, 0xCF, 0x73, 0x3D, 0x8C, 0xF6, 0x33, 0xD8, 0xDB, 0x3F, 0xC6, 0x7E,
0x0C, 0x03, 0x18, 0x18, 0x7F, 0xE0, 0xFF, 0x00, 0x38, 0xFB, 0xBE, 0x3C, 0x7F, 0xFF, 0xE3, 0xC7, 0x8C, 0xFC, 0xFE,
0xC7, 0xC7, 0xFE, 0xFE, 0xC7, 0xC7, 0xFE, 0xFC, 0x3F, 0x3F, 0xF8, 0x78, 0x0C, 0x06, 0x03, 0x01, 0x83, 0x7F, 0x9F,
0x80, 0xFC, 0xFE, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFE, 0xFC, 0xFF, 0xFF, 0x06, 0x0F, 0xDF, 0xB0, 0x60, 0xFD,
0xFC, 0xFF, 0xFF, 0x06, 0x0F, 0xDF, 0xB0, 0x60, 0xC1, 0x80, 0x3F, 0x3F, 0xF8, 0x78, 0x0C, 0x7E, 0x3F, 0x07, 0x83,
0x7F, 0x9F, 0x80, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0xFF, 0xF0, 0x0C, 0x30, 0xC3,
0x0C, 0x38, 0xF3, 0xFD, 0xE0, 0xC3, 0xC7, 0xCE, 0xDC, 0xF8, 0xF8, 0xDC, 0xCC, 0xC6, 0xC3, 0xC1, 0x83, 0x06, 0x0C,
0x18, 0x30, 0x60, 0xFD, 0xFC, 0xE1, 0xF8, 0x7F, 0x3F, 0xCF, 0xFF, 0xF7, 0xBD, 0xEF, 0x7B, 0xCC, 0xF3, 0x30, 0xC3,
0xE3, 0xF3, 0xF3, 0xFB, 0xDF, 0xCF, 0xCF, 0xC7, 0xC3, 0x3E, 0x3F, 0xB8, 0xF8, 0x3C, 0x1E, 0x0F, 0x07, 0x87, 0x7F,
0x1F, 0x00, 0xFE, 0xFF, 0xC3, 0xC3, 0xFF, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0x3E, 0x3F, 0xB8, 0xF8, 0x3C, 0x1E, 0x0F,
0x37, 0x9F, 0x7F, 0x1F, 0xC0, 0xFE, 0xFF, 0xC3, 0xC7, 0xFE, 0xFE, 0xC7, 0xC3, 0xC3, 0xC3, 0x7D, 0xFF, 0x1F, 0x87,
0xC3, 0xC1, 0xC3, 0xFE, 0xF8, 0xFF, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0xC3, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0xC3, 0xC3, 0xC3, 0xE7, 0x66, 0x7E, 0x3C, 0x3C, 0x3C, 0x18, 0x83, 0x0F, 0x0C,
0x3C, 0x30, 0xF1, 0xE7, 0x67, 0x99, 0xBF, 0xE3, 0xCF, 0x0F, 0x3C, 0x3C, 0xF0, 0x61, 0x80, 0x80, 0xF8, 0x77, 0x38,
0xFC, 0x1E, 0x07, 0x83, 0xF1, 0xCE, 0xE1, 0xB0, 0x30, 0xC3, 0xC3, 0xE7, 0x7E, 0x3C, 0x18, 0x18, 0x18, 0x18, 0x18,
0xFF, 0xFF, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xFE, 0xFF, 0xFF, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xFE, 0x81, 0xC1,
0x83, 0x83, 0x83, 0x06, 0x06, 0x0C, 0x0C, 0xFF, 0x33, 0x33, 0x33, 0x33, 0x33, 0xFE, 0x18, 0x3C, 0x7E, 0x66, 0xE7,
0xC3, 0x83, 0xFF, 0xFF, 0x9D, 0x80, 0x7D, 0xFE, 0x1B, 0xFF, 0xF8, 0xFF, 0xBF, 0xC1, 0x83, 0xE7, 0xEC, 0xF8, 0xF1,
0xE7, 0xFD, 0xF0, 0x3C, 0xFF, 0x9E, 0x0C, 0x18, 0x9F, 0x9E, 0x06, 0x0C, 0xFB, 0xFE, 0x78, 0xF1, 0xE3, 0x7E, 0x7C,
0x3C, 0x7E, 0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x3B, 0xD9, 0xFF, 0xB1, 0x8C, 0x63, 0x18, 0x3E, 0xFF, 0x9E, 0x3C,
0x78, 0xDF, 0x9F, 0x06, 0x0D, 0xF3, 0xC0, 0xC3, 0x0F, 0xBF, 0xEF, 0x3C, 0xF3, 0xCF, 0x30, 0xFB, 0xFF, 0xF0, 0x6D,
0x36, 0xDB, 0x6D, 0xBD, 0x00, 0xC3, 0x0C, 0xF7, 0xFB, 0xCE, 0x3C, 0xDB, 0x30, 0xFF, 0xFF, 0xF0, 0x7F, 0xBF, 0xFC,
0xCF, 0x33, 0xCC, 0xF3, 0x3C, 0xCF, 0x33, 0x7B, 0xFC, 0xF3, 0xCF, 0x3C, 0xF3, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC7,
0x7E, 0x3C, 0x79, 0xFB, 0x3E, 0x3C, 0x79, 0xFF, 0x7C, 0xC1, 0x82, 0x00, 0x3C, 0xFF, 0x9E, 0x3C, 0x78, 0xDF, 0x9F,
0x06, 0x0C, 0x10, 0x77, 0xF7, 0x8C, 0x63, 0x18, 0x7D, 0xFF, 0x1F, 0xE7, 0xF0, 0xFF, 0xBE, 0x63, 0x3D, 0xE6, 0x31,
0x8C, 0x71, 0xC0, 0x8F, 0x3C, 0xF3, 0xCF, 0x3F, 0xDE, 0x83, 0xC3, 0xC7, 0x66, 0x66, 0x6E, 0x3C, 0x18, 0x80, 0xF3,
0x3D, 0xFD, 0xFE, 0x7F, 0x9F, 0xE3, 0x30, 0xCC, 0x83, 0xC7, 0x6E, 0x3C, 0x38, 0x7C, 0xE6, 0xC3, 0x83, 0xC7, 0x66,
0x6E, 0x3C, 0x3C, 0x18, 0x18, 0x18, 0x70, 0x60, 0xFF, 0xFC, 0x71, 0xC7, 0x1C, 0x3F, 0x7F, 0x19, 0xCC, 0x63, 0x3B,
0x98, 0x61, 0x8C, 0x63, 0x1C, 0x40, 0xFF, 0xFF, 0xFF, 0xF8, 0x83, 0x87, 0x0C, 0x30, 0xE1, 0xC7, 0x38, 0xC3, 0x0C,
0x63, 0x08, 0x00, 0x79, 0xFF, 0xE3, 0xC0, 0xF2, 0xFF, 0xFE, 0x18, 0x30, 0xF3, 0xFF, 0xFB, 0x36, 0x6E, 0x7E, 0x78,
0x60, 0xC1, 0x00, 0x3C, 0x7E, 0x66, 0x60, 0xFC, 0xFC, 0x30, 0x72, 0xFF, 0xFE, 0x83, 0xFF, 0x7E, 0x66, 0x66, 0x7E,
0xFE, 0x83, 0x83, 0xE7, 0x7E, 0x3C, 0xFF, 0xFF, 0xFF, 0xFF, 0x18, 0x18, 0xFF, 0xFC, 0xBF, 0xF8, 0x3C, 0x7E, 0x66,
0x7E, 0x7C, 0xEE, 0xC7, 0xC3, 0x77, 0x3E, 0x0C, 0x66, 0x66, 0x7C, 0x38, 0x9E, 0xE6, 0x3F, 0x1F, 0xEF, 0xFF, 0xFF,
0xF3, 0xFC, 0x3F, 0x3F, 0xFF, 0xDF, 0xDF, 0xE3, 0xF0, 0x77, 0xFF, 0xFF, 0xBC, 0x36, 0xFF, 0xF6, 0xCD, 0xCD, 0x8D,
0x80, 0xFF, 0xFF, 0x03, 0x03, 0x03, 0x3F, 0x1F, 0xEF, 0xFF, 0xFB, 0xF6, 0xFF, 0xBF, 0xEF, 0xDB, 0xF7, 0xDF, 0xE3,
0xF0, 0xFF, 0xFF, 0x6F, 0xFF, 0x60, 0x18, 0x18, 0x18, 0xFF, 0xFF, 0x18, 0x18, 0xFE, 0xFF, 0x77, 0xE6, 0x77, 0x73,
0xFF, 0x77, 0xEF, 0x7F, 0xB8, 0x7F, 0x00, 0xCF, 0x3C, 0xF3, 0xCF, 0x3F, 0xFE, 0xC3, 0x0C, 0x20, 0x7F, 0xFF, 0xFE,
0xFE, 0xFE, 0x7E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x16, 0xB0, 0x63, 0xEC, 0x37, 0xFB, 0x33, 0x30, 0x7B,
0xFC, 0xF3, 0xFD, 0xE0, 0x99, 0xF9, 0xF9, 0xB7, 0xFF, 0xB6, 0x00, 0x30, 0x07, 0x03, 0xF0, 0x7B, 0x0E, 0x31, 0xC3,
0x38, 0x37, 0x60, 0xEE, 0x1D, 0xE3, 0xBF, 0x33, 0xF6, 0x06, 0x30, 0x67, 0x0E, 0xF1, 0xCB, 0x38, 0x37, 0x03, 0xEF,
0x1D, 0xF3, 0x9F, 0x70, 0xEE, 0x0E, 0xC1, 0xF0, 0x70, 0x6F, 0x8E, 0xB9, 0xCB, 0xB8, 0xFF, 0x07, 0xE6, 0x1C, 0xE3,
0x9E, 0x73, 0xFE, 0x3F, 0xC0, 0x60, 0x30, 0x60, 0xC1, 0x87, 0x1C, 0x30, 0x60, 0xC6, 0xD9, 0xE1, 0x80, 0x30, 0x70,
0x61, 0xC7, 0xDD, 0xF1, 0xE3, 0xFF, 0xFF, 0x1E, 0x3C, 0x60, 0x18, 0x70, 0xC1, 0xC7, 0xDD, 0xF1, 0xE3, 0xFF, 0xFF,
0x1E, 0x3C, 0x60, 0x38, 0xF9, 0xB1, 0xC7, 0xDD, 0xF1, 0xE3, 0xFF, 0xFF, 0x1E, 0x3C, 0x60, 0x3C, 0xF9, 0xE1, 0xC7,
0xDD, 0xF1, 0xE3, 0xFF, 0xFF, 0x1E, 0x3C, 0x60, 0x6C, 0xD8, 0xE3, 0xEE, 0xF8, 0xF1, 0xFF, 0xFF, 0x8F, 0x1E, 0x30,
0x38, 0xF9, 0xF1, 0xC7, 0xDD, 0xF1, 0xE3, 0xFF, 0xFF, 0x1E, 0x3C, 0x60, 0x0F, 0xF8, 0x7F, 0xC7, 0xC0, 0x36, 0x03,
0xB0, 0x19, 0xF9, 0xFF, 0xCF, 0xE0, 0xE3, 0x06, 0x1F, 0xB0, 0xFE, 0x3F, 0x3F, 0xF8, 0x78, 0x0C, 0x06, 0x03, 0x01,
0x83, 0x7F, 0x9F, 0x86, 0x01, 0x83, 0x81, 0x80, 0x30, 0x70, 0x67, 0xFF, 0xF8, 0x30, 0x7E, 0xFD, 0x83, 0x07, 0xEF,
0xE0, 0x0C, 0x38, 0x67, 0xFF, 0xF8, 0x30, 0x7E, 0xFD, 0x83, 0x07, 0xEF, 0xE0, 0x18, 0x78, 0xF7, 0xFF, 0xF8, 0x30,
0x7E, 0xFD, 0x83, 0x07, 0xEF, 0xE0, 0x3C, 0x7B, 0xFF, 0xFC, 0x18, 0x3F, 0x7E, 0xC1, 0x83, 0xF7, 0xF0, 0x9D, 0x36,
0xDB, 0x6D, 0xB6, 0x7A, 0x6D, 0xB6, 0xDB, 0x6C, 0x6F, 0xB6, 0x66, 0x66, 0x66, 0x66, 0x60, 0xBB, 0x66, 0x66, 0x66,
0x66, 0x66, 0x7E, 0x3F, 0x98, 0xEC, 0x3F, 0xDF, 0xED, 0x86, 0xC7, 0x7F, 0x3F, 0x00, 0x1E, 0x3E, 0x3C, 0xC3, 0xE3,
0xF3, 0xF3, 0xFB, 0xDF, 0xCF, 0xCF, 0xC7, 0xC3, 0x18, 0x0E, 0x03, 0x07, 0xC7, 0xF7, 0x1F, 0x07, 0x83, 0xC1, 0xE0,
0xF0, 0xEF, 0xE3, 0xE0, 0x0C, 0x0E, 0x06, 0x07, 0xC7, 0xF7, 0x1F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0xEF, 0xE3, 0xE0,
0x1C, 0x1F, 0x0D, 0x87, 0xC7, 0xF7, 0x1F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0xEF, 0xE3, 0xE0, 0x1E, 0x1F, 0x0F, 0x07,
0xC7, 0xF7, 0x1F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0xEF, 0xE3, 0xE0, 0x36, 0x1B, 0x0F, 0x8F, 0xEE, 0x3E, 0x0F, 0x07,
0x83, 0xC1, 0xE1, 0xDF, 0xC7, 0xC0, 0x8F, 0xF7, 0x9E, 0xFF, 0x30, 0x1F, 0xCF, 0xF7, 0x3D, 0x9F, 0x6E, 0xDF, 0x37,
0x8D, 0xC7, 0xFF, 0xB7, 0xC0, 0x30, 0x38, 0x18, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x0C,
0x1C, 0x18, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x18, 0x3C, 0x3C, 0xC3, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x3C, 0x3C, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x0C,
0x1C, 0x18, 0xC3, 0xC3, 0xE7, 0x7E, 0x3C, 0x18, 0x18, 0x18, 0x18, 0x18, 0xC0, 0xFC, 0xFE, 0xC7, 0xC3, 0xC7, 0xFE,
0xFC, 0xC0, 0xC0, 0x38, 0xFB, 0xB6, 0x6D, 0xDB, 0x37, 0x67, 0xE7, 0xFF, 0x70, 0x30, 0x70, 0x63, 0xEF, 0xF0, 0xDF,
0xFF, 0xC7, 0xFD, 0xF8, 0x0C, 0x38, 0x63, 0xEF, 0xF0, 0xDF, 0xFF, 0xC7, 0xFD, 0xF8, 0x18, 0x78, 0xF3, 0xEF, 0xF0,
0xDF, 0xFF, 0xC7, 0xFD, 0xF8, 0x3C, 0xF9, 0xE3, 0xEF, 0xF0, 0xDF, 0xFF, 0xC7, 0xFD, 0xF8, 0x3C, 0x79, 0xF7, 0xF8,
0x6F, 0xFF, 0xE3, 0xFE, 0xFC, 0x18, 0x78, 0xF0, 0xC7, 0xDF, 0xE1, 0xBF, 0xFF, 0x8F, 0xFB, 0xF0, 0x7F, 0xEF, 0xFF,
0x86, 0x37, 0xFF, 0xFF, 0xFC, 0x61, 0xFF, 0xF7, 0xFE, 0x3C, 0xFF, 0x9E, 0x0C, 0x18, 0x9F, 0x9E, 0x18, 0x18, 0xE1,
0x80, 0x30, 0x38, 0x18, 0x3C, 0x7E, 0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x0C, 0x1C, 0x18, 0x3C, 0x7E, 0xE7, 0xFF,
0xFF, 0xC0, 0x7E, 0x3F, 0x18, 0x3C, 0x3C, 0x3C, 0x7E, 0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x3C, 0x3C, 0x3C, 0x7E,
0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x9D, 0xA6, 0xDB, 0x6D, 0x80, 0x7F, 0x4D, 0xB6, 0xDB, 0x00, 0x6F, 0xB4, 0x66,
0x66, 0x66, 0x60, 0xBB, 0x46, 0x66, 0x66, 0x66, 0x3E, 0x3E, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C,
0x3D, 0xF7, 0x9E, 0xFF, 0x3C, 0xF3, 0xCF, 0x3C, 0xC0, 0x30, 0x38, 0x18, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC7, 0x7E,
0x3C, 0x0C, 0x1C, 0x18, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x18, 0x3C, 0x3C, 0x3C, 0x7E, 0xE7, 0xC3,
0xC3, 0xC7, 0x7E, 0x3C, 0x1E, 0x3E, 0x3C, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x3C, 0x3C, 0x3C, 0x7E,
0xE7, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x18, 0x18, 0xFF, 0xFF, 0x10, 0x18, 0x3F, 0x7F, 0xEF, 0xDF, 0xFB, 0xF7, 0xFE,
0xFC, 0x61, 0xC3, 0x23, 0xCF, 0x3C, 0xF3, 0xCF, 0xF7, 0x80, 0x18, 0xE3, 0x23, 0xCF, 0x3C, 0xF3, 0xCF, 0xF7, 0x80,
0x31, 0xE7, 0xA3, 0xCF, 0x3C, 0xF3, 0xCF, 0xF7, 0x80, 0x79, 0xE8, 0xF3, 0xCF, 0x3C, 0xF3, 0xFD, 0xE0, 0x0C, 0x1C,
0x18, 0x83, 0xC7, 0x66, 0x6E, 0x3C, 0x3C, 0x18, 0x18, 0x18, 0x70, 0x60, 0xC1, 0x83, 0xE7, 0xEE, 0xF8, 0xF1, 0xE7,
0xFD, 0xF3, 0x06, 0x08, 0x00, 0x3C, 0x3C, 0x83, 0xC7, 0x66, 0x6E, 0x3C, 0x3C, 0x18, 0x18, 0x18, 0x70, 0x60, 0x7C,
0xF8, 0xE3, 0xEE, 0xF8, 0xF1, 0xFF, 0xFF, 0x8F, 0x1E, 0x30, 0x7C, 0xF9, 0xF7, 0xF8, 0x6F, 0xFF, 0xE3, 0xFE, 0xFC,
0x6C, 0xF8, 0xE1, 0xC7, 0xDD, 0xF1, 0xE3, 0xFF, 0xFF, 0x1E, 0x3C, 0x60, 0x6C, 0xF8, 0xE3, 0xEF, 0xF0, 0xDF, 0xFF,
0xC7, 0xFD, 0xF8, 0x38, 0x7C, 0xEE, 0xC6, 0xC6, 0xFE, 0xFE, 0xC6, 0xC6, 0xC6, 0x0C, 0x0C, 0x0F, 0x07, 0x7C, 0xFE,
0x86, 0x7E, 0xFE, 0xC6, 0xFE, 0x7E, 0x06, 0x0C, 0x0F, 0x0F, 0x0C, 0x0E, 0x06, 0x07, 0xE7, 0xFF, 0x0F, 0x01, 0x80,
0xC0, 0x60, 0x30, 0x6F, 0xF3, 0xF0, 0x0C, 0x38, 0x61, 0xE7, 0xFC, 0xF0, 0x60, 0xC4, 0xFC, 0xF0, 0x0C, 0x0F, 0x07,
0x87, 0xE7, 0xFF, 0x0F, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x6F, 0xF3, 0xF0, 0x18, 0x78, 0xF1, 0xE7, 0xFC, 0xF0, 0x60,
0xC4, 0xFC, 0xF0, 0x0C, 0x06, 0x0F, 0xCF, 0xFE, 0x1E, 0x03, 0x01, 0x80, 0xC0, 0x60, 0xDF, 0xE7, 0xE0, 0x18, 0x30,
0xF3, 0xFE, 0x78, 0x30, 0x62, 0x7E, 0x78, 0x1E, 0x0F, 0x03, 0x07, 0xE7, 0xFF, 0x0F, 0x01, 0x80, 0xC0, 0x60, 0x30,
0x6F, 0xF3, 0xF0, 0x3C, 0x78, 0x61, 0xE7, 0xFC, 0xF0, 0x60, 0xC4, 0xFC, 0xF0, 0x78, 0x78, 0x30, 0xFC, 0xFE, 0xC7,
0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFE, 0xFC, 0x01, 0x83, 0xC1, 0xE7, 0xC7, 0xE7, 0x33, 0x19, 0x8C, 0xC6, 0x3F, 0x0F,
0x80, 0x7E, 0x3F, 0x98, 0xEC, 0x3F, 0xDF, 0xED, 0x86, 0xC7, 0x7F, 0x3F, 0x00, 0x06, 0x1F, 0x1F, 0x3E, 0x7E, 0xE6,
0xC6, 0xC6, 0xC6, 0x7E, 0x3E, 0x3C, 0x7B, 0xFF, 0xFC, 0x18, 0x3F, 0x7E, 0xC1, 0x83, 0xF7, 0xF0, 0x3C, 0x3C, 0x3C,
0x7E, 0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x66, 0xFC, 0xF7, 0xFF, 0xF8, 0x30, 0x7E, 0xFD, 0x83, 0x07, 0xEF, 0xE0,
0x66, 0x7E, 0x3C, 0x3C, 0x7E, 0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x18, 0x33, 0xFF, 0xFC, 0x18, 0x3F, 0x7E, 0xC1,
0x83, 0xF7, 0xF0, 0x18, 0x18, 0x3C, 0x7E, 0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0xFE, 0xFE, 0xC0, 0xC0, 0xFC, 0xFC,
0xC0, 0xC0, 0xFC, 0xFE, 0x06, 0x0C, 0x0F, 0x0F, 0x3C, 0x7E, 0xE7, 0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x06, 0x0C, 0x0F,
0x07, 0x3C, 0x78, 0x67, 0xFF, 0xF8, 0x30, 0x7E, 0xFD, 0x83, 0x07, 0xEF, 0xE0, 0x3C, 0x3C, 0x18, 0x3C, 0x7E, 0xE7,
0xFF, 0xFF, 0xC0, 0x7E, 0x3F, 0x0C, 0x0F, 0x07, 0x87, 0xE7, 0xFF, 0x0F, 0x01, 0x8F, 0xC7, 0xE0, 0xF0, 0x6F, 0xF3,
0xF0, 0x18, 0x78, 0xF1, 0xF7, 0xFC, 0xF1, 0xE3, 0xC6, 0xFC, 0xF8, 0x30, 0x6F, 0x9E, 0x00, 0x36, 0x1F, 0x07, 0x07,
0xE7, 0xFF, 0x0F, 0x01, 0x8F, 0xC7, 0xE0, 0xF0, 0x6F, 0xF3, 0xF0, 0x36, 0x7C, 0x71, 0xF7, 0xFC, 0xF1, 0xE3, 0xC6,
0xFC, 0xF8, 0x30, 0x6F, 0x9E, 0x00, 0x0C, 0x06, 0x0F, 0xCF, 0xFE, 0x1E, 0x03, 0x1F, 0x8F, 0xC1, 0xE0, 0xDF, 0xE7,
0xE0, 0x18, 0x30, 0xFB, 0xFE, 0x78, 0xF1, 0xE3, 0x7E, 0x7C, 0x18, 0x37, 0xCF, 0x00, 0x3F, 0x3F, 0xF8, 0x78, 0x0C,
0x7E, 0x3F, 0x07, 0x83, 0x7F, 0x9F, 0x83, 0x00, 0xC1, 0xC0, 0xE0, 0x18, 0x30, 0x61, 0xF7, 0xFC, 0xF1, 0xE3, 0xC6,
0xFC, 0xF8, 0x30, 0x6F, 0x9E, 0x00, 0x18, 0x3C, 0x3C, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3,
0x31, 0xE7, 0xB0, 0xC3, 0xEF, 0xFB, 0xCF, 0x3C, 0xF3, 0xCC, 0x61, 0xBF, 0xFF, 0xFD, 0x86, 0x7F, 0x9F, 0xE6, 0x19,
0x86, 0x61, 0x98, 0x60, 0x61, 0xF3, 0xE3, 0xE7, 0xEE, 0xD9, 0xB3, 0x66, 0xCD, 0x98, 0x7F, 0xEC, 0xC6, 0x31, 0x8C,
0x63, 0x18, 0xC6, 0x00, 0x7F, 0xEC, 0x86, 0x31, 0x8C, 0x63, 0x18, 0xFF, 0x66, 0x66, 0x66, 0x66, 0x66, 0xFF, 0x46,
0x66, 0x66, 0x66, 0x9F, 0xDC, 0xC6, 0x31, 0x8C, 0x63, 0x18, 0xC6, 0x00, 0x9F, 0xDC, 0x86, 0x31, 0x8C, 0x63, 0x18,
0x66, 0x66, 0x66, 0x66, 0x66, 0x6C, 0xF6, 0x66, 0x46, 0x66, 0x66, 0x66, 0x6C, 0xF6, 0xEF, 0xFF, 0xFF, 0xBF, 0xFF,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xE3, 0xF3, 0xFF, 0xDE, 0xDE, 0xE7, 0xBD, 0xEF, 0x7B, 0xDE, 0xC6, 0x33, 0x10,
0x0C, 0x3C, 0x78, 0x60, 0xC1, 0x83, 0x06, 0x0D, 0x1B, 0x37, 0xE7, 0x80, 0x6F, 0xB4, 0x66, 0x66, 0x66, 0x66, 0x6C,
0x80, 0xC3, 0xC7, 0xCE, 0xDC, 0xF8, 0xF8, 0xDC, 0xCC, 0xC6, 0xC3, 0x38, 0x1C, 0x38, 0x30, 0xC3, 0x0C, 0xF7, 0xFB,
0xCE, 0x3C, 0xDB, 0x37, 0x0E, 0x71, 0x80, 0x8F, 0x7F, 0xBC, 0xE3, 0xCD, 0xB3, 0x30, 0xE1, 0x86, 0x0C, 0x18, 0x30,
0x60, 0xC1, 0x83, 0x07, 0xEF, 0xE0, 0x7A, 0x6D, 0xB6, 0xDB, 0x6C, 0xC1, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xFD,
0xFD, 0xC1, 0xC7, 0x0C, 0x00, 0x66, 0x66, 0x66, 0x66, 0x66, 0xEF, 0xEC, 0x0D, 0x9B, 0x36, 0x6C, 0x18, 0x30, 0x60,
0xC1, 0xFB, 0xF8, 0x3F, 0xFF, 0xCC, 0xCC, 0xCC, 0xC0, 0xC1, 0x83, 0x06, 0x0C, 0xD9, 0xB0, 0x60, 0xFD, 0xFC, 0xCC,
0xCC, 0xFF, 0xCC, 0xCC, 0x60, 0x60, 0x78, 0x78, 0xF0, 0xE0, 0x60, 0x60, 0x7E, 0x7F, 0x66, 0x67, 0xFE, 0x66, 0x66,
0x0C, 0x1C, 0x18, 0xC3, 0xE3, 0xF3, 0xF3, 0xFB, 0xDF, 0xCF, 0xCF, 0xC7, 0xC3, 0x18, 0x63, 0x8C, 0x7B, 0xFC, 0xF3,
0xCF, 0x3C, 0xF3, 0xC3, 0xE3, 0xF3, 0xF3, 0xFB, 0xDF, 0xCF, 0xCF, 0xC7, 0xC3, 0x38, 0x1C, 0x38, 0x30, 0x7B, 0xFC,
0xF3, 0xCF, 0x3C, 0xF3, 0x70, 0xE7, 0x18, 0x3C, 0x3C, 0x18, 0xC3, 0xE3, 0xF3, 0xF3, 0xFB, 0xDF, 0xCF, 0xCF, 0xC7,
0xC3, 0x79, 0xE3, 0x1E, 0xFF, 0x3C, 0xF3, 0xCF, 0x3C, 0xC0, 0x81, 0x83, 0x05, 0xE7, 0xEC, 0xD9, 0xB3, 0x66, 0xCD,
0x98, 0xC3, 0xE3, 0xF3, 0xF3, 0xFB, 0xDF, 0xCF, 0xCF, 0xC7, 0xC3, 0x03, 0x03, 0x03, 0x7B, 0xFC, 0xF3, 0xCF, 0x3C,
0xF3, 0x0C, 0x31, 0x84, 0x3E, 0x1F, 0x0F, 0x8F, 0xEE, 0x3E, 0x0F, 0x07, 0x83, 0xC1, 0xE1, 0xDF, 0xC7, 0xC0, 0x3C,
0x3C, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x36, 0x1F, 0x07, 0x07, 0xC7, 0xF7, 0x1F, 0x07, 0x83, 0xC1,
0xE0, 0xF0, 0xEF, 0xE3, 0xE0, 0x36, 0x3E, 0x1C, 0x3C, 0x7E, 0xE7, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x1B, 0x1F, 0x8D,
0x87, 0xC7, 0xF7, 0x1F, 0x07, 0x83, 0xC1, 0xE0, 0xF0, 0xEF, 0xE3, 0xE0, 0x1E, 0x3E, 0x3C, 0x3C, 0x7E, 0xE7, 0xC3,
0xC3, 0xC7, 0x7E, 0x3C, 0x3F, 0xFB, 0xFF, 0xF9, 0xC1, 0x86, 0x0C, 0x3F, 0x61, 0xFB, 0x0C, 0x18, 0x60, 0xC7, 0x03,
0xFF, 0x0F, 0xFC, 0x3D, 0xE3, 0xFF, 0xB9, 0xCF, 0x87, 0xFC, 0x3F, 0xE3, 0x85, 0xFF, 0xE7, 0xBE, 0x18, 0x38, 0x30,
0xFE, 0xFF, 0xC3, 0xC7, 0xFE, 0xFE, 0xC7, 0xC3, 0xC3, 0xC3, 0x33, 0x98, 0xEF, 0xEF, 0x18, 0xC6, 0x30, 0xFE, 0xFF,
0xC3, 0xC7, 0xFE, 0xFE, 0xC7, 0xC3, 0xC3, 0xC3, 0x38, 0x1C, 0x38, 0x30, 0x77, 0xF7, 0x8C, 0x63, 0x18, 0xE3, 0xB9,
0x80, 0x3C, 0x3C, 0x18, 0xFE, 0xFF, 0xC3, 0xC7, 0xFE, 0xFE, 0xC7, 0xC3, 0xC3, 0xC3, 0xF7, 0x98, 0xEF, 0xEF, 0x18,
0xC6, 0x30, 0x18, 0x70, 0xC3, 0xEF, 0xF8, 0xFC, 0x3E, 0x1E, 0x0E, 0x1F, 0xF7, 0xC0, 0x18, 0x70, 0xC3, 0xEF, 0xF8,
0xFF, 0x3F, 0x87, 0xFD, 0xF0, 0x18, 0x78, 0xF3, 0xEF, 0xF8, 0xFC, 0x3E, 0x1E, 0x0E, 0x1F, 0xF7, 0xC0, 0x18, 0x78,
0xF3, 0xEF, 0xF8, 0xFF, 0x3F, 0x87, 0xFD, 0xF0, 0x7D, 0xFF, 0x1F, 0x87, 0xC3, 0xC1, 0xC3, 0xFE, 0xF8, 0xC0, 0xC7,
0x0C, 0x00, 0x7D, 0xFF, 0x1F, 0xE7, 0xF0, 0xFF, 0xBE, 0x18, 0x18, 0xE1, 0x80, 0x3C, 0x78, 0x63, 0xEF, 0xF8, 0xFC,
0x3E, 0x1E, 0x0E, 0x1F, 0xF7, 0xC0, 0x3C, 0x78, 0x63, 0xEF, 0xF8, 0xFF, 0x3F, 0x87, 0xFD, 0xF0, 0xFF, 0xFF, 0xC6,
0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x01, 0x83, 0x81, 0x80, 0x63, 0x3D, 0xE6, 0x31, 0x8C, 0x71,
0xCC, 0x37, 0x30, 0x3C, 0x3C, 0x18, 0xFF, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x0D, 0xB6, 0xFF,
0xF1, 0x86, 0x18, 0x61, 0xC3, 0x80, 0xFF, 0xFF, 0x18, 0x18, 0x7E, 0x7E, 0x18, 0x18, 0x18, 0x18, 0x63, 0x3D, 0xE6,
0x73, 0xCC, 0x71, 0xC0, 0x1E, 0x3E, 0x3C, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x3D, 0xF7,
0xA3, 0xCF, 0x3C, 0xF3, 0xCF, 0xF7, 0x80, 0x3C, 0x3C, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C,
0x79, 0xE8, 0xF3, 0xCF, 0x3C, 0xF3, 0xFD, 0xE0, 0x36, 0x3E, 0x1C, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7,
0x7E, 0x3C, 0x6D, 0xF3, 0xA3, 0xCF, 0x3C, 0xF3, 0xCF, 0xF7, 0x80, 0x18, 0x3C, 0x3C, 0xDB, 0xC3, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x31, 0xE7, 0xAF, 0xCF, 0x3C, 0xF3, 0xCF, 0xF7, 0x80, 0x1E, 0x3E, 0x3C, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x3D, 0xF7, 0xA3, 0xCF, 0x3C, 0xF3, 0xCF, 0xF7, 0x80, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0x3C, 0x18, 0x30, 0x3C, 0x1C, 0x8D, 0x9B, 0x36, 0x6C, 0xD9, 0xBF, 0x3E,
0x0C, 0x30, 0x78, 0x70, 0x03, 0x00, 0x1E, 0x00, 0x78, 0x20, 0xC3, 0xC3, 0x0F, 0x0C, 0x3C, 0x79, 0xD9, 0xE6, 0x6F,
0xF8, 0xF3, 0xC3, 0xCF, 0x0F, 0x3C, 0x18, 0x60, 0x0C, 0x07, 0x81, 0xE2, 0x03, 0xCC, 0xF7, 0xF7, 0xF9, 0xFE, 0x7F,
0x8C, 0xC3, 0x30, 0x18, 0x3C, 0x3C, 0xC3, 0xC3, 0xE7, 0x7E, 0x3C, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x3C,
0x83, 0xC7, 0x66, 0x6E, 0x3C, 0x3C, 0x18, 0x18, 0x18, 0x70, 0x60, 0x3C, 0x3C, 0xC3, 0xC3, 0xE7, 0x7E, 0x3C, 0x18,
0x18, 0x18, 0x18, 0x18, 0x0C, 0x1C, 0x18, 0xFF, 0xFF, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xFE, 0xFF, 0x0C, 0x38,
0x67, 0xFF, 0xE3, 0x8E, 0x38, 0xE1, 0xFB, 0xF8, 0x0C, 0x0C, 0xFF, 0xFF, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xE0, 0xFE,
0xFF, 0x18, 0x33, 0xFF, 0xF1, 0xC7, 0x1C, 0x70, 0xFD, 0xFC, 0x3C, 0x3C, 0x18, 0xFF, 0xFF, 0x07, 0x0E, 0x1C, 0x38,
0x70, 0xE0, 0xFE, 0xFF, 0x3C, 0x78, 0x67, 0xFF, 0xE3, 0x8E, 0x38, 0xE1, 0xFB, 0xF8, 0x1E, 0x3F, 0x73, 0xFE, 0xFE,
0xFE, 0xFE, 0x62, 0x3F, 0x1E,
};
static const EpdGlyph babyblueGlyphs[] = {
{0, 0, 5, 0, 0, 0, 0}, //
{2, 10, 3, 1, 10, 3, 0}, // !
{4, 5, 5, 1, 11, 3, 3}, // "
{9, 11, 10, 0, 11, 13, 6}, // #
{8, 14, 9, 0, 12, 14, 19}, // $
{11, 11, 13, 1, 11, 16, 33}, // %
{9, 11, 11, 1, 11, 13, 49}, // &
{2, 4, 3, 1, 11, 1, 62}, // '
{4, 15, 5, 1, 11, 8, 63}, // (
{4, 15, 5, 1, 11, 8, 71}, // )
{6, 6, 6, 0, 11, 5, 79}, // *
{8, 8, 9, 0, 9, 8, 84}, // +
{2, 5, 3, 1, 3, 2, 92}, // ,
{5, 2, 5, 0, 5, 2, 94}, // -
{2, 2, 3, 1, 2, 1, 96}, // .
{6, 11, 6, 0, 11, 9, 97}, // /
{8, 11, 9, 0, 11, 11, 106}, // 0
{5, 11, 6, 1, 11, 7, 117}, // 1
{8, 11, 9, 0, 11, 11, 124}, // 2
{8, 11, 10, 1, 11, 11, 135}, // 3
{8, 11, 9, 0, 11, 11, 146}, // 4
{7, 11, 9, 1, 11, 10, 157}, // 5
{8, 11, 9, 0, 11, 11, 167}, // 6
{9, 11, 10, 0, 11, 13, 178}, // 7
{8, 11, 9, 0, 11, 11, 191}, // 8
{8, 11, 9, 0, 11, 11, 202}, // 9
{2, 8, 3, 1, 8, 2, 213}, // :
{2, 11, 3, 1, 8, 3, 215}, // ;
{7, 8, 9, 1, 9, 7, 218}, // <
{8, 5, 9, 0, 8, 5, 225}, // =
{7, 8, 7, 0, 9, 7, 230}, // >
{7, 11, 9, 1, 11, 10, 237}, // ?
{14, 15, 15, 0, 11, 27, 247}, // @
{7, 10, 9, 1, 10, 9, 274}, // A
{8, 10, 10, 1, 10, 10, 283}, // B
{9, 10, 11, 1, 10, 12, 293}, // C
{8, 10, 10, 1, 10, 10, 305}, // D
{7, 10, 9, 1, 10, 9, 315}, // E
{7, 10, 9, 1, 10, 9, 324}, // F
{9, 10, 11, 1, 10, 12, 333}, // G
{8, 10, 10, 1, 10, 10, 345}, // H
{2, 10, 3, 1, 10, 3, 355}, // I
{6, 10, 6, 0, 10, 8, 358}, // J
{8, 10, 10, 1, 10, 10, 366}, // K
{7, 10, 9, 1, 10, 9, 376}, // L
{10, 10, 12, 1, 10, 13, 385}, // M
{8, 10, 10, 1, 10, 10, 398}, // N
{9, 10, 11, 1, 10, 12, 408}, // O
{8, 10, 10, 1, 10, 10, 420}, // P
{9, 10, 11, 1, 10, 12, 430}, // Q
{8, 10, 10, 1, 10, 10, 442}, // R
{7, 10, 9, 1, 10, 9, 452}, // S
{8, 10, 9, 0, 10, 10, 461}, // T
{8, 10, 10, 1, 10, 10, 471}, // U
{8, 10, 10, 1, 10, 10, 481}, // V
{14, 10, 15, 0, 10, 18, 491}, // W
{10, 10, 11, 0, 10, 13, 509}, // X
{8, 10, 10, 1, 10, 10, 522}, // Y
{8, 10, 9, 0, 10, 10, 532}, // Z
{4, 14, 5, 1, 10, 7, 542}, // [
{7, 10, 7, 0, 10, 9, 549}, // <backslash>
{4, 14, 4, 0, 10, 7, 558}, // ]
{8, 7, 9, 0, 11, 7, 565}, // ^
{8, 2, 9, 0, -2, 2, 572}, // _
{3, 3, 3, 0, 11, 2, 574}, // `
{7, 8, 7, 0, 8, 7, 576}, // a
{7, 10, 9, 1, 10, 9, 583}, // b
{7, 8, 7, 0, 8, 7, 592}, // c
{7, 10, 7, 0, 10, 9, 599}, // d
{8, 8, 9, 0, 8, 8, 608}, // e
{5, 11, 5, 0, 11, 7, 616}, // f
{7, 12, 7, 0, 8, 11, 623}, // g
{6, 10, 7, 1, 10, 8, 634}, // h
{2, 10, 3, 1, 10, 3, 642}, // i
{3, 14, 3, 0, 10, 6, 645}, // j
{6, 10, 7, 1, 10, 8, 651}, // k
{2, 10, 3, 1, 10, 3, 659}, // l
{10, 8, 12, 1, 8, 10, 662}, // m
{6, 8, 7, 1, 8, 6, 672}, // n
{8, 8, 9, 0, 8, 8, 678}, // o
{7, 11, 9, 1, 8, 10, 686}, // p
{7, 11, 7, 0, 8, 10, 696}, // q
{5, 8, 6, 1, 8, 5, 706}, // r
{7, 8, 7, 0, 8, 7, 711}, // s
{5, 10, 5, 0, 10, 7, 718}, // t
{6, 8, 7, 1, 8, 6, 725}, // u
{8, 8, 9, 0, 8, 8, 731}, // v
{10, 8, 11, 0, 8, 10, 739}, // w
{8, 8, 9, 0, 8, 8, 749}, // x
{8, 11, 9, 0, 8, 11, 757}, // y
{7, 8, 7, 0, 8, 7, 768}, // z
{5, 15, 5, 0, 11, 10, 775}, // {
{2, 15, 3, 1, 11, 4, 785}, // |
{6, 15, 6, 0, 11, 12, 789}, // }
{9, 3, 10, 0, 7, 4, 801}, // ~
{2, 12, 4, 2, 8, 3, 805}, // ¡
{7, 13, 7, 0, 10, 12, 808}, // ¢
{8, 10, 9, 0, 10, 10, 820}, // £
{8, 8, 9, 0, 9, 8, 830}, // ¤
{8, 10, 9, 0, 10, 10, 838}, // ¥
{2, 15, 3, 1, 11, 4, 848}, // ¦
{8, 15, 9, 0, 11, 15, 852}, // §
{5, 3, 5, 0, 11, 2, 867}, // ¨
{10, 11, 11, 0, 11, 14, 869}, // ©
{5, 6, 5, 0, 11, 4, 883}, // ª
{7, 7, 9, 1, 8, 7, 887}, // «
{8, 5, 9, 0, 8, 5, 894}, // ¬
{10, 11, 11, 0, 11, 14, 899}, // ®
{8, 2, 9, 0, 12, 2, 913}, // ¯
{4, 5, 5, 1, 11, 3, 915}, // °
{8, 9, 9, 0, 9, 9, 918}, // ±
{5, 8, 5, 0, 11, 5, 927}, // ²
{5, 6, 5, 0, 11, 4, 932}, // ³
{3, 3, 4, 1, 11, 2, 936}, // ´
{6, 12, 9, 2, 8, 9, 938}, // µ
{8, 14, 9, 0, 10, 14, 947}, // ¶
{2, 2, 3, 1, 6, 1, 961}, // ·
{4, 4, 4, 0, 0, 2, 962}, // ¸
{4, 7, 4, 0, 12, 4, 964}, // ¹
{6, 6, 6, 0, 11, 5, 968}, // º
{7, 7, 9, 1, 8, 7, 973}, // »
{12, 12, 13, 0, 12, 18, 980}, // ¼
{12, 11, 13, 0, 11, 17, 998}, // ½
{12, 11, 13, 0, 11, 17, 1015}, // ¾
{7, 12, 9, 1, 8, 11, 1032}, // ¿
{7, 13, 9, 1, 13, 12, 1043}, // À
{7, 13, 9, 1, 13, 12, 1055}, // Á
{7, 13, 9, 1, 13, 12, 1067}, // Â
{7, 13, 9, 1, 13, 12, 1079}, // Ã
{7, 12, 9, 1, 12, 11, 1091}, // Ä
{7, 13, 9, 1, 13, 12, 1102}, // Å
{13, 11, 14, 0, 11, 18, 1114}, // Æ
{9, 14, 11, 1, 10, 16, 1132}, // Ç
{7, 13, 9, 1, 13, 12, 1148}, // È
{7, 13, 9, 1, 13, 12, 1160}, // É
{7, 13, 9, 1, 13, 12, 1172}, // Ê
{7, 12, 9, 1, 12, 11, 1184}, // Ë
{3, 13, 3, 0, 13, 5, 1195}, // Ì
{3, 13, 4, 1, 13, 5, 1200}, // Í
{4, 13, 4, 0, 13, 7, 1205}, // Î
{4, 12, 4, 0, 12, 6, 1212}, // Ï
{9, 10, 10, 0, 10, 12, 1218}, // Ð
{8, 13, 10, 1, 13, 13, 1230}, // Ñ
{9, 13, 11, 1, 13, 15, 1243}, // Ò
{9, 13, 11, 1, 13, 15, 1258}, // Ó
{9, 13, 11, 1, 13, 15, 1273}, // Ô
{9, 13, 11, 1, 13, 15, 1288}, // Õ
{9, 12, 11, 1, 12, 14, 1303}, // Ö
{6, 6, 7, 1, 8, 5, 1317}, // ×
{10, 10, 11, 0, 10, 13, 1322}, // Ø
{8, 13, 10, 1, 13, 13, 1335}, // Ù
{8, 13, 10, 1, 13, 13, 1348}, // Ú
{8, 13, 10, 1, 13, 13, 1361}, // Û
{8, 12, 10, 1, 12, 12, 1374}, // Ü
{8, 13, 10, 1, 13, 13, 1386}, // Ý
{8, 10, 10, 1, 10, 10, 1399}, // Þ
{7, 11, 9, 1, 11, 10, 1409}, // ß
{7, 11, 7, 0, 11, 10, 1419}, // à
{7, 11, 7, 0, 11, 10, 1429}, // á
{7, 11, 7, 0, 11, 10, 1439}, // â
{7, 11, 7, 0, 11, 10, 1449}, // ã
{7, 10, 7, 0, 10, 9, 1459}, // ä
{7, 12, 7, 0, 12, 11, 1468}, // å
{12, 8, 13, 0, 8, 12, 1479}, // æ
{7, 12, 7, 0, 8, 11, 1491}, // ç
{8, 11, 9, 0, 11, 11, 1502}, // è
{8, 11, 9, 0, 11, 11, 1513}, // é
{8, 11, 9, 0, 11, 11, 1524}, // ê
{8, 10, 9, 0, 10, 10, 1535}, // ë
{3, 11, 3, 0, 11, 5, 1545}, // ì
{3, 11, 4, 1, 11, 5, 1550}, // í
{4, 11, 4, 0, 11, 6, 1555}, // î
{4, 10, 4, 0, 10, 5, 1561}, // ï
{8, 11, 9, 0, 11, 11, 1566}, // ð
{6, 11, 7, 1, 11, 9, 1577}, // ñ
{8, 11, 9, 0, 11, 11, 1586}, // ò
{8, 11, 9, 0, 11, 11, 1597}, // ó
{8, 11, 9, 0, 11, 11, 1608}, // ô
{8, 11, 9, 0, 11, 11, 1619}, // õ
{8, 10, 9, 0, 10, 10, 1630}, // ö
{8, 6, 9, 0, 8, 6, 1640}, // ÷
{8, 8, 9, 0, 8, 8, 1646}, // ø
{6, 11, 7, 1, 11, 9, 1654}, // ù
{6, 11, 7, 1, 11, 9, 1663}, // ú
{6, 11, 7, 1, 11, 9, 1672}, // û
{6, 10, 7, 1, 10, 8, 1681}, // ü
{8, 14, 9, 0, 11, 14, 1689}, // ý
{7, 13, 9, 1, 10, 12, 1703}, // þ
{8, 13, 9, 0, 10, 13, 1715}, // ÿ
{7, 12, 9, 1, 12, 11, 1728}, // Ā
{7, 10, 7, 0, 10, 9, 1739}, // ā
{7, 13, 9, 1, 13, 12, 1748}, // Ă
{7, 11, 7, 0, 11, 10, 1760}, // ă
{8, 14, 10, 1, 10, 14, 1770}, // Ą
{8, 12, 9, 0, 8, 12, 1784}, // ą
{9, 13, 11, 1, 13, 15, 1796}, // Ć
{7, 11, 7, 0, 11, 10, 1811}, // ć
{9, 13, 11, 1, 13, 15, 1821}, // Ĉ
{7, 11, 7, 0, 11, 10, 1836}, // ĉ
{9, 12, 11, 1, 12, 14, 1846}, // Ċ
{7, 10, 7, 0, 10, 9, 1860}, // ċ
{9, 13, 11, 1, 13, 15, 1869}, // Č
{7, 11, 7, 0, 11, 10, 1884}, // č
{8, 13, 10, 1, 13, 13, 1894}, // Ď
{9, 11, 10, 0, 11, 13, 1907}, // ď
{9, 10, 10, 0, 10, 12, 1920}, // Đ
{8, 11, 9, 0, 11, 11, 1932}, // đ
{7, 12, 9, 1, 12, 11, 1943}, // Ē
{8, 10, 9, 0, 10, 10, 1954}, // ē
{7, 13, 9, 1, 13, 12, 1964}, // Ĕ
{8, 11, 9, 0, 11, 11, 1976}, // ĕ
{7, 12, 9, 1, 12, 11, 1987}, // Ė
{8, 10, 9, 0, 10, 10, 1998}, // ė
{8, 14, 10, 1, 10, 14, 2008}, // Ę
{8, 12, 9, 0, 8, 12, 2022}, // ę
{7, 13, 9, 1, 13, 12, 2034}, // Ě
{8, 11, 9, 0, 11, 11, 2046}, // ě
{9, 13, 11, 1, 13, 15, 2057}, // Ĝ
{7, 15, 7, 0, 11, 14, 2072}, // ĝ
{9, 13, 11, 1, 13, 15, 2086}, // Ğ
{7, 15, 7, 0, 11, 14, 2101}, // ğ
{9, 12, 11, 1, 12, 14, 2115}, // Ġ
{7, 14, 7, 0, 10, 13, 2129}, // ġ
{9, 14, 11, 1, 10, 16, 2142}, // Ģ
{7, 15, 7, 0, 11, 14, 2158}, // ģ
{8, 13, 10, 1, 13, 13, 2172}, // Ĥ
{6, 13, 7, 1, 13, 10, 2185}, // ĥ
{10, 10, 11, 0, 10, 13, 2195}, // Ħ
{7, 11, 7, 0, 11, 10, 2208}, // ħ
{5, 13, 5, 0, 13, 9, 2218}, // Ĩ
{5, 11, 5, 0, 11, 7, 2227}, // ĩ
{4, 12, 4, 0, 12, 6, 2234}, // Ī
{4, 10, 4, 0, 10, 5, 2240}, // ī
{5, 13, 5, 0, 13, 9, 2245}, // Ĭ
{5, 11, 5, 0, 11, 7, 2254}, // ĭ
{4, 14, 4, 0, 10, 7, 2261}, // Į
{4, 14, 4, 0, 10, 7, 2268}, // į
{2, 12, 3, 1, 12, 3, 2275}, // İ
{2, 8, 3, 1, 8, 2, 2278}, // ı
{8, 10, 10, 1, 10, 10, 2280}, // IJ
{5, 14, 6, 1, 10, 9, 2290}, // ij
{7, 13, 7, 0, 13, 12, 2299}, // Ĵ
{4, 15, 4, 0, 11, 8, 2311}, // ĵ
{8, 14, 10, 1, 10, 14, 2319}, // Ķ
{6, 14, 7, 1, 10, 11, 2333}, // ķ
{6, 8, 7, 1, 8, 6, 2344}, // ĸ
{7, 13, 9, 1, 13, 12, 2350}, // Ĺ
{3, 13, 4, 1, 13, 5, 2362}, // ĺ
{7, 14, 9, 1, 10, 13, 2367}, // Ļ
{4, 14, 4, 0, 10, 7, 2380}, // ļ
{7, 11, 9, 1, 11, 10, 2387}, // Ľ
{4, 11, 5, 1, 11, 6, 2397}, // ľ
{7, 10, 9, 1, 10, 9, 2403}, // Ŀ
{4, 10, 5, 1, 10, 5, 2412}, // ŀ
{8, 10, 9, 0, 10, 10, 2417}, // Ł
{4, 10, 4, 0, 10, 5, 2427}, // ł
{8, 13, 10, 1, 13, 13, 2432}, // Ń
{6, 12, 7, 1, 12, 9, 2445}, // ń
{8, 14, 10, 1, 10, 14, 2454}, // Ņ
{6, 12, 7, 1, 8, 9, 2468}, // ņ
{8, 13, 10, 1, 13, 13, 2477}, // Ň
{6, 11, 7, 1, 11, 9, 2490}, // ň
{7, 11, 7, 0, 11, 10, 2499}, // ʼn
{8, 13, 10, 1, 10, 13, 2509}, // Ŋ
{6, 12, 7, 1, 8, 9, 2522}, // ŋ
{9, 12, 11, 1, 12, 14, 2531}, // Ō
{8, 10, 9, 0, 10, 10, 2545}, // ō
{9, 13, 11, 1, 13, 15, 2555}, // Ŏ
{8, 11, 9, 0, 11, 11, 2570}, // ŏ
{9, 13, 11, 1, 13, 15, 2581}, // Ő
{8, 11, 9, 0, 11, 11, 2596}, // ő
{13, 11, 15, 1, 11, 18, 2607}, // Œ
{13, 8, 14, 0, 8, 13, 2625}, // œ
{8, 13, 10, 1, 13, 13, 2638}, // Ŕ
{5, 11, 6, 1, 11, 7, 2651}, // ŕ
{8, 14, 10, 1, 10, 14, 2658}, // Ŗ
{5, 12, 6, 1, 8, 8, 2672}, // ŗ
{8, 13, 10, 1, 13, 13, 2680}, // Ř
{5, 11, 6, 1, 11, 7, 2693}, // ř
{7, 13, 9, 1, 13, 12, 2700}, // Ś
{7, 11, 7, 0, 11, 10, 2712}, // ś
{7, 13, 9, 1, 13, 12, 2722}, // Ŝ
{7, 11, 7, 0, 11, 10, 2734}, // ŝ
{7, 14, 9, 1, 10, 13, 2744}, // Ş
{7, 12, 7, 0, 8, 11, 2757}, // ş
{7, 13, 9, 1, 13, 12, 2768}, // Š
{7, 11, 7, 0, 11, 10, 2780}, // š
{9, 14, 10, 0, 10, 16, 2790}, // Ţ
{5, 14, 5, 0, 10, 9, 2806}, // ţ
{8, 13, 9, 0, 13, 13, 2815}, // Ť
{6, 11, 6, 0, 11, 9, 2828}, // ť
{8, 10, 9, 0, 10, 10, 2837}, // Ŧ
{5, 10, 5, 0, 10, 7, 2847}, // ŧ
{8, 13, 10, 1, 13, 13, 2854}, // Ũ
{6, 11, 7, 1, 11, 9, 2867}, // ũ
{8, 12, 10, 1, 12, 12, 2876}, // Ū
{6, 10, 7, 1, 10, 8, 2888}, // ū
{8, 13, 10, 1, 13, 13, 2896}, // Ŭ
{6, 11, 7, 1, 11, 9, 2909}, // ŭ
{8, 13, 10, 1, 13, 13, 2918}, // Ů
{6, 11, 7, 1, 11, 9, 2931}, // ů
{8, 13, 10, 1, 13, 13, 2940}, // Ű
{6, 11, 7, 1, 11, 9, 2953}, // ű
{8, 14, 10, 1, 10, 14, 2962}, // Ų
{7, 12, 9, 1, 8, 11, 2976}, // ų
{14, 13, 15, 0, 13, 23, 2987}, // Ŵ
{10, 11, 11, 0, 11, 14, 3010}, // ŵ
{8, 13, 10, 1, 13, 13, 3024}, // Ŷ
{8, 14, 9, 0, 11, 14, 3037}, // ŷ
{8, 12, 10, 1, 12, 12, 3051}, // Ÿ
{8, 13, 9, 0, 13, 13, 3063}, // Ź
{7, 11, 7, 0, 11, 10, 3076}, // ź
{8, 12, 9, 0, 12, 12, 3086}, // Ż
{7, 10, 7, 0, 10, 9, 3098}, // ż
{8, 13, 9, 0, 13, 13, 3107}, // Ž
{7, 11, 7, 0, 11, 10, 3120}, // ž
{8, 10, 9, 0, 10, 10, 3130}, // €
};
static const EpdUnicodeInterval babyblueIntervals[] = {
{0x20, 0x7E, 0x0}, {0xA1, 0xAC, 0x5F}, {0xAE, 0xFF, 0x6B}, {0x100, 0x17E, 0xBD}, {0x20AC, 0x20AC, 0x13C},
};
static const EpdFontData babyblue = {
babyblueBitmaps, babyblueGlyphs, babyblueIntervals, 5, 17, 13, -4, false,
};

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

View File

@ -8,67 +8,67 @@
#include "EpdFontData.h" #include "EpdFontData.h"
static const uint8_t pixelarial14Bitmaps[1145] = { static const uint8_t pixelarial14Bitmaps[1145] = {
0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x1C, 0x63, 0x8C, 0xF7, 0x98, 0xCF, 0xFF, 0xFF, 0xDC, 0xE7, 0xFE, 0xFF, 0xFF, 0xFF, 0xFA, 0xC0, 0xFF, 0xFF, 0xFB, 0x18, 0x63, 0x0C, 0x63, 0x98, 0xCF, 0xFF, 0xFF, 0x9C, 0xE3, 0x18, 0xFF,
0xFF, 0xFB, 0x9C, 0x63, 0x0C, 0x60, 0x30, 0xF3, 0xFF, 0xBF, 0x1F, 0x9F, 0x9B, 0x37, 0xEF, 0xFB, 0xE3, 0x00, 0x70, 0xFF, 0xFB, 0x9C, 0x63, 0x0C, 0x60, 0x30, 0xF3, 0xF7, 0xBF, 0x1F, 0x1F, 0x9B, 0x37, 0xEF, 0xFB, 0xC3, 0x00, 0x70,
0x67, 0xCF, 0x37, 0x61, 0xBB, 0x0D, 0xF0, 0x7D, 0x81, 0xDD, 0xC0, 0xDF, 0x07, 0x98, 0xFC, 0xC7, 0x66, 0x7B, 0xF3, 0x67, 0xCE, 0x36, 0x61, 0xB3, 0x0D, 0xB0, 0x7D, 0x81, 0xDD, 0xC0, 0xDE, 0x07, 0x98, 0xEC, 0xC6, 0x66, 0x33, 0xF3,
0x07, 0x00, 0x3E, 0x0F, 0xE1, 0x8C, 0x31, 0x86, 0x60, 0xFC, 0x1F, 0x07, 0xE4, 0xC7, 0x98, 0xF3, 0x0E, 0x7F, 0xF7, 0x07, 0x00, 0x3E, 0x0F, 0xE1, 0x8C, 0x31, 0x86, 0x60, 0xFC, 0x1E, 0x03, 0xE0, 0xC7, 0x98, 0xF3, 0x0E, 0x7F, 0xE7,
0xE6, 0xFF, 0xF0, 0x37, 0x66, 0xCC, 0xCC, 0xCC, 0xCC, 0xC6, 0x67, 0x30, 0xCE, 0x66, 0x33, 0x33, 0x33, 0x33, 0x36, 0xE6, 0xFF, 0xE0, 0x37, 0x66, 0xCC, 0xCC, 0xCC, 0xCC, 0xC6, 0x67, 0x30, 0xCE, 0x66, 0x33, 0x33, 0x33, 0x33, 0x36,
0x6E, 0xC0, 0x6F, 0xF6, 0xFF, 0x08, 0x0E, 0x07, 0x03, 0x8F, 0xFF, 0xFC, 0x70, 0x38, 0x1C, 0x0E, 0x00, 0xFF, 0xC0, 0x6E, 0xC0, 0x6F, 0xF6, 0xFB, 0x08, 0x0C, 0x06, 0x03, 0x0F, 0xFF, 0xFC, 0x60, 0x30, 0x18, 0x04, 0x00, 0xBF, 0xC0,
0xFB, 0xFF, 0x80, 0xF0, 0x1C, 0x73, 0xCC, 0x30, 0xC7, 0x18, 0x61, 0x8E, 0x30, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0x03, 0xEF, 0x80, 0xB0, 0x18, 0x61, 0x8C, 0x30, 0xC7, 0x18, 0x61, 0x8E, 0x30, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x37, 0xFF, 0x33, 0x33, 0x33, 0x33, 0x30, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x37, 0xFF, 0x33, 0x33, 0x33, 0x33, 0x30, 0x7E, 0xFF, 0xC3, 0xC3,
0x03, 0x03, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xFF, 0xFF, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x3F, 0x3F, 0x03, 0x03, 0xC3, 0x03, 0x03, 0x07, 0x06, 0x18, 0x38, 0x70, 0xFF, 0xFF, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x3F, 0x3F, 0x03, 0x03, 0xC3,
0xC3, 0xFF, 0x7E, 0x03, 0x03, 0x83, 0xC3, 0xE3, 0x31, 0x99, 0xCD, 0xC6, 0xC3, 0x7F, 0xFF, 0xE0, 0x60, 0x30, 0x7F, 0xC3, 0xFF, 0x7E, 0x03, 0x03, 0x83, 0xC3, 0x63, 0x31, 0x99, 0xCC, 0xC6, 0xC3, 0x7F, 0xFF, 0xE0, 0x60, 0x30, 0x7F,
0x7F, 0xE0, 0xC0, 0xFE, 0xFF, 0xC3, 0x03, 0x03, 0xC3, 0xC7, 0xFE, 0x7E, 0x7E, 0xFF, 0xC3, 0xC0, 0xC0, 0xFE, 0xFF, 0x7F, 0xE0, 0xC0, 0xFE, 0xFF, 0xC3, 0x03, 0x03, 0xC3, 0xC7, 0xFE, 0x7E, 0x7E, 0xFF, 0xC3, 0xC0, 0xC0, 0xFE, 0xFF,
0xE3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFF, 0xFF, 0x07, 0x06, 0x06, 0x1E, 0x1C, 0x1C, 0x1C, 0x38, 0x30, 0x30, 0x30, 0xE3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFF, 0xFF, 0x07, 0x06, 0x06, 0x0E, 0x18, 0x18, 0x18, 0x38, 0x30, 0x30, 0x30,
0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xFF, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xC7, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xFF, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xC7,
0xFF, 0x7F, 0x03, 0x03, 0xC7, 0xFE, 0x7C, 0xF0, 0x00, 0x3C, 0xF0, 0x00, 0x3F, 0xF0, 0x03, 0x03, 0x1E, 0x7E, 0xF0, 0xFF, 0x7B, 0x03, 0x03, 0xC7, 0xFE, 0x78, 0xB0, 0x00, 0x2C, 0xB0, 0x00, 0x2F, 0xF0, 0x03, 0x03, 0x1E, 0x7E, 0xF0,
0xF0, 0x70, 0x7E, 0x1F, 0x03, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xC0, 0xC0, 0x70, 0x7E, 0x1F, 0x0F, 0x1E, 0x7E, 0xC0, 0x70, 0x3E, 0x0F, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFE, 0x80, 0xC0, 0x70, 0x7E, 0x0F, 0x03, 0x1E, 0x7C,
0xF0, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x07, 0x1E, 0x1C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3F, 0x87, 0xFE, 0xFF, 0xF0, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x07, 0x0E, 0x18, 0x30, 0x30, 0x30, 0x10, 0x30, 0x3F, 0x87, 0xFE, 0xEF,
0x7D, 0xFB, 0xF1, 0xBF, 0x1B, 0xF3, 0xBF, 0xFF, 0xDF, 0xE6, 0x00, 0x7F, 0x03, 0xF0, 0x06, 0x01, 0xF0, 0x1F, 0x01, 0x7D, 0xF3, 0xF1, 0xBF, 0x1B, 0xF3, 0xBF, 0xFF, 0xDF, 0xE6, 0x00, 0x7F, 0x03, 0xF0, 0x06, 0x00, 0xF0, 0x1B, 0x01,
0xF0, 0x1F, 0x03, 0xB8, 0x31, 0x87, 0xFC, 0x7F, 0xE7, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x30, 0xFF, 0x7F, 0xF0, 0x78, 0xB0, 0x1B, 0x03, 0xB8, 0x31, 0x83, 0x18, 0x7F, 0xE7, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x30, 0xFF, 0x7F, 0xF0, 0x78,
0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x7F, 0xFF, 0xF0, 0x3F, 0x0F, 0xF3, 0x87, 0xE0, 0x3C, 0x01, 0x80, 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x7F, 0xFF, 0xF0, 0x3F, 0x0F, 0xF3, 0x87, 0x60, 0x3C, 0x01, 0x80,
0x30, 0x06, 0x00, 0xC0, 0x18, 0x0F, 0x87, 0xBF, 0xC3, 0xF0, 0xFF, 0x1F, 0xF3, 0x07, 0xE0, 0x3C, 0x07, 0x80, 0xF0, 0x30, 0x06, 0x00, 0xC0, 0x18, 0x0F, 0x87, 0x3F, 0xC3, 0xF0, 0xFF, 0x1F, 0xF3, 0x07, 0x60, 0x3C, 0x07, 0x80, 0xF0,
0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x07, 0xFF, 0xCF, 0xF0, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFF, 0xFF, 0x80, 0xC0, 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x07, 0x7F, 0xCF, 0xF0, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFF, 0xFF, 0x80, 0xC0,
0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFB, 0xFD, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFB, 0xFD, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C,
0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x0C, 0x00, 0xC1, 0xFC, 0x1F, 0xC0, 0x3C, 0x03, 0xE0, 0x77, 0xFE, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x0C, 0x00, 0xC1, 0xFC, 0x1F, 0xC0, 0x3C, 0x03, 0xE0, 0x77, 0xFE,
0x3F, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x18, 0xFF, 0xFF, 0x3F, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x18, 0xFF, 0xFF,
0xFF, 0xC0, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x07, 0x8F, 0x1F, 0xF7, 0xC0, 0xC0, 0x78, 0x3F, 0x0E, 0x61, 0xFF, 0xC0, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x07, 0x8F, 0x1F, 0xF7, 0x80, 0xC0, 0x78, 0x3B, 0x0E, 0x61,
0x8C, 0x61, 0xBC, 0x3F, 0x87, 0xB8, 0xE3, 0x1C, 0x63, 0x0E, 0x60, 0xFC, 0x06, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x8C, 0x61, 0x9C, 0x3F, 0x87, 0xB0, 0xE3, 0x18, 0x63, 0x0E, 0x60, 0xEC, 0x06, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06,
0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xC0, 0x3E, 0x07, 0xE0, 0x7E, 0x07, 0xF1, 0xBF, 0x1B, 0xFB, 0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xC0, 0x3E, 0x07, 0xE0, 0x7E, 0x07, 0xF1, 0xBF, 0x1B, 0xFB,
0xBD, 0xF3, 0xDF, 0x3D, 0xF3, 0xDF, 0x3C, 0x63, 0xC6, 0x30, 0xC1, 0xF0, 0xFC, 0x7E, 0x3F, 0x1F, 0xEF, 0x77, 0xBB, 0xBD, 0xB3, 0xDB, 0x3D, 0xB3, 0xCF, 0x3C, 0x63, 0xC6, 0x30, 0xC1, 0xF0, 0xFC, 0x7E, 0x3F, 0x1F, 0xCF, 0x67, 0xBB,
0xC7, 0xE3, 0xF1, 0xF8, 0x7C, 0x18, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0xC7, 0xE3, 0xF1, 0xF8, 0x7C, 0x18, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0,
0x3C, 0x03, 0xE0, 0x77, 0xFE, 0x3F, 0x80, 0xFF, 0x7F, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0xFF, 0xFE, 0xC0, 0x60, 0x30, 0x3C, 0x03, 0xE0, 0x77, 0xFE, 0x3F, 0x80, 0xFF, 0x7F, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0xFF, 0xFE, 0xC0, 0x60, 0x30,
0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x37, 0xE3, 0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x36, 0xE3,
0xE7, 0xFF, 0x3F, 0x70, 0xFF, 0x9F, 0xFF, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xFF, 0xFF, 0xFC, 0xC6, 0x18, 0xE3, 0x0E, 0xE7, 0xFF, 0x3F, 0x70, 0xFF, 0x9F, 0xFF, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xFF, 0xFF, 0xFC, 0xC6, 0x18, 0xE3, 0x0E,
0x60, 0xFC, 0x06, 0x7F, 0x7F, 0xF0, 0x78, 0x3C, 0x07, 0xE1, 0xFE, 0x0F, 0x01, 0xE0, 0xF0, 0x7F, 0xF7, 0xF0, 0xFF, 0x60, 0xEC, 0x06, 0x7F, 0x7F, 0xF0, 0x78, 0x3C, 0x07, 0xC1, 0xFE, 0x0F, 0x01, 0xE0, 0xF0, 0x7F, 0xF7, 0xF0, 0xFF,
0xFF, 0xC7, 0x03, 0x81, 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xC0, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0xFF, 0xC6, 0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C,
0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF8, 0xEF, 0xE3, 0xE0, 0xC0, 0x3C, 0x03, 0xE0, 0x76, 0x06, 0x60, 0x67, 0x1E, 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF8, 0xEF, 0xE3, 0xE0, 0xC0, 0x3C, 0x03, 0xE0, 0x76, 0x06, 0x60, 0x67, 0x1C,
0x31, 0x83, 0xB8, 0x1F, 0x01, 0xF0, 0x1F, 0x00, 0x60, 0x06, 0x00, 0xC1, 0x81, 0xE1, 0xF0, 0xF8, 0xD8, 0xEC, 0x6C, 0x31, 0x83, 0x18, 0x1B, 0x01, 0xB0, 0x0F, 0x00, 0x60, 0x06, 0x00, 0xC1, 0x81, 0xE1, 0xF0, 0xF8, 0xD8, 0xEC, 0x6C,
0x66, 0x36, 0x33, 0x1B, 0x19, 0xDD, 0xFC, 0x6C, 0x7C, 0x36, 0x3E, 0x1B, 0x1F, 0x0F, 0x8F, 0x03, 0x83, 0x01, 0xC1, 0x66, 0x36, 0x33, 0x1B, 0x19, 0xDD, 0xDC, 0x6C, 0x78, 0x36, 0x3C, 0x1B, 0x1E, 0x0F, 0x8F, 0x03, 0x03, 0x01, 0x81,
0x80, 0xC0, 0x7C, 0x3D, 0x86, 0x30, 0xC3, 0x30, 0x7E, 0x07, 0x80, 0xF8, 0x33, 0x0E, 0x71, 0x86, 0x70, 0xFC, 0x06, 0x80, 0xC0, 0x7C, 0x39, 0x86, 0x30, 0xC3, 0x30, 0x7E, 0x07, 0x80, 0xF0, 0x33, 0x0E, 0x31, 0x86, 0x70, 0xEC, 0x06,
0xC0, 0x3E, 0x07, 0x71, 0xE3, 0x18, 0x31, 0x83, 0xF8, 0x1F, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, 0xC0, 0x3E, 0x07, 0x70, 0xE3, 0x18, 0x31, 0x83, 0xB8, 0x0F, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06,
0x00, 0xFF, 0xFF, 0xFC, 0x0E, 0x01, 0x80, 0x30, 0x0E, 0x07, 0x80, 0xE0, 0x30, 0x06, 0x01, 0xC0, 0x7F, 0xFF, 0xFE, 0x00, 0xFF, 0xFF, 0xF8, 0x0E, 0x01, 0x80, 0x30, 0x0E, 0x03, 0x80, 0xC0, 0x30, 0x06, 0x01, 0xC0, 0x7F, 0xEF, 0xFE,
0xFF, 0x6D, 0xB6, 0xDB, 0x6D, 0xB7, 0xE0, 0xC3, 0x0E, 0x18, 0x61, 0x87, 0x0C, 0x30, 0xC3, 0x87, 0x1C, 0xFD, 0xB6, 0xFF, 0x6D, 0xB6, 0xDB, 0x6D, 0xB7, 0xE0, 0xC3, 0x0E, 0x18, 0x61, 0x87, 0x0C, 0x30, 0xC3, 0x86, 0x18, 0xFD, 0xB6,
0xDB, 0x6D, 0xB6, 0xDF, 0xE0, 0x30, 0xF1, 0xF3, 0xE7, 0xDD, 0xF1, 0x80, 0xFF, 0xFF, 0xFC, 0xCE, 0x73, 0x00, 0x7E, 0xDB, 0x6D, 0xB6, 0xDF, 0xE0, 0x30, 0xF1, 0xE3, 0xC7, 0x9D, 0xF1, 0x80, 0xFF, 0xDF, 0xFC, 0xCE, 0x73, 0x00, 0x7C,
0x7E, 0xC3, 0xC3, 0x3F, 0x7F, 0x63, 0xE3, 0xC7, 0xFF, 0x7F, 0xC0, 0xC0, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0x7E, 0xC3, 0x83, 0x3F, 0x3F, 0x63, 0xC3, 0xC7, 0xFF, 0x7B, 0xC0, 0xC0, 0xDC, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3,
0xC3, 0xE3, 0xFF, 0xFE, 0x78, 0xFB, 0x1E, 0x3C, 0x18, 0x30, 0x60, 0xC7, 0xFD, 0xF0, 0x03, 0x03, 0x7B, 0x7F, 0xC7, 0xC3, 0xE3, 0xFF, 0xFE, 0x78, 0xF3, 0x1E, 0x3C, 0x18, 0x30, 0x60, 0xC7, 0xFD, 0xE0, 0x03, 0x03, 0x7B, 0x7B, 0xC7,
0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, 0x7E, 0x7E, 0xC3, 0xC3, 0xFF, 0xFF, 0xC0, 0xC0, 0xC3, 0xFF, 0x7E, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, 0x7C, 0x7E, 0xC3, 0xC3, 0xFF, 0xFF, 0xC0, 0xC0, 0xC3, 0xFF, 0x7E,
0x3D, 0xEF, 0xBF, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x60, 0x7B, 0x7F, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0x39, 0xEF, 0xBE, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x60, 0x7B, 0x7B, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7,
0xFF, 0x7F, 0x03, 0xC3, 0xFF, 0x7E, 0xC0, 0xC0, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7B, 0x03, 0xC3, 0xFF, 0x7E, 0xC0, 0xC0, 0xDC, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3,
0xFF, 0xFF, 0xFF, 0xC0, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0xE0, 0xC0, 0xC0, 0xC3, 0xC3, 0xC6, 0xDE, 0xFB, 0xFF, 0xFF, 0xC0, 0x33, 0x13, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0xE0, 0xC0, 0xC0, 0xC3, 0xC3, 0xC6, 0xCE,
0xFC, 0xF8, 0xFC, 0xEE, 0xC6, 0xC7, 0xC3, 0xFF, 0xFF, 0xFF, 0xC0, 0xF9, 0xEF, 0xDE, 0xE7, 0x3E, 0x73, 0xC6, 0x3C, 0xF8, 0xF0, 0xF8, 0xCE, 0xC6, 0xC7, 0xC3, 0xFF, 0xFF, 0xFF, 0xC0, 0x98, 0xCF, 0x9E, 0xE7, 0x3E, 0x73, 0xC6, 0x3C,
0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x30, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x30, 0x9C, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3,
0xC3, 0x7E, 0x7E, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0x7C, 0x7E, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x9C, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3,
0xC3, 0xE3, 0xFF, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0x7B, 0x7F, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, 0xC3, 0xE3, 0xFF, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0x7B, 0x7B, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B,
0x03, 0x03, 0x03, 0x03, 0xFB, 0xFE, 0x38, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x78, 0xFB, 0x1E, 0x3F, 0x8F, 0x81, 0x03, 0x03, 0x03, 0x03, 0x9B, 0xEE, 0x38, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x78, 0xF3, 0x1E, 0x3F, 0x8F, 0x81,
0x83, 0xC7, 0xFD, 0xF0, 0x61, 0x8F, 0xBF, 0x61, 0x86, 0x18, 0x61, 0x86, 0x1E, 0x7C, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0x83, 0xC7, 0xFD, 0xE0, 0x61, 0x8F, 0xBE, 0x61, 0x86, 0x18, 0x61, 0x86, 0x1E, 0x78, 0x83, 0xC3, 0xC3, 0xC3, 0xC3,
0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, 0xC1, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0xB8, 0xD8, 0x6C, 0x3E, 0x0E, 0x07, 0x00, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, 0x80, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0x18, 0xD8, 0x6C, 0x3E, 0x0C, 0x06, 0x00,
0xC6, 0x3C, 0x63, 0xC6, 0x3F, 0xF7, 0x7F, 0x67, 0xF6, 0x7F, 0x67, 0xF6, 0x7B, 0xE3, 0x18, 0x31, 0x80, 0xC3, 0xC3, 0x84, 0x3C, 0x63, 0xC6, 0x3E, 0xF7, 0x7B, 0x67, 0xB6, 0x7B, 0x67, 0xB6, 0x7B, 0xC3, 0x18, 0x31, 0x80, 0x83, 0xC3,
0x66, 0x66, 0x7E, 0x3C, 0x3C, 0x7E, 0x66, 0xE7, 0xC3, 0xC1, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0xB8, 0xD8, 0x6C, 0x36, 0x66, 0x66, 0x7E, 0x38, 0x38, 0x7E, 0x66, 0xE7, 0xC3, 0x80, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0x18, 0xD8, 0x6C, 0x36,
0x1F, 0x07, 0x03, 0x81, 0xC3, 0xE1, 0xC0, 0xFF, 0xFF, 0x06, 0x1E, 0x1C, 0x1C, 0x30, 0x30, 0x70, 0xFF, 0xFF, 0x37, 0x1F, 0x06, 0x03, 0x01, 0x83, 0xC1, 0xC0, 0xFF, 0xFF, 0x06, 0x0E, 0x18, 0x18, 0x30, 0x30, 0x70, 0xFF, 0xFF, 0x37,
0x66, 0x66, 0x6E, 0xCE, 0x66, 0x66, 0x67, 0x30, 0xFF, 0xFF, 0xFF, 0xC0, 0xCE, 0x66, 0x66, 0x67, 0x37, 0x66, 0x66, 0x66, 0x66, 0x66, 0xCE, 0x66, 0x66, 0x67, 0x30, 0xFF, 0xFF, 0xFF, 0xC0, 0xCE, 0x66, 0x66, 0x66, 0x37, 0x66, 0x66,
0x6E, 0xC0, 0xC3, 0x9B, 0xFF, 0xF9, 0xB8, 0x30, 0xDB, 0x66, 0xC0, 0x6D, 0xBD, 0x80, 0x7F, 0xEF, 0x3C, 0xF3, 0xC0, 0x6E, 0xC0, 0xC3, 0x99, 0xFF, 0xF9, 0xB8, 0x30, 0xDB, 0x66, 0xC0, 0x6D, 0xBD, 0x00, 0x7B, 0xEF, 0x3C, 0xF2, 0xC0,
0x7D, 0xF7, 0xDF, 0xF3, 0xC0, 0x79, 0xE7, 0x9E, 0xF2, 0xC0,
}; };
static const EpdGlyph pixelarial14Glyphs[] = { static const EpdGlyph pixelarial14Glyphs[] = {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -54,7 +54,7 @@ intervals = [
# (0x0370, 0x03FF), # (0x0370, 0x03FF),
### Cyrillic ### ### Cyrillic ###
# Russian, Ukrainian, Bulgarian, etc. # Russian, Ukrainian, Bulgarian, etc.
# (0x0400, 0x04FF), (0x0400, 0x04FF),
### Math Symbols (common subset) ### ### Math Symbols (common subset) ###
# General math operators # General math operators
(0x2200, 0x22FF), (0x2200, 0x22FF),
@ -176,7 +176,7 @@ for i_start, i_end in intervals:
px = 0 px = 0
if is2Bit: if is2Bit:
# 0 = white, 15 black, 8+ dark grey, 7- light grey # 0-3 white, 4-7 light grey, 8-11 dark grey, 12-15 black
# Downsample to 2-bit bitmap # Downsample to 2-bit bitmap
pixels2b = [] pixels2b = []
px = 0 px = 0
@ -187,11 +187,11 @@ for i_start, i_end in intervals:
bm = pixels4g[y * pitch + (x // 2)] bm = pixels4g[y * pitch + (x // 2)]
bm = (bm >> ((x % 2) * 4)) & 0xF bm = (bm >> ((x % 2) * 4)) & 0xF
if bm == 15: if bm >= 12:
px += 3 px += 3
elif bm >= 8: elif bm >= 8:
px += 2 px += 2
elif bm > 0: elif bm >= 4:
px += 1 px += 1
if (y * bitmap.width + x) % 4 == 3: if (y * bitmap.width + x) % 4 == 3:
@ -211,7 +211,7 @@ for i_start, i_end in intervals:
# print(line) # print(line)
# print('') # print('')
else: else:
# Downsample to 1-bit bitmap - treat any non-zero as black # Downsample to 1-bit bitmap - treat any 2+ as black
pixelsbw = [] pixelsbw = []
px = 0 px = 0
pitch = (bitmap.width // 2) + (bitmap.width % 2) pitch = (bitmap.width // 2) + (bitmap.width % 2)
@ -219,7 +219,7 @@ for i_start, i_end in intervals:
for x in range(bitmap.width): for x in range(bitmap.width):
px = px << 1 px = px << 1
bm = pixels4g[y * pitch + (x // 2)] bm = pixels4g[y * pitch + (x // 2)]
px += 1 if ((x & 1) == 0 and bm & 0xF > 0) or ((x & 1) == 1 and bm & 0xF0 > 0) else 0 px += 1 if ((x & 1) == 0 and bm & 0xE > 0) or ((x & 1) == 1 and bm & 0xE0 > 0) else 0
if (y * bitmap.width + x) % 8 == 7: if (y * bitmap.width + x) % 8 == 7:
pixelsbw.append(px) pixelsbw.append(px)

View File

@ -7,250 +7,195 @@
#include <map> #include <map>
#include "Epub/FsHelpers.h" #include "Epub/FsHelpers.h"
#include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNcxParser.h"
bool Epub::findContentOpfFile(const ZipFile& zip, std::string& contentOpfFile) { bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
// open up the meta data to find where the content.opf file lives const auto containerPath = "META-INF/container.xml";
size_t s; size_t containerSize;
const auto metaInfo = reinterpret_cast<char*>(zip.readFileToMemory("META-INF/container.xml", &s, true));
if (!metaInfo) { // Get file size without loading it all into heap
Serial.printf("[%lu] [EBP] Could not find META-INF/container.xml\n", millis()); if (!getItemSize(containerPath, &containerSize)) {
Serial.printf("[%lu] [EBP] Could not find or size META-INF/container.xml\n", millis());
return false; return false;
} }
// parse the meta data ContainerParser containerParser(containerSize);
tinyxml2::XMLDocument metaDataDoc;
const auto result = metaDataDoc.Parse(metaInfo);
free(metaInfo);
if (result != tinyxml2::XML_SUCCESS) { if (!containerParser.setup()) {
Serial.printf("[%lu] [EBP] Could not parse META-INF/container.xml. Error: %d\n", millis(), result);
return false; return false;
} }
const auto container = metaDataDoc.FirstChildElement("container"); // Stream read (reusing your existing stream logic)
if (!container) { if (!readItemContentsToStream(containerPath, containerParser, 512)) {
Serial.printf("[%lu] [EBP] Could not find container element in META-INF/container.xml\n", millis()); Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis());
containerParser.teardown();
return false; return false;
} }
const auto rootfiles = container->FirstChildElement("rootfiles"); // Extract the result
if (!rootfiles) { if (containerParser.fullPath.empty()) {
Serial.printf("[%lu] [EBP] Could not find rootfiles element in META-INF/container.xml\n", millis()); Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis());
containerParser.teardown();
return false; return false;
} }
// find the root file that has the media-type="application/oebps-package+xml" *contentOpfFile = std::move(containerParser.fullPath);
auto rootfile = rootfiles->FirstChildElement("rootfile");
while (rootfile) {
const char* mediaType = rootfile->Attribute("media-type");
if (mediaType && strcmp(mediaType, "application/oebps-package+xml") == 0) {
const char* full_path = rootfile->Attribute("full-path");
if (full_path) {
contentOpfFile = full_path;
return true;
}
}
rootfile = rootfile->NextSiblingElement("rootfile");
}
Serial.printf("[%lu] [EBP] Could not get path to content.opf file\n", millis()); containerParser.teardown();
return false;
}
bool Epub::parseContentOpf(ZipFile& zip, std::string& content_opf_file) {
// read in the content.opf file and parse it
auto contents = reinterpret_cast<char*>(zip.readFileToMemory(content_opf_file.c_str(), nullptr, true));
// parse the contents
tinyxml2::XMLDocument doc;
auto result = doc.Parse(contents);
free(contents);
if (result != tinyxml2::XML_SUCCESS) {
Serial.printf("[%lu] [EBP] Error parsing content.opf - %s\n", millis(),
tinyxml2::XMLDocument::ErrorIDToName(result));
return false;
}
auto package = doc.FirstChildElement("package");
if (!package) package = doc.FirstChildElement("opf:package");
if (!package) {
Serial.printf("[%lu] [EBP] Could not find package element in content.opf\n", millis());
return false;
}
// get the metadata - title and cover image
auto metadata = package->FirstChildElement("metadata");
if (!metadata) metadata = package->FirstChildElement("opf:metadata");
if (!metadata) {
Serial.printf("[%lu] [EBP] Missing metadata\n", millis());
return false;
}
auto titleEl = metadata->FirstChildElement("dc:title");
if (!titleEl) {
Serial.printf("[%lu] [EBP] Missing title\n", millis());
return false;
}
this->title = titleEl->GetText();
auto cover = metadata->FirstChildElement("meta");
if (!cover) cover = metadata->FirstChildElement("opf:meta");
while (cover && cover->Attribute("name") && strcmp(cover->Attribute("name"), "cover") != 0) {
cover = cover->NextSiblingElement("meta");
}
if (!cover) {
Serial.printf("[%lu] [EBP] Missing cover\n", millis());
}
auto coverItem = cover ? cover->Attribute("content") : nullptr;
// read the manifest and spine
// the manifest gives us the names of the files
// the spine gives us the order of the files
// we can then read the files in the order they are in the spine
auto manifest = package->FirstChildElement("manifest");
if (!manifest) manifest = package->FirstChildElement("opf:manifest");
if (!manifest) {
Serial.printf("[%lu] [EBP] Missing manifest\n", millis());
return false;
}
// create a mapping from id to file name
auto item = manifest->FirstChildElement("item");
if (!item) item = manifest->FirstChildElement("opf:item");
std::map<std::string, std::string> items;
while (item) {
std::string itemId = item->Attribute("id");
std::string href = contentBasePath + item->Attribute("href");
// grab the cover image
if (coverItem && itemId == coverItem) {
coverImageItem = href;
}
// grab the ncx file
if (itemId == "ncx" || itemId == "ncxtoc") {
tocNcxItem = href;
}
items[itemId] = href;
auto nextItem = item->NextSiblingElement("item");
if (!nextItem) nextItem = item->NextSiblingElement("opf:item");
item = nextItem;
}
// find the spine
auto spineEl = package->FirstChildElement("spine");
if (!spineEl) spineEl = package->FirstChildElement("opf:spine");
if (!spineEl) {
Serial.printf("[%lu] [EBP] Missing spine\n", millis());
return false;
}
// read the spine
auto itemref = spineEl->FirstChildElement("itemref");
if (!itemref) itemref = spineEl->FirstChildElement("opf:itemref");
while (itemref) {
auto id = itemref->Attribute("idref");
if (items.find(id) != items.end()) {
spine.emplace_back(id, items[id]);
}
auto nextItemRef = itemref->NextSiblingElement("itemref");
if (!nextItemRef) nextItemRef = itemref->NextSiblingElement("opf:itemref");
itemref = nextItemRef;
}
return true; return true;
} }
bool Epub::parseTocNcxFile(const ZipFile& zip) { bool Epub::parseContentOpf(const std::string& contentOpfFilePath) {
size_t contentOpfSize;
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis());
return false;
}
ContentOpfParser opfParser(getBasePath(), contentOpfSize);
if (!opfParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis());
return false;
}
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis());
opfParser.teardown();
return false;
}
// Grab data from opfParser into epub
title = opfParser.title;
if (!opfParser.coverItemId.empty() && opfParser.items.count(opfParser.coverItemId) > 0) {
coverImageItem = opfParser.items.at(opfParser.coverItemId);
}
if (!opfParser.tocNcxPath.empty()) {
tocNcxItem = opfParser.tocNcxPath;
}
for (auto& spineRef : opfParser.spineRefs) {
if (opfParser.items.count(spineRef)) {
spine.emplace_back(spineRef, opfParser.items.at(spineRef));
}
}
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
opfParser.teardown();
return true;
}
bool Epub::parseTocNcxFile() {
// the ncx file should have been specified in the content.opf file // the ncx file should have been specified in the content.opf file
if (tocNcxItem.empty()) { if (tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); Serial.printf("[%lu] [EBP] No ncx file specified\n", millis());
return false; return false;
} }
const auto ncxData = reinterpret_cast<char*>(zip.readFileToMemory(tocNcxItem.c_str(), nullptr, true)); size_t tocSize;
if (!ncxData) { if (!getItemSize(tocNcxItem, &tocSize)) {
Serial.printf("[%lu] [EBP] Could not find %s\n", millis(), tocNcxItem.c_str()); Serial.printf("[%lu] [EBP] Could not get size of toc ncx\n", millis());
return false; return false;
} }
// Parse the Toc contents TocNcxParser ncxParser(contentBasePath, tocSize);
tinyxml2::XMLDocument doc;
const auto result = doc.Parse(ncxData);
free(ncxData);
if (result != tinyxml2::XML_SUCCESS) { if (!ncxParser.setup()) {
Serial.printf("[%lu] [EBP] Error parsing toc %s\n", millis(), tinyxml2::XMLDocument::ErrorIDToName(result)); Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis());
return false; return false;
} }
const auto ncx = doc.FirstChildElement("ncx"); if (!readItemContentsToStream(tocNcxItem, ncxParser, 1024)) {
if (!ncx) { Serial.printf("[%lu] [EBP] Could not read toc ncx stream\n", millis());
Serial.printf("[%lu] [EBP] Could not find first child ncx in toc\n", millis()); ncxParser.teardown();
return false; return false;
} }
const auto navMap = ncx->FirstChildElement("navMap"); this->toc = std::move(ncxParser.toc);
if (!navMap) {
Serial.printf("[%lu] [EBP] Could not find navMap child in ncx\n", millis());
return false;
}
recursivelyParseNavMap(navMap->FirstChildElement("navPoint")); Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size());
ncxParser.teardown();
return true; return true;
} }
void Epub::recursivelyParseNavMap(tinyxml2::XMLElement* element) {
// Fills toc map
while (element) {
std::string navTitle = element->FirstChildElement("navLabel")->FirstChildElement("text")->FirstChild()->Value();
const auto content = element->FirstChildElement("content");
std::string href = contentBasePath + content->Attribute("src");
// split the href on the # to get the href and the anchor
const size_t pos = href.find('#');
std::string anchor;
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
toc.emplace_back(navTitle, href, anchor, 0);
tinyxml2::XMLElement* nestedNavPoint = element->FirstChildElement("navPoint");
if (nestedNavPoint) {
recursivelyParseNavMap(nestedNavPoint);
}
element = element->NextSiblingElement("navPoint");
}
}
// load in the meta data for the epub file // load in the meta data for the epub file
bool Epub::load() { bool Epub::load() {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
ZipFile zip("/sd" + filepath); ZipFile zip("/sd" + filepath);
std::string contentOpfFile; std::string contentOpfFilePath;
if (!findContentOpfFile(zip, contentOpfFile)) { if (!findContentOpfFile(&contentOpfFilePath)) {
Serial.printf("[%lu] [EBP] Could not open ePub\n", millis()); Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
return false; return false;
} }
contentBasePath = contentOpfFile.substr(0, contentOpfFile.find_last_of('/') + 1); Serial.printf("[%lu] [EBP] Found content.opf at: %s\n", millis(), contentOpfFilePath.c_str());
if (!parseContentOpf(zip, contentOpfFile)) { contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
if (!parseContentOpf(contentOpfFilePath)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis());
return false; return false;
} }
if (!parseTocNcxFile(zip)) { if (!parseTocNcxFile()) {
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis());
return false; return false;
} }
initializeSpineItemSizes();
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
return true; return true;
} }
void Epub::initializeSpineItemSizes() {
setupCacheDir();
size_t spineItemsCount = getSpineItemsCount();
size_t cumSpineItemSize = 0;
if (SD.exists((getCachePath() + "/spine_size.bin").c_str())) {
File f = SD.open((getCachePath() + "/spine_size.bin").c_str());
uint8_t data[4];
for (size_t i = 0; i < spineItemsCount; i++) {
f.read(data, 4);
cumSpineItemSize = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
cumulativeSpineItemSize.emplace_back(cumSpineItemSize);
// Serial.printf("[%lu] [EBP] Loading item %d size %u to %u %u\n", millis(),
// i, cumSpineItemSize, data[1], data[0]);
}
f.close();
} else {
File f = SD.open((getCachePath() + "/spine_size.bin").c_str(), FILE_WRITE);
uint8_t data[4];
// determine size of spine items
for (size_t i = 0; i < spineItemsCount; i++) {
std::string spineItem = getSpineItem(i);
size_t s = 0;
getItemSize(spineItem, &s);
cumSpineItemSize += s;
cumulativeSpineItemSize.emplace_back(cumSpineItemSize);
// and persist to cache
data[0] = cumSpineItemSize & 0xFF;
data[1] = (cumSpineItemSize >> 8) & 0xFF;
data[2] = (cumSpineItemSize >> 16) & 0xFF;
data[3] = (cumSpineItemSize >> 24) & 0xFF;
// Serial.printf("[%lu] [EBP] Persisting item %d size %u to %u %u\n", millis(),
// i, cumSpineItemSize, data[1], data[0]);
f.write(data, 4);
}
f.close();
}
Serial.printf("[%lu] [EBP] Book size: %lu\n", millis(), cumSpineItemSize);
}
bool Epub::clearCache() const { bool Epub::clearCache() const {
if (!SD.exists(cachePath.c_str())) { if (!SD.exists(cachePath.c_str())) {
Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis()); Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis());
@ -344,8 +289,17 @@ bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, con
return zip.readFileToStream(path.c_str(), out, chunkSize); return zip.readFileToStream(path.c_str(), out, chunkSize);
} }
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
const ZipFile zip("/sd" + filepath);
const std::string path = normalisePath(itemHref);
return zip.getInflatedFileSize(path.c_str(), size);
}
int Epub::getSpineItemsCount() const { return spine.size(); } int Epub::getSpineItemsCount() const { return spine.size(); }
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return cumulativeSpineItemSize.at(spineIndex); }
std::string& Epub::getSpineItem(const int spineIndex) { std::string& Epub::getSpineItem(const int spineIndex) {
if (spineIndex < 0 || spineIndex >= spine.size()) { if (spineIndex < 0 || spineIndex >= spine.size()) {
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex);
@ -368,6 +322,11 @@ int Epub::getTocItemsCount() const { return toc.size(); }
// work out the section index for a toc index // work out the section index for a toc index
int Epub::getSpineIndexForTocIndex(const int tocIndex) const { int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
if (tocIndex < 0 || tocIndex >= toc.size()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex);
return 0;
}
// the toc entry should have an href that matches the spine item // the toc entry should have an href that matches the spine item
// so we can find the spine index by looking for the href // so we can find the spine index by looking for the href
for (int i = 0; i < spine.size(); i++) { for (int i = 0; i < spine.size(); i++) {
@ -382,6 +341,11 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
} }
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
if (spineIndex < 0 || spineIndex >= spine.size()) {
Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex: spineIndex %d out of range\n", millis(), spineIndex);
return -1;
}
// the toc entry should have an href that matches the spine item // the toc entry should have an href that matches the spine item
// so we can find the toc index by looking for the href // so we can find the toc index by looking for the href
for (int i = 0; i < toc.size(); i++) { for (int i = 0; i < toc.size(); i++) {
@ -391,6 +355,24 @@ int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
} }
Serial.printf("[%lu] [EBP] TOC item not found\n", millis()); Serial.printf("[%lu] [EBP] TOC item not found\n", millis());
// not found - default to first item return -1;
}
size_t Epub::getBookSize() const {
if (spine.empty()) {
return 0; return 0;
} }
return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
}
// Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) {
size_t bookSize = getBookSize();
if (bookSize == 0) {
return 0;
}
size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
size_t sectionProgSize = currentSpineRead * curChapterSize;
return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0);
}

View File

@ -1,22 +1,13 @@
#pragma once #pragma once
#include <Print.h> #include <Print.h>
#include <tinyxml2.h>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
class ZipFile; #include "Epub/EpubTocEntry.h"
class EpubTocEntry { class ZipFile;
public:
std::string title;
std::string href;
std::string anchor;
int level;
EpubTocEntry(std::string title, std::string href, std::string anchor, const int level)
: title(std::move(title)), href(std::move(href)), anchor(std::move(anchor)), level(level) {}
};
class Epub { class Epub {
// the title read from the EPUB meta data // the title read from the EPUB meta data
@ -29,6 +20,8 @@ class Epub {
std::string filepath; std::string filepath;
// the spine of the EPUB file // the spine of the EPUB file
std::vector<std::pair<std::string, std::string>> spine; std::vector<std::pair<std::string, std::string>> spine;
// the file size of the spine items (proxy to book progress)
std::vector<size_t> cumulativeSpineItemSize;
// the toc of the EPUB file // the toc of the EPUB file
std::vector<EpubTocEntry> toc; std::vector<EpubTocEntry> toc;
// the base path for items in the EPUB file // the base path for items in the EPUB file
@ -36,11 +29,10 @@ class Epub {
// Uniq cache key based on filepath // Uniq cache key based on filepath
std::string cachePath; std::string cachePath;
// find the path for the content.opf file bool findContentOpfFile(std::string* contentOpfFile) const;
static bool findContentOpfFile(const ZipFile& zip, std::string& contentOpfFile); bool parseContentOpf(const std::string& contentOpfFilePath);
bool parseContentOpf(ZipFile& zip, std::string& content_opf_file); bool parseTocNcxFile();
bool parseTocNcxFile(const ZipFile& zip); void initializeSpineItemSizes();
void recursivelyParseNavMap(tinyxml2::XMLElement* element);
public: public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
@ -59,10 +51,15 @@ class Epub {
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const; bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
bool getItemSize(const std::string& itemHref, size_t* size) const;
std::string& getSpineItem(int spineIndex); std::string& getSpineItem(int spineIndex);
int getSpineItemsCount() const; int getSpineItemsCount() const;
EpubTocEntry& getTocItem(int tocTndex); size_t getCumulativeSpineItemSize(const int spineIndex) const;
EpubTocEntry& getTocItem(int tocIndex);
int getTocItemsCount() const; int getTocItemsCount() const;
int getSpineIndexForTocIndex(int tocIndex) const; int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const; int getTocIndexForSpineIndex(int spineIndex) const;
size_t getBookSize() const;
uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead);
}; };

View File

@ -0,0 +1,13 @@
#pragma once
#include <string>
class EpubTocEntry {
public:
std::string title;
std::string href;
std::string anchor;
int level;
EpubTocEntry(std::string title, std::string href, std::string anchor, const int level)
: title(std::move(title)), href(std::move(href)), anchor(std::move(anchor)), level(level) {}
};

View File

@ -3,7 +3,9 @@
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <Serialization.h> #include <Serialization.h>
namespace {
constexpr uint8_t PAGE_FILE_VERSION = 3; constexpr uint8_t PAGE_FILE_VERSION = 3;
}
void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); }

View File

@ -4,6 +4,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <functional>
#include <limits> #include <limits>
#include <vector> #include <vector>
@ -17,10 +18,10 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
} }
// Consumes data to minimize memory usage // Consumes data to minimize memory usage
std::list<std::shared_ptr<TextBlock>> ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin,
const int horizontalMargin) { const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
if (words.empty()) { if (words.empty()) {
return {}; return;
} }
const size_t totalWordCount = words.size(); const size_t totalWordCount = words.size();
@ -30,6 +31,12 @@ std::list<std::shared_ptr<TextBlock>> ParsedText::layoutAndExtractLines(const Gf
std::vector<uint16_t> wordWidths; std::vector<uint16_t> wordWidths;
wordWidths.reserve(totalWordCount); wordWidths.reserve(totalWordCount);
// add em-space at the beginning of first word in paragraph to indent
if (!extraParagraphSpacing) {
std::string& first_word = words.front();
first_word.insert(0, "\xe2\x80\x83");
}
auto wordsIt = words.begin(); auto wordsIt = words.begin();
auto wordStylesIt = wordStyles.begin(); auto wordStylesIt = wordStyles.begin();
@ -99,8 +106,6 @@ std::list<std::shared_ptr<TextBlock>> ParsedText::layoutAndExtractLines(const Gf
currentWordIndex = nextBreakIndex; currentWordIndex = nextBreakIndex;
} }
std::list<std::shared_ptr<TextBlock>> lines;
// Initialize iterators for consumption // Initialize iterators for consumption
auto wordStartIt = words.begin(); auto wordStartIt = words.begin();
auto wordStyleStartIt = wordStyles.begin(); auto wordStyleStartIt = wordStyles.begin();
@ -123,7 +128,8 @@ std::list<std::shared_ptr<TextBlock>> ParsedText::layoutAndExtractLines(const Gf
} }
// Calculate spacing // Calculate spacing
const int spareSpace = pageWidth - lineWordWidthSum; int spareSpace = pageWidth - lineWordWidthSum;
int spacing = spaceWidth; int spacing = spaceWidth;
const bool isLastLine = lineBreak == totalWordCount; const bool isLastLine = lineBreak == totalWordCount;
@ -153,7 +159,7 @@ std::list<std::shared_ptr<TextBlock>> ParsedText::layoutAndExtractLines(const Gf
std::list<EpdFontStyle> lineWordStyles; std::list<EpdFontStyle> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyleStartIt, wordStyleEndIt); lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyleStartIt, wordStyleEndIt);
lines.push_back( processLine(
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style));
// Update pointers/indices for the next line // Update pointers/indices for the next line
@ -162,6 +168,4 @@ std::list<std::shared_ptr<TextBlock>> ParsedText::layoutAndExtractLines(const Gf
wordWidthIndex += lineWordCount; wordWidthIndex += lineWordCount;
lastBreakAt = lineBreak; lastBreakAt = lineBreak;
} }
return lines;
} }

View File

@ -3,6 +3,7 @@
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <cstdint> #include <cstdint>
#include <functional>
#include <list> #include <list>
#include <memory> #include <memory>
#include <string> #include <string>
@ -15,15 +16,17 @@ class ParsedText {
std::list<std::string> words; std::list<std::string> words;
std::list<EpdFontStyle> wordStyles; std::list<EpdFontStyle> wordStyles;
TextBlock::BLOCK_STYLE style; TextBlock::BLOCK_STYLE style;
bool extraParagraphSpacing;
public: public:
explicit ParsedText(const TextBlock::BLOCK_STYLE style) : style(style) {} explicit ParsedText(const TextBlock::BLOCK_STYLE style, const bool extraParagraphSpacing)
: style(style), extraParagraphSpacing(extraParagraphSpacing) {}
~ParsedText() = default; ~ParsedText() = default;
void addWord(std::string word, EpdFontStyle fontStyle); void addWord(std::string word, EpdFontStyle fontStyle);
void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; } void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; }
TextBlock::BLOCK_STYLE getStyle() const { return style; } TextBlock::BLOCK_STYLE getStyle() const { return style; }
bool isEmpty() const { return words.empty(); } bool isEmpty() const { return words.empty(); }
std::list<std::shared_ptr<TextBlock>> layoutAndExtractLines(const GfxRenderer& renderer, int fontId, void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int horizontalMargin,
int horizontalMargin); const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
}; };

View File

@ -5,11 +5,13 @@
#include <fstream> #include <fstream>
#include "EpubHtmlParserSlim.h"
#include "FsHelpers.h" #include "FsHelpers.h"
#include "Page.h" #include "Page.h"
#include "parsers/ChapterHtmlSlimParser.h"
constexpr uint8_t SECTION_FILE_VERSION = 4; namespace {
constexpr uint8_t SECTION_FILE_VERSION = 5;
}
void Section::onPageComplete(std::unique_ptr<Page> page) { void Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin";
@ -24,7 +26,8 @@ void Section::onPageComplete(std::unique_ptr<Page> page) {
} }
void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft) const { const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) const {
std::ofstream outputFile(("/sd" + cachePath + "/section.bin").c_str()); std::ofstream outputFile(("/sd" + cachePath + "/section.bin").c_str());
serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, SECTION_FILE_VERSION);
serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, fontId);
@ -33,12 +36,14 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression,
serialization::writePod(outputFile, marginRight); serialization::writePod(outputFile, marginRight);
serialization::writePod(outputFile, marginBottom); serialization::writePod(outputFile, marginBottom);
serialization::writePod(outputFile, marginLeft); serialization::writePod(outputFile, marginLeft);
serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, pageCount); serialization::writePod(outputFile, pageCount);
outputFile.close(); outputFile.close();
} }
bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft) { const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) {
if (!SD.exists(cachePath.c_str())) { if (!SD.exists(cachePath.c_str())) {
return false; return false;
} }
@ -63,15 +68,18 @@ bool Section::loadCacheMetadata(const int fontId, const float lineCompression, c
int fileFontId, fileMarginTop, fileMarginRight, fileMarginBottom, fileMarginLeft; int fileFontId, fileMarginTop, fileMarginRight, fileMarginBottom, fileMarginLeft;
float fileLineCompression; float fileLineCompression;
bool fileExtraParagraphSpacing;
serialization::readPod(inputFile, fileFontId); serialization::readPod(inputFile, fileFontId);
serialization::readPod(inputFile, fileLineCompression); serialization::readPod(inputFile, fileLineCompression);
serialization::readPod(inputFile, fileMarginTop); serialization::readPod(inputFile, fileMarginTop);
serialization::readPod(inputFile, fileMarginRight); serialization::readPod(inputFile, fileMarginRight);
serialization::readPod(inputFile, fileMarginBottom); serialization::readPod(inputFile, fileMarginBottom);
serialization::readPod(inputFile, fileMarginLeft); serialization::readPod(inputFile, fileMarginLeft);
serialization::readPod(inputFile, fileExtraParagraphSpacing);
if (fontId != fileFontId || lineCompression != fileLineCompression || marginTop != fileMarginTop || if (fontId != fileFontId || lineCompression != fileLineCompression || marginTop != fileMarginTop ||
marginRight != fileMarginRight || marginBottom != fileMarginBottom || marginLeft != fileMarginLeft) { marginRight != fileMarginRight || marginBottom != fileMarginBottom || marginLeft != fileMarginLeft ||
extraParagraphSpacing != fileExtraParagraphSpacing) {
inputFile.close(); inputFile.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
clearCache(); clearCache();
@ -107,7 +115,8 @@ bool Section::clearCache() const {
} }
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft) { const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing) {
const auto localPath = epub->getSpineItem(spineIndex); const auto localPath = epub->getSpineItem(spineIndex);
// TODO: Should we get rid of this file all together? // TODO: Should we get rid of this file all together?
@ -127,8 +136,8 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath; const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath;
EpubHtmlParserSlim visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight, ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight,
marginBottom, marginLeft, marginBottom, marginLeft, extraParagraphSpacing,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }); [this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); });
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
@ -138,7 +147,7 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
return false; return false;
} }
writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft); writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing);
return true; return true;
} }

View File

@ -13,7 +13,7 @@ class Section {
std::string cachePath; std::string cachePath;
void writeCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, void writeCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft) const; int marginLeft, bool extraParagraphSpacing) const;
void onPageComplete(std::unique_ptr<Page> page); void onPageComplete(std::unique_ptr<Page> page);
public: public:
@ -26,10 +26,10 @@ class Section {
} }
~Section() = default; ~Section() = default;
bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft); int marginLeft, bool extraParagraphSpacing);
void setupCacheDir() const; void setupCacheDir() const;
bool clearCache() const; bool clearCache() const;
bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft); int marginLeft, bool extraParagraphSpacing);
std::unique_ptr<Page> loadPageFromSD() const; std::unique_ptr<Page> loadPageFromSD() const;
}; };

View File

@ -1,11 +1,11 @@
#include "EpubHtmlParserSlim.h" #include "ChapterHtmlSlimParser.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <expat.h> #include <expat.h>
#include "Page.h" #include "../Page.h"
#include "htmlEntities.h" #include "../htmlEntities.h"
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
@ -25,7 +25,7 @@ constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);
const char* SKIP_TAGS[] = {"head", "table"}; const char* SKIP_TAGS[] = {"head", "table"};
constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]); constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]);
bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n'; } bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; }
// given the start and end of a tag, check to see if it matches a known tag // given the start and end of a tag, check to see if it matches a known tag
bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) { bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) {
@ -38,7 +38,7 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
} }
// start a new text block if needed // start a new text block if needed
void EpubHtmlParserSlim::startNewTextBlock(const TextBlock::BLOCK_STYLE style) { void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::BLOCK_STYLE style) {
if (currentTextBlock) { if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it // already have a text block running and it is empty - just reuse it
if (currentTextBlock->isEmpty()) { if (currentTextBlock->isEmpty()) {
@ -48,11 +48,11 @@ void EpubHtmlParserSlim::startNewTextBlock(const TextBlock::BLOCK_STYLE style) {
makePages(); makePages();
} }
currentTextBlock.reset(new ParsedText(style)); currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing));
} }
void XMLCALL EpubHtmlParserSlim::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<EpubHtmlParserSlim*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
(void)atts; (void)atts;
// Middle of skip // Middle of skip
@ -62,23 +62,7 @@ void XMLCALL EpubHtmlParserSlim::startElement(void* userData, const XML_Char* na
} }
if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) {
// const char* src = element.Attribute("src"); // TODO: Start processing image tags
// if (src) {
// // don't leave an empty text block in the list
// // const BLOCK_STYLE style = currentTextBlock->get_style();
// if (currentTextBlock->isEmpty()) {
// delete currentTextBlock;
// currentTextBlock = nullptr;
// }
// // TODO: Fix this
// // blocks.push_back(new ImageBlock(m_base_path + src));
// // start a new text block - with the same style as before
// // startNewTextBlock(style);
// } else {
// // ESP_LOGE(TAG, "Could not find src attribute");
// }
// start skip
self->skipUntilDepth = self->depth; self->skipUntilDepth = self->depth;
self->depth += 1; self->depth += 1;
return; return;
@ -91,6 +75,18 @@ void XMLCALL EpubHtmlParserSlim::startElement(void* userData, const XML_Char* na
return; return;
} }
// Skip blocks with role="doc-pagebreak" and epub:type="pagebreak"
if (atts != nullptr) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "role") == 0 && strcmp(atts[i + 1], "doc-pagebreak") == 0 ||
strcmp(atts[i], "epub:type") == 0 && strcmp(atts[i + 1], "pagebreak") == 0) {
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
}
}
}
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
self->startNewTextBlock(TextBlock::CENTER_ALIGN); self->startNewTextBlock(TextBlock::CENTER_ALIGN);
self->boldUntilDepth = min(self->boldUntilDepth, self->depth); self->boldUntilDepth = min(self->boldUntilDepth, self->depth);
@ -109,8 +105,8 @@ void XMLCALL EpubHtmlParserSlim::startElement(void* userData, const XML_Char* na
self->depth += 1; self->depth += 1;
} }
void XMLCALL EpubHtmlParserSlim::characterData(void* userData, const XML_Char* s, const int len) { void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<EpubHtmlParserSlim*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
// Middle of skip // Middle of skip
if (self->skipUntilDepth < self->depth) { if (self->skipUntilDepth < self->depth) {
@ -149,8 +145,8 @@ void XMLCALL EpubHtmlParserSlim::characterData(void* userData, const XML_Char* s
} }
} }
void XMLCALL EpubHtmlParserSlim::endElement(void* userData, const XML_Char* name) { void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<EpubHtmlParserSlim*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
(void)name; (void)name;
if (self->partWordBufferIndex > 0) { if (self->partWordBufferIndex > 0) {
@ -196,7 +192,7 @@ void XMLCALL EpubHtmlParserSlim::endElement(void* userData, const XML_Char* name
} }
} }
bool EpubHtmlParserSlim::parseAndBuildPages() { bool ChapterHtmlSlimParser::parseAndBuildPages() {
startNewTextBlock(TextBlock::JUSTIFIED); startNewTextBlock(TextBlock::JUSTIFIED);
const XML_Parser parser = XML_ParserCreate(nullptr); const XML_Parser parser = XML_ParserCreate(nullptr);
@ -261,7 +257,21 @@ bool EpubHtmlParserSlim::parseAndBuildPages() {
return true; return true;
} }
void EpubHtmlParserSlim::makePages() { void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom;
if (currentPageNextY + lineHeight > pageHeight) {
completePageFn(std::move(currentPage));
currentPage.reset(new Page());
currentPageNextY = marginTop;
}
currentPage->elements.push_back(std::make_shared<PageLine>(line, marginLeft, currentPageNextY));
currentPageNextY += lineHeight;
}
void ChapterHtmlSlimParser::makePages() {
if (!currentTextBlock) { if (!currentTextBlock) {
Serial.printf("[%lu] [EHP] !! No text block to make pages for !!\n", millis()); Serial.printf("[%lu] [EHP] !! No text block to make pages for !!\n", millis());
return; return;
@ -273,23 +283,11 @@ void EpubHtmlParserSlim::makePages() {
} }
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression; const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
const int pageHeight = GfxRenderer::getScreenHeight() - marginTop - marginBottom; currentTextBlock->layoutAndExtractLines(
renderer, fontId, marginLeft + marginRight,
// Long running task, make sure to let other things happen [this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
vTaskDelay(1); // Extra paragraph spacing if enabled
if (extraParagraphSpacing) {
const auto lines = currentTextBlock->layoutAndExtractLines(renderer, fontId, marginLeft + marginRight);
for (auto&& line : lines) {
if (currentPageNextY + lineHeight > pageHeight) {
completePageFn(std::move(currentPage));
currentPage.reset(new Page());
currentPageNextY = marginTop;
}
currentPage->elements.push_back(std::make_shared<PageLine>(line, marginLeft, currentPageNextY));
currentPageNextY += lineHeight;
}
// add some extra line between blocks
currentPageNextY += lineHeight / 2; currentPageNextY += lineHeight / 2;
} }
}

View File

@ -6,15 +6,15 @@
#include <functional> #include <functional>
#include <memory> #include <memory>
#include "ParsedText.h" #include "../ParsedText.h"
#include "blocks/TextBlock.h" #include "../blocks/TextBlock.h"
class Page; class Page;
class GfxRenderer; class GfxRenderer;
#define MAX_WORD_SIZE 200 #define MAX_WORD_SIZE 200
class EpubHtmlParserSlim { class ChapterHtmlSlimParser {
const char* filepath; const char* filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
@ -35,6 +35,7 @@ class EpubHtmlParserSlim {
int marginRight; int marginRight;
int marginBottom; int marginBottom;
int marginLeft; int marginLeft;
bool extraParagraphSpacing;
void startNewTextBlock(TextBlock::BLOCK_STYLE style); void startNewTextBlock(TextBlock::BLOCK_STYLE style);
void makePages(); void makePages();
@ -44,9 +45,9 @@ class EpubHtmlParserSlim {
static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL endElement(void* userData, const XML_Char* name);
public: public:
explicit EpubHtmlParserSlim(const char* filepath, GfxRenderer& renderer, const int fontId, explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const int marginTop, const int marginRight, const float lineCompression, const int marginTop, const int marginRight,
const int marginBottom, const int marginLeft, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing,
const std::function<void(std::unique_ptr<Page>)>& completePageFn) const std::function<void(std::unique_ptr<Page>)>& completePageFn)
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
@ -56,7 +57,9 @@ class EpubHtmlParserSlim {
marginRight(marginRight), marginRight(marginRight),
marginBottom(marginBottom), marginBottom(marginBottom),
marginLeft(marginLeft), marginLeft(marginLeft),
extraParagraphSpacing(extraParagraphSpacing),
completePageFn(completePageFn) {} completePageFn(completePageFn) {}
~EpubHtmlParserSlim() = default; ~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);
}; };

View File

@ -0,0 +1,96 @@
#include "ContainerParser.h"
#include <HardwareSerial.h>
bool ContainerParser::setup() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
Serial.printf("[%lu] [CTR] Couldn't allocate memory for parser\n", millis());
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
return true;
}
bool ContainerParser::teardown() {
if (parser) {
XML_ParserFree(parser);
parser = nullptr;
}
return true;
}
size_t ContainerParser::write(const uint8_t data) { return write(&data, 1); }
size_t ContainerParser::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] [CTR] Couldn't allocate buffer\n", millis());
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] [CTR] Parse error: %s\n", millis(), XML_ErrorString(XML_GetErrorCode(parser)));
return 0;
}
currentBufferPos += toRead;
remainingInBuffer -= toRead;
remainingSize -= toRead;
}
return size;
}
void XMLCALL ContainerParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<ContainerParser*>(userData);
// Simple state tracking to ensure we are looking at the valid schema structure
if (self->state == START && strcmp(name, "container") == 0) {
self->state = IN_CONTAINER;
return;
}
if (self->state == IN_CONTAINER && strcmp(name, "rootfiles") == 0) {
self->state = IN_ROOTFILES;
return;
}
if (self->state == IN_ROOTFILES && strcmp(name, "rootfile") == 0) {
const char* mediaType = nullptr;
const char* path = nullptr;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "media-type") == 0) {
mediaType = atts[i + 1];
} else if (strcmp(atts[i], "full-path") == 0) {
path = atts[i + 1];
}
}
// Check if this is the standard OEBPS package
if (mediaType && path && strcmp(mediaType, "application/oebps-package+xml") == 0) {
self->fullPath = path;
}
}
}
void XMLCALL ContainerParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<ContainerParser*>(userData);
if (self->state == IN_ROOTFILES && strcmp(name, "rootfiles") == 0) {
self->state = IN_CONTAINER;
} else if (self->state == IN_CONTAINER && strcmp(name, "container") == 0) {
self->state = START;
}
}

View File

@ -0,0 +1,32 @@
#pragma once
#include <Print.h>
#include <string>
#include "expat.h"
class ContainerParser final : public Print {
enum ParserState {
START,
IN_CONTAINER,
IN_ROOTFILES,
};
size_t remainingSize;
XML_Parser parser = nullptr;
ParserState state = START;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void endElement(void* userData, const XML_Char* name);
public:
std::string fullPath;
explicit ContainerParser(const size_t xmlSize) : remainingSize(xmlSize) {}
bool setup();
bool teardown();
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
};

View File

@ -0,0 +1,191 @@
#include "ContentOpfParser.h"
#include <HardwareSerial.h>
#include <ZipFile.h>
namespace {
constexpr const char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
}
bool ContentOpfParser::setup() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
Serial.printf("[%lu] [COF] 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;
}
bool ContentOpfParser::teardown() {
if (parser) {
XML_ParserFree(parser);
parser = nullptr;
}
return true;
}
size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); }
size_t ContentOpfParser::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] [COF] Couldn't allocate memory for buffer\n", millis());
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] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
currentBufferPos += toRead;
remainingInBuffer -= toRead;
remainingSize -= toRead;
}
return size;
}
void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<ContentOpfParser*>(userData);
(void)atts;
if (self->state == START && (strcmp(name, "package") == 0 || strcmp(name, "opf:package") == 0)) {
self->state = IN_PACKAGE;
return;
}
if (self->state == IN_PACKAGE && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) {
self->state = IN_METADATA;
return;
}
if (self->state == IN_METADATA && strcmp(name, "dc:title") == 0) {
self->state = IN_BOOK_TITLE;
return;
}
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_MANIFEST;
return;
}
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_SPINE;
return;
}
if (self->state == IN_METADATA && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) {
bool isCover = false;
std::string coverItemId;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "name") == 0 && strcmp(atts[i + 1], "cover") == 0) {
isCover = true;
} else if (strcmp(atts[i], "content") == 0) {
coverItemId = atts[i + 1];
}
}
if (isCover) {
self->coverItemId = coverItemId;
}
return;
}
if (self->state == IN_MANIFEST && (strcmp(name, "item") == 0 || strcmp(name, "opf:item") == 0)) {
std::string itemId;
std::string href;
std::string mediaType;
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];
} else if (strcmp(atts[i], "media-type") == 0) {
mediaType = atts[i + 1];
}
}
self->items[itemId] = href;
if (mediaType == MEDIA_TYPE_NCX) {
if (self->tocNcxPath.empty()) {
self->tocNcxPath = href;
} else {
Serial.printf("[%lu] [COF] Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s\n", millis(),
href.c_str());
}
}
return;
}
if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "idref") == 0) {
self->spineRefs.emplace_back(atts[i + 1]);
break;
}
}
return;
}
}
void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<ContentOpfParser*>(userData);
if (self->state == IN_BOOK_TITLE) {
self->title.append(s, len);
return;
}
}
void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<ContentOpfParser*>(userData);
(void)name;
if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_PACKAGE;
return;
}
if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_PACKAGE;
return;
}
if (self->state == IN_BOOK_TITLE && strcmp(name, "dc:title") == 0) {
self->state = IN_METADATA;
return;
}
if (self->state == IN_METADATA && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) {
self->state = IN_PACKAGE;
return;
}
if (self->state == IN_PACKAGE && (strcmp(name, "package") == 0 || strcmp(name, "opf:package") == 0)) {
self->state = START;
return;
}
}

View File

@ -0,0 +1,43 @@
#pragma once
#include <Print.h>
#include <map>
#include "Epub.h"
#include "expat.h"
class ContentOpfParser final : public Print {
enum ParserState {
START,
IN_PACKAGE,
IN_METADATA,
IN_BOOK_TITLE,
IN_MANIFEST,
IN_SPINE,
};
const std::string& baseContentPath;
size_t remainingSize;
XML_Parser parser = nullptr;
ParserState state = START;
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:
std::string title;
std::string tocNcxPath;
std::string coverItemId;
std::map<std::string, std::string> items;
std::vector<std::string> spineRefs;
explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize)
: baseContentPath(baseContentPath), remainingSize(xmlSize) {}
bool setup();
bool teardown();
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
};

View File

@ -0,0 +1,165 @@
#include "TocNcxParser.h"
#include <HardwareSerial.h>
bool TocNcxParser::setup() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
Serial.printf("[%lu] [TOC] 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;
}
bool TocNcxParser::teardown() {
if (parser) {
XML_ParserFree(parser);
parser = nullptr;
}
return true;
}
size_t TocNcxParser::write(const uint8_t data) { return write(&data, 1); }
size_t TocNcxParser::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] [TOC] Couldn't allocate memory for buffer\n", millis());
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] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
return 0;
}
currentBufferPos += toRead;
remainingInBuffer -= toRead;
remainingSize -= toRead;
}
return size;
}
void XMLCALL TocNcxParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
// NOTE: We rely on navPoint label and content coming before any nested navPoints, this will be fine:
// <navPoint>
// <navLabel><text>Chapter 1</text></navLabel>
// <content src="ch1.html"/>
// <navPoint> ...nested... </navPoint>
// </navPoint>
//
// This will NOT:
// <navPoint>
// <navPoint> ...nested... </navPoint>
// <navLabel><text>Chapter 1</text></navLabel>
// <content src="ch1.html"/>
// </navPoint>
auto* self = static_cast<TocNcxParser*>(userData);
if (self->state == START && strcmp(name, "ncx") == 0) {
self->state = IN_NCX;
return;
}
if (self->state == IN_NCX && strcmp(name, "navMap") == 0) {
self->state = IN_NAV_MAP;
return;
}
// Handles both top-level and nested navPoints
if ((self->state == IN_NAV_MAP || self->state == IN_NAV_POINT) && strcmp(name, "navPoint") == 0) {
self->state = IN_NAV_POINT;
self->currentDepth++;
self->currentLabel.clear();
self->currentSrc.clear();
return;
}
if (self->state == IN_NAV_POINT && strcmp(name, "navLabel") == 0) {
self->state = IN_NAV_LABEL;
return;
}
if (self->state == IN_NAV_LABEL && strcmp(name, "text") == 0) {
self->state = IN_NAV_LABEL_TEXT;
return;
}
if (self->state == IN_NAV_POINT && strcmp(name, "content") == 0) {
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "src") == 0) {
self->currentSrc = atts[i + 1];
break;
}
}
return;
}
}
void XMLCALL TocNcxParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<TocNcxParser*>(userData);
if (self->state == IN_NAV_LABEL_TEXT) {
self->currentLabel.append(s, len);
}
}
void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<TocNcxParser*>(userData);
if (self->state == IN_NAV_LABEL_TEXT && strcmp(name, "text") == 0) {
self->state = IN_NAV_LABEL;
return;
}
if (self->state == IN_NAV_LABEL && strcmp(name, "navLabel") == 0) {
self->state = IN_NAV_POINT;
return;
}
if (self->state == IN_NAV_POINT && strcmp(name, "navPoint") == 0) {
self->currentDepth--;
if (self->currentDepth == 0) {
self->state = IN_NAV_MAP;
}
return;
}
if (self->state == IN_NAV_POINT && strcmp(name, "content") == 0) {
// At this point (end of content tag), we likely have both Label (from previous tags) and Src.
// 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 anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
// Push to vector
self->toc.emplace_back(self->currentLabel, href, anchor, self->currentDepth);
// Clear them so we don't re-add them if there are weird XML structures
self->currentLabel.clear();
self->currentSrc.clear();
}
}
}

View File

@ -0,0 +1,37 @@
#pragma once
#include <Print.h>
#include <string>
#include <vector>
#include "Epub/EpubTocEntry.h"
#include "expat.h"
class TocNcxParser final : public Print {
enum ParserState { START, IN_NCX, IN_NAV_MAP, IN_NAV_POINT, IN_NAV_LABEL, IN_NAV_LABEL_TEXT, IN_CONTENT };
const std::string& baseContentPath;
size_t remainingSize;
XML_Parser parser = nullptr;
ParserState state = START;
std::string currentLabel;
std::string currentSrc;
size_t currentDepth = 0;
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:
std::vector<EpubTocEntry> toc;
explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize)
: baseContentPath(baseContentPath), remainingSize(xmlSize) {}
bool setup();
bool teardown();
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
};

189
lib/GfxRenderer/Bitmap.cpp Normal file
View File

@ -0,0 +1,189 @@
#include "Bitmap.h"
#include <cstdlib>
#include <cstring>
uint16_t Bitmap::readLE16(File& f) {
const int c0 = f.read();
const int c1 = f.read();
const auto b0 = static_cast<uint8_t>(c0 < 0 ? 0 : c0);
const auto b1 = static_cast<uint8_t>(c1 < 0 ? 0 : c1);
return static_cast<uint16_t>(b0) | (static_cast<uint16_t>(b1) << 8);
}
uint32_t Bitmap::readLE32(File& f) {
const int c0 = f.read();
const int c1 = f.read();
const int c2 = f.read();
const int c3 = f.read();
const auto b0 = static_cast<uint8_t>(c0 < 0 ? 0 : c0);
const auto b1 = static_cast<uint8_t>(c1 < 0 ? 0 : c1);
const auto b2 = static_cast<uint8_t>(c2 < 0 ? 0 : c2);
const auto b3 = static_cast<uint8_t>(c3 < 0 ? 0 : c3);
return static_cast<uint32_t>(b0) | (static_cast<uint32_t>(b1) << 8) | (static_cast<uint32_t>(b2) << 16) |
(static_cast<uint32_t>(b3) << 24);
}
const char* Bitmap::errorToString(BmpReaderError err) {
switch (err) {
case BmpReaderError::Ok:
return "Ok";
case BmpReaderError::FileInvalid:
return "FileInvalid";
case BmpReaderError::SeekStartFailed:
return "SeekStartFailed";
case BmpReaderError::NotBMP:
return "NotBMP (missing 'BM')";
case BmpReaderError::DIBTooSmall:
return "DIBTooSmall (<40 bytes)";
case BmpReaderError::BadPlanes:
return "BadPlanes (!= 1)";
case BmpReaderError::UnsupportedBpp:
return "UnsupportedBpp (expected 1, 2, 8, 24, or 32)";
case BmpReaderError::UnsupportedCompression:
return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)";
case BmpReaderError::BadDimensions:
return "BadDimensions";
case BmpReaderError::PaletteTooLarge:
return "PaletteTooLarge";
case BmpReaderError::SeekPixelDataFailed:
return "SeekPixelDataFailed";
case BmpReaderError::BufferTooSmall:
return "BufferTooSmall";
case BmpReaderError::OomRowBuffer:
return "OomRowBuffer";
case BmpReaderError::ShortReadRow:
return "ShortReadRow";
}
return "Unknown";
}
BmpReaderError Bitmap::parseHeaders() {
if (!file) return BmpReaderError::FileInvalid;
if (!file.seek(0)) return BmpReaderError::SeekStartFailed;
// --- BMP FILE HEADER ---
const uint16_t bfType = readLE16(file);
if (bfType != 0x4D42) return BmpReaderError::NotBMP;
file.seek(8, SeekCur);
bfOffBits = readLE32(file);
// --- DIB HEADER ---
const uint32_t biSize = readLE32(file);
if (biSize < 40) return BmpReaderError::DIBTooSmall;
width = static_cast<int32_t>(readLE32(file));
const auto rawHeight = static_cast<int32_t>(readLE32(file));
topDown = rawHeight < 0;
height = topDown ? -rawHeight : rawHeight;
const uint16_t planes = readLE16(file);
bpp = readLE16(file);
const uint32_t comp = readLE32(file);
const bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32;
if (planes != 1) return BmpReaderError::BadPlanes;
if (!validBpp) return BmpReaderError::UnsupportedBpp;
// Allow BI_RGB (0) for all, and BI_BITFIELDS (3) for 32bpp which is common for BGRA masks.
if (!(comp == 0 || (bpp == 32 && comp == 3))) return BmpReaderError::UnsupportedCompression;
file.seek(12, SeekCur); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter
const uint32_t colorsUsed = readLE32(file);
if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge;
file.seek(4, SeekCur); // biClrImportant
if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions;
// Pre-calculate Row Bytes to avoid doing this every row
rowBytes = (width * bpp + 31) / 32 * 4;
for (int i = 0; i < 256; i++) paletteLum[i] = static_cast<uint8_t>(i);
if (colorsUsed > 0) {
for (uint32_t i = 0; i < colorsUsed; i++) {
uint8_t rgb[4];
file.read(rgb, 4); // Read B, G, R, Reserved in one go
paletteLum[i] = (77u * rgb[2] + 150u * rgb[1] + 29u * rgb[0]) >> 8;
}
}
if (!file.seek(bfOffBits)) {
return BmpReaderError::SeekPixelDataFailed;
}
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) const {
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
uint8_t* outPtr = data;
uint8_t currentOutByte = 0;
int bitShift = 6;
// Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](uint8_t lum) {
uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3
currentOutByte |= (color << bitShift);
if (bitShift == 0) {
*outPtr++ = currentOutByte;
currentOutByte = 0;
bitShift = 6;
} else {
bitShift -= 2;
}
};
switch (bpp) {
case 8: {
for (int x = 0; x < width; x++) {
packPixel(paletteLum[rowBuffer[x]]);
}
break;
}
case 24: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) {
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 3;
}
break;
}
case 1: {
for (int x = 0; x < width; x++) {
uint8_t lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00;
packPixel(lum);
}
break;
}
case 32: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) {
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 4;
}
break;
}
}
// Flush remaining bits if width is not a multiple of 4
if (bitShift != 6) *outPtr = currentOutByte;
return BmpReaderError::Ok;
}
BmpReaderError Bitmap::rewindToData() const {
if (!file.seek(bfOffBits)) {
return BmpReaderError::SeekPixelDataFailed;
}
return BmpReaderError::Ok;
}

52
lib/GfxRenderer/Bitmap.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
#include <FS.h>
enum class BmpReaderError : uint8_t {
Ok = 0,
FileInvalid,
SeekStartFailed,
NotBMP,
DIBTooSmall,
BadPlanes,
UnsupportedBpp,
UnsupportedCompression,
BadDimensions,
PaletteTooLarge,
SeekPixelDataFailed,
BufferTooSmall,
OomRowBuffer,
ShortReadRow,
};
class Bitmap {
public:
static const char* errorToString(BmpReaderError err);
explicit Bitmap(File& file) : file(file) {}
BmpReaderError parseHeaders();
BmpReaderError readRow(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; }
private:
static uint16_t readLE16(File& f);
static uint32_t readLE32(File& f);
File& file;
int width = 0;
int height = 0;
bool topDown = false;
uint32_t bfOffBits = 0;
uint16_t bpp = 0;
int rowBytes = 0;
uint8_t paletteLum[256] = {};
};

View File

@ -119,6 +119,66 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
einkDisplay.drawImage(bitmap, y, x, height, width); einkDisplay.drawImage(bitmap, y, x, height, width);
} }
void GfxRenderer::drawBitmap(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;
}
const uint8_t outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
for (int bmpY = 0; bmpY < bitmap.getHeight(); 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);
if (isScaled) {
screenY = std::floor(screenY * scale);
}
if (screenY >= getScreenHeight()) {
break;
}
if (bitmap.readRow(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 (isScaled) {
screenX = std::floor(screenX * scale);
}
if (screenX >= getScreenWidth()) {
break;
}
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
if (renderMode == BW && val < 3) {
drawPixel(screenX, screenY);
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
drawPixel(screenX, screenY, false);
}
}
}
free(outputRow);
free(rowBytes);
}
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
void GfxRenderer::invertScreen() const { void GfxRenderer::invertScreen() const {
@ -132,13 +192,19 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons
einkDisplay.displayBuffer(refreshMode); einkDisplay.displayBuffer(refreshMode);
} }
// TODO: Support partial window update void GfxRenderer::displayWindow(const int x, const int y, const int width, const int height) const {
// void GfxRenderer::flushArea(const int x, const int y, const int width, const int height) const { // Rotate coordinates from portrait (480x800) to landscape (800x480)
// const int rotatedX = y; // Rotation: 90 degrees clockwise
// const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; // Portrait coordinates: (x, y) with dimensions (width, height)
// // Landscape coordinates: (rotatedX, rotatedY) with dimensions (rotatedWidth, rotatedHeight)
// einkDisplay.displayBuffer(EInkDisplay::FAST_REFRESH, rotatedX, rotatedY, height, width);
// } const int rotatedX = y;
const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x - width + 1;
const int rotatedWidth = height;
const int rotatedHeight = width;
einkDisplay.displayWindow(rotatedX, rotatedY, rotatedWidth, rotatedHeight);
}
// Note: Internal driver treats screen in command orientation, this library treats in portrait orientation // Note: Internal driver treats screen in command orientation, this library treats in portrait orientation
int GfxRenderer::getScreenWidth() { return EInkDisplay::DISPLAY_HEIGHT; } int GfxRenderer::getScreenWidth() { return EInkDisplay::DISPLAY_HEIGHT; }
@ -162,11 +228,9 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return fontMap.at(fontId).getData(REGULAR)->advanceY; return fontMap.at(fontId).getData(REGULAR)->advanceY;
} }
uint8_t *GfxRenderer::getFrameBuffer() const { uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
return einkDisplay.getFrameBuffer();
}
void GfxRenderer::swapBuffers() const { einkDisplay.swapBuffers(); } size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); } void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); }
@ -176,6 +240,90 @@ void GfxRenderer::copyGrayscaleMsbBuffers() const { einkDisplay.copyGrayscaleMsb
void GfxRenderer::displayGrayBuffer() const { einkDisplay.displayGrayBuffer(); } void GfxRenderer::displayGrayBuffer() const { einkDisplay.displayGrayBuffer(); }
void GfxRenderer::freeBwBufferChunks() {
for (auto& bwBufferChunk : bwBufferChunks) {
if (bwBufferChunk) {
free(bwBufferChunk);
bwBufferChunk = nullptr;
}
}
}
/**
* This should be called before grayscale buffers are populated.
* A `restoreBwBuffer` call should always follow the grayscale render if this method was called.
* Uses chunked allocation to avoid needing 48KB of contiguous memory.
*/
void GfxRenderer::storeBwBuffer() {
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
// Allocate and copy each chunk
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if any chunks are already allocated
if (bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk\n",
millis(), i);
free(bwBufferChunks[i]);
bwBufferChunks[i] = nullptr;
}
const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
bwBufferChunks[i] = static_cast<uint8_t*>(malloc(BW_BUFFER_CHUNK_SIZE));
if (!bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! Failed to allocate BW buffer chunk %zu (%zu bytes)\n", millis(), i,
BW_BUFFER_CHUNK_SIZE);
// Free previously allocated chunks
freeBwBufferChunks();
return;
}
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE);
}
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS,
BW_BUFFER_CHUNK_SIZE);
}
/**
* This can only be called if `storeBwBuffer` was called prior to the grayscale render.
* It should be called to restore the BW buffer state after grayscale rendering is complete.
* Uses chunked restoration to match chunked storage.
*/
void GfxRenderer::restoreBwBuffer() {
// Check if any all chunks are allocated
bool missingChunks = false;
for (const auto& bwBufferChunk : bwBufferChunks) {
if (!bwBufferChunk) {
missingChunks = true;
break;
}
}
if (missingChunks) {
freeBwBufferChunks();
return;
}
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if chunk is missing
if (!bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
freeBwBufferChunks();
return;
}
const size_t offset = i * BW_BUFFER_CHUNK_SIZE;
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
}
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks();
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
}
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, 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 EpdFontStyle style) const {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
@ -209,14 +357,20 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
if (is2Bit) { if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4]; const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2; const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black
// we swap this to better match the way images and screen think about colors:
// 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
const uint8_t val = (byte >> bit_index) & 0x3; if (renderMode == BW && bmpVal < 3) {
if (fontRenderMode == BW && val > 0) { // Black (also paints over the grays in BW mode)
drawPixel(screenX, screenY, pixelState); drawPixel(screenX, screenY, pixelState);
} else if (fontRenderMode == GRAYSCALE_MSB && val == 1) { } else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
// TODO: Not sure how this anti-aliasing goes on black backgrounds // Light gray (also mark the MSB if it's going to be a dark gray too)
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
drawPixel(screenX, screenY, false); drawPixel(screenX, screenY, false);
} else if (fontRenderMode == GRAYSCALE_LSB && val == 2) { } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
// Dark gray
drawPixel(screenX, screenY, false); drawPixel(screenX, screenY, false);
} }
} else { } else {

View File

@ -2,22 +2,32 @@
#include <EInkDisplay.h> #include <EInkDisplay.h>
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <FS.h>
#include <map> #include <map>
#include "Bitmap.h"
class GfxRenderer { class GfxRenderer {
public: public:
enum FontRenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB };
private: private:
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE,
"BW buffer chunking does not line up with display buffer size");
EInkDisplay& einkDisplay; EInkDisplay& einkDisplay;
FontRenderMode fontRenderMode; RenderMode renderMode;
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap; std::map<int, EpdFontFamily> fontMap;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
EpdFontStyle style) const; EpdFontStyle style) const;
void freeBwBufferChunks();
public: public:
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), fontRenderMode(BW) {} explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW) {}
~GfxRenderer() = default; ~GfxRenderer() = default;
// Setup // Setup
@ -27,6 +37,8 @@ class GfxRenderer {
static int getScreenWidth(); static int getScreenWidth();
static int getScreenHeight(); static int getScreenHeight();
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const; void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region (portrait coordinates)
void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const; void invertScreen() const;
void clearScreen(uint8_t color = 0xFF) const; void clearScreen(uint8_t color = 0xFF) const;
@ -36,20 +48,25 @@ class GfxRenderer {
void drawRect(int x, int y, int width, int height, bool state = true) const; 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 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 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;
// Text // Text
int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const; 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 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; void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const;
void setFontRenderMode(const FontRenderMode mode) { this->fontRenderMode = mode; }
int getSpaceWidth(int fontId) const; int getSpaceWidth(int fontId) const;
int getLineHeight(int fontId) const; int getLineHeight(int fontId) const;
// Low level functions // Grayscale functions
uint8_t* getFrameBuffer() const; void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void swapBuffers() const;
void grayscaleRevert() const;
void copyGrayscaleLsbBuffers() const; void copyGrayscaleLsbBuffers() const;
void copyGrayscaleMsbBuffers() const; void copyGrayscaleMsbBuffers() const;
void displayGrayBuffer() const; void displayGrayBuffer() const;
void storeBwBuffer();
void restoreBwBuffer();
// Low level functions
uint8_t* getFrameBuffer() const;
static size_t getBufferSize();
void grayscaleRevert() const;
}; };

View File

@ -40,7 +40,7 @@ bool ZipFile::loadFileStat(const char* filename, mz_zip_archive_file_stat* fileS
// find the file // find the file
mz_uint32 fileIndex = 0; mz_uint32 fileIndex = 0;
if (!mz_zip_reader_locate_file_v2(&zipArchive, filename, nullptr, 0, &fileIndex)) { if (!mz_zip_reader_locate_file_v2(&zipArchive, filename, nullptr, 0, &fileIndex)) {
Serial.printf("[%lu] [ZIP] Could not find file %s\n", millis, filename); Serial.printf("[%lu] [ZIP] Could not find file %s\n", millis(), filename);
mz_zip_reader_end(&zipArchive); mz_zip_reader_end(&zipArchive);
return false; return false;
} }
@ -62,6 +62,10 @@ long ZipFile::getDataOffset(const mz_zip_archive_file_stat& fileStat) const {
const uint64_t fileOffset = fileStat.m_local_header_ofs; const uint64_t fileOffset = fileStat.m_local_header_ofs;
FILE* file = fopen(filePath.c_str(), "r"); FILE* file = fopen(filePath.c_str(), "r");
if (!file) {
Serial.printf("[%lu] [ZIP] Failed to open file for reading local header\n", millis());
return -1;
}
fseek(file, fileOffset, SEEK_SET); fseek(file, fileOffset, SEEK_SET);
const size_t read = fread(pLocalHeader, 1, localHeaderSize, file); const size_t read = fread(pLocalHeader, 1, localHeaderSize, file);
fclose(file); fclose(file);
@ -82,6 +86,16 @@ long ZipFile::getDataOffset(const mz_zip_archive_file_stat& fileStat) const {
return fileOffset + localHeaderSize + filenameLength + extraOffset; return fileOffset + localHeaderSize + filenameLength + extraOffset;
} }
bool ZipFile::getInflatedFileSize(const char* filename, size_t* size) const {
mz_zip_archive_file_stat fileStat;
if (!loadFileStat(filename, &fileStat)) {
return false;
}
*size = static_cast<size_t>(fileStat.m_uncomp_size);
return true;
}
uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const bool trailingNullByte) const { uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const bool trailingNullByte) const {
mz_zip_archive_file_stat fileStat; mz_zip_archive_file_stat fileStat;
if (!loadFileStat(filename, &fileStat)) { if (!loadFileStat(filename, &fileStat)) {
@ -94,6 +108,10 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
} }
FILE* file = fopen(filePath.c_str(), "rb"); FILE* file = fopen(filePath.c_str(), "rb");
if (!file) {
Serial.printf("[%lu] [ZIP] Failed to open file for reading\n", millis());
return nullptr;
}
fseek(file, fileOffset, SEEK_SET); fseek(file, fileOffset, SEEK_SET);
const auto deflatedDataSize = static_cast<size_t>(fileStat.m_comp_size); const auto deflatedDataSize = static_cast<size_t>(fileStat.m_comp_size);
@ -165,6 +183,10 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
FILE* file = fopen(filePath.c_str(), "rb"); FILE* file = fopen(filePath.c_str(), "rb");
if (!file) {
Serial.printf("[%lu] [ZIP] Failed to open file for streaming\n", millis());
return false;
}
fseek(file, fileOffset, SEEK_SET); fseek(file, fileOffset, SEEK_SET);
const auto deflatedDataSize = static_cast<size_t>(fileStat.m_comp_size); const auto deflatedDataSize = static_cast<size_t>(fileStat.m_comp_size);
@ -268,7 +290,14 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
// Write output chunk // Write output chunk
if (outBytes > 0) { if (outBytes > 0) {
processedOutputBytes += outBytes; processedOutputBytes += outBytes;
out.write(outputBuffer + outputCursor, outBytes); if (out.write(outputBuffer + outputCursor, outBytes) != outBytes) {
Serial.printf("[%lu] [ZIP] Failed to write all output bytes to stream\n", millis());
fclose(file);
free(outputBuffer);
free(fileReadBuffer);
free(inflator);
return false;
}
// Update output position in buffer (with wraparound) // Update output position in buffer (with wraparound)
outputCursor = (outputCursor + outBytes) & (TINFL_LZ_DICT_SIZE - 1); outputCursor = (outputCursor + outBytes) & (TINFL_LZ_DICT_SIZE - 1);
} }

View File

@ -14,6 +14,7 @@ class ZipFile {
public: public:
explicit ZipFile(std::string filePath) : filePath(std::move(filePath)) {} explicit ZipFile(std::string filePath) : filePath(std::move(filePath)) {}
~ZipFile() = default; ~ZipFile() = default;
bool getInflatedFileSize(const char* filename, size_t* size) const;
uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false) const; uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false) const;
bool readFileToStream(const char* filename, Print& out, size_t chunkSize) const; bool readFileToStream(const char* filename, Print& out, size_t chunkSize) const;
}; };

@ -1 +1 @@
Subproject commit 7e0dce916706da7d80ec225fade191aea6b87fb6 Subproject commit 98a5aa1f8969ccd317c9b45bf0fa84b6c82e167f

View File

@ -1,5 +1,5 @@
[platformio] [platformio]
crosspoint_version = 0.4.0 crosspoint_version = 0.7.0
default_envs = default default_envs = default
[base] [base]
@ -8,6 +8,9 @@ board = esp32-c3-devkitm-1
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
upload_speed = 921600 upload_speed = 921600
check_tool = cppcheck
check_skip_packages = yes
check_severity = medium, high
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216 board_upload.maximum_size = 16777216
@ -17,6 +20,7 @@ build_flags =
-DARDUINO_USB_MODE=1 -DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_CDC_ON_BOOT=1
-DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1
-DEINK_DISPLAY_SINGLE_BUFFER_MODE=1
# https://libexpat.github.io/doc/api/latest/#XML_GE # https://libexpat.github.io/doc/api/latest/#XML_GE
-DXML_GE=0 -DXML_GE=0
-DXML_CONTEXT_BYTES=1024 -DXML_CONTEXT_BYTES=1024
@ -27,9 +31,11 @@ board_build.flash_mode = dio
board_build.flash_size = 16MB board_build.flash_size = 16MB
board_build.partitions = partitions.csv board_build.partitions = partitions.csv
extra_scripts =
pre:scripts/build_html.py
; Libraries ; Libraries
lib_deps = lib_deps =
https://github.com/leethomason/tinyxml2.git#11.0.0
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay

51
scripts/build_html.py Normal file
View File

@ -0,0 +1,51 @@
import os
import re
SRC_DIR = "src"
def minify_html(html: str) -> str:
# Tags where whitespace should be preserved
preserve_tags = ['pre', 'code', 'textarea', 'script', 'style']
preserve_regex = '|'.join(preserve_tags)
# Protect preserve blocks with placeholders
preserve_blocks = []
def preserve(match):
preserve_blocks.append(match.group(0))
return f"__PRESERVE_BLOCK_{len(preserve_blocks)-1}__"
html = re.sub(rf'<({preserve_regex})[\s\S]*?</\1>', preserve, html, flags=re.IGNORECASE)
# Remove HTML comments
html = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL)
# Collapse all whitespace between tags
html = re.sub(r'>\s+<', '><', html)
# Collapse multiple spaces inside tags
html = re.sub(r'\s+', ' ', html)
# Restore preserved blocks
for i, block in enumerate(preserve_blocks):
html = html.replace(f"__PRESERVE_BLOCK_{i}__", block)
return html.strip()
for root, _, files in os.walk(SRC_DIR):
for file in files:
if file.endswith(".html"):
html_path = os.path.join(root, file)
with open(html_path, "r", encoding="utf-8") as f:
html_content = f.read()
# minified = regex.sub("\g<1>", html_content)
minified = minify_html(html_content)
base_name = f"{os.path.splitext(file)[0]}Html"
header_path = os.path.join(root, f"{base_name}.generated.h")
with open(header_path, "w", encoding="utf-8") as h:
h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n")
h.write(f"#pragma once\n")
h.write(f'constexpr char {base_name}[] PROGMEM = R"rawliteral({minified})rawliteral";\n')
print(f"Generated: {header_path}")

View File

@ -0,0 +1,68 @@
#include "CrossPointSettings.h"
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <cstdint>
#include <fstream>
// Initialize the static instance
CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
constexpr uint8_t SETTINGS_COUNT = 3;
constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin";
} // namespace
bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists
SD.mkdir("/.crosspoint");
std::ofstream outputFile(SETTINGS_FILE);
serialization::writePod(outputFile, SETTINGS_FILE_VERSION);
serialization::writePod(outputFile, SETTINGS_COUNT);
serialization::writePod(outputFile, whiteSleepScreen);
serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, shortPwrBtn);
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
return true;
}
bool CrossPointSettings::loadFromFile() {
if (!SD.exists(SETTINGS_FILE + 3)) { // +3 to skip "/sd" prefix
Serial.printf("[%lu] [CPS] Settings file does not exist, using defaults\n", millis());
return false;
}
std::ifstream inputFile(SETTINGS_FILE);
uint8_t version;
serialization::readPod(inputFile, version);
if (version != SETTINGS_FILE_VERSION) {
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version);
inputFile.close();
return false;
}
uint8_t fileSettingsCount = 0;
serialization::readPod(inputFile, fileSettingsCount);
// load settings that exist
uint8_t settingsRead = 0;
do {
serialization::readPod(inputFile, whiteSleepScreen);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, extraParagraphSpacing);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, shortPwrBtn);
if (++settingsRead >= fileSettingsCount) break;
} while (false);
inputFile.close();
Serial.printf("[%lu] [CPS] Settings loaded from file\n", millis());
return true;
}

37
src/CrossPointSettings.h Normal file
View File

@ -0,0 +1,37 @@
#pragma once
#include <cstdint>
#include <iosfwd>
class CrossPointSettings {
private:
// Private constructor for singleton
CrossPointSettings() = default;
// Static instance
static CrossPointSettings instance;
public:
// Delete copy constructor and assignment
CrossPointSettings(const CrossPointSettings&) = delete;
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
// Sleep screen settings
uint8_t whiteSleepScreen = 0;
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
// Duration of the power button press
uint8_t shortPwrBtn = 0;
~CrossPointSettings() = default;
// Get singleton instance
static CrossPointSettings& getInstance() { return instance; }
uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 500; }
bool saveToFile() const;
bool loadFromFile();
};
// Helper macro to access settings
#define SETTINGS CrossPointSettings::getInstance()

View File

@ -6,8 +6,12 @@
#include <fstream> #include <fstream>
namespace {
constexpr uint8_t STATE_FILE_VERSION = 1; constexpr uint8_t STATE_FILE_VERSION = 1;
constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin"; constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin";
} // namespace
CrossPointState CrossPointState::instance;
bool CrossPointState::saveToFile() const { bool CrossPointState::saveToFile() const {
std::ofstream outputFile(STATE_FILE); std::ofstream outputFile(STATE_FILE);

View File

@ -3,11 +3,20 @@
#include <string> #include <string>
class CrossPointState { class CrossPointState {
// Static instance
static CrossPointState instance;
public: public:
std::string openEpubPath; std::string openEpubPath;
~CrossPointState() = default; ~CrossPointState() = default;
// Get singleton instance
static CrossPointState& getInstance() { return instance; }
bool saveToFile() const; bool saveToFile() const;
bool loadFromFile(); bool loadFromFile();
}; };
// Helper macro to access settings
#define APP_STATE CrossPointState::getInstance()

160
src/WifiCredentialStore.cpp Normal file
View File

@ -0,0 +1,160 @@
#include "WifiCredentialStore.h"
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <fstream>
// Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance;
namespace {
// File format version
constexpr uint8_t WIFI_FILE_VERSION = 1;
// WiFi credentials file path
constexpr char WIFI_FILE[] = "/sd/.crosspoint/wifi.bin";
// Obfuscation key - "CrossPoint" in ASCII
// This is NOT cryptographic security, just prevents casual file reading
constexpr uint8_t OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74};
constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY);
} // namespace
void WifiCredentialStore::obfuscate(std::string& data) const {
Serial.printf("[%lu] [WCS] Obfuscating/deobfuscating %zu bytes\n", millis(), data.size());
for (size_t i = 0; i < data.size(); i++) {
data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH];
}
}
bool WifiCredentialStore::saveToFile() const {
// Make sure the directory exists
SD.mkdir("/.crosspoint");
std::ofstream file(WIFI_FILE, std::ios::binary);
if (!file) {
Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis());
return false;
}
// Write header
serialization::writePod(file, WIFI_FILE_VERSION);
serialization::writePod(file, static_cast<uint8_t>(credentials.size()));
// Write each credential
for (const auto& cred : credentials) {
// Write SSID (plaintext - not sensitive)
serialization::writeString(file, cred.ssid);
Serial.printf("[%lu] [WCS] Saving SSID: %s, password length: %zu\n", millis(), cred.ssid.c_str(),
cred.password.size());
// Write password (obfuscated)
std::string obfuscatedPwd = cred.password;
obfuscate(obfuscatedPwd);
serialization::writeString(file, obfuscatedPwd);
}
file.close();
Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size());
return true;
}
bool WifiCredentialStore::loadFromFile() {
if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix
Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis());
return false;
}
std::ifstream file(WIFI_FILE, std::ios::binary);
if (!file) {
Serial.printf("[%lu] [WCS] Failed to open wifi.bin for reading\n", millis());
return false;
}
// Read and verify version
uint8_t version;
serialization::readPod(file, version);
if (version != WIFI_FILE_VERSION) {
Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version);
file.close();
return false;
}
// Read credential count
uint8_t count;
serialization::readPod(file, count);
// Read credentials
credentials.clear();
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
WifiCredential cred;
// Read SSID
serialization::readString(file, cred.ssid);
// Read and deobfuscate password
serialization::readString(file, cred.password);
Serial.printf("[%lu] [WCS] Loaded SSID: %s, obfuscated password length: %zu\n", millis(), cred.ssid.c_str(),
cred.password.size());
obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates
Serial.printf("[%lu] [WCS] After deobfuscation, password length: %zu\n", millis(), cred.password.size());
credentials.push_back(cred);
}
file.close();
Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size());
return true;
}
bool WifiCredentialStore::addCredential(const std::string& ssid, const std::string& password) {
// Check if this SSID already exists and update it
for (auto& cred : credentials) {
if (cred.ssid == ssid) {
cred.password = password;
Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str());
return saveToFile();
}
}
// Check if we've reached the limit
if (credentials.size() >= MAX_NETWORKS) {
Serial.printf("[%lu] [WCS] Cannot add more networks, limit of %zu reached\n", millis(), MAX_NETWORKS);
return false;
}
// Add new credential
credentials.push_back({ssid, password});
Serial.printf("[%lu] [WCS] Added credentials for: %s\n", millis(), ssid.c_str());
return saveToFile();
}
bool WifiCredentialStore::removeCredential(const std::string& ssid) {
for (auto it = credentials.begin(); it != credentials.end(); ++it) {
if (it->ssid == ssid) {
credentials.erase(it);
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
return saveToFile();
}
}
return false; // Not found
}
const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssid) const {
for (const auto& cred : credentials) {
if (cred.ssid == ssid) {
return &cred;
}
}
return nullptr;
}
bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
void WifiCredentialStore::clearAll() {
credentials.clear();
saveToFile();
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis());
}

56
src/WifiCredentialStore.h Normal file
View File

@ -0,0 +1,56 @@
#pragma once
#include <string>
#include <vector>
struct WifiCredential {
std::string ssid;
std::string password; // Stored obfuscated in file
};
/**
* Singleton class for storing WiFi credentials on the SD card.
* Credentials are stored in /sd/.crosspoint/wifi.bin with basic
* XOR obfuscation to prevent casual reading (not cryptographically secure).
*/
class WifiCredentialStore {
private:
static WifiCredentialStore instance;
std::vector<WifiCredential> credentials;
static constexpr size_t MAX_NETWORKS = 8;
// Private constructor for singleton
WifiCredentialStore() = default;
// XOR obfuscation (symmetric - same for encode/decode)
void obfuscate(std::string& data) const;
public:
// Delete copy constructor and assignment
WifiCredentialStore(const WifiCredentialStore&) = delete;
WifiCredentialStore& operator=(const WifiCredentialStore&) = delete;
// Get singleton instance
static WifiCredentialStore& getInstance() { return instance; }
// Save/load from SD card
bool saveToFile() const;
bool loadFromFile();
// Credential management
bool addCredential(const std::string& ssid, const std::string& password);
bool removeCredential(const std::string& ssid);
const WifiCredential* findCredential(const std::string& ssid) const;
// Get all stored credentials (for UI display)
const std::vector<WifiCredential>& getCredentials() const { return credentials; }
// Check if a network is saved
bool hasSavedCredential(const std::string& ssid) const;
// Clear all credentials
void clearAll();
};
// Helper macro to access credentials store
#define WIFI_STORE WifiCredentialStore::getInstance()

19
src/activities/Activity.h Normal file
View File

@ -0,0 +1,19 @@
#pragma once
#include <InputManager.h>
class GfxRenderer;
class Activity {
protected:
GfxRenderer& renderer;
InputManager& inputManager;
public:
explicit Activity(GfxRenderer& renderer, InputManager& inputManager)
: renderer(renderer), inputManager(inputManager) {}
virtual ~Activity() = default;
virtual void onEnter() {}
virtual void onExit() {}
virtual void loop() {}
virtual bool skipLoopDelay() { return false; }
};

View File

@ -0,0 +1,21 @@
#include "ActivityWithSubactivity.h"
void ActivityWithSubactivity::exitActivity() {
if (subActivity) {
subActivity->onExit();
subActivity.reset();
}
}
void ActivityWithSubactivity::enterNewActivity(Activity* activity) {
subActivity.reset(activity);
subActivity->onEnter();
}
void ActivityWithSubactivity::loop() {
if (subActivity) {
subActivity->loop();
}
}
void ActivityWithSubactivity::onExit() { exitActivity(); }

View File

@ -0,0 +1,17 @@
#pragma once
#include <memory>
#include "Activity.h"
class ActivityWithSubactivity : public Activity {
protected:
std::unique_ptr<Activity> subActivity = nullptr;
void exitActivity();
void enterNewActivity(Activity* activity);
public:
explicit ActivityWithSubactivity(GfxRenderer& renderer, InputManager& inputManager)
: Activity(renderer, inputManager) {}
void loop() override;
void onExit() override;
};

View File

@ -1,11 +1,11 @@
#include "BootLogoScreen.h" #include "BootActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include "config.h" #include "config.h"
#include "images/CrossLarge.h" #include "images/CrossLarge.h"
void BootLogoScreen::onEnter() { void BootActivity::onEnter() {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = GfxRenderer::getScreenHeight();

View File

@ -0,0 +1,8 @@
#pragma once
#include "../Activity.h"
class BootActivity final : public Activity {
public:
explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {}
void onEnter() override;
};

View File

@ -0,0 +1,155 @@
#include "SleepActivity.h"
#include <GfxRenderer.h>
#include <vector>
#include "CrossPointSettings.h"
#include "SD.h"
#include "config.h"
#include "images/CrossLarge.h"
void SleepActivity::onEnter() {
renderPopup("Entering Sleep...");
// Check if we have a /sleep directory
auto dir = SD.open("/sleep");
if (dir && dir.isDirectory()) {
std::vector<std::string> files;
// collect all valid BMP files
for (File file = dir.openNextFile(); file; file = dir.openNextFile()) {
if (file.isDirectory()) {
file.close();
continue;
}
auto filename = std::string(file.name());
if (filename[0] == '.') {
file.close();
continue;
}
if (filename.substr(filename.length() - 4) != ".bmp") {
Serial.printf("[%lu] [Slp] Skipping non-.bmp file name: %s\n", millis(), file.name());
file.close();
continue;
}
Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [Slp] Skipping invalid BMP file: %s\n", millis(), file.name());
file.close();
continue;
}
files.emplace_back(filename);
file.close();
}
int numFiles = files.size();
if (numFiles > 0) {
// Generate a random number between 1 and numFiles
int randomFileIndex = random(numFiles);
auto filename = "/sleep/" + files[randomFileIndex];
auto file = SD.open(filename.c_str());
if (file) {
Serial.printf("[%lu] [Slp] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100);
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderCustomSleepScreen(bitmap);
dir.close();
return;
}
}
}
}
if (dir) dir.close();
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
auto file = SD.open("/sleep.bmp");
if (file) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [Slp] Loading: /sleep.bmp\n", millis());
renderCustomSleepScreen(bitmap);
return;
}
}
renderDefaultSleepScreen();
}
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(READER_FONT_ID, message);
constexpr int margin = 20;
const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, message);
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer();
}
void SleepActivity::renderDefaultSleepScreen() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
// Apply white screen if enabled in settings
if (!SETTINGS.whiteSleepScreen) {
renderer.invertScreen();
}
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
}
void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const {
int x, y;
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
// image will scale, make sure placement is right
const float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
if (ratio > screenRatio) {
// image wider than viewport ratio, scaled down image needs to be centered vertically
x = 0;
y = (pageHeight - pageWidth / ratio) / 2;
} else {
// image taller than viewport ratio, scaled down image needs to be centered horizontally
x = (pageWidth - pageHeight * ratio) / 2;
y = 0;
}
} else {
// center the image
x = (pageWidth - bitmap.getWidth()) / 2;
y = (pageHeight - bitmap.getHeight()) / 2;
}
renderer.clearScreen();
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight);
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
if (bitmap.hasGreyscale()) {
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight);
renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight);
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
}
}

View File

@ -0,0 +1,15 @@
#pragma once
#include "../Activity.h"
class Bitmap;
class SleepActivity final : public Activity {
public:
explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {}
void onEnter() override;
private:
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen(const Bitmap& bitmap) const;
void renderPopup(const char* message) const;
};

View File

@ -0,0 +1,106 @@
#include "HomeActivity.h"
#include <GfxRenderer.h>
#include <SD.h>
#include "config.h"
namespace {
constexpr int menuItemCount = 3;
}
void HomeActivity::taskTrampoline(void* param) {
auto* self = static_cast<HomeActivity*>(param);
self->displayTaskLoop();
}
void HomeActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
selectorIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void HomeActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void HomeActivity::loop() {
const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (selectorIndex == 0) {
onReaderOpen();
} else if (selectorIndex == 1) {
onFileTransferOpen();
} else if (selectorIndex == 2) {
onSettingsOpen();
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuItemCount;
updateRequired = true;
}
}
void HomeActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void HomeActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30);
renderer.drawText(UI_FONT_ID, 20, 60, "Read", selectorIndex != 0);
renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1);
renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2);
renderer.drawRect(25, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back");
renderer.drawRect(130, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35,
"Confirm");
renderer.drawRect(245, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left");
renderer.drawRect(350, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 350 + (105 - renderer.getTextWidth(UI_FONT_ID, "Right")) / 2, pageHeight - 35, "Right");
renderer.displayBuffer();
}

View File

@ -0,0 +1,33 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "../Activity.h"
class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
: Activity(renderer, inputManager),
onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -0,0 +1,243 @@
#include "CrossPointWebServerActivity.h"
#include <GfxRenderer.h>
#include <WiFi.h>
#include "config.h"
void CrossPointWebServerActivity::taskTrampoline(void* param) {
auto* self = static_cast<CrossPointWebServerActivity*>(param);
self->displayTaskLoop();
}
void CrossPointWebServerActivity::onEnter() {
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onEnter ==========\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap());
renderingMutex = xSemaphoreCreateMutex();
// Reset state
state = WebServerActivityState::WIFI_SELECTION;
connectedIP.clear();
connectedSSID.clear();
lastHandleClientTime = 0;
updateRequired = true;
xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
// Turn on WiFi immediately
Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis());
WiFi.mode(WIFI_STA);
// Launch WiFi selection subactivity
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
wifiSelection.reset(new WifiSelectionActivity(renderer, inputManager,
[this](bool connected) { onWifiSelectionComplete(connected); }));
wifiSelection->onEnter();
}
void CrossPointWebServerActivity::onExit() {
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit START ==========\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
state = WebServerActivityState::SHUTTING_DOWN;
// Stop the web server first (before disconnecting WiFi)
stopWebServer();
// Exit WiFi selection subactivity if still active
if (wifiSelection) {
Serial.printf("[%lu] [WEBACT] Exiting WifiSelectionActivity...\n", millis());
wifiSelection->onExit();
wifiSelection.reset();
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity exited\n", millis());
}
// CRITICAL: Wait for LWIP stack to flush any pending packets
Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis());
delay(500);
// Disconnect WiFi gracefully
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
delay(100); // Allow disconnect frame to be sent
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis());
WiFi.mode(WIFI_OFF);
delay(100); // Allow WiFi hardware to fully power down
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap());
// Acquire mutex before deleting task
Serial.printf("[%lu] [WEBACT] Acquiring rendering mutex before task deletion...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task
Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis());
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
Serial.printf("[%lu] [WEBACT] Display task deleted\n", millis());
}
// Delete the mutex
Serial.printf("[%lu] [WEBACT] Deleting mutex...\n", millis());
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit COMPLETE ==========\n", millis());
}
void CrossPointWebServerActivity::onWifiSelectionComplete(bool connected) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected);
if (connected) {
// Get connection info before exiting subactivity
connectedIP = wifiSelection->getConnectedIP();
connectedSSID = WiFi.SSID().c_str();
// Exit the wifi selection subactivity
wifiSelection->onExit();
wifiSelection.reset();
// Start the web server
startWebServer();
} else {
// User cancelled - go back
onGoBack();
}
}
void CrossPointWebServerActivity::startWebServer() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
// Create the web server instance
webServer.reset(new CrossPointWebServer());
webServer->begin();
if (webServer->isRunning()) {
state = WebServerActivityState::SERVER_RUNNING;
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
// Force an immediate render since we're transitioning from a subactivity
// that had its own rendering task. We need to make sure our display is shown.
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
Serial.printf("[%lu] [WEBACT] Rendered File Transfer screen\n", millis());
} else {
Serial.printf("[%lu] [WEBACT] ERROR: Failed to start web server!\n", millis());
webServer.reset();
// Go back on error
onGoBack();
}
}
void CrossPointWebServerActivity::stopWebServer() {
if (webServer && webServer->isRunning()) {
Serial.printf("[%lu] [WEBACT] Stopping web server...\n", millis());
webServer->stop();
Serial.printf("[%lu] [WEBACT] Web server stopped\n", millis());
}
webServer.reset();
}
void CrossPointWebServerActivity::loop() {
// Handle different states
switch (state) {
case WebServerActivityState::WIFI_SELECTION:
// Forward loop to WiFi selection subactivity
if (wifiSelection) {
wifiSelection->loop();
}
break;
case WebServerActivityState::SERVER_RUNNING:
// Handle web server requests - call handleClient multiple times per loop
// to improve responsiveness and upload throughput
if (webServer && webServer->isRunning()) {
unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
// Log if there's a significant gap between handleClient calls (>100ms)
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(),
timeSinceLastHandleClient);
}
// Call handleClient multiple times to process pending requests faster
// This is critical for upload performance - HTTP file uploads send data
// in chunks and each handleClient() call processes incoming data
constexpr int HANDLE_CLIENT_ITERATIONS = 10;
for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
}
lastHandleClientTime = millis();
}
// Handle exit on Back button
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack();
return;
}
break;
case WebServerActivityState::SHUTTING_DOWN:
// Do nothing - waiting for cleanup
break;
}
}
void CrossPointWebServerActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void CrossPointWebServerActivity::render() const {
// Only render our own UI when server is running
// WiFi selection handles its own rendering
if (state == WebServerActivityState::SERVER_RUNNING) {
renderer.clearScreen();
renderServerRunning();
renderer.displayBuffer();
}
}
void CrossPointWebServerActivity::renderServerRunning() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 5) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 30, "File Transfer", true, BOLD);
std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
}
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR);
}

View File

@ -0,0 +1,66 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <memory>
#include <string>
#include "../Activity.h"
#include "WifiSelectionActivity.h"
#include "server/CrossPointWebServer.h"
// Web server activity states
enum class WebServerActivityState {
WIFI_SELECTION, // WiFi selection subactivity is active
SERVER_RUNNING, // Web server is running and handling requests
SHUTTING_DOWN // Shutting down server and WiFi
};
/**
* CrossPointWebServerActivity is the entry point for file transfer functionality.
* It:
* - Immediately turns on WiFi and launches WifiSelectionActivity on enter
* - When WifiSelectionActivity completes successfully, starts the CrossPointWebServer
* - Handles client requests in its loop() function
* - Cleans up the server and shuts down WiFi on exit
*/
class CrossPointWebServerActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
WebServerActivityState state = WebServerActivityState::WIFI_SELECTION;
const std::function<void()> onGoBack;
// WiFi selection subactivity
std::unique_ptr<WifiSelectionActivity> wifiSelection;
// Web server - owned by this activity
std::unique_ptr<CrossPointWebServer> webServer;
// Server status
std::string connectedIP;
std::string connectedSSID;
// Performance monitoring
unsigned long lastHandleClientTime = 0;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void renderServerRunning() const;
void onWifiSelectionComplete(bool connected);
void startWebServer();
void stopWebServer();
public:
explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void()>& onGoBack)
: Activity(renderer, inputManager), onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
};

View File

@ -0,0 +1,691 @@
#include "WifiSelectionActivity.h"
#include <GfxRenderer.h>
#include <WiFi.h>
#include <map>
#include "WifiCredentialStore.h"
#include "config.h"
void WifiSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<WifiSelectionActivity*>(param);
self->displayTaskLoop();
}
void WifiSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Load saved WiFi credentials
WIFI_STORE.loadFromFile();
// Reset state
selectedNetworkIndex = 0;
networks.clear();
state = WifiSelectionState::SCANNING;
selectedSSID.clear();
connectedIP.clear();
connectionError.clear();
enteredPassword.clear();
usedSavedPassword = false;
savePromptSelection = 0;
forgetPromptSelection = 0;
keyboard.reset();
// Trigger first update to show scanning message
updateRequired = true;
xTaskCreate(&WifiSelectionActivity::taskTrampoline, "WifiSelectionTask",
4096, // Stack size (larger for WiFi operations)
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
// Start WiFi scan
startWifiScan();
}
void WifiSelectionActivity::onExit() {
Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit START ==========\n", millis());
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
// Stop any ongoing WiFi scan
Serial.printf("[%lu] [WIFI] Deleting WiFi scan...\n", millis());
WiFi.scanDelete();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap());
// Note: We do NOT disconnect WiFi here - the parent activity (CrossPointWebServerActivity)
// manages WiFi connection state. We just clean up the scan and task.
// Acquire mutex before deleting task to ensure task isn't using it
// This prevents hangs/crashes if the task holds the mutex when deleted
Serial.printf("[%lu] [WIFI] Acquiring rendering mutex before task deletion...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task (we now hold the mutex, so task is blocked if it needs it)
Serial.printf("[%lu] [WIFI] Deleting display task...\n", millis());
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
Serial.printf("[%lu] [WIFI] Display task deleted\n", millis());
}
// Now safe to delete the mutex since we own it
Serial.printf("[%lu] [WIFI] Deleting mutex...\n", millis());
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis());
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit COMPLETE ==========\n", millis());
}
void WifiSelectionActivity::startWifiScan() {
state = WifiSelectionState::SCANNING;
networks.clear();
updateRequired = true;
// Set WiFi mode to station
WiFi.mode(WIFI_STA);
WiFi.disconnect();
delay(100);
// Start async scan
WiFi.scanNetworks(true); // true = async scan
}
void WifiSelectionActivity::processWifiScanResults() {
int16_t scanResult = WiFi.scanComplete();
if (scanResult == WIFI_SCAN_RUNNING) {
// Scan still in progress
return;
}
if (scanResult == WIFI_SCAN_FAILED) {
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
return;
}
// Scan complete, process results
// Use a map to deduplicate networks by SSID, keeping the strongest signal
std::map<std::string, WifiNetworkInfo> uniqueNetworks;
for (int i = 0; i < scanResult; i++) {
std::string ssid = WiFi.SSID(i).c_str();
int32_t rssi = WiFi.RSSI(i);
// Skip hidden networks (empty SSID)
if (ssid.empty()) {
continue;
}
// Check if we've already seen this SSID
auto it = uniqueNetworks.find(ssid);
if (it == uniqueNetworks.end() || rssi > it->second.rssi) {
// New network or stronger signal than existing entry
WifiNetworkInfo network;
network.ssid = ssid;
network.rssi = rssi;
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
uniqueNetworks[ssid] = network;
}
}
// Convert map to vector
networks.clear();
for (const auto& pair : uniqueNetworks) {
networks.push_back(pair.second);
}
// Sort by signal strength (strongest first)
std::sort(networks.begin(), networks.end(),
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
WiFi.scanDelete();
state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0;
updateRequired = true;
}
void WifiSelectionActivity::selectNetwork(int index) {
if (index < 0 || index >= static_cast<int>(networks.size())) {
return;
}
const auto& network = networks[index];
selectedSSID = network.ssid;
selectedRequiresPassword = network.isEncrypted;
usedSavedPassword = false;
enteredPassword.clear();
// Check if we have saved credentials for this network
const auto* savedCred = WIFI_STORE.findCredential(selectedSSID);
if (savedCred && !savedCred->password.empty()) {
// Use saved password - connect directly
enteredPassword = savedCred->password;
usedSavedPassword = true;
Serial.printf("[%lu] [WiFi] Using saved password for %s, length: %zu\n", millis(), selectedSSID.c_str(),
enteredPassword.size());
attemptConnection();
return;
}
if (selectedRequiresPassword) {
// Show password entry
state = WifiSelectionState::PASSWORD_ENTRY;
keyboard.reset(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password",
"", // No initial text
64, // Max password length
false // Show password by default (hard keyboard to use)
));
updateRequired = true;
} else {
// Connect directly for open networks
attemptConnection();
}
}
void WifiSelectionActivity::attemptConnection() {
state = WifiSelectionState::CONNECTING;
connectionStartTime = millis();
connectedIP.clear();
connectionError.clear();
updateRequired = true;
WiFi.mode(WIFI_STA);
// Get password from keyboard if we just entered it
if (keyboard && !usedSavedPassword) {
enteredPassword = keyboard->getText();
}
if (selectedRequiresPassword && !enteredPassword.empty()) {
WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str());
} else {
WiFi.begin(selectedSSID.c_str());
}
}
void WifiSelectionActivity::checkConnectionStatus() {
if (state != WifiSelectionState::CONNECTING) {
return;
}
wl_status_t status = WiFi.status();
if (status == WL_CONNECTED) {
// Successfully connected
IPAddress ip = WiFi.localIP();
char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
connectedIP = ipStr;
// If we entered a new password, ask if user wants to save it
// Otherwise, immediately complete so parent can start web server
if (!usedSavedPassword && !enteredPassword.empty()) {
state = WifiSelectionState::SAVE_PROMPT;
savePromptSelection = 0; // Default to "Yes"
updateRequired = true;
} else {
// Using saved password or open network - complete immediately
Serial.printf("[%lu] [WIFI] Connected with saved/open credentials, completing immediately\n", millis());
onComplete(true);
}
return;
}
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
connectionError = "Connection failed";
if (status == WL_NO_SSID_AVAIL) {
connectionError = "Network not found";
}
state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true;
return;
}
// Check for timeout
if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) {
WiFi.disconnect();
connectionError = "Connection timeout";
state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true;
return;
}
}
void WifiSelectionActivity::loop() {
// Check scan progress
if (state == WifiSelectionState::SCANNING) {
processWifiScanResults();
return;
}
// Check connection progress
if (state == WifiSelectionState::CONNECTING) {
checkConnectionStatus();
return;
}
// Handle password entry state
if (state == WifiSelectionState::PASSWORD_ENTRY && keyboard) {
keyboard->handleInput();
if (keyboard->isComplete()) {
attemptConnection();
return;
}
if (keyboard->isCancelled()) {
state = WifiSelectionState::NETWORK_LIST;
keyboard.reset();
updateRequired = true;
return;
}
updateRequired = true;
return;
}
// Handle save prompt state
if (state == WifiSelectionState::SAVE_PROMPT) {
if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) {
if (savePromptSelection > 0) {
savePromptSelection--;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (savePromptSelection < 1) {
savePromptSelection++;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (savePromptSelection == 0) {
// User chose "Yes" - save the password
WIFI_STORE.addCredential(selectedSSID, enteredPassword);
}
// Complete - parent will start web server
onComplete(true);
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
// Skip saving, complete anyway
onComplete(true);
}
return;
}
// Handle forget prompt state (connection failed with saved credentials)
if (state == WifiSelectionState::FORGET_PROMPT) {
if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) {
if (forgetPromptSelection > 0) {
forgetPromptSelection--;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (forgetPromptSelection < 1) {
forgetPromptSelection++;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (forgetPromptSelection == 0) {
// User chose "Yes" - forget the network
WIFI_STORE.removeCredential(selectedSSID);
// Update the network list to reflect the change
for (auto& network : networks) {
if (network.ssid == selectedSSID) {
network.hasSavedPassword = false;
break;
}
}
}
// Go back to network list
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
// Skip forgetting, go back to network list
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
}
return;
}
// Handle connected state (should not normally be reached - connection completes immediately)
if (state == WifiSelectionState::CONNECTED) {
// Safety fallback - immediately complete
onComplete(true);
return;
}
// Handle connection failed state
if (state == WifiSelectionState::CONNECTION_FAILED) {
if (inputManager.wasPressed(InputManager::BTN_BACK) || inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
// If we used saved credentials, offer to forget the network
if (usedSavedPassword) {
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Yes"
} else {
// Go back to network list on failure
state = WifiSelectionState::NETWORK_LIST;
}
updateRequired = true;
return;
}
}
// Handle network list state
if (state == WifiSelectionState::NETWORK_LIST) {
// Check for Back button to exit (cancel)
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onComplete(false);
return;
}
// Check for Confirm button to select network or rescan
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (!networks.empty()) {
selectNetwork(selectedNetworkIndex);
} else {
startWifiScan();
}
return;
}
// Handle UP/DOWN navigation
if (inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT)) {
if (selectedNetworkIndex > 0) {
selectedNetworkIndex--;
updateRequired = true;
}
} else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) {
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) {
selectedNetworkIndex++;
updateRequired = true;
}
}
}
}
std::string WifiSelectionActivity::getSignalStrengthIndicator(int32_t rssi) const {
// Convert RSSI to signal bars representation
if (rssi >= -50) {
return "||||"; // Excellent
} else if (rssi >= -60) {
return "||| "; // Good
} else if (rssi >= -70) {
return "|| "; // Fair
} else if (rssi >= -80) {
return "| "; // Weak
}
return " "; // Very weak
}
void WifiSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void WifiSelectionActivity::render() const {
renderer.clearScreen();
switch (state) {
case WifiSelectionState::SCANNING:
renderConnecting(); // Reuse connecting screen with different message
break;
case WifiSelectionState::NETWORK_LIST:
renderNetworkList();
break;
case WifiSelectionState::PASSWORD_ENTRY:
renderPasswordEntry();
break;
case WifiSelectionState::CONNECTING:
renderConnecting();
break;
case WifiSelectionState::CONNECTED:
renderConnected();
break;
case WifiSelectionState::SAVE_PROMPT:
renderSavePrompt();
break;
case WifiSelectionState::CONNECTION_FAILED:
renderConnectionFailed();
break;
case WifiSelectionState::FORGET_PROMPT:
renderForgetPrompt();
break;
}
renderer.displayBuffer();
}
void WifiSelectionActivity::renderNetworkList() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD);
if (networks.empty()) {
// No networks found or scan failed
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height) / 2;
renderer.drawCenteredText(UI_FONT_ID, top, "No networks found", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR);
} else {
// Calculate how many networks we can display
const int startY = 60;
const int lineHeight = 25;
const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight;
// Calculate scroll offset to keep selected item visible
int scrollOffset = 0;
if (selectedNetworkIndex >= maxVisibleNetworks) {
scrollOffset = selectedNetworkIndex - maxVisibleNetworks + 1;
}
// Draw networks
int displayIndex = 0;
for (size_t i = scrollOffset; i < networks.size() && displayIndex < maxVisibleNetworks; i++, displayIndex++) {
const int networkY = startY + displayIndex * lineHeight;
const auto& network = networks[i];
// Draw selection indicator
if (static_cast<int>(i) == selectedNetworkIndex) {
renderer.drawText(UI_FONT_ID, 5, networkY, ">");
}
// Draw network name (truncate if too long)
std::string displayName = network.ssid;
if (displayName.length() > 16) {
displayName = displayName.substr(0, 13) + "...";
}
renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str());
// Draw signal strength indicator
std::string signalStr = getSignalStrengthIndicator(network.rssi);
renderer.drawText(UI_FONT_ID, pageWidth - 90, networkY, signalStr.c_str());
// Draw saved indicator (checkmark) for networks with saved passwords
if (network.hasSavedPassword) {
renderer.drawText(UI_FONT_ID, pageWidth - 50, networkY, "+");
}
// Draw lock icon for encrypted networks
if (network.isEncrypted) {
renderer.drawText(UI_FONT_ID, pageWidth - 30, networkY, "*");
}
}
// Draw scroll indicators if needed
if (scrollOffset > 0) {
renderer.drawText(SMALL_FONT_ID, pageWidth - 15, startY - 10, "^");
}
if (scrollOffset + maxVisibleNetworks < static_cast<int>(networks.size())) {
renderer.drawText(SMALL_FONT_ID, pageWidth - 15, startY + maxVisibleNetworks * lineHeight, "v");
}
// Show network count
char countStr[32];
snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size());
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 45, countStr);
}
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "OK: Connect | * = Encrypted | + = Saved");
}
void WifiSelectionActivity::renderPasswordEntry() const {
const auto pageHeight = GfxRenderer::getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD);
// Draw network name with good spacing from header
std::string networkInfo = "Network: " + selectedSSID;
if (networkInfo.length() > 30) {
networkInfo = networkInfo.substr(0, 27) + "...";
}
renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR);
// Draw keyboard
if (keyboard) {
keyboard->render(58);
}
}
void WifiSelectionActivity::renderConnecting() const {
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height) / 2;
if (state == WifiSelectionState::SCANNING) {
renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR);
} else {
renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connecting...", true, BOLD);
std::string ssidInfo = "to " + selectedSSID;
if (ssidInfo.length() > 25) {
ssidInfo = ssidInfo.substr(0, 22) + "...";
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
}
}
void WifiSelectionActivity::renderConnected() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 4) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connected!", true, BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
}
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR);
}
void WifiSelectionActivity::renderSavePrompt() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 40, "Connected!", true, BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_FONT_ID, top + 40, "Save password for next time?", true, REGULAR);
// Draw Yes/No buttons
const int buttonY = top + 80;
const int buttonWidth = 60;
const int buttonSpacing = 30;
const int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button
if (savePromptSelection == 0) {
renderer.drawText(UI_FONT_ID, startX, buttonY, "[Yes]");
} else {
renderer.drawText(UI_FONT_ID, startX + 4, buttonY, "Yes");
}
// Draw "No" button
if (savePromptSelection == 1) {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
} else {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm", true, REGULAR);
}
void WifiSelectionActivity::renderConnectionFailed() const {
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 2) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 20, "Connection Failed", true, BOLD);
renderer.drawCenteredText(UI_FONT_ID, top + 20, connectionError.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR);
}
void WifiSelectionActivity::renderForgetPrompt() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 40, "Forget Network?", true, BOLD);
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(UI_FONT_ID, top + 40, "Remove saved password?", true, REGULAR);
// Draw Yes/No buttons
const int buttonY = top + 80;
const int buttonWidth = 60;
const int buttonSpacing = 30;
const int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button
if (forgetPromptSelection == 0) {
renderer.drawText(UI_FONT_ID, startX, buttonY, "[Yes]");
} else {
renderer.drawText(UI_FONT_ID, startX + 4, buttonY, "Yes");
}
// Draw "No" button
if (forgetPromptSelection == 1) {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]");
} else {
renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No");
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm", true, REGULAR);
}

View File

@ -0,0 +1,108 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "../Activity.h"
#include "../util/KeyboardEntryActivity.h"
// Structure to hold WiFi network information
struct WifiNetworkInfo {
std::string ssid;
int32_t rssi;
bool isEncrypted;
bool hasSavedPassword; // Whether we have saved credentials for this network
};
// WiFi selection states
enum class WifiSelectionState {
SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks
PASSWORD_ENTRY, // Entering password for selected network
CONNECTING, // Attempting to connect
CONNECTED, // Successfully connected
SAVE_PROMPT, // Asking user if they want to save the password
CONNECTION_FAILED, // Connection failed
FORGET_PROMPT // Asking user if they want to forget the network
};
/**
* WifiSelectionActivity is responsible for scanning WiFi APs and connecting to them.
* It will:
* - Enter scanning mode on entry
* - List available WiFi networks
* - Allow selection and launch KeyboardEntryActivity for password if needed
* - Save the password if requested
* - Call onComplete callback when connected or cancelled
*
* The onComplete callback receives true if connected successfully, false if cancelled.
*/
class WifiSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
WifiSelectionState state = WifiSelectionState::SCANNING;
int selectedNetworkIndex = 0;
std::vector<WifiNetworkInfo> networks;
const std::function<void(bool connected)> onComplete;
// Selected network for connection
std::string selectedSSID;
bool selectedRequiresPassword = false;
// On-screen keyboard for password entry
std::unique_ptr<KeyboardEntryActivity> keyboard;
// Connection result
std::string connectedIP;
std::string connectionError;
// Password to potentially save (from keyboard or saved credentials)
std::string enteredPassword;
// Whether network was connected using a saved password (skip save prompt)
bool usedSavedPassword = false;
// Save/forget prompt selection (0 = Yes, 1 = No)
int savePromptSelection = 0;
int forgetPromptSelection = 0;
// Connection timeout
static constexpr unsigned long CONNECTION_TIMEOUT_MS = 15000;
unsigned long connectionStartTime = 0;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void renderNetworkList() const;
void renderPasswordEntry() const;
void renderConnecting() const;
void renderConnected() const;
void renderSavePrompt() const;
void renderConnectionFailed() const;
void renderForgetPrompt() const;
void startWifiScan();
void processWifiScanResults();
void selectNetwork(int index);
void attemptConnection();
void checkConnectionStatus();
std::string getSignalStrengthIndicator(int32_t rssi) const;
public:
explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(bool connected)>& onComplete)
: Activity(renderer, inputManager), onComplete(onComplete) {}
void onEnter() override;
void onExit() override;
void loop() override;
// Get the IP address after successful connection
const std::string& getConnectedIP() const { return connectedIP; }
};

View File

@ -0,0 +1,735 @@
#include "CrossPointWebServer.h"
#include <SD.h>
#include <WiFi.h>
#include <algorithm>
#include "config.h"
#include "html/FilesPageFooterHtml.generated.h"
#include "html/FilesPageHeaderHtml.generated.h"
#include "html/HomePageHtml.generated.h"
namespace {
// Folders/files to hide from the web interface file browser
// Note: Items starting with "." are automatically hidden
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
const size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
// Helper function to escape HTML special characters to prevent XSS
String escapeHtml(const String& input) {
String output;
output.reserve(input.length() * 1.1); // Pre-allocate with some extra space
for (size_t i = 0; i < input.length(); i++) {
char c = input.charAt(i);
switch (c) {
case '&':
output += "&amp;";
break;
case '<':
output += "&lt;";
break;
case '>':
output += "&gt;";
break;
case '"':
output += "&quot;";
break;
case '\'':
output += "&#39;";
break;
default:
output += c;
break;
}
}
return output;
}
} // namespace
// File listing page template - now using generated headers:
// - HomePageHtml (from html/HomePage.html)
// - FilesPageHeaderHtml (from html/FilesPageHeader.html)
// - FilesPageFooterHtml (from html/FilesPageFooter.html)
CrossPointWebServer::CrossPointWebServer() {}
CrossPointWebServer::~CrossPointWebServer() { stop(); }
void CrossPointWebServer::begin() {
if (running) {
Serial.printf("[%lu] [WEB] Web server already running\n", millis());
return;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis());
return;
}
Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port);
server = new WebServer(port);
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap());
if (!server) {
Serial.printf("[%lu] [WEB] Failed to create WebServer!\n", millis());
return;
}
// Setup routes
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis());
server->on("/", HTTP_GET, [this]() { handleRoot(); });
server->on("/status", HTTP_GET, [this]() { handleStatus(); });
server->on("/files", HTTP_GET, [this]() { handleFileList(); });
// Upload endpoint with special handling for multipart form data
server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); });
// Create folder endpoint
server->on("/mkdir", HTTP_POST, [this]() { handleCreateFolder(); });
// Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this]() { handleDelete(); });
server->onNotFound([this]() { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
server->begin();
running = true;
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str());
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
}
void CrossPointWebServer::stop() {
if (!running || !server) {
Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, server);
return;
}
Serial.printf("[%lu] [WEB] STOP INITIATED - setting running=false first\n", millis());
running = false; // Set this FIRST to prevent handleClient from using server
Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap());
// Add delay to allow any in-flight handleClient() calls to complete
delay(100);
Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis());
server->stop();
Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap());
// Add another delay before deletion to ensure server->stop() completes
delay(50);
Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis());
delete server;
server = nullptr;
Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis());
Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap());
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
// later in the file and will be cleared when they go out of scope or on next upload
Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap());
}
void CrossPointWebServer::handleClient() {
static unsigned long lastDebugPrint = 0;
// Check running flag FIRST before accessing server
if (!running) {
return;
}
// Double-check server pointer is valid
if (!server) {
Serial.printf("[%lu] [WEB] WARNING: handleClient called with null server!\n", millis());
return;
}
// Print debug every 10 seconds to confirm handleClient is being called
if (millis() - lastDebugPrint > 10000) {
Serial.printf("[%lu] [WEB] handleClient active, server running on port %d\n", millis(), port);
lastDebugPrint = millis();
}
server->handleClient();
}
void CrossPointWebServer::handleRoot() {
String html = HomePageHtml;
// Replace placeholders with actual values
html.replace("%VERSION%", CROSSPOINT_VERSION);
html.replace("%IP_ADDRESS%", WiFi.localIP().toString());
html.replace("%FREE_HEAP%", String(ESP.getFreeHeap()));
server->send(200, "text/html", html);
Serial.printf("[%lu] [WEB] Served root page\n", millis());
}
void CrossPointWebServer::handleNotFound() {
String message = "404 Not Found\n\n";
message += "URI: " + server->uri() + "\n";
server->send(404, "text/plain", message);
}
void CrossPointWebServer::handleStatus() {
String json = "{";
json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\",";
json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"uptime\":" + String(millis() / 1000);
json += "}";
server->send(200, "application/json", json);
}
std::vector<FileInfo> CrossPointWebServer::scanFiles(const char* path) {
std::vector<FileInfo> files;
File root = SD.open(path);
if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
return files;
}
if (!root.isDirectory()) {
Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path);
root.close();
return files;
}
Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path);
File file = root.openNextFile();
while (file) {
String fileName = String(file.name());
// Skip hidden items (starting with ".")
bool shouldHide = fileName.startsWith(".");
// Check against explicitly hidden items list
if (!shouldHide) {
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (fileName.equals(HIDDEN_ITEMS[i])) {
shouldHide = true;
break;
}
}
}
if (!shouldHide) {
FileInfo info;
info.name = fileName;
info.isDirectory = file.isDirectory();
if (info.isDirectory) {
info.size = 0;
info.isEpub = false;
} else {
info.size = file.size();
info.isEpub = isEpubFile(info.name);
}
files.push_back(info);
}
file.close();
file = root.openNextFile();
}
root.close();
Serial.printf("[%lu] [WEB] Found %d items (files and folders)\n", millis(), files.size());
return files;
}
String CrossPointWebServer::formatFileSize(size_t bytes) {
if (bytes < 1024) {
return String(bytes) + " B";
} else if (bytes < 1024 * 1024) {
return String(bytes / 1024.0, 1) + " KB";
} else {
return String(bytes / (1024.0 * 1024.0), 1) + " MB";
}
}
bool CrossPointWebServer::isEpubFile(const String& filename) {
String lower = filename;
lower.toLowerCase();
return lower.endsWith(".epub");
}
void CrossPointWebServer::handleFileList() {
String html = FilesPageHeaderHtml;
// Get current path from query string (default to root)
String currentPath = "/";
if (server->hasArg("path")) {
currentPath = server->arg("path");
// Ensure path starts with /
if (!currentPath.startsWith("/")) {
currentPath = "/" + currentPath;
}
// Remove trailing slash unless it's root
if (currentPath.length() > 1 && currentPath.endsWith("/")) {
currentPath = currentPath.substring(0, currentPath.length() - 1);
}
}
// Get message from query string if present
if (server->hasArg("msg")) {
String msg = escapeHtml(server->arg("msg"));
String msgType = server->hasArg("type") ? escapeHtml(server->arg("type")) : "success";
html += "<div class=\"message " + msgType + "\">" + msg + "</div>";
}
// Hidden input to store current path for JavaScript
html += "<input type=\"hidden\" id=\"currentPath\" value=\"" + currentPath + "\">";
// Scan files in current path first (we need counts for the header)
std::vector<FileInfo> files = scanFiles(currentPath.c_str());
// Count items
int epubCount = 0;
int folderCount = 0;
size_t totalSize = 0;
for (const auto& file : files) {
if (file.isDirectory) {
folderCount++;
} else {
if (file.isEpub) epubCount++;
totalSize += file.size;
}
}
// Page header with inline breadcrumb and action buttons
html += "<div class=\"page-header\">";
html += "<div class=\"page-header-left\">";
html += "<h1>📁 File Manager</h1>";
// Inline breadcrumb
html += "<div class=\"breadcrumb-inline\">";
html += "<span class=\"sep\">/</span>";
if (currentPath == "/") {
html += "<span class=\"current\">🏠</span>";
} else {
html += "<a href=\"/files\">🏠</a>";
String pathParts = currentPath.substring(1); // Remove leading /
String buildPath = "";
int start = 0;
int end = pathParts.indexOf('/');
while (start < (int)pathParts.length()) {
String part;
if (end == -1) {
part = pathParts.substring(start);
buildPath += "/" + part;
html += "<span class=\"sep\">/</span><span class=\"current\">" + escapeHtml(part) + "</span>";
break;
} else {
part = pathParts.substring(start, end);
buildPath += "/" + part;
html += "<span class=\"sep\">/</span><a href=\"/files?path=" + buildPath + "\">" + escapeHtml(part) + "</a>";
start = end + 1;
end = pathParts.indexOf('/', start);
}
}
}
html += "</div>";
html += "</div>";
// Action buttons
html += "<div class=\"action-buttons\">";
html += "<button class=\"action-btn upload-action-btn\" onclick=\"openUploadModal()\">";
html += "📤 Upload";
html += "</button>";
html += "<button class=\"action-btn folder-action-btn\" onclick=\"openFolderModal()\">";
html += "📁 New Folder";
html += "</button>";
html += "</div>";
html += "</div>"; // end page-header
// Contents card with inline summary
html += "<div class=\"card\">";
// Contents header with inline stats
html += "<div class=\"contents-header\">";
html += "<h2 class=\"contents-title\">Contents</h2>";
html += "<span class=\"summary-inline\">";
html += String(folderCount) + " folder" + (folderCount != 1 ? "s" : "") + ", ";
html += String(files.size() - folderCount) + " file" + ((files.size() - folderCount) != 1 ? "s" : "") + ", ";
html += formatFileSize(totalSize);
html += "</span>";
html += "</div>";
if (files.empty()) {
html += "<div class=\"no-files\">This folder is empty</div>";
} else {
html += "<table class=\"file-table\">";
html += "<tr><th>Name</th><th>Type</th><th>Size</th><th class=\"actions-col\">Actions</th></tr>";
// Sort files: folders first, then epub files, then other files, alphabetically within each group
std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) {
// Folders come first
if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory;
// Then sort by epub status (epubs first among files)
if (!a.isDirectory && !b.isDirectory) {
if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub;
}
// Then alphabetically
return a.name < b.name;
});
for (const auto& file : files) {
String rowClass;
String icon;
String badge;
String typeStr;
String sizeStr;
if (file.isDirectory) {
rowClass = "folder-row";
icon = "📁";
badge = "<span class=\"folder-badge\">FOLDER</span>";
typeStr = "Folder";
sizeStr = "-";
// Build the path to this folder
String folderPath = currentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += file.name;
html += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>";
html += "<a href=\"/files?path=" + folderPath + "\" class=\"folder-link\">" + escapeHtml(file.name) + "</a>" +
badge + "</td>";
html += "<td>" + typeStr + "</td>";
html += "<td>" + sizeStr + "</td>";
// Escape quotes for JavaScript string
String escapedName = file.name;
escapedName.replace("'", "\\'");
String escapedPath = folderPath;
escapedPath.replace("'", "\\'");
html += "<td class=\"actions-col\"><button class=\"delete-btn\" onclick=\"openDeleteModal('" + escapedName +
"', '" + escapedPath + "', true)\" title=\"Delete folder\">🗑️</button></td>";
html += "</tr>";
} else {
rowClass = file.isEpub ? "epub-file" : "";
icon = file.isEpub ? "📗" : "📄";
badge = file.isEpub ? "<span class=\"epub-badge\">EPUB</span>" : "";
String ext = file.name.substring(file.name.lastIndexOf('.') + 1);
ext.toUpperCase();
typeStr = ext;
sizeStr = formatFileSize(file.size);
// Build file path for delete
String filePath = currentPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += file.name;
html += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>" + escapeHtml(file.name) + badge + "</td>";
html += "<td>" + typeStr + "</td>";
html += "<td>" + sizeStr + "</td>";
// Escape quotes for JavaScript string
String escapedName = file.name;
escapedName.replace("'", "\\'");
String escapedPath = filePath;
escapedPath.replace("'", "\\'");
html += "<td class=\"actions-col\"><button class=\"delete-btn\" onclick=\"openDeleteModal('" + escapedName +
"', '" + escapedPath + "', false)\" title=\"Delete file\">🗑️</button></td>";
html += "</tr>";
}
}
html += "</table>";
}
html += "</div>";
html += FilesPageFooterHtml;
server->send(200, "text/html", html);
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
}
// Static variables for upload handling
static File uploadFile;
static String uploadFileName;
static String uploadPath = "/";
static size_t uploadSize = 0;
static bool uploadSuccess = false;
static String uploadError = "";
void CrossPointWebServer::handleUpload() {
static unsigned long lastWriteTime = 0;
static unsigned long uploadStartTime = 0;
static size_t lastLoggedSize = 0;
// Safety check: ensure server is still valid
if (!running || !server) {
Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis());
return;
}
HTTPUpload& upload = server->upload();
if (upload.status == UPLOAD_FILE_START) {
uploadFileName = upload.filename;
uploadSize = 0;
uploadSuccess = false;
uploadError = "";
uploadStartTime = millis();
lastWriteTime = millis();
lastLoggedSize = 0;
// Get upload path from query parameter (defaults to root if not specified)
// Note: We use query parameter instead of form data because multipart form
// fields aren't available until after file upload completes
if (server->hasArg("path")) {
uploadPath = server->arg("path");
// Ensure path starts with /
if (!uploadPath.startsWith("/")) {
uploadPath = "/" + uploadPath;
}
// Remove trailing slash unless it's root
if (uploadPath.length() > 1 && uploadPath.endsWith("/")) {
uploadPath = uploadPath.substring(0, uploadPath.length() - 1);
}
} else {
uploadPath = "/";
}
Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str());
Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap());
// Validate file extension
if (!isEpubFile(uploadFileName)) {
uploadError = "Only .epub files are allowed";
Serial.printf("[%lu] [WEB] [UPLOAD] REJECTED - not an epub file\n", millis());
return;
}
// Create file path
String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
// Check if file already exists
if (SD.exists(filePath.c_str())) {
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str());
SD.remove(filePath.c_str());
}
// Open file for writing
uploadFile = SD.open(filePath.c_str(), FILE_WRITE);
if (!uploadFile) {
uploadError = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
return;
}
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (uploadFile && uploadError.isEmpty()) {
unsigned long writeStartTime = millis();
size_t written = uploadFile.write(upload.buf, upload.currentSize);
unsigned long writeEndTime = millis();
unsigned long writeDuration = writeEndTime - writeStartTime;
if (written != upload.currentSize) {
uploadError = "Failed to write to SD card - disk may be full";
uploadFile.close();
Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize,
written);
} else {
uploadSize += written;
// Log progress every 50KB or if write took >100ms
if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) {
unsigned long timeSinceStart = millis() - uploadStartTime;
unsigned long timeSinceLastWrite = millis() - lastWriteTime;
float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0);
Serial.printf(
"[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu "
"ms\n",
millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite);
lastLoggedSize = uploadSize;
}
lastWriteTime = millis();
}
}
} else if (upload.status == UPLOAD_FILE_END) {
if (uploadFile) {
uploadFile.close();
if (uploadError.isEmpty()) {
uploadSuccess = true;
Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize);
}
}
} else if (upload.status == UPLOAD_FILE_ABORTED) {
if (uploadFile) {
uploadFile.close();
// Try to delete the incomplete file
String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
SD.remove(filePath.c_str());
}
uploadError = "Upload aborted";
Serial.printf("[%lu] [WEB] Upload aborted\n", millis());
}
}
void CrossPointWebServer::handleUploadPost() {
if (uploadSuccess) {
server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName);
} else {
String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError;
server->send(400, "text/plain", error);
}
}
void CrossPointWebServer::handleCreateFolder() {
// Get folder name from form data
if (!server->hasArg("name")) {
server->send(400, "text/plain", "Missing folder name");
return;
}
String folderName = server->arg("name");
// Validate folder name
if (folderName.isEmpty()) {
server->send(400, "text/plain", "Folder name cannot be empty");
return;
}
// Get parent path
String parentPath = "/";
if (server->hasArg("path")) {
parentPath = server->arg("path");
if (!parentPath.startsWith("/")) {
parentPath = "/" + parentPath;
}
if (parentPath.length() > 1 && parentPath.endsWith("/")) {
parentPath = parentPath.substring(0, parentPath.length() - 1);
}
}
// Build full folder path
String folderPath = parentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += folderName;
Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str());
// Check if already exists
if (SD.exists(folderPath.c_str())) {
server->send(400, "text/plain", "Folder already exists");
return;
}
// Create the folder
if (SD.mkdir(folderPath.c_str())) {
Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName);
} else {
Serial.printf("[%lu] [WEB] Failed to create folder: %s\n", millis(), folderPath.c_str());
server->send(500, "text/plain", "Failed to create folder");
}
}
void CrossPointWebServer::handleDelete() {
// Get path from form data
if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path");
return;
}
String itemPath = server->arg("path");
String itemType = server->hasArg("type") ? server->arg("type") : "file";
// Validate path
if (itemPath.isEmpty() || itemPath == "/") {
server->send(400, "text/plain", "Cannot delete root directory");
return;
}
// Ensure path starts with /
if (!itemPath.startsWith("/")) {
itemPath = "/" + itemPath;
}
// Security check: prevent deletion of protected items
String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
// Check if item starts with a dot (hidden/system file)
if (itemName.startsWith(".")) {
Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str());
server->send(403, "text/plain", "Cannot delete system files");
return;
}
// Check against explicitly protected items
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (itemName.equals(HIDDEN_ITEMS[i])) {
Serial.printf("[%lu] [WEB] Delete rejected - protected item: %s\n", millis(), itemPath.c_str());
server->send(403, "text/plain", "Cannot delete protected items");
return;
}
}
// Check if item exists
if (!SD.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str());
server->send(404, "text/plain", "Item not found");
return;
}
Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str());
bool success = false;
if (itemType == "folder") {
// For folders, try to remove (will fail if not empty)
File dir = SD.open(itemPath.c_str());
if (dir && dir.isDirectory()) {
// Check if folder is empty
File entry = dir.openNextFile();
if (entry) {
// Folder is not empty
entry.close();
dir.close();
Serial.printf("[%lu] [WEB] Delete failed - folder not empty: %s\n", millis(), itemPath.c_str());
server->send(400, "text/plain", "Folder is not empty. Delete contents first.");
return;
}
dir.close();
}
success = SD.rmdir(itemPath.c_str());
} else {
// For files, use remove
success = SD.remove(itemPath.c_str());
}
if (success) {
Serial.printf("[%lu] [WEB] Successfully deleted: %s\n", millis(), itemPath.c_str());
server->send(200, "text/plain", "Deleted successfully");
} else {
Serial.printf("[%lu] [WEB] Failed to delete: %s\n", millis(), itemPath.c_str());
server->send(500, "text/plain", "Failed to delete item");
}
}

View File

@ -0,0 +1,56 @@
#pragma once
#include <WebServer.h>
#include <functional>
#include <string>
#include <vector>
// Structure to hold file information
struct FileInfo {
String name;
size_t size;
bool isEpub;
bool isDirectory;
};
class CrossPointWebServer {
public:
CrossPointWebServer();
~CrossPointWebServer();
// Start the web server (call after WiFi is connected)
void begin();
// Stop the web server
void stop();
// Call this periodically to handle client requests
void handleClient();
// Check if server is running
bool isRunning() const { return running; }
// Get the port number
uint16_t getPort() const { return port; }
private:
WebServer* server = nullptr;
bool running = false;
uint16_t port = 80;
// File scanning
std::vector<FileInfo> scanFiles(const char* path = "/");
String formatFileSize(size_t bytes);
bool isEpubFile(const String& filename);
// Request handlers
void handleRoot();
void handleNotFound();
void handleStatus();
void handleFileList();
void handleUpload();
void handleUploadPost();
void handleCreateFolder();
void handleDelete();
};

View File

@ -1,26 +1,30 @@
#include "EpubReaderScreen.h" #include "EpubReaderActivity.h"
#include <Epub/Page.h> #include <Epub/Page.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SD.h> #include <SD.h>
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "config.h" #include "config.h"
constexpr int PAGES_PER_REFRESH = 15; namespace {
constexpr unsigned long SKIP_CHAPTER_MS = 700; constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700;
constexpr float lineCompression = 0.95f; constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8; constexpr int marginTop = 8;
constexpr int marginRight = 10; constexpr int marginRight = 10;
constexpr int marginBottom = 22; constexpr int marginBottom = 22;
constexpr int marginLeft = 10; constexpr int marginLeft = 10;
} // namespace
void EpubReaderScreen::taskTrampoline(void* param) { void EpubReaderActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderScreen*>(param); auto* self = static_cast<EpubReaderActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
} }
void EpubReaderScreen::onEnter() { void EpubReaderActivity::onEnter() {
if (!epub) { if (!epub) {
return; return;
} }
@ -29,21 +33,21 @@ void EpubReaderScreen::onEnter() {
epub->setupCacheDir(); epub->setupCacheDir();
// TODO: Move this to a state object
if (SD.exists((epub->getCachePath() + "/progress.bin").c_str())) {
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str()); File f = SD.open((epub->getCachePath() + "/progress.bin").c_str());
if (f) {
uint8_t data[4]; uint8_t data[4];
f.read(data, 4); if (f.read(data, 4) == 4) {
currentSpineIndex = data[0] + (data[1] << 8); currentSpineIndex = data[0] + (data[1] << 8);
nextPageNumber = data[2] + (data[3] << 8); nextPageNumber = data[2] + (data[3] << 8);
Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber); Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber);
}
f.close(); f.close();
} }
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
xTaskCreate(&EpubReaderScreen::taskTrampoline, "EpubReaderScreenTask", xTaskCreate(&EpubReaderActivity::taskTrampoline, "EpubReaderActivityTask",
8192, // Stack size 8192, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
@ -51,7 +55,7 @@ void EpubReaderScreen::onEnter() {
); );
} }
void EpubReaderScreen::onExit() { void EpubReaderActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
@ -64,9 +68,40 @@ void EpubReaderScreen::onExit() {
epub.reset(); epub.reset();
} }
void EpubReaderScreen::handleInput() { void EpubReaderActivity::loop() {
// Pass input responsibility to sub activity if exists
if (subAcitivity) {
subAcitivity->loop();
return;
}
// Enter chapter selection activity
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
// Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY);
subAcitivity.reset(new EpubReaderChapterSelectionActivity(
this->renderer, this->inputManager, epub, currentSpineIndex,
[this] {
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
}));
subAcitivity->onEnter();
xSemaphoreGive(renderingMutex);
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoHome(); onGoBack();
return; return;
} }
@ -79,7 +114,15 @@ void EpubReaderScreen::handleInput() {
return; return;
} }
const bool skipChapter = inputManager.getHeldTime() > SKIP_CHAPTER_MS; // any botton press when at end of the book goes back to the last page
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
currentSpineIndex = epub->getSpineItemsCount() - 1;
nextPageNumber = UINT16_MAX;
updateRequired = true;
return;
}
const bool skipChapter = inputManager.getHeldTime() > skipChapterMs;
if (skipChapter) { if (skipChapter) {
// We don't want to delete the section mid-render, so grab the semaphore // We don't want to delete the section mid-render, so grab the semaphore
@ -125,7 +168,7 @@ void EpubReaderScreen::handleInput() {
} }
} }
void EpubReaderScreen::displayTaskLoop() { void EpubReaderActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired) {
updateRequired = false; updateRequired = false;
@ -138,21 +181,34 @@ void EpubReaderScreen::displayTaskLoop() {
} }
// TODO: Failure handling // TODO: Failure handling
void EpubReaderScreen::renderScreen() { void EpubReaderActivity::renderScreen() {
if (!epub) { if (!epub) {
return; return;
} }
if (currentSpineIndex >= epub->getSpineItemsCount() || currentSpineIndex < 0) { // edge case handling for sub-zero spine index
if (currentSpineIndex < 0) {
currentSpineIndex = 0; currentSpineIndex = 0;
} }
// based bounds of book, show end of book screen
if (currentSpineIndex > epub->getSpineItemsCount()) {
currentSpineIndex = epub->getSpineItemsCount();
}
// Show end of book screen
if (currentSpineIndex == epub->getSpineItemsCount()) {
renderer.clearScreen();
renderer.drawCenteredText(READER_FONT_ID, 300, "End of book", true, BOLD);
renderer.displayBuffer();
return;
}
if (!section) { if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex); const auto filepath = epub->getSpineItem(currentSpineIndex);
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer)); section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
marginLeft)) { SETTINGS.extraParagraphSpacing)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
{ {
@ -162,11 +218,7 @@ void EpubReaderScreen::renderScreen() {
constexpr int y = 50; constexpr int y = 50;
const int w = textWidth + margin * 2; const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
renderer.grayscaleRevert(); renderer.fillRect(x, y, w, h, false);
uint8_t *fb1 = renderer.getFrameBuffer();
renderer.swapBuffers();
memcpy(fb1, renderer.getFrameBuffer(), EInkDisplay::BUFFER_SIZE);
renderer.fillRect(x, y, w, h, 0);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing..."); renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing...");
renderer.drawRect(x + 5, y + 5, w - 10, h - 10); renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer(); renderer.displayBuffer();
@ -175,7 +227,7 @@ void EpubReaderScreen::renderScreen() {
section->setupCacheDir(); section->setupCacheDir();
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom,
marginLeft)) { marginLeft, SETTINGS.extraParagraphSpacing)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset(); section.reset();
return; return;
@ -232,41 +284,53 @@ void EpubReaderScreen::renderScreen() {
f.close(); f.close();
} }
void EpubReaderScreen::renderContents(std::unique_ptr<Page> page) { void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
page->render(renderer, READER_FONT_ID); page->render(renderer, READER_FONT_ID);
renderStatusBar(); renderStatusBar();
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = PAGES_PER_REFRESH; pagesUntilFullRefresh = pagesPerRefresh;
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh--; pagesUntilFullRefresh--;
} }
// Save bw buffer to reset buffer state after grayscale data sync
renderer.storeBwBuffer();
// grayscale rendering // grayscale rendering
// TODO: Only do this if font supports it // TODO: Only do this if font supports it
{ {
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setFontRenderMode(GfxRenderer::GRAYSCALE_LSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page->render(renderer, READER_FONT_ID); page->render(renderer, READER_FONT_ID);
renderer.copyGrayscaleLsbBuffers(); renderer.copyGrayscaleLsbBuffers();
// Render and copy to MSB buffer // Render and copy to MSB buffer
renderer.clearScreen(0x00); renderer.clearScreen(0x00);
renderer.setFontRenderMode(GfxRenderer::GRAYSCALE_MSB); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
page->render(renderer, READER_FONT_ID); page->render(renderer, READER_FONT_ID);
renderer.copyGrayscaleMsbBuffers(); renderer.copyGrayscaleMsbBuffers();
// display grayscale part // display grayscale part
renderer.displayGrayBuffer(); renderer.displayGrayBuffer();
renderer.setFontRenderMode(GfxRenderer::BW); renderer.setRenderMode(GfxRenderer::BW);
}
} }
void EpubReaderScreen::renderStatusBar() const { // restore the bw data
renderer.restoreBwBuffer();
}
void EpubReaderActivity::renderStatusBar() const {
constexpr auto textY = 776; constexpr auto textY = 776;
// Calculate progress in book
float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
// Right aligned text for progress counter // Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + " / " + std::to_string(section->pageCount); const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
" " + std::to_string(bookProgress) + "%";
const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY,
progress.c_str()); progress.c_str());
@ -307,13 +371,22 @@ void EpubReaderScreen::renderStatusBar() const {
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft;
const int titleMarginRight = progressTextWidth + 30 + marginRight; const int titleMarginRight = progressTextWidth + 30 + marginRight;
const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight;
const auto tocItem = epub->getTocItem(epub->getTocIndexForSpineIndex(currentSpineIndex)); const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
auto title = tocItem.title;
int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); std::string title;
while (titleWidth > availableTextWidth) { int titleWidth;
if (tocIndex == -1) {
title = "Unnamed";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed");
} else {
const auto tocItem = epub->getTocItem(tocIndex);
title = tocItem.title;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) {
title = title.substr(0, title.length() - 8) + "..."; title = title.substr(0, title.length() - 8) + "...";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
} }
}
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
} }

View File

@ -5,18 +5,19 @@
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include "Screen.h" #include "../Activity.h"
class EpubReaderScreen final : public Screen { class EpubReaderActivity final : public Activity {
std::shared_ptr<Epub> epub; std::shared_ptr<Epub> epub;
std::unique_ptr<Section> section = nullptr; std::unique_ptr<Section> section = nullptr;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
std::unique_ptr<Activity> subAcitivity = nullptr;
int currentSpineIndex = 0; int currentSpineIndex = 0;
int nextPageNumber = 0; int nextPageNumber = 0;
int pagesUntilFullRefresh = 0; int pagesUntilFullRefresh = 0;
bool updateRequired = false; bool updateRequired = false;
const std::function<void()> onGoHome; const std::function<void()> onGoBack;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
@ -25,10 +26,10 @@ class EpubReaderScreen final : public Screen {
void renderStatusBar() const; void renderStatusBar() const;
public: public:
explicit EpubReaderScreen(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoHome) const std::function<void()>& onGoBack)
: Screen(renderer, inputManager), epub(std::move(epub)), onGoHome(onGoHome) {} : Activity(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void handleInput() override; void loop() override;
}; };

View File

@ -0,0 +1,107 @@
#include "EpubReaderChapterSelectionActivity.h"
#include <GfxRenderer.h>
#include <SD.h>
#include "config.h"
constexpr int PAGE_ITEMS = 24;
constexpr int SKIP_PAGE_MS = 700;
void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderChapterSelectionActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderChapterSelectionActivity::onEnter() {
if (!epub) {
return;
}
renderingMutex = xSemaphoreCreateMutex();
selectorIndex = currentSpineIndex;
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void EpubReaderChapterSelectionActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void EpubReaderChapterSelectionActivity::loop() {
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
onSelectSpineIndex(selectorIndex);
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex =
((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + epub->getSpineItemsCount()) % epub->getSpineItemsCount();
} else {
selectorIndex = (selectorIndex + epub->getSpineItemsCount() - 1) % epub->getSpineItemsCount();
}
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % epub->getSpineItemsCount();
} else {
selectorIndex = (selectorIndex + 1) % epub->getSpineItemsCount();
}
updateRequired = true;
}
}
void EpubReaderChapterSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderChapterSelectionActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "Select Chapter", true, BOLD);
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < epub->getSpineItemsCount() && i < pageStartIndex + PAGE_ITEMS; i++) {
const int tocIndex = epub->getTocIndexForSpineIndex(i);
if (tocIndex == -1) {
renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, "Unnamed", i != selectorIndex);
} else {
auto item = epub->getTocItem(tocIndex);
renderer.drawText(UI_FONT_ID, 20 + (item.level - 1) * 15, 60 + (i % PAGE_ITEMS) * 30, item.title.c_str(),
i != selectorIndex);
}
}
renderer.displayBuffer();
}

View File

@ -0,0 +1,38 @@
#pragma once
#include <Epub.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <memory>
#include "../Activity.h"
class EpubReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Epub> epub;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int currentSpineIndex = 0;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void(int newSpineIndex)> onSelectSpineIndex;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
public:
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
: Activity(renderer, inputManager),
epub(epub),
currentSpineIndex(currentSpineIndex),
onGoBack(onGoBack),
onSelectSpineIndex(onSelectSpineIndex) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -1,4 +1,4 @@
#include "FileSelectionScreen.h" #include "FileSelectionActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SD.h> #include <SD.h>
@ -15,12 +15,12 @@ void sortFileList(std::vector<std::string>& strs) {
}); });
} }
void FileSelectionScreen::taskTrampoline(void* param) { void FileSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<FileSelectionScreen*>(param); auto* self = static_cast<FileSelectionActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
} }
void FileSelectionScreen::loadFiles() { void FileSelectionActivity::loadFiles() {
files.clear(); files.clear();
selectorIndex = 0; selectorIndex = 0;
auto root = SD.open(basepath.c_str()); auto root = SD.open(basepath.c_str());
@ -42,7 +42,7 @@ void FileSelectionScreen::loadFiles() {
sortFileList(files); sortFileList(files);
} }
void FileSelectionScreen::onEnter() { void FileSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
basepath = "/"; basepath = "/";
@ -52,7 +52,7 @@ void FileSelectionScreen::onEnter() {
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
xTaskCreate(&FileSelectionScreen::taskTrampoline, "FileSelectionScreenTask", xTaskCreate(&FileSelectionActivity::taskTrampoline, "FileSelectionActivityTask",
2048, // Stack size 2048, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
@ -60,7 +60,7 @@ void FileSelectionScreen::onEnter() {
); );
} }
void FileSelectionScreen::onExit() { void FileSelectionActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
@ -72,7 +72,7 @@ void FileSelectionScreen::onExit() {
files.clear(); files.clear();
} }
void FileSelectionScreen::handleInput() { void FileSelectionActivity::loop() {
const bool prevPressed = const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed = const bool nextPressed =
@ -91,11 +91,16 @@ void FileSelectionScreen::handleInput() {
} else { } else {
onSelect(basepath + files[selectorIndex]); onSelect(basepath + files[selectorIndex]);
} }
} else if (inputManager.wasPressed(InputManager::BTN_BACK) && basepath != "/") { } else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (basepath != "/") {
basepath = basepath.substr(0, basepath.rfind('/')); basepath = basepath.substr(0, basepath.rfind('/'));
if (basepath.empty()) basepath = "/"; if (basepath.empty()) basepath = "/";
loadFiles(); loadFiles();
updateRequired = true; updateRequired = true;
} else {
// At root level, go back home
onGoHome();
}
} else if (prevPressed) { } else if (prevPressed) {
selectorIndex = (selectorIndex + files.size() - 1) % files.size(); selectorIndex = (selectorIndex + files.size() - 1) % files.size();
updateRequired = true; updateRequired = true;
@ -105,7 +110,7 @@ void FileSelectionScreen::handleInput() {
} }
} }
void FileSelectionScreen::displayTaskLoop() { void FileSelectionActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired) {
updateRequired = false; updateRequired = false;
@ -117,12 +122,15 @@ void FileSelectionScreen::displayTaskLoop() {
} }
} }
void FileSelectionScreen::render() const { void FileSelectionActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = GfxRenderer::getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Help text
renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home");
if (files.empty()) { if (files.empty()) {
renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found");
} else { } else {

View File

@ -7,9 +7,9 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "Screen.h" #include "../Activity.h"
class FileSelectionScreen final : public Screen { class FileSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
std::string basepath = "/"; std::string basepath = "/";
@ -17,6 +17,7 @@ class FileSelectionScreen final : public Screen {
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;
const std::function<void(const std::string&)> onSelect; const std::function<void(const std::string&)> onSelect;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
@ -24,10 +25,11 @@ class FileSelectionScreen final : public Screen {
void loadFiles(); void loadFiles();
public: public:
explicit FileSelectionScreen(GfxRenderer& renderer, InputManager& inputManager, explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(const std::string&)>& onSelect) const std::function<void(const std::string&)>& onSelect,
: Screen(renderer, inputManager), onSelect(onSelect) {} const std::function<void()>& onGoHome)
: Activity(renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void handleInput() override; void loop() override;
}; };

View File

@ -0,0 +1,68 @@
#include "ReaderActivity.h"
#include <SD.h>
#include "CrossPointState.h"
#include "Epub.h"
#include "EpubReaderActivity.h"
#include "FileSelectionActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
if (epub->load()) {
return epub;
}
Serial.printf("[%lu] [ ] Failed to load epub\n", millis());
return nullptr;
}
void ReaderActivity::onSelectEpubFile(const std::string& path) {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
auto epub = loadEpub(path);
if (epub) {
APP_STATE.openEpubPath = path;
APP_STATE.saveToFile();
onGoToEpubReader(std::move(epub));
} else {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR,
EInkDisplay::HALF_REFRESH));
delay(2000);
onGoToFileSelection();
}
}
void ReaderActivity::onGoToFileSelection() {
exitActivity();
enterNewActivity(new FileSelectionActivity(
renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack));
}
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
exitActivity();
enterNewActivity(new EpubReaderActivity(renderer, inputManager, std::move(epub), [this] { onGoToFileSelection(); }));
}
void ReaderActivity::onEnter() {
if (initialEpubPath.empty()) {
onGoToFileSelection();
return;
}
auto epub = loadEpub(initialEpubPath);
if (!epub) {
onGoBack();
return;
}
onGoToEpubReader(std::move(epub));
}

View File

@ -0,0 +1,24 @@
#pragma once
#include <memory>
#include "../ActivityWithSubactivity.h"
class Epub;
class ReaderActivity final : public ActivityWithSubactivity {
std::string initialEpubPath;
const std::function<void()> onGoBack;
static std::unique_ptr<Epub> loadEpub(const std::string& path);
void onSelectEpubFile(const std::string& path);
void onGoToFileSelection();
void onGoToEpubReader(std::unique_ptr<Epub> epub);
public:
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
const std::function<void()>& onGoBack)
: ActivityWithSubactivity(renderer, inputManager),
initialEpubPath(std::move(initialEpubPath)),
onGoBack(onGoBack) {}
void onEnter() override;
};

View File

@ -0,0 +1,142 @@
#include "SettingsActivity.h"
#include <GfxRenderer.h>
#include "CrossPointSettings.h"
#include "config.h"
// Define the static settings list
const SettingInfo SettingsActivity::settingsList[settingsCount] = {
{"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen},
{"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing},
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn}};
void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop();
}
void SettingsActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Reset selection to first item
selectedSettingIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&SettingsActivity::taskTrampoline, "SettingsActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void SettingsActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void SettingsActivity::loop() {
// Handle actions with early return
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
toggleCurrentSetting();
updateRequired = true;
return;
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
SETTINGS.saveToFile();
onGoHome();
return;
}
// Handle navigation
if (inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT)) {
// Move selection up (with wrap-around)
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1);
updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) {
// Move selection down
if (selectedSettingIndex < settingsCount - 1) {
selectedSettingIndex++;
updateRequired = true;
}
}
}
void SettingsActivity::toggleCurrentSetting() {
// Validate index
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
return;
}
const auto& setting = settingsList[selectedSettingIndex];
// Only toggle if it's a toggle type and has a value pointer
if (setting.type != SettingType::TOGGLE || setting.valuePtr == nullptr) {
return;
}
// Toggle the boolean value using the member pointer
bool currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = !currentValue;
// Save settings when they change
SETTINGS.saveToFile();
}
void SettingsActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void SettingsActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "Settings", true, BOLD);
// Draw all settings
for (int i = 0; i < settingsCount; i++) {
const int settingY = 60 + i * 30; // 30 pixels between settings
// Draw selection indicator for the selected setting
if (i == selectedSettingIndex) {
renderer.drawText(UI_FONT_ID, 5, settingY, ">");
}
// Draw setting name
renderer.drawText(UI_FONT_ID, 20, settingY, settingsList[i].name);
// Draw value based on setting type
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
bool value = SETTINGS.*(settingsList[i].valuePtr);
renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF");
}
}
// Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit");
// Always use standard refresh for settings screen
renderer.displayBuffer();
}

View File

@ -0,0 +1,46 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
class CrossPointSettings;
enum class SettingType { TOGGLE };
// Structure to hold setting information
struct SettingInfo {
const char* name; // Display name of the setting
SettingType type; // Type of setting
uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE)
};
class SettingsActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
int selectedSettingIndex = 0; // Currently selected setting
const std::function<void()> onGoHome;
// Static settings list
static constexpr int settingsCount = 3; // Number of settings
static const SettingInfo settingsList[settingsCount];
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void toggleCurrentSetting();
public:
explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome)
: Activity(renderer, inputManager), onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -1,10 +1,10 @@
#include "FullScreenMessageScreen.h" #include "FullScreenMessageActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include "config.h" #include "config.h"
void FullScreenMessageScreen::onEnter() { void FullScreenMessageActivity::onEnter() {
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (GfxRenderer::getScreenHeight() - height) / 2; const auto top = (GfxRenderer::getScreenHeight() - height) / 2;

View File

@ -0,0 +1,21 @@
#pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h>
#include <string>
#include <utility>
#include "../Activity.h"
class FullScreenMessageActivity final : public Activity {
std::string text;
EpdFontStyle style;
EInkDisplay::RefreshMode refreshMode;
public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text,
const EpdFontStyle style = REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Activity(renderer, inputManager), text(std::move(text)), style(style), refreshMode(refreshMode) {}
void onEnter() override;
};

View File

@ -0,0 +1,315 @@
#include "KeyboardEntryActivity.h"
#include "../../config.h"
// Keyboard layouts - lowercase
const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
"`1234567890-=", "qwertyuiop[]\\", "asdfghjkl;'", "zxcvbnm,./",
"^ _____<OK" // ^ = shift, _ = space, < = backspace, OK = done
};
// Keyboard layouts - uppercase/symbols
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "^ _____<OK"};
KeyboardEntryActivity::KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::string& title, const std::string& initialText, size_t maxLength,
bool isPassword)
: Activity(renderer, inputManager), title(title), text(initialText), maxLength(maxLength), isPassword(isPassword) {}
void KeyboardEntryActivity::setText(const std::string& newText) {
text = newText;
if (maxLength > 0 && text.length() > maxLength) {
text = text.substr(0, maxLength);
}
}
void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string& newInitialText) {
if (!newTitle.empty()) {
title = newTitle;
}
text = newInitialText;
selectedRow = 0;
selectedCol = 0;
shiftActive = false;
complete = false;
cancelled = false;
}
void KeyboardEntryActivity::onEnter() {
// Reset state when entering the activity
complete = false;
cancelled = false;
}
void KeyboardEntryActivity::onExit() {
// Clean up if needed
}
void KeyboardEntryActivity::loop() {
handleInput();
render(10);
}
int KeyboardEntryActivity::getRowLength(int row) const {
if (row < 0 || row >= NUM_ROWS) return 0;
// Return actual length of each row based on keyboard layout
switch (row) {
case 0:
return 13; // `1234567890-=
case 1:
return 13; // qwertyuiop[]backslash
case 2:
return 11; // asdfghjkl;'
case 3:
return 10; // zxcvbnm,./
case 4:
return 10; // ^, space (5 wide), backspace, OK (2 wide)
default:
return 0;
}
}
char KeyboardEntryActivity::getSelectedChar() const {
const char* const* layout = shiftActive ? keyboardShift : keyboard;
if (selectedRow < 0 || selectedRow >= NUM_ROWS) return '\0';
if (selectedCol < 0 || selectedCol >= getRowLength(selectedRow)) return '\0';
return layout[selectedRow][selectedCol];
}
void KeyboardEntryActivity::handleKeyPress() {
// Handle special row (bottom row with shift, space, backspace, done)
if (selectedRow == SHIFT_ROW) {
if (selectedCol == SHIFT_COL) {
// Shift toggle
shiftActive = !shiftActive;
return;
}
if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// Space bar
if (maxLength == 0 || text.length() < maxLength) {
text += ' ';
}
return;
}
if (selectedCol == BACKSPACE_COL) {
// Backspace
if (!text.empty()) {
text.pop_back();
}
return;
}
if (selectedCol >= DONE_COL) {
// Done button
complete = true;
if (onComplete) {
onComplete(text);
}
return;
}
}
// Regular character
char c = getSelectedChar();
if (c != '\0' && c != '^' && c != '_' && c != '<') {
if (maxLength == 0 || text.length() < maxLength) {
text += c;
// Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
shiftActive = false;
}
}
}
}
bool KeyboardEntryActivity::handleInput() {
if (complete || cancelled) {
return false;
}
bool handled = false;
// Navigation
if (inputManager.wasPressed(InputManager::BTN_UP)) {
if (selectedRow > 0) {
selectedRow--;
// Clamp column to valid range for new row
int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
handled = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (selectedRow < NUM_ROWS - 1) {
selectedRow++;
int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
handled = true;
} else if (inputManager.wasPressed(InputManager::BTN_LEFT)) {
if (selectedCol > 0) {
selectedCol--;
} else if (selectedRow > 0) {
// Wrap to previous row
selectedRow--;
selectedCol = getRowLength(selectedRow) - 1;
}
handled = true;
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) {
int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol < maxCol) {
selectedCol++;
} else if (selectedRow < NUM_ROWS - 1) {
// Wrap to next row
selectedRow++;
selectedCol = 0;
}
handled = true;
}
// Selection
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
handleKeyPress();
handled = true;
}
// Cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
cancelled = true;
if (onCancel) {
onCancel();
}
handled = true;
}
return handled;
}
void KeyboardEntryActivity::render(int startY) const {
const auto pageWidth = GfxRenderer::getScreenWidth();
// Draw title
renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR);
// Draw input field
int inputY = startY + 22;
renderer.drawText(UI_FONT_ID, 10, inputY, "[");
std::string displayText;
if (isPassword) {
displayText = std::string(text.length(), '*');
} else {
displayText = text;
}
// Show cursor at end
displayText += "_";
// Truncate if too long for display - use actual character width from font
int charWidth = renderer.getSpaceWidth(UI_FONT_ID);
if (charWidth < 1) charWidth = 8; // Fallback to approximate width
int maxDisplayLen = (pageWidth - 40) / charWidth;
if (displayText.length() > static_cast<size_t>(maxDisplayLen)) {
displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3);
}
renderer.drawText(UI_FONT_ID, 20, inputY, displayText.c_str());
renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]");
// Draw keyboard - use compact spacing to fit 5 rows on screen
int keyboardStartY = inputY + 25;
const int keyWidth = 18;
const int keyHeight = 18;
const int keySpacing = 3;
const char* const* layout = shiftActive ? keyboardShift : keyboard;
// Calculate left margin to center the longest row (13 keys)
int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing);
int leftMargin = (pageWidth - maxRowWidth) / 2;
for (int row = 0; row < NUM_ROWS; row++) {
int rowY = keyboardStartY + row * (keyHeight + keySpacing);
// Left-align all rows for consistent navigation
int startX = leftMargin;
// Handle bottom row (row 4) specially with proper multi-column keys
if (row == 4) {
// Bottom row layout: CAPS (2 cols) | SPACE (5 cols) | <- (2 cols) | OK (2 cols)
// Total: 11 visual columns, but we use logical positions for selection
int currentX = startX;
// CAPS key (logical col 0, spans 2 key widths)
int capsWidth = 2 * keyWidth + keySpacing;
bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL);
if (capsSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps");
currentX += capsWidth + keySpacing;
// Space bar (logical cols 2-6, spans 5 key widths)
int spaceWidth = 5 * keyWidth + 4 * keySpacing;
bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL);
if (spaceSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]");
}
// Draw centered underscores for space bar
int spaceTextX = currentX + (spaceWidth / 2) - 12;
renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____");
currentX += spaceWidth + keySpacing;
// Backspace key (logical col 7, spans 2 key widths)
int bsWidth = 2 * keyWidth + keySpacing;
bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL);
if (bsSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-");
currentX += bsWidth + keySpacing;
// OK button (logical col 9, spans 2 key widths)
int okWidth = 2 * keyWidth + keySpacing;
bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL);
if (okSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK");
} else {
// Regular rows: render each key individually
for (int col = 0; col < getRowLength(row); col++) {
int keyX = startX + col * (keyWidth + keySpacing);
// Get the character to display
char c = layout[row][col];
std::string keyLabel(1, c);
// Draw selection highlight
bool isSelected = (row == selectedRow && col == selectedCol);
if (isSelected) {
renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str());
}
}
}
// Draw help text at absolute bottom of screen (consistent with other screens)
const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
}

View File

@ -0,0 +1,127 @@
#pragma once
#include <GfxRenderer.h>
#include <InputManager.h>
#include <functional>
#include <string>
#include "../Activity.h"
/**
* Reusable keyboard entry activity for text input.
* Can be started from any activity that needs text entry.
*
* Usage:
* 1. Create a KeyboardEntryActivity instance
* 2. Set callbacks with setOnComplete() and setOnCancel()
* 3. Call onEnter() to start the activity
* 4. Call loop() in your main loop
* 5. When complete or cancelled, callbacks will be invoked
*/
class KeyboardEntryActivity : public Activity {
public:
// Callback types
using OnCompleteCallback = std::function<void(const std::string&)>;
using OnCancelCallback = std::function<void()>;
/**
* Constructor
* @param renderer Reference to the GfxRenderer for drawing
* @param inputManager Reference to InputManager for handling input
* @param title Title to display above the keyboard
* @param initialText Initial text to show in the input field
* @param maxLength Maximum length of input text (0 for unlimited)
* @param isPassword If true, display asterisks instead of actual characters
*/
KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text",
const std::string& initialText = "", size_t maxLength = 0, bool isPassword = false);
/**
* Handle button input. Call this in your main loop.
* @return true if input was handled, false otherwise
*/
bool handleInput();
/**
* Render the keyboard at the specified Y position.
* @param startY Y-coordinate where keyboard rendering starts (default 10)
*/
void render(int startY = 10) const;
/**
* Get the current text entered by the user.
*/
const std::string& getText() const { return text; }
/**
* Set the current text.
*/
void setText(const std::string& newText);
/**
* Check if the user has completed text entry (pressed OK on Done).
*/
bool isComplete() const { return complete; }
/**
* Check if the user has cancelled text entry.
*/
bool isCancelled() const { return cancelled; }
/**
* Reset the keyboard state for reuse.
*/
void reset(const std::string& newTitle = "", const std::string& newInitialText = "");
/**
* Set callback for when input is complete.
*/
void setOnComplete(OnCompleteCallback callback) { onComplete = callback; }
/**
* Set callback for when input is cancelled.
*/
void setOnCancel(OnCancelCallback callback) { onCancel = callback; }
// Activity overrides
void onEnter() override;
void onExit() override;
void loop() override;
private:
std::string title;
std::string text;
size_t maxLength;
bool isPassword;
// Keyboard state
int selectedRow = 0;
int selectedCol = 0;
bool shiftActive = false;
bool complete = false;
bool cancelled = false;
// Callbacks
OnCompleteCallback onComplete;
OnCancelCallback onCancel;
// Keyboard layout
static constexpr int NUM_ROWS = 5;
static constexpr int KEYS_PER_ROW = 13; // Max keys per row (rows 0 and 1 have 13 keys)
static const char* const keyboard[NUM_ROWS];
static const char* const keyboardShift[NUM_ROWS];
// Special key positions (bottom row)
static constexpr int SHIFT_ROW = 4;
static constexpr int SHIFT_COL = 0;
static constexpr int SPACE_ROW = 4;
static constexpr int SPACE_COL = 2;
static constexpr int BACKSPACE_ROW = 4;
static constexpr int BACKSPACE_COL = 7;
static constexpr int DONE_ROW = 4;
static constexpr int DONE_COL = 9;
char getSelectedChar() const;
void handleKeyPress();
int getRowLength(int row) const;
};

View File

@ -9,7 +9,7 @@
* "./lib/EpdFont/builtinFonts/bookerly_italic_2b.h", * "./lib/EpdFont/builtinFonts/bookerly_italic_2b.h",
* ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)' * ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
*/ */
#define READER_FONT_ID 1747632454 #define READER_FONT_ID 1818981670
/** /**
* Generated with: * Generated with:
@ -18,7 +18,7 @@
* "./lib/EpdFont/builtinFonts/ubuntu_bold_10.h", * "./lib/EpdFont/builtinFonts/ubuntu_bold_10.h",
* ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)' * ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
*/ */
#define UI_FONT_ID 225955604 #define UI_FONT_ID (-1619831379)
/** /**
* Generated with: * Generated with:
@ -26,4 +26,4 @@
* "./lib/EpdFont/builtinFonts/pixelarial14.h", * "./lib/EpdFont/builtinFonts/pixelarial14.h",
* ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)' * ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
*/ */
#define SMALL_FONT_ID 2037928017 #define SMALL_FONT_ID (-139796914)

View File

@ -0,0 +1,251 @@
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0;">
CrossPoint E-Reader • Open Source
</p>
</div>
<!-- Upload Modal -->
<div class="modal-overlay" id="uploadModal">
<div class="modal">
<button class="modal-close" onclick="closeUploadModal()">&times;</button>
<h3>📤 Upload eBook</h3>
<div class="upload-form">
<p class="file-info">Select an .epub file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" accept=".epub" onchange="validateFile()">
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
<div id="progress-container">
<div id="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-text"></div>
</div>
</div>
</div>
</div>
<!-- New Folder Modal -->
<div class="modal-overlay" id="folderModal">
<div class="modal">
<button class="modal-close" onclick="closeFolderModal()">&times;</button>
<h3>📁 New Folder</h3>
<div class="folder-form">
<p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p>
<input type="text" id="folderName" class="folder-input" placeholder="Folder name...">
<button class="folder-btn" onclick="createFolder()">Create Folder</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="deleteModal">
<div class="modal">
<button class="modal-close" onclick="closeDeleteModal()">&times;</button>
<h3>🗑️ Delete Item</h3>
<div class="folder-form">
<p class="delete-warning">⚠️ This action cannot be undone!</p>
<p class="file-info">Are you sure you want to delete:</p>
<p class="delete-item-name" id="deleteItemName"></p>
<input type="hidden" id="deleteItemPath">
<input type="hidden" id="deleteItemType">
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
</div>
</div>
</div>
<script>
// Modal functions
function openUploadModal() {
const currentPath = document.getElementById('currentPath').value;
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('uploadModal').classList.add('open');
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('open');
document.getElementById('fileInput').value = '';
document.getElementById('uploadBtn').disabled = true;
document.getElementById('progress-container').style.display = 'none';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
}
function openFolderModal() {
const currentPath = document.getElementById('currentPath').value;
document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('folderModal').classList.add('open');
document.getElementById('folderName').value = '';
}
function closeFolderModal() {
document.getElementById('folderModal').classList.remove('open');
}
// Close modals when clicking overlay
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
overlay.classList.remove('open');
}
});
});
function validateFile() {
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const file = fileInput.files[0];
if (file) {
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.epub')) {
alert('Only .epub files are allowed!');
fileInput.value = '';
uploadBtn.disabled = true;
return;
}
uploadBtn.disabled = false;
} else {
uploadBtn.disabled = true;
}
}
function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
const currentPath = document.getElementById('currentPath').value;
if (!file) {
alert('Please select a file first!');
return;
}
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.epub')) {
alert('Only .epub files are allowed!');
return;
}
const formData = new FormData();
formData.append('file', file);
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const uploadBtn = document.getElementById('uploadBtn');
progressContainer.style.display = 'block';
uploadBtn.disabled = true;
const xhr = new XMLHttpRequest();
// Include path as query parameter since multipart form data doesn't make
// form fields available until after file upload completes
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = 'Uploading: ' + percent + '%';
}
};
xhr.onload = function() {
if (xhr.status === 200) {
progressText.textContent = 'Upload complete!';
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
progressText.textContent = 'Upload failed: ' + xhr.responseText;
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
}
};
xhr.onerror = function() {
progressText.textContent = 'Upload failed - network error';
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
};
xhr.send(formData);
}
function createFolder() {
const folderName = document.getElementById('folderName').value.trim();
const currentPath = document.getElementById('currentPath').value;
if (!folderName) {
alert('Please enter a folder name!');
return;
}
// Validate folder name (no special characters except underscore and hyphen)
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
if (!validName) {
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
return;
}
const formData = new FormData();
formData.append('name', folderName);
formData.append('path', currentPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/mkdir', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to create folder: ' + xhr.responseText);
}
};
xhr.onerror = function() {
alert('Failed to create folder - network error');
};
xhr.send(formData);
}
// Delete functions
function openDeleteModal(name, path, isFolder) {
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
document.getElementById('deleteItemPath').value = path;
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
document.getElementById('deleteModal').classList.add('open');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('open');
}
function confirmDelete() {
const path = document.getElementById('deleteItemPath').value;
const itemType = document.getElementById('deleteItemType').value;
const formData = new FormData();
formData.append('path', path);
formData.append('type', itemType);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/delete', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to delete: ' + xhr.responseText);
closeDeleteModal();
}
};
xhr.onerror = function() {
alert('Failed to delete - network error');
closeDeleteModal();
};
xhr.send(formData);
}
</script>
</body>
</html>

View File

@ -0,0 +1,472 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader - Files</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
margin-bottom: 5px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #3498db;
}
.page-header-left {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.breadcrumb-inline {
color: #7f8c8d;
font-size: 1.1em;
}
.breadcrumb-inline a {
color: #3498db;
text-decoration: none;
}
.breadcrumb-inline a:hover {
text-decoration: underline;
}
.breadcrumb-inline .sep {
margin: 0 6px;
color: #bdc3c7;
}
.breadcrumb-inline .current {
color: #2c3e50;
font-weight: 500;
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 10px;
}
.action-btn {
color: white;
padding: 10px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.upload-action-btn {
background-color: #27ae60;
}
.upload-action-btn:hover {
background-color: #219a52;
}
.folder-action-btn {
background-color: #f39c12;
}
.folder-action-btn:hover {
background-color: #d68910;
}
/* Upload modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
justify-content: center;
align-items: center;
}
.modal-overlay.open {
display: flex;
}
.modal {
background: white;
border-radius: 8px;
padding: 25px;
max-width: 450px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal h3 {
margin: 0 0 15px 0;
color: #2c3e50;
}
.modal-close {
float: right;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: #7f8c8d;
line-height: 1;
}
.modal-close:hover {
color: #2c3e50;
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th,
.file-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.file-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #7f8c8d;
}
.file-table tr:hover {
background-color: #f8f9fa;
}
.epub-file {
background-color: #e8f6e9 !important;
}
.epub-file:hover {
background-color: #d4edda !important;
}
.folder-row {
background-color: #fff9e6 !important;
}
.folder-row:hover {
background-color: #fff3cd !important;
}
.epub-badge {
display: inline-block;
padding: 2px 8px;
background-color: #27ae60;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.folder-badge {
display: inline-block;
padding: 2px 8px;
background-color: #f39c12;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.file-icon {
margin-right: 8px;
}
.folder-link {
color: #2c3e50;
text-decoration: none;
cursor: pointer;
}
.folder-link:hover {
color: #3498db;
text-decoration: underline;
}
.upload-form {
margin-top: 10px;
}
.upload-form input[type="file"] {
margin: 10px 0;
width: 100%;
}
.upload-btn {
background-color: #27ae60;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.upload-btn:hover {
background-color: #219a52;
}
.upload-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.file-info {
color: #7f8c8d;
font-size: 0.85em;
margin: 8px 0;
}
.no-files {
text-align: center;
color: #95a5a6;
padding: 40px;
font-style: italic;
}
.message {
padding: 15px;
border-radius: 4px;
margin: 15px 0;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.contents-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.contents-title {
font-size: 1.1em;
font-weight: 600;
color: #34495e;
margin: 0;
}
.summary-inline {
color: #7f8c8d;
font-size: 0.9em;
}
#progress-container {
display: none;
margin-top: 10px;
}
#progress-bar {
width: 100%;
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
#progress-fill {
height: 100%;
background-color: #27ae60;
width: 0%;
transition: width 0.3s;
}
#progress-text {
text-align: center;
margin-top: 5px;
font-size: 0.9em;
color: #7f8c8d;
}
.folder-form {
margin-top: 10px;
}
.folder-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
margin-bottom: 10px;
box-sizing: border-box;
}
.folder-btn {
background-color: #f39c12;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.folder-btn:hover {
background-color: #d68910;
}
/* Delete button styles */
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1em;
padding: 4px 8px;
border-radius: 4px;
color: #95a5a6;
transition: all 0.15s;
}
.delete-btn:hover {
background-color: #fee;
color: #e74c3c;
}
.actions-col {
width: 60px;
text-align: center;
}
/* Delete modal */
.delete-warning {
color: #e74c3c;
font-weight: 600;
margin: 10px 0;
}
.delete-item-name {
font-weight: 600;
color: #2c3e50;
word-break: break-all;
}
.delete-btn-confirm {
background-color: #e74c3c;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.delete-btn-confirm:hover {
background-color: #c0392b;
}
.delete-btn-cancel {
background-color: #95a5a6;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
margin-top: 10px;
}
.delete-btn-cancel:hover {
background-color: #7f8c8d;
}
/* Mobile responsive styles */
@media (max-width: 600px) {
body {
padding: 10px;
font-size: 14px;
}
.card {
padding: 12px;
margin: 10px 0;
}
.page-header {
gap: 10px;
margin-bottom: 12px;
padding-bottom: 10px;
}
.page-header-left {
gap: 8px;
}
h1 {
font-size: 1.3em;
}
.breadcrumb-inline {
font-size: 0.95em;
}
.nav-links a {
padding: 8px 12px;
margin-right: 6px;
font-size: 0.9em;
}
.action-buttons {
gap: 6px;
}
.action-btn {
padding: 8px 10px;
font-size: 0.85em;
}
.file-table th,
.file-table td {
padding: 8px 6px;
font-size: 0.9em;
}
.file-table th {
font-size: 0.85em;
}
.file-icon {
margin-right: 4px;
}
.epub-badge,
.folder-badge {
padding: 2px 5px;
font-size: 0.65em;
margin-left: 4px;
}
.contents-header {
margin-bottom: 8px;
flex-wrap: wrap;
gap: 4px;
}
.contents-title {
font-size: 1em;
}
.summary-inline {
font-size: 0.8em;
}
.modal {
padding: 15px;
}
.modal h3 {
font-size: 1.1em;
}
.actions-col {
width: 40px;
}
.delete-btn {
font-size: 1em;
padding: 2px 4px;
}
.no-files {
padding: 20px;
font-size: 0.9em;
}
}
</style>
</head>
<body>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
</div>
</body>
</html>

108
src/html/HomePage.html Normal file
View File

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.info-row:last-child {
border-bottom: none;
}
.label {
font-weight: 600;
color: #7f8c8d;
}
.value {
color: #2c3e50;
}
.status {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
background-color: #27ae60;
color: white;
font-size: 0.9em;
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
</style>
</head>
<body>
<h1>📚 CrossPoint Reader</h1>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
</div>
<div class="card">
<h2>Device Status</h2>
<div class="info-row">
<span class="label">Version</span>
<span class="value">%VERSION%</span>
</div>
<div class="info-row">
<span class="label">WiFi Status</span>
<span class="status">Connected</span>
</div>
<div class="info-row">
<span class="label">IP Address</span>
<span class="value">%IP_ADDRESS%</span>
</div>
<div class="info-row">
<span class="label">Free Memory</span>
<span class="value">%FREE_HEAP% bytes</span>
</div>
</div>
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0">
CrossPoint E-Reader • Open Source
</p>
</div>
</body>
</html>

View File

@ -5,6 +5,7 @@
#include <InputManager.h> #include <InputManager.h>
#include <SD.h> #include <SD.h>
#include <SPI.h> #include <SPI.h>
#include <WiFi.h>
#include <builtinFonts/bookerly_2b.h> #include <builtinFonts/bookerly_2b.h>
#include <builtinFonts/bookerly_bold_2b.h> #include <builtinFonts/bookerly_bold_2b.h>
#include <builtinFonts/bookerly_bold_italic_2b.h> #include <builtinFonts/bookerly_bold_italic_2b.h>
@ -14,13 +15,16 @@
#include <builtinFonts/ubuntu_bold_10.h> #include <builtinFonts/ubuntu_bold_10.h>
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
#include "activities/home/HomeActivity.h"
#include "activities/network/CrossPointWebServerActivity.h"
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
#include "config.h" #include "config.h"
#include "screens/BootLogoScreen.h"
#include "screens/EpubReaderScreen.h"
#include "screens/FileSelectionScreen.h"
#include "screens/FullScreenMessageScreen.h"
#include "screens/SleepScreen.h"
#define SPI_FQ 40000000 #define SPI_FQ 40000000
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) // Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
@ -39,8 +43,7 @@
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY); EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
InputManager inputManager; InputManager inputManager;
GfxRenderer renderer(einkDisplay); GfxRenderer renderer(einkDisplay);
Screen* currentScreen; Activity* currentActivity;
CrossPointState appState;
// Fonts // Fonts
EpdFont bookerlyFont(&bookerly_2b); EpdFont bookerlyFont(&bookerly_2b);
@ -56,58 +59,42 @@ EpdFont ubuntu10Font(&ubuntu_10);
EpdFont ubuntuBold10Font(&ubuntu_bold_10); EpdFont ubuntuBold10Font(&ubuntu_bold_10);
EpdFontFamily ubuntuFontFamily(&ubuntu10Font, &ubuntuBold10Font); EpdFontFamily ubuntuFontFamily(&ubuntu10Font, &ubuntuBold10Font);
// Power button timing // Auto-sleep timeout (10 minutes of inactivity)
// Time required to confirm boot from sleep constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000;
constexpr unsigned long POWER_BUTTON_WAKEUP_MS = 1000;
// Time required to enter sleep mode
constexpr unsigned long POWER_BUTTON_SLEEP_MS = 1000;
std::unique_ptr<Epub> loadEpub(const std::string& path) { void exitActivity() {
if (!SD.exists(path.c_str())) { if (currentActivity) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); currentActivity->onExit();
return nullptr; delete currentActivity;
} currentActivity = nullptr;
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
if (epub->load()) {
return epub;
}
Serial.printf("[%lu] [ ] Failed to load epub\n", millis());
return nullptr;
}
void exitScreen() {
if (currentScreen) {
currentScreen->onExit();
delete currentScreen;
} }
} }
void enterNewScreen(Screen* screen) { void enterNewActivity(Activity* activity) {
currentScreen = screen; currentActivity = activity;
currentScreen->onEnter(); currentActivity->onEnter();
} }
// Verify long press on wake-up from deep sleep // Verify long press on wake-up from deep sleep
void verifyWakeupLongPress() { void verifyWakeupLongPress() {
// Give the user up to 1000ms to start holding the power button, and must hold for POWER_BUTTON_WAKEUP_MS // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
const auto start = millis(); const auto start = millis();
bool abort = false; bool abort = false;
Serial.printf("[%lu] [ ] Verifying power button press\n", millis());
inputManager.update(); inputManager.update();
// Verify the user has actually pressed
while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) { while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) {
delay(50); delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
inputManager.update(); inputManager.update();
} }
if (inputManager.isPressed(InputManager::BTN_POWER)) { if (inputManager.isPressed(InputManager::BTN_POWER)) {
do { do {
delay(50); delay(10);
inputManager.update(); inputManager.update();
} while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < POWER_BUTTON_WAKEUP_MS); } while (inputManager.isPressed(InputManager::BTN_POWER) &&
abort = inputManager.getHeldTime() < POWER_BUTTON_WAKEUP_MS; inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration());
abort = inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration();
} else { } else {
abort = true; abort = true;
} }
@ -130,10 +117,10 @@ void waitForPowerRelease() {
// Enter deep sleep mode // Enter deep sleep mode
void enterDeepSleep() { void enterDeepSleep() {
exitScreen(); exitActivity();
enterNewScreen(new SleepScreen(renderer, inputManager)); enterNewActivity(new SleepActivity(renderer, inputManager));
Serial.printf("[%lu] [ ] Power button released after a long press. Entering deep sleep.\n", millis()); Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
delay(1000); // Allow Serial buffer to empty and display to update delay(1000); // Allow Serial buffer to empty and display to update
// Enable Wakeup on LOW (button press) // Enable Wakeup on LOW (button press)
@ -146,28 +133,25 @@ void enterDeepSleep() {
} }
void onGoHome(); void onGoHome();
void onSelectEpubFile(const std::string& path) { void onGoToReader(const std::string& initialEpubPath) {
exitScreen(); exitActivity();
enterNewScreen(new FullScreenMessageScreen(renderer, inputManager, "Loading...")); enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome));
auto epub = loadEpub(path);
if (epub) {
appState.openEpubPath = path;
appState.saveToFile();
exitScreen();
enterNewScreen(new EpubReaderScreen(renderer, inputManager, std::move(epub), onGoHome));
} else {
exitScreen();
enterNewScreen(
new FullScreenMessageScreen(renderer, inputManager, "Failed to load epub", REGULAR, EInkDisplay::HALF_REFRESH));
delay(2000);
onGoHome();
} }
void onGoToReaderHome() { onGoToReader(std::string()); }
void onGoToFileTransfer() {
exitActivity();
enterNewActivity(new CrossPointWebServerActivity(renderer, inputManager, onGoHome));
}
void onGoToSettings() {
exitActivity();
enterNewActivity(new SettingsActivity(renderer, inputManager, onGoHome));
} }
void onGoHome() { void onGoHome() {
exitScreen(); exitActivity();
enterNewScreen(new FileSelectionScreen(renderer, inputManager, onSelectEpubFile)); enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer));
} }
void setup() { void setup() {
@ -176,14 +160,25 @@ void setup() {
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis());
inputManager.begin(); inputManager.begin();
verifyWakeupLongPress();
// Initialize pins // Initialize pins
pinMode(BAT_GPIO0, INPUT); pinMode(BAT_GPIO0, INPUT);
// Initialize SPI with custom pins // Initialize SPI with custom pins
SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS); SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS);
// SD Card Initialization
if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD));
return;
}
SETTINGS.loadFromFile();
// verify power button press duration after we've read settings.
verifyWakeupLongPress();
// Initialize display // Initialize display
einkDisplay.begin(); einkDisplay.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis()); Serial.printf("[%lu] [ ] Display initialized\n", millis());
@ -193,33 +188,25 @@ void setup() {
renderer.insertFont(SMALL_FONT_ID, smallFontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
Serial.printf("[%lu] [ ] Fonts setup\n", millis()); Serial.printf("[%lu] [ ] Fonts setup\n", millis());
exitScreen(); exitActivity();
enterNewScreen(new BootLogoScreen(renderer, inputManager)); enterNewActivity(new BootActivity(renderer, inputManager));
// SD Card Initialization APP_STATE.loadFromFile();
SD.begin(SD_SPI_CS, SPI, SPI_FQ); if (APP_STATE.openEpubPath.empty()) {
onGoHome();
appState.loadFromFile(); } else {
if (!appState.openEpubPath.empty()) { onGoToReader(APP_STATE.openEpubPath);
auto epub = loadEpub(appState.openEpubPath);
if (epub) {
exitScreen();
enterNewScreen(new EpubReaderScreen(renderer, inputManager, std::move(epub), onGoHome));
// Ensure we're not still holding the power button before leaving setup
waitForPowerRelease();
return;
} }
}
exitScreen();
enterNewScreen(new FileSelectionScreen(renderer, inputManager, onSelectEpubFile));
// Ensure we're not still holding the power button before leaving setup // Ensure we're not still holding the power button before leaving setup
waitForPowerRelease(); waitForPowerRelease();
} }
void loop() { void loop() {
delay(10); static unsigned long lastLoopTime = 0;
static unsigned long maxLoopDuration = 0;
unsigned long loopStartTime = millis();
static unsigned long lastMemPrint = 0; static unsigned long lastMemPrint = 0;
if (Serial && millis() - lastMemPrint >= 10000) { if (Serial && millis() - lastMemPrint >= 10000) {
@ -229,13 +216,50 @@ void loop() {
} }
inputManager.update(); inputManager.update();
if (inputManager.wasReleased(InputManager::BTN_POWER) && inputManager.getHeldTime() > POWER_BUTTON_WAKEUP_MS) {
// Check for any user activity (button press or release)
static unsigned long lastActivityTime = millis();
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) {
lastActivityTime = millis(); // Reset inactivity timer
}
if (millis() - lastActivityTime >= AUTO_SLEEP_TIMEOUT_MS) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), AUTO_SLEEP_TIMEOUT_MS);
enterDeepSleep(); enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return; return;
} }
if (currentScreen) { if (inputManager.wasReleased(InputManager::BTN_POWER) &&
currentScreen->handleInput(); inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return;
}
unsigned long activityStartTime = millis();
if (currentActivity) {
currentActivity->loop();
}
unsigned long activityDuration = millis() - activityStartTime;
unsigned long loopDuration = millis() - loopStartTime;
if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration;
if (maxLoopDuration > 50) {
Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration,
activityDuration);
}
}
lastLoopTime = loopStartTime;
// Add delay at the end of the loop to prevent tight spinning
// When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response
// Otherwise, use longer delay to save power
if (currentActivity && currentActivity->skipLoopDelay()) {
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else {
delay(10); // Normal delay when no activity requires fast response
} }
} }

View File

@ -1,8 +0,0 @@
#pragma once
#include "Screen.h"
class BootLogoScreen final : public Screen {
public:
explicit BootLogoScreen(GfxRenderer& renderer, InputManager& inputManager) : Screen(renderer, inputManager) {}
void onEnter() override;
};

View File

@ -1,21 +0,0 @@
#pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h>
#include <string>
#include <utility>
#include "Screen.h"
class FullScreenMessageScreen final : public Screen {
std::string text;
EpdFontStyle style;
EInkDisplay::RefreshMode refreshMode;
public:
explicit FullScreenMessageScreen(GfxRenderer& renderer, InputManager& inputManager, std::string text,
const EpdFontStyle style = REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Screen(renderer, inputManager), text(std::move(text)), style(style), refreshMode(refreshMode) {}
void onEnter() override;
};

View File

@ -1,17 +0,0 @@
#pragma once
#include <InputManager.h>
class GfxRenderer;
class Screen {
protected:
GfxRenderer& renderer;
InputManager& inputManager;
public:
explicit Screen(GfxRenderer& renderer, InputManager& inputManager) : renderer(renderer), inputManager(inputManager) {}
virtual ~Screen() = default;
virtual void onEnter() {}
virtual void onExit() {}
virtual void handleInput() {}
};

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