Compare commits
119 Commits
0.16.0
...
1383d75c84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1383d75c84
|
||
|
|
632b76c9ed
|
||
|
|
5dc9d21bdb
|
||
|
|
c1dfe92ea3
|
||
|
|
5816ab2a47 | ||
|
|
2c0a105550 | ||
|
|
82bfbd8fa6
|
||
|
|
6aa0b865c2
|
||
|
|
0c71e0b13f
|
||
|
|
ea11d2f7d3
|
||
|
|
6e51afb977 | ||
|
|
cb24947477 | ||
|
|
31878a77bc
|
||
|
|
21a75c624d
|
||
|
|
8d4bbf284d
|
||
|
|
7a385d78a4 | ||
|
|
905f694576
|
||
|
|
e798065a5c
|
||
|
|
5e269f912f
|
||
|
|
182c236050
|
||
|
|
73cd05827a | ||
|
|
ea32ba0f8d | ||
|
|
f7b1113819 | ||
|
|
228a1cb511 | ||
|
|
0991782fb4 | ||
|
|
3ae1007cbe | ||
|
|
efb9b72e64 | ||
|
|
4a210823a8 | ||
|
|
b72283d304 | ||
|
|
f5b85f5ca1 | ||
|
|
8cf226613b | ||
|
|
7e93411f46 | ||
|
|
44452a42e9 | ||
|
|
0c2df24f5c | ||
|
|
3a12ca2725 | ||
|
|
d4f25c44bf | ||
|
|
98e6789626 | ||
|
|
b5d28a3a9c | ||
|
|
bc12556da1 | ||
|
|
4e7bb8979c | ||
|
|
4edb14bdd9
|
||
|
|
eb79b98f2b | ||
|
|
14ef625679 | ||
|
|
64d161e88b | ||
|
|
a85d5e627b
|
||
|
|
e73bb3213f | ||
|
|
6202bfd651 | ||
|
|
9e04eec072 | ||
|
|
9b04c2ec76 | ||
|
|
def1094411 | ||
|
|
ffddc2472b | ||
|
|
5765bbe821 | ||
|
|
7538e55795 | ||
|
|
21e7d29286 | ||
|
|
b4b028be3a | ||
|
|
f34d7d2aac | ||
|
|
71769490fb | ||
|
|
cda0a3f898 | ||
|
|
7f40c3f477 | ||
|
|
5e52a46837 | ||
|
|
6909f127b4 | ||
|
|
4f0a3aa4dd | ||
|
|
bb983d0ef4 | ||
|
|
b45eaf7ded | ||
|
|
a87eacc6ab | ||
|
|
1caad578fc | ||
|
|
75b0ed7781 | ||
|
|
5b90b68e99 | ||
|
|
67ddd60fce | ||
|
|
76908d38e1 | ||
|
|
e6f5fa43e6 | ||
|
|
e7e31ac487 | ||
|
|
47f3137dee | ||
|
|
d8632eae08 | ||
|
|
9f78fd33e8 | ||
|
|
bd8132a260 | ||
|
|
3223e85ea5 | ||
|
|
f89ce514c8 | ||
|
|
211153fcd5 | ||
|
|
91777a9023 | ||
|
|
d8e813a78d | ||
|
|
c3b9bc38b9 | ||
|
|
fb0af32ec0 | ||
|
|
cb4d86fec6 | ||
|
|
e94f056e8a | ||
|
|
20c5d8ccf8 | ||
|
|
d35bda8023 | ||
|
|
d762325035 | ||
|
|
7f2b1a818e | ||
|
|
ddbe49f536 | ||
|
|
17fedd2a69 | ||
|
|
768c2f8eed | ||
|
|
216dbc8ee3 | ||
|
|
ee987f07ff | ||
|
|
23ecc52261 | ||
|
|
edaf8fff9d | ||
|
|
c8683340ab | ||
|
|
5a9ee19eb8 | ||
|
|
c49a819939 | ||
|
|
bf87a7dc60 | ||
|
|
2cf799f45b | ||
|
|
db659f3ea2 | ||
|
|
78d6e5931c | ||
|
|
dac11c3fdd | ||
|
|
d403044f76 | ||
|
|
f67c544e16 | ||
|
|
e5c0ddc9fa | ||
|
|
b1dcb7733b | ||
|
|
0d82b03981 | ||
|
|
5a97334ace | ||
|
|
4dd73a211a | ||
|
|
634f6279cb | ||
|
|
11b2a59233 | ||
|
|
12c20bb09e | ||
|
|
6b7065b986 | ||
|
|
f4df513bf3 | ||
|
|
f935b59a41 | ||
|
|
da4d3b5ea5 | ||
|
|
172916afd4 |
106
.github/workflows/ci.yml
vendored
106
.github/workflows/ci.yml
vendored
@@ -1,10 +1,55 @@
|
||||
name: CI
|
||||
'on':
|
||||
name: CI (build)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
clang-format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- 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 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)
|
||||
|
||||
cppcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Install PlatformIO Core
|
||||
run: pip install --upgrade platformio
|
||||
|
||||
- name: Run cppcheck
|
||||
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -19,19 +64,46 @@ jobs:
|
||||
- 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 low --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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pio run | tee pio.log
|
||||
|
||||
- name: Extract firmware stats
|
||||
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ram_line="$(grep -E "RAM:\\s" -m1 pio.log || true)"
|
||||
flash_line="$(grep -E "Flash:\\s" -m1 pio.log || true)"
|
||||
echo "ram_line=${ram_line}" >> "$GITHUB_OUTPUT"
|
||||
echo "flash_line=${flash_line}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## Firmware build stats"
|
||||
if [ -n "$ram_line" ]; then echo "- ${ram_line}"; else echo "- RAM: not found"; fi
|
||||
if [ -n "$flash_line" ]; then echo "- ${flash_line}"; else echo "- Flash: not found"; fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload firmware.bin artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: firmware.bin
|
||||
path: .pio/build/default/firmware.bin
|
||||
if-no-files-found: error
|
||||
|
||||
# This job is used as the PR required actions check, allows for changes to other steps in the future without breaking
|
||||
# PR requirements.
|
||||
test-status:
|
||||
name: Test Status
|
||||
needs:
|
||||
- build
|
||||
- clang-format
|
||||
- cppcheck
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fail because needed jobs failed
|
||||
# Fail if any job failed or was cancelled (skipped jobs are ok)
|
||||
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
run: exit 1
|
||||
- name: Success
|
||||
run: exit 0
|
||||
|
||||
1
.github/workflows/pr-formatting-check.yml
vendored
1
.github/workflows/pr-formatting-check.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: "PR Formatting"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
48
.github/workflows/release_candidate.yml
vendored
Normal file
48
.github/workflows/release_candidate.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Compile Release Candidate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-release-candidate:
|
||||
if: startsWith(github.ref_name, '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: Extract env
|
||||
run: |
|
||||
echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
echo "BRANCH_SUFFIX=${GITHUB_REF_NAME#release/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build CrossPoint Release Candidate
|
||||
env:
|
||||
CROSSPOINT_RC_HASH: ${{ env.SHORT_SHA }}
|
||||
run: pio run -e gh_release_rc
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: CrossPoint-RC-${{ env.BRANCH_SUFFIX }}
|
||||
path: |
|
||||
.pio/build/gh_release_rc/bootloader.bin
|
||||
.pio/build/gh_release_rc/firmware.bin
|
||||
.pio/build/gh_release_rc/firmware.elf
|
||||
.pio/build/gh_release_rc/firmware.map
|
||||
.pio/build/gh_release_rc/partitions.bin
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,3 +9,8 @@ build
|
||||
**/__pycache__/
|
||||
/compile_commands.json
|
||||
/.cache
|
||||
|
||||
# mod
|
||||
mod/*
|
||||
.cursor/*
|
||||
chat-summaries/*
|
||||
38
GOVERNANCE.md
Normal file
38
GOVERNANCE.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Project Governance & Community Principles
|
||||
|
||||
CrossPoint Reader is a community-driven, open-source project. Our goal is to provide a high-quality, open-source
|
||||
firmware alternative for the Xteink X4 hardware. To keep this project productive and welcoming as we grow, we ask all
|
||||
contributors to follow these principles.
|
||||
|
||||
### 1. The "Human First" Rule
|
||||
Technical discussions can get heated, but they should never be personal.
|
||||
- **Assume good intent:** We are all volunteers working on this in our free time. If a comment seems abrasive, assume
|
||||
it’s a language barrier or a misunderstanding before taking offense.
|
||||
- **Focus on the code, not the person:** Critique the implementation, the performance, or the UX. Never the intelligence
|
||||
or character of the contributor.
|
||||
- **Inflammatory language:** Personal attacks, trolling, or exclusionary language (based on race, gender, background,
|
||||
etc.) are not welcome here and will be moderated.
|
||||
|
||||
### 2. A "Do-ocracy" with Guidance
|
||||
CrossPoint thrives because people step up to build what they want to see.
|
||||
- If you want a feature, the best way to get it is to start an
|
||||
[Idea Discussion](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas) or open a PR.
|
||||
- If you want to report a bug, check for duplicates and create an
|
||||
[Issue](https://github.com/crosspoint-reader/crosspoint-reader/issues).
|
||||
- While we encourage experimentation, the maintainers reserve the right to guide the project’s technical direction to
|
||||
ensure stability on the ESP32-C3’s constrained hardware.
|
||||
- For more guidance on the scope of the project, see the [SCOPE.md](SCOPE.md) document.
|
||||
|
||||
### 3. Transparent Communication
|
||||
To keep the project healthy, we keep our "work" in the open.
|
||||
- **Public by Default:** All technical decisions and project management discussions happen in GitHub Issues, Pull
|
||||
Requests, or the public Discussions tab.
|
||||
- **Clarity in Writing:** Because we have a global community with different levels of English proficiency, please be as
|
||||
explicit and clear as possible in your PR descriptions and bug reports.
|
||||
|
||||
### 4. Moderation & Safety
|
||||
The maintainers are responsible for keeping the community a safe place to contribute.
|
||||
- We reserve the right to hide comments, lock threads, or block users who repeatedly violate these principles or engage
|
||||
in harassment.
|
||||
- **Reporting:** If you feel you are being harassed or see behavior that is damaging the community, please reach out
|
||||
privately to @daveallie.
|
||||
32
README.md
32
README.md
@@ -45,11 +45,13 @@ Multi-language support: Read EPUBs in various languages, including English, Span
|
||||
|
||||
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
|
||||
|
||||
For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) document.
|
||||
|
||||
## Installing
|
||||
|
||||
### Web (latest firmware)
|
||||
|
||||
1. Connect your Xteink X4 to your computer via USB-C
|
||||
1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device
|
||||
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
|
||||
|
||||
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||
@@ -58,7 +60,7 @@ back to the other partition using the "Swap boot partition" button here https://
|
||||
### Web (specific firmware version)
|
||||
|
||||
1. Connect your Xteink X4 to your computer via USB-C
|
||||
2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/daveallie/crosspoint-reader/releases)
|
||||
2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases)
|
||||
3. Go to https://xteink.dve.al/ and flash the firmware file using the "OTA fast flash controls" section
|
||||
|
||||
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||
@@ -82,7 +84,7 @@ See [Development](#development) below.
|
||||
CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone the repository:
|
||||
|
||||
```
|
||||
git clone --recursive https://github.com/daveallie/crosspoint-reader
|
||||
git clone --recursive https://github.com/crosspoint-reader/crosspoint-reader
|
||||
|
||||
# Or, if you've already cloned without --recursive:
|
||||
git submodule update --init --recursive
|
||||
@@ -95,6 +97,25 @@ Connect your Xteink X4 to your computer via USB-C and run the following command.
|
||||
```sh
|
||||
pio run --target upload
|
||||
```
|
||||
### Debugging
|
||||
|
||||
After flashing the new features, it’s recommended to capture detailed logs from the serial port.
|
||||
|
||||
First, make sure all required Python packages are installed:
|
||||
|
||||
```python
|
||||
python3 -m pip install pyserial colorama matplotlib
|
||||
```
|
||||
after that run the script:
|
||||
```sh
|
||||
# For Linux
|
||||
# This was tested on Debian and should work on most Linux systems.
|
||||
python3 scripts/debugging_monitor.py
|
||||
|
||||
# For macOS
|
||||
python3 scripts/debugging_monitor.py /dev/cu.usbmodem2101
|
||||
```
|
||||
Minor adjustments may be required for Windows.
|
||||
|
||||
## Internals
|
||||
|
||||
@@ -133,9 +154,12 @@ For more details on the internal file structures, see the [file formats document
|
||||
|
||||
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 you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas).
|
||||
If there's something there you'd like to work on, leave a comment so that we can avoid duplicated effort.
|
||||
|
||||
Everyone here is a volunteer, so please be respectful and patient. For more details on our goverance and community
|
||||
principles, please see [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
### To submit a contribution:
|
||||
|
||||
1. Fork the repo
|
||||
|
||||
48
SCOPE.md
Normal file
48
SCOPE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Project Vision & Scope: CrossPoint Reader
|
||||
|
||||
The goal of CrossPoint Reader is to create an efficient, open-source reading experience for the Xteink X4. We believe a
|
||||
dedicated e-reader should do one thing exceptionally well: **facilitate focused reading.**
|
||||
|
||||
## 1. Core Mission
|
||||
|
||||
To provide a lightweight, high-performance firmware that maximizes the potential of the X4, prioritizing legibility and
|
||||
usability over "swiss-army-knife" functionality.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### In-Scope
|
||||
|
||||
*These are features that directly improve the primary purpose of the device.*
|
||||
|
||||
* **User Experience:** E.g. User-friendly interfaces, and interactions, both inside the reader and navigating the
|
||||
firmware. This includes things like button mapping, book loading, and book navigation like bookmarks.
|
||||
* **Document Rendering:** E.g. Support for rendering documents (primarily EPUB) and improvements to the rendering
|
||||
engine.
|
||||
* **Format Optimization:** E.g. Efficiently parsing EPUB (CSS/Images) and other documents within the device's
|
||||
capabilities.
|
||||
* **Typography & Legibility:** E.g. Custom font support, hyphenation engines, and adjustable line spacing.
|
||||
* **E-Ink Driver Refinement:** E.g. Reducing full-screen flashes (ghosting management) and improving general rendering.
|
||||
* **Library Management:** E.g. Simple, intuitive ways to organize and navigate a collection of books.
|
||||
* **Local Transfer:** E.g. Simple, "pull" based book loading via a basic web-server or public and widely-used standards.
|
||||
* **Language Support:** E.g. Support for multiple languages both in the reader and in the interfaces.
|
||||
|
||||
### Out-of-Scope
|
||||
|
||||
*These items are rejected because they compromise the device's stability or mission.*
|
||||
|
||||
* **Interactive Apps:** No Notepads, Calculators, or Games. This is a reader, not a PDA.
|
||||
* **Active Connectivity:** No RSS readers, News aggregators, or Web browsers. Background Wi-Fi tasks drain the battery
|
||||
and complicate the single-core CPU's execution.
|
||||
* **Media Playback:** No Audio players or Audio-books.
|
||||
* **Complex Reader Features:** No highlighting, notes, or dictionary lookup. These features are better suited for
|
||||
devices with better input capabilities and more powerful chips.
|
||||
|
||||
## 3. Idea Evaluation
|
||||
|
||||
While I appreciate the desire to add new and exciting features to CrossPoint Reader, CrossPoint Reader is designed to be
|
||||
a lightweight, reliable, and performant e-reader. Things which distract or compromise the device's core mission will not
|
||||
be accepted. As a guiding question, consider if your idea improve the "core reading experience" for the average user,
|
||||
and, critically, not distract from that reading experience.
|
||||
|
||||
> **Note to Contributors:** If you are unsure if your idea fits the scope, please open a **Discussion** before you start
|
||||
> coding!
|
||||
@@ -13,14 +13,17 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.2 Book Selection](#32-book-selection)
|
||||
- [3.3 Reading Mode](#33-reading-mode)
|
||||
- [3.4 File Upload Screen](#34-file-upload-screen)
|
||||
- [3.4.1 Calibre Wireless Transfers](#341-calibre-wireless-transfers)
|
||||
- [3.5 Settings](#35-settings)
|
||||
- [3.6 Sleep Screen](#36-sleep-screen)
|
||||
- [4. Reading Mode](#4-reading-mode)
|
||||
- [Page Turning](#page-turning)
|
||||
- [Chapter Navigation](#chapter-navigation)
|
||||
- [System Navigation](#system-navigation)
|
||||
- [Supported Languages](#supported-languages)
|
||||
- [5. Chapter Selection Screen](#5-chapter-selection-screen)
|
||||
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
||||
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||
|
||||
|
||||
## 1. Hardware Overview
|
||||
@@ -102,6 +105,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
- "Custom" - Custom images from the SD card; see [Sleep Screen](#36-sleep-screen) below for more information
|
||||
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
|
||||
- "None" - A blank screen
|
||||
- "Cover + Custom" - The book cover image, fallbacks to "Custom" behavior
|
||||
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
|
||||
- "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary
|
||||
- "Crop" - Scale the image down and crop as necessary to try to to fill the screen (Note: this is experimental and may not work as expected)
|
||||
@@ -112,7 +116,10 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
- **Status Bar**: Configure the status bar displayed while reading:
|
||||
- "None" - No status bar
|
||||
- "No Progress" - Show status bar without reading progress
|
||||
- "Full" - Show status bar with reading progress
|
||||
- "Full w/ Percentage" - Show status bar with book progress (as percentage)
|
||||
- "Full w/ Book Bar" - Show status bar with book progress (as bar)
|
||||
- "Book Bar Only" - Show book progress (as bar)
|
||||
- "Full w/ Chapter Bar" - Show status bar with chapter progress (as bar)
|
||||
- **Hide Battery %**: Configure where to suppress the battery pecentage display in the status bar; the battery icon will still be shown:
|
||||
- "Never" - Always show battery percentage (default)
|
||||
- "In Reader" - Show battery percentage everywhere except in reading mode
|
||||
@@ -148,6 +155,9 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
- **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right".
|
||||
- **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep.
|
||||
- **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting.
|
||||
- **Sunlight Fading Fix**: Configure whether to enable a software-fix for the issue where white X4 models may fade when used in direct sunlight
|
||||
- "OFF" (default) - Disable the fix
|
||||
- "ON" - Enable the fix
|
||||
- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication.
|
||||
- **Check for updates**: Check for firmware updates over WiFi.
|
||||
|
||||
@@ -201,7 +211,7 @@ CrossPoint renders text using the following Unicode character blocks, enabling s
|
||||
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
|
||||
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
|
||||
|
||||
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi.
|
||||
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic, Greek and Farsi.
|
||||
|
||||
---
|
||||
|
||||
@@ -220,3 +230,18 @@ Accessible by pressing **Confirm** while inside a book.
|
||||
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.
|
||||
* **Cover Images:** Large cover images embedded into EPUB require several seconds (~10s for ~2000 pixel tall image) to convert for sleep screen and home screen thumbnail. Consider optimizing the EPUB with e.g. https://github.com/bigbag/epub-to-xtc-converter to speed this up.
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting Issues & Escaping Bootloop
|
||||
|
||||
If an issue or crash is encountered while using Crosspoint, feel free to raise an issue ticket and attach the serial monitor logs. The logs can be obtained by connecting the device to a computer and starting a serial monitor. Either [Serial Monitor](https://www.serialmonitor.org/) or the following command can be used:
|
||||
|
||||
```
|
||||
pio device monitor
|
||||
```
|
||||
|
||||
If the device is stuck in a bootloop, press and release the Reset button. Then, press and hold on to the configured Back button and the Power Button to boot to the Home Screen.
|
||||
|
||||
There can be issues with broken cache or config. In this case, delete the `.crosspoint` directory on your SD card (or consider deleting only `settings.bin`, `state.bin`, or `epub_*` cache directories in the `.crosspoint/` folder).
|
||||
|
||||
@@ -13,7 +13,9 @@ fi
|
||||
# --modified: files tracked by git that have been modified (staged or unstaged)
|
||||
# --exclude-standard: ignores files in .gitignore
|
||||
# Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated.
|
||||
# Also exclude files in 'lib/Epub/Epub/hyphenation/generated/' as they are script-generated.
|
||||
git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
|
||||
| grep -E '\.(c|cpp|h|hpp)$' \
|
||||
| grep -v -E '^lib/EpdFont/builtinFonts/' \
|
||||
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
|
||||
| xargs -r clang-format -style=file -i
|
||||
|
||||
@@ -153,7 +153,7 @@ Click **File Manager** to access file management features.
|
||||
|
||||
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)
|
||||
3. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
||||
4. Click **Create Folder**
|
||||
|
||||
This is useful for organizing your ebooks by genre, author, or series.
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
#include "EpdFontFamily.h"
|
||||
|
||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
||||
if (style == BOLD && bold) {
|
||||
// Extract font style bits (ignore UNDERLINE bit for font selection)
|
||||
const bool hasBold = (style & BOLD) != 0;
|
||||
const bool hasItalic = (style & ITALIC) != 0;
|
||||
|
||||
if (hasBold && hasItalic) {
|
||||
if (boldItalic) return boldItalic;
|
||||
if (bold) return bold;
|
||||
if (italic) return italic;
|
||||
} else if (hasBold && bold) {
|
||||
return bold;
|
||||
}
|
||||
if (style == ITALIC && italic) {
|
||||
} else if (hasItalic && italic) {
|
||||
return italic;
|
||||
}
|
||||
if (style == BOLD_ITALIC) {
|
||||
if (boldItalic) {
|
||||
return boldItalic;
|
||||
}
|
||||
if (bold) {
|
||||
return bold;
|
||||
}
|
||||
if (italic) {
|
||||
return italic;
|
||||
}
|
||||
}
|
||||
|
||||
return regular;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
class EpdFontFamily {
|
||||
public:
|
||||
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3 };
|
||||
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3, UNDERLINE = 4 };
|
||||
|
||||
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||
const EpdFont* boldItalic = nullptr)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef OMIT_BOOKERLY
|
||||
#include <builtinFonts/bookerly_12_bold.h>
|
||||
#include <builtinFonts/bookerly_12_bolditalic.h>
|
||||
#include <builtinFonts/bookerly_12_italic.h>
|
||||
@@ -16,7 +17,10 @@
|
||||
#include <builtinFonts/bookerly_18_bolditalic.h>
|
||||
#include <builtinFonts/bookerly_18_italic.h>
|
||||
#include <builtinFonts/bookerly_18_regular.h>
|
||||
#endif // OMIT_BOOKERLY
|
||||
|
||||
#include <builtinFonts/notosans_8_regular.h>
|
||||
#ifndef OMIT_NOTOSANS
|
||||
#include <builtinFonts/notosans_12_bold.h>
|
||||
#include <builtinFonts/notosans_12_bolditalic.h>
|
||||
#include <builtinFonts/notosans_12_italic.h>
|
||||
@@ -33,6 +37,9 @@
|
||||
#include <builtinFonts/notosans_18_bolditalic.h>
|
||||
#include <builtinFonts/notosans_18_italic.h>
|
||||
#include <builtinFonts/notosans_18_regular.h>
|
||||
#endif // OMIT_NOTOSANS
|
||||
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
#include <builtinFonts/opendyslexic_10_bold.h>
|
||||
#include <builtinFonts/opendyslexic_10_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_10_italic.h>
|
||||
@@ -49,6 +56,8 @@
|
||||
#include <builtinFonts/opendyslexic_8_bolditalic.h>
|
||||
#include <builtinFonts/opendyslexic_8_italic.h>
|
||||
#include <builtinFonts/opendyslexic_8_regular.h>
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
|
||||
#include <builtinFonts/ubuntu_10_bold.h>
|
||||
#include <builtinFonts/ubuntu_10_regular.h>
|
||||
#include <builtinFonts/ubuntu_12_bold.h>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#include "Epub.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <HalStorage.h>
|
||||
#include <JpegToBmpConverter.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Logging.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
#include "Epub/parsers/ContainerParser.h"
|
||||
@@ -17,7 +17,7 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
||||
|
||||
// Get file size without loading it all into heap
|
||||
if (!getItemSize(containerPath, &containerSize)) {
|
||||
Serial.printf("[%lu] [EBP] Could not find or size META-INF/container.xml\n", millis());
|
||||
LOG_ERR("EBP", "Could not find or size META-INF/container.xml");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
||||
|
||||
// Stream read (reusing your existing stream logic)
|
||||
if (!readItemContentsToStream(containerPath, containerParser, 512)) {
|
||||
Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis());
|
||||
LOG_ERR("EBP", "Could not read META-INF/container.xml");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the result
|
||||
if (containerParser.fullPath.empty()) {
|
||||
Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis());
|
||||
LOG_ERR("EBP", "Could not find valid rootfile in container.xml");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -46,28 +46,28 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
||||
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
||||
std::string contentOpfFilePath;
|
||||
if (!findContentOpfFile(&contentOpfFilePath)) {
|
||||
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
|
||||
LOG_ERR("EBP", "Could not find content.opf in zip");
|
||||
return false;
|
||||
}
|
||||
|
||||
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
|
||||
|
||||
Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str());
|
||||
LOG_DBG("EBP", "Parsing content.opf: %s", contentOpfFilePath.c_str());
|
||||
|
||||
size_t contentOpfSize;
|
||||
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
|
||||
Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis());
|
||||
LOG_ERR("EBP", "Could not get size of content.opf");
|
||||
return false;
|
||||
}
|
||||
|
||||
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
|
||||
if (!opfParser.setup()) {
|
||||
Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis());
|
||||
LOG_ERR("EBP", "Could not setup content.opf parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
|
||||
Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis());
|
||||
LOG_ERR("EBP", "Could not read content.opf");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -86,27 +86,31 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
||||
tocNavItem = opfParser.tocNavPath;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
||||
if (!opfParser.cssFiles.empty()) {
|
||||
cssFiles = opfParser.cssFiles;
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Successfully parsed content.opf");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::parseTocNcxFile() const {
|
||||
// the ncx file should have been specified in the content.opf file
|
||||
if (tocNcxItem.empty()) {
|
||||
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis());
|
||||
LOG_DBG("EBP", "No ncx file specified");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str());
|
||||
LOG_DBG("EBP", "Parsing toc ncx file: %s", tocNcxItem.c_str());
|
||||
|
||||
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
|
||||
FsFile tempNcxFile;
|
||||
if (!SdMan.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
|
||||
if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
|
||||
tempNcxFile.close();
|
||||
if (!SdMan.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
|
||||
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
|
||||
return false;
|
||||
}
|
||||
const auto ncxSize = tempNcxFile.size();
|
||||
@@ -114,14 +118,14 @@ bool Epub::parseTocNcxFile() const {
|
||||
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
|
||||
|
||||
if (!ncxParser.setup()) {
|
||||
Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis());
|
||||
LOG_ERR("EBP", "Could not setup toc ncx parser");
|
||||
tempNcxFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||
if (!ncxBuffer) {
|
||||
Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis());
|
||||
LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
|
||||
tempNcxFile.close();
|
||||
return false;
|
||||
}
|
||||
@@ -132,7 +136,7 @@ bool Epub::parseTocNcxFile() const {
|
||||
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
|
||||
|
||||
if (processedSize != readSize) {
|
||||
Serial.printf("[%lu] [EBP] Could not process all toc ncx data\n", millis());
|
||||
LOG_ERR("EBP", "Could not process all toc ncx data");
|
||||
free(ncxBuffer);
|
||||
tempNcxFile.close();
|
||||
return false;
|
||||
@@ -141,29 +145,29 @@ bool Epub::parseTocNcxFile() const {
|
||||
|
||||
free(ncxBuffer);
|
||||
tempNcxFile.close();
|
||||
SdMan.remove(tmpNcxPath.c_str());
|
||||
Storage.remove(tmpNcxPath.c_str());
|
||||
|
||||
Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis());
|
||||
LOG_DBG("EBP", "Parsed TOC items");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::parseTocNavFile() const {
|
||||
// the nav file should have been specified in the content.opf file (EPUB 3)
|
||||
if (tocNavItem.empty()) {
|
||||
Serial.printf("[%lu] [EBP] No nav file specified\n", millis());
|
||||
LOG_DBG("EBP", "No nav file specified");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str());
|
||||
LOG_DBG("EBP", "Parsing toc nav file: %s", tocNavItem.c_str());
|
||||
|
||||
const auto tmpNavPath = getCachePath() + "/toc.nav";
|
||||
FsFile tempNavFile;
|
||||
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
||||
if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
||||
tempNavFile.close();
|
||||
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
||||
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
||||
return false;
|
||||
}
|
||||
const auto navSize = tempNavFile.size();
|
||||
@@ -174,13 +178,13 @@ bool Epub::parseTocNavFile() const {
|
||||
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
|
||||
|
||||
if (!navParser.setup()) {
|
||||
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
|
||||
LOG_ERR("EBP", "Could not setup toc nav parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||
if (!navBuffer) {
|
||||
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis());
|
||||
LOG_ERR("EBP", "Could not allocate memory for toc nav parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -189,7 +193,7 @@ bool Epub::parseTocNavFile() const {
|
||||
const auto processedSize = navParser.write(navBuffer, readSize);
|
||||
|
||||
if (processedSize != readSize) {
|
||||
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis());
|
||||
LOG_ERR("EBP", "Could not process all toc nav data");
|
||||
free(navBuffer);
|
||||
tempNavFile.close();
|
||||
return false;
|
||||
@@ -198,22 +202,97 @@ bool Epub::parseTocNavFile() const {
|
||||
|
||||
free(navBuffer);
|
||||
tempNavFile.close();
|
||||
SdMan.remove(tmpNavPath.c_str());
|
||||
Storage.remove(tmpNavPath.c_str());
|
||||
|
||||
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis());
|
||||
LOG_DBG("EBP", "Parsed TOC nav items");
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
|
||||
|
||||
bool Epub::loadCssRulesFromCache() const {
|
||||
FsFile cssCacheFile;
|
||||
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
|
||||
if (cssParser->loadFromCache(cssCacheFile)) {
|
||||
cssCacheFile.close();
|
||||
LOG_DBG("EBP", "Loaded CSS rules from cache");
|
||||
return true;
|
||||
}
|
||||
cssCacheFile.close();
|
||||
LOG_DBG("EBP", "CSS cache invalid, reparsing");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Epub::parseCssFiles() const {
|
||||
if (cssFiles.empty()) {
|
||||
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
||||
}
|
||||
|
||||
// Try to load from CSS cache first
|
||||
if (!loadCssRulesFromCache()) {
|
||||
// Cache miss - parse CSS files
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
// Extract CSS file to temp location
|
||||
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
||||
FsFile tempCssFile;
|
||||
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not create temp CSS file");
|
||||
continue;
|
||||
}
|
||||
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
||||
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
|
||||
tempCssFile.close();
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
tempCssFile.close();
|
||||
|
||||
// Parse the CSS file
|
||||
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
||||
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
continue;
|
||||
}
|
||||
cssParser->loadFromStream(tempCssFile);
|
||||
tempCssFile.close();
|
||||
Storage.remove(tmpCssPath.c_str());
|
||||
}
|
||||
|
||||
// Save to cache for next time
|
||||
FsFile cssCacheFile;
|
||||
if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
|
||||
cssParser->saveToCache(cssCacheFile);
|
||||
cssCacheFile.close();
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
}
|
||||
}
|
||||
|
||||
// load in the meta data for the epub file
|
||||
bool Epub::load(const bool buildIfMissing) {
|
||||
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
||||
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
|
||||
|
||||
// Initialize spine/TOC cache
|
||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||
cssParser.reset(new CssParser());
|
||||
|
||||
// Try to load existing cache first
|
||||
if (bookMetadataCache->load()) {
|
||||
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
||||
if (!skipLoadingCss && !loadCssRulesFromCache()) {
|
||||
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
|
||||
// to get CSS file list
|
||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
||||
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
||||
}
|
||||
parseCssFiles();
|
||||
}
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -223,14 +302,14 @@ bool Epub::load(const bool buildIfMissing) {
|
||||
}
|
||||
|
||||
// Cache doesn't exist or is invalid, build it
|
||||
Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis());
|
||||
LOG_DBG("EBP", "Cache not found, building spine/TOC cache");
|
||||
setupCacheDir();
|
||||
|
||||
const uint32_t indexingStart = millis();
|
||||
|
||||
// Begin building cache - stream entries to disk immediately
|
||||
if (!bookMetadataCache->beginWrite()) {
|
||||
Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis());
|
||||
LOG_ERR("EBP", "Could not begin writing cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -238,23 +317,23 @@ bool Epub::load(const bool buildIfMissing) {
|
||||
const uint32_t opfStart = millis();
|
||||
BookMetadataCache::BookMetadata bookMetadata;
|
||||
if (!bookMetadataCache->beginContentOpfPass()) {
|
||||
Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis());
|
||||
LOG_ERR("EBP", "Could not begin writing content.opf pass");
|
||||
return false;
|
||||
}
|
||||
if (!parseContentOpf(bookMetadata)) {
|
||||
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis());
|
||||
LOG_ERR("EBP", "Could not parse content.opf");
|
||||
return false;
|
||||
}
|
||||
if (!bookMetadataCache->endContentOpfPass()) {
|
||||
Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis());
|
||||
LOG_ERR("EBP", "Could not end writing content.opf pass");
|
||||
return false;
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] OPF pass completed in %lu ms\n", millis(), millis() - opfStart);
|
||||
LOG_DBG("EBP", "OPF pass completed in %lu ms", millis() - opfStart);
|
||||
|
||||
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
||||
const uint32_t tocStart = millis();
|
||||
if (!bookMetadataCache->beginTocPass()) {
|
||||
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
|
||||
LOG_ERR("EBP", "Could not begin writing toc pass");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -262,78 +341,83 @@ bool Epub::load(const bool buildIfMissing) {
|
||||
|
||||
// Try EPUB 3 nav document first (preferred)
|
||||
if (!tocNavItem.empty()) {
|
||||
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis());
|
||||
LOG_DBG("EBP", "Attempting to parse EPUB 3 nav document");
|
||||
tocParsed = parseTocNavFile();
|
||||
}
|
||||
|
||||
// Fall back to NCX if nav parsing failed or wasn't available
|
||||
if (!tocParsed && !tocNcxItem.empty()) {
|
||||
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis());
|
||||
LOG_DBG("EBP", "Falling back to NCX TOC");
|
||||
tocParsed = parseTocNcxFile();
|
||||
}
|
||||
|
||||
if (!tocParsed) {
|
||||
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis());
|
||||
LOG_ERR("EBP", "Warning: Could not parse any TOC format");
|
||||
// Continue anyway - book will work without TOC
|
||||
}
|
||||
|
||||
if (!bookMetadataCache->endTocPass()) {
|
||||
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
|
||||
LOG_ERR("EBP", "Could not end writing toc pass");
|
||||
return false;
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] TOC pass completed in %lu ms\n", millis(), millis() - tocStart);
|
||||
LOG_DBG("EBP", "TOC pass completed in %lu ms", millis() - tocStart);
|
||||
|
||||
// Close the cache files
|
||||
if (!bookMetadataCache->endWrite()) {
|
||||
Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis());
|
||||
LOG_ERR("EBP", "Could not end writing cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build final book.bin
|
||||
const uint32_t buildStart = millis();
|
||||
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
|
||||
Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis());
|
||||
LOG_ERR("EBP", "Could not update mappings and sizes");
|
||||
return false;
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] buildBookBin completed in %lu ms\n", millis(), millis() - buildStart);
|
||||
Serial.printf("[%lu] [EBP] Total indexing completed in %lu ms\n", millis(), millis() - indexingStart);
|
||||
LOG_DBG("EBP", "buildBookBin completed in %lu ms", millis() - buildStart);
|
||||
LOG_DBG("EBP", "Total indexing completed in %lu ms", millis() - indexingStart);
|
||||
|
||||
if (!bookMetadataCache->cleanupTmpFiles()) {
|
||||
Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis());
|
||||
LOG_DBG("EBP", "Could not cleanup tmp files - ignoring");
|
||||
}
|
||||
|
||||
// Reload the cache from disk so it's in the correct state
|
||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||
if (!bookMetadataCache->load()) {
|
||||
Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis());
|
||||
LOG_ERR("EBP", "Failed to reload cache after writing");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
||||
if (!skipLoadingCss) {
|
||||
// Parse CSS files after cache reload
|
||||
parseCssFiles();
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Epub::clearCache() const {
|
||||
if (!SdMan.exists(cachePath.c_str())) {
|
||||
Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis());
|
||||
if (!Storage.exists(cachePath.c_str())) {
|
||||
LOG_DBG("EPB", "Cache does not exist, no action needed");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!SdMan.removeDir(cachePath.c_str())) {
|
||||
Serial.printf("[%lu] [EPB] Failed to clear cache\n", millis());
|
||||
if (!Storage.removeDir(cachePath.c_str())) {
|
||||
LOG_ERR("EPB", "Failed to clear cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [EPB] Cache cleared successfully\n", millis());
|
||||
LOG_DBG("EPB", "Cache cleared successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Epub::setupCacheDir() const {
|
||||
if (SdMan.exists(cachePath.c_str())) {
|
||||
if (Storage.exists(cachePath.c_str())) {
|
||||
return;
|
||||
}
|
||||
|
||||
SdMan.mkdir(cachePath.c_str());
|
||||
Storage.mkdir(cachePath.c_str());
|
||||
}
|
||||
|
||||
const std::string& Epub::getCachePath() const { return cachePath; }
|
||||
@@ -374,127 +458,128 @@ std::string Epub::getCoverBmpPath(bool cropped) const {
|
||||
|
||||
bool Epub::generateCoverBmp(bool cropped) const {
|
||||
// Already generated, return true
|
||||
if (SdMan.exists(getCoverBmpPath(cropped).c_str())) {
|
||||
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis());
|
||||
LOG_ERR("EBP", "Cannot generate cover BMP, cache not loaded");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
if (coverImageHref.empty()) {
|
||||
Serial.printf("[%lu] [EBP] No known cover image\n", millis());
|
||||
LOG_ERR("EBP", "No known cover image");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image (%s mode)\n", millis(), cropped ? "cropped" : "fit");
|
||||
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
FsFile coverJpg;
|
||||
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
coverJpg.close();
|
||||
|
||||
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile coverBmp;
|
||||
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
|
||||
coverJpg.close();
|
||||
coverBmp.close();
|
||||
SdMan.remove(coverJpgTempPath.c_str());
|
||||
Storage.remove(coverJpgTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
|
||||
SdMan.remove(getCoverBmpPath(cropped).c_str());
|
||||
LOG_ERR("EBP", "Failed to generate BMP from cover image");
|
||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no");
|
||||
LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis());
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
|
||||
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
||||
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||
|
||||
bool Epub::generateThumbBmp() const {
|
||||
bool Epub::generateThumbBmp(int height) const {
|
||||
// Already generated, return true
|
||||
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
||||
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis());
|
||||
LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded");
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
if (coverImageHref.empty()) {
|
||||
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis());
|
||||
LOG_DBG("EBP", "No known cover image for thumbnail");
|
||||
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
|
||||
FsFile coverJpg;
|
||||
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||
coverJpg.close();
|
||||
|
||||
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
|
||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
||||
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
||||
constexpr int THUMB_TARGET_WIDTH = 240;
|
||||
constexpr int THUMB_TARGET_HEIGHT = 400;
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
||||
THUMB_TARGET_HEIGHT);
|
||||
coverJpg.close();
|
||||
thumbBmp.close();
|
||||
SdMan.remove(coverJpgTempPath.c_str());
|
||||
Storage.remove(coverJpgTempPath.c_str());
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
|
||||
SdMan.remove(getThumbBmpPath().c_str());
|
||||
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
||||
Storage.remove(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
|
||||
success ? "yes" : "no");
|
||||
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||
return success;
|
||||
} else {
|
||||
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis());
|
||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||
}
|
||||
|
||||
// Write an empty bmp file to avoid generation attempts in the future
|
||||
FsFile thumbBmp;
|
||||
Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
|
||||
thumbBmp.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
||||
if (itemHref.empty()) {
|
||||
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -502,7 +587,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
||||
|
||||
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
||||
if (!content) {
|
||||
Serial.printf("[%lu] [EBP] Failed to read item %s\n", millis(), path.c_str());
|
||||
LOG_DBG("EBP", "Failed to read item %s", path.c_str());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -511,7 +596,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
||||
|
||||
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
|
||||
if (itemHref.empty()) {
|
||||
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis());
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -535,12 +620,12 @@ size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return get
|
||||
|
||||
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis());
|
||||
LOG_ERR("EBP", "getSpineItem called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
|
||||
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex);
|
||||
LOG_ERR("EBP", "getSpineItem index:%d is out of range", spineIndex);
|
||||
return bookMetadataCache->getSpineEntry(0);
|
||||
}
|
||||
|
||||
@@ -549,12 +634,12 @@ BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
||||
|
||||
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis());
|
||||
LOG_DBG("EBP", "getTocItem called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
||||
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex);
|
||||
LOG_DBG("EBP", "getTocItem index:%d is out of range", tocIndex);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -572,18 +657,18 @@ int Epub::getTocItemsCount() const {
|
||||
// work out the section index for a toc index
|
||||
int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis());
|
||||
LOG_ERR("EBP", "getSpineIndexForTocIndex called but cache not loaded");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
||||
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex);
|
||||
LOG_ERR("EBP", "getSpineIndexForTocIndex: tocIndex %d out of range", tocIndex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
|
||||
if (spineIndex < 0) {
|
||||
Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex);
|
||||
LOG_DBG("EBP", "Section not found for TOC index %d", tocIndex);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -601,14 +686,13 @@ size_t Epub::getBookSize() const {
|
||||
|
||||
int Epub::getSpineIndexForTextReference() const {
|
||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
||||
Serial.printf("[%lu] [EBP] getSpineIndexForTextReference called but cache not loaded\n", millis());
|
||||
LOG_ERR("EBP", "getSpineIndexForTextReference called but cache not loaded");
|
||||
return 0;
|
||||
}
|
||||
Serial.printf("[%lu] [ERS] Core Metadata: cover(%d)=%s, textReference(%d)=%s\n", millis(),
|
||||
bookMetadataCache->coreMetadata.coverItemHref.size(),
|
||||
bookMetadataCache->coreMetadata.coverItemHref.c_str(),
|
||||
bookMetadataCache->coreMetadata.textReferenceHref.size(),
|
||||
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
|
||||
LOG_DBG("EBP", "Core Metadata: cover(%d)=%s, textReference(%d)=%s",
|
||||
bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.c_str(),
|
||||
bookMetadataCache->coreMetadata.textReferenceHref.size(),
|
||||
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
|
||||
|
||||
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
|
||||
// there was no textReference in epub, so we return 0 (the first chapter)
|
||||
@@ -618,13 +702,13 @@ int Epub::getSpineIndexForTextReference() const {
|
||||
// loop through spine items to get the correct index matching the text href
|
||||
for (size_t i = 0; i < getSpineItemsCount(); i++) {
|
||||
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
|
||||
Serial.printf("[%lu] [ERS] Text reference %s found at index %d\n", millis(),
|
||||
bookMetadataCache->coreMetadata.textReferenceHref.c_str(), i);
|
||||
LOG_DBG("EBP", "Text reference %s found at index %d", bookMetadataCache->coreMetadata.textReferenceHref.c_str(),
|
||||
i);
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// This should not happen, as we checked for empty textReferenceHref earlier
|
||||
Serial.printf("[%lu] [EBP] Section not found for text reference\n", millis());
|
||||
LOG_DBG("EBP", "Section not found for text reference");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "Epub/BookMetadataCache.h"
|
||||
#include "Epub/css/CssParser.h"
|
||||
|
||||
class ZipFile;
|
||||
|
||||
@@ -24,11 +25,18 @@ class Epub {
|
||||
std::string cachePath;
|
||||
// Spine and TOC cache
|
||||
std::unique_ptr<BookMetadataCache> bookMetadataCache;
|
||||
// CSS parser for styling
|
||||
std::unique_ptr<CssParser> cssParser;
|
||||
// CSS files
|
||||
std::vector<std::string> cssFiles;
|
||||
|
||||
bool findContentOpfFile(std::string* contentOpfFile) const;
|
||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
||||
bool parseTocNcxFile() const;
|
||||
bool parseTocNavFile() const;
|
||||
void parseCssFiles() const;
|
||||
std::string getCssRulesCache() const;
|
||||
bool loadCssRulesFromCache() const;
|
||||
|
||||
public:
|
||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||
@@ -37,7 +45,7 @@ class Epub {
|
||||
}
|
||||
~Epub() = default;
|
||||
std::string& getBasePath() { return contentBasePath; }
|
||||
bool load(bool buildIfMissing = true);
|
||||
bool load(bool buildIfMissing = true, bool skipLoadingCss = false);
|
||||
bool clearCache() const;
|
||||
void setupCacheDir() const;
|
||||
const std::string& getCachePath() const;
|
||||
@@ -48,7 +56,8 @@ class Epub {
|
||||
std::string getCoverBmpPath(bool cropped = false) const;
|
||||
bool generateCoverBmp(bool cropped = false) const;
|
||||
std::string getThumbBmpPath() const;
|
||||
bool generateThumbBmp() const;
|
||||
std::string getThumbBmpPath(int height) const;
|
||||
bool generateThumbBmp(int height) const;
|
||||
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
|
||||
bool trailingNullByte = false) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||
@@ -64,4 +73,5 @@ class Epub {
|
||||
|
||||
size_t getBookSize() const;
|
||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||
const CssParser* getCssParser() const { return cssParser.get(); }
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "BookMetadataCache.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
#include <ZipFile.h>
|
||||
|
||||
@@ -21,15 +21,15 @@ bool BookMetadataCache::beginWrite() {
|
||||
buildMode = true;
|
||||
spineCount = 0;
|
||||
tocCount = 0;
|
||||
Serial.printf("[%lu] [BMC] Entering write mode\n", millis());
|
||||
LOG_DBG("BMC", "Entering write mode");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::beginContentOpfPass() {
|
||||
Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis());
|
||||
LOG_DBG("BMC", "Beginning content opf pass");
|
||||
|
||||
// Open spine file for writing
|
||||
return SdMan.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
|
||||
return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
|
||||
}
|
||||
|
||||
bool BookMetadataCache::endContentOpfPass() {
|
||||
@@ -38,12 +38,12 @@ bool BookMetadataCache::endContentOpfPass() {
|
||||
}
|
||||
|
||||
bool BookMetadataCache::beginTocPass() {
|
||||
Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis());
|
||||
LOG_DBG("BMC", "Beginning toc pass");
|
||||
|
||||
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||
return false;
|
||||
}
|
||||
if (!SdMan.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||
if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||
spineFile.close();
|
||||
return false;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ bool BookMetadataCache::beginTocPass() {
|
||||
});
|
||||
spineFile.seek(0);
|
||||
useSpineHrefIndex = true;
|
||||
Serial.printf("[%lu] [BMC] Using fast index for %d spine items\n", millis(), spineCount);
|
||||
LOG_DBG("BMC", "Using fast index for %d spine items", spineCount);
|
||||
} else {
|
||||
useSpineHrefIndex = false;
|
||||
}
|
||||
@@ -87,27 +87,27 @@ bool BookMetadataCache::endTocPass() {
|
||||
|
||||
bool BookMetadataCache::endWrite() {
|
||||
if (!buildMode) {
|
||||
Serial.printf("[%lu] [BMC] endWrite called but not in build mode\n", millis());
|
||||
LOG_DBG("BMC", "endWrite called but not in build mode");
|
||||
return false;
|
||||
}
|
||||
|
||||
buildMode = false;
|
||||
Serial.printf("[%lu] [BMC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
|
||||
LOG_DBG("BMC", "Wrote %d spine, %d TOC entries", spineCount, tocCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) {
|
||||
// Open all three files, writing to meta, reading from spine and toc
|
||||
if (!SdMan.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) {
|
||||
if (!Storage.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||
bookFile.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SdMan.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||
if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||
bookFile.close();
|
||||
spineFile.close();
|
||||
return false;
|
||||
@@ -167,7 +167,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
||||
ZipFile zip(epubPath);
|
||||
// Pre-open zip file to speed up size calculations
|
||||
if (!zip.open()) {
|
||||
Serial.printf("[%lu] [BMC] Could not open EPUB zip for size calculations\n", millis());
|
||||
LOG_ERR("BMC", "Could not open EPUB zip for size calculations");
|
||||
bookFile.close();
|
||||
spineFile.close();
|
||||
tocFile.close();
|
||||
@@ -185,7 +185,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
||||
bool useBatchSizes = false;
|
||||
|
||||
if (spineCount >= LARGE_SPINE_THRESHOLD) {
|
||||
Serial.printf("[%lu] [BMC] Using batch size lookup for %d spine items\n", millis(), spineCount);
|
||||
LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount);
|
||||
|
||||
std::vector<ZipFile::SizeTarget> targets;
|
||||
targets.reserve(spineCount);
|
||||
@@ -208,7 +208,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
||||
|
||||
spineSizes.resize(spineCount, 0);
|
||||
int matched = zip.fillUncompressedSizes(targets, spineSizes);
|
||||
Serial.printf("[%lu] [BMC] Batch lookup matched %d/%d spine items\n", millis(), matched, spineCount);
|
||||
LOG_DBG("BMC", "Batch lookup matched %d/%d spine items", matched, spineCount);
|
||||
|
||||
targets.clear();
|
||||
targets.shrink_to_fit();
|
||||
@@ -227,9 +227,8 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
||||
// Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs
|
||||
// Logging here is for debugging
|
||||
if (spineEntry.tocIndex == -1) {
|
||||
Serial.printf(
|
||||
"[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s, using title from last section\n",
|
||||
millis(), i, spineEntry.href.c_str());
|
||||
LOG_DBG("BMC", "Warning: Could not find TOC entry for spine item %d: %s, using title from last section", i,
|
||||
spineEntry.href.c_str());
|
||||
spineEntry.tocIndex = lastSpineTocIndex;
|
||||
}
|
||||
lastSpineTocIndex = spineEntry.tocIndex;
|
||||
@@ -240,13 +239,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
||||
if (itemSize == 0) {
|
||||
const std::string path = FsHelpers::normalisePath(spineEntry.href);
|
||||
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
|
||||
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str());
|
||||
LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const std::string path = FsHelpers::normalisePath(spineEntry.href);
|
||||
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
|
||||
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str());
|
||||
LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,16 +269,16 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
||||
spineFile.close();
|
||||
tocFile.close();
|
||||
|
||||
Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis());
|
||||
LOG_DBG("BMC", "Successfully built book.bin");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookMetadataCache::cleanupTmpFiles() const {
|
||||
if (SdMan.exists((cachePath + tmpSpineBinFile).c_str())) {
|
||||
SdMan.remove((cachePath + tmpSpineBinFile).c_str());
|
||||
if (Storage.exists((cachePath + tmpSpineBinFile).c_str())) {
|
||||
Storage.remove((cachePath + tmpSpineBinFile).c_str());
|
||||
}
|
||||
if (SdMan.exists((cachePath + tmpTocBinFile).c_str())) {
|
||||
SdMan.remove((cachePath + tmpTocBinFile).c_str());
|
||||
if (Storage.exists((cachePath + tmpTocBinFile).c_str())) {
|
||||
Storage.remove((cachePath + tmpTocBinFile).c_str());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -306,7 +305,7 @@ uint32_t BookMetadataCache::writeTocEntry(FsFile& file, const TocEntry& entry) c
|
||||
// this is because in this function we're marking positions of the items
|
||||
void BookMetadataCache::createSpineEntry(const std::string& href) {
|
||||
if (!buildMode || !spineFile) {
|
||||
Serial.printf("[%lu] [BMC] createSpineEntry called but not in build mode\n", millis());
|
||||
LOG_DBG("BMC", "createSpineEntry called but not in build mode");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -318,7 +317,7 @@ void BookMetadataCache::createSpineEntry(const std::string& href) {
|
||||
void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
|
||||
const uint8_t level) {
|
||||
if (!buildMode || !tocFile || !spineFile) {
|
||||
Serial.printf("[%lu] [BMC] createTocEntry called but not in build mode\n", millis());
|
||||
LOG_DBG("BMC", "createTocEntry called but not in build mode");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -340,7 +339,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
|
||||
}
|
||||
|
||||
if (spineIndex == -1) {
|
||||
Serial.printf("[%lu] [BMC] createTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str());
|
||||
LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
|
||||
}
|
||||
} else {
|
||||
spineFile.seek(0);
|
||||
@@ -352,7 +351,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
|
||||
}
|
||||
}
|
||||
if (spineIndex == -1) {
|
||||
Serial.printf("[%lu] [BMC] createTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str());
|
||||
LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,14 +363,14 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
|
||||
/* ============= READING / LOADING FUNCTIONS ================ */
|
||||
|
||||
bool BookMetadataCache::load() {
|
||||
if (!SdMan.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
|
||||
if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(bookFile, version);
|
||||
if (version != BOOK_CACHE_VERSION) {
|
||||
Serial.printf("[%lu] [BMC] Cache version mismatch: expected %d, got %d\n", millis(), BOOK_CACHE_VERSION, version);
|
||||
LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version);
|
||||
bookFile.close();
|
||||
return false;
|
||||
}
|
||||
@@ -387,18 +386,18 @@ bool BookMetadataCache::load() {
|
||||
serialization::readString(bookFile, coreMetadata.textReferenceHref);
|
||||
|
||||
loaded = true;
|
||||
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
|
||||
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
|
||||
if (!loaded) {
|
||||
Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis());
|
||||
LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (index < 0 || index >= static_cast<int>(spineCount)) {
|
||||
Serial.printf("[%lu] [BMC] getSpineEntry index %d out of range\n", millis(), index);
|
||||
LOG_ERR("BMC", "getSpineEntry index %d out of range", index);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -412,12 +411,12 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
|
||||
|
||||
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
|
||||
if (!loaded) {
|
||||
Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis());
|
||||
LOG_ERR("BMC", "getTocEntry called but cache not loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (index < 0 || index >= static_cast<int>(tocCount)) {
|
||||
Serial.printf("[%lu] [BMC] getTocEntry index %d out of range\n", millis(), index);
|
||||
LOG_ERR("BMC", "getTocEntry index %d out of range", index);
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "Page.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||
@@ -60,7 +60,7 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
||||
auto pl = PageLine::deserialize(file);
|
||||
page->elements.push_back(std::move(pl));
|
||||
} else {
|
||||
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);
|
||||
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#pragma once
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -28,6 +28,7 @@ class PageLine final : public PageElement {
|
||||
public:
|
||||
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
|
||||
: PageElement(xPos, yPos), block(std::move(block)) {}
|
||||
const std::shared_ptr<TextBlock>& getBlock() const { return block; }
|
||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||
bool serialize(FsFile& file) override;
|
||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
@@ -32,6 +31,9 @@ void stripSoftHyphensInPlace(std::string& word) {
|
||||
// Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
|
||||
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
|
||||
const EpdFontFamily::Style style, const bool appendHyphen = false) {
|
||||
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
|
||||
return renderer.getSpaceWidth(fontId);
|
||||
}
|
||||
const bool hasSoftHyphen = containsSoftHyphen(word);
|
||||
if (!hasSoftHyphen && !appendHyphen) {
|
||||
return renderer.getTextWidth(fontId, word.c_str(), style);
|
||||
@@ -49,11 +51,17 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
|
||||
|
||||
} // namespace
|
||||
|
||||
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle) {
|
||||
void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline,
|
||||
const bool attachToPrevious) {
|
||||
if (word.empty()) return;
|
||||
|
||||
words.push_back(std::move(word));
|
||||
wordStyles.push_back(fontStyle);
|
||||
EpdFontFamily::Style combinedStyle = fontStyle;
|
||||
if (underline) {
|
||||
combinedStyle = static_cast<EpdFontFamily::Style>(combinedStyle | EpdFontFamily::UNDERLINE);
|
||||
}
|
||||
wordStyles.push_back(combinedStyle);
|
||||
wordContinues.push_back(attachToPrevious);
|
||||
}
|
||||
|
||||
// Consumes data to minimize memory usage
|
||||
@@ -70,49 +78,52 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
||||
const int pageWidth = viewportWidth;
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
if (hyphenationEnabled) {
|
||||
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
||||
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths);
|
||||
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
|
||||
} else {
|
||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths);
|
||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
|
||||
}
|
||||
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||
|
||||
for (size_t i = 0; i < lineCount; ++i) {
|
||||
extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine);
|
||||
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
||||
const size_t totalWordCount = words.size();
|
||||
|
||||
std::vector<uint16_t> wordWidths;
|
||||
wordWidths.reserve(totalWordCount);
|
||||
wordWidths.reserve(words.size());
|
||||
|
||||
auto wordsIt = words.begin();
|
||||
auto wordStylesIt = wordStyles.begin();
|
||||
|
||||
while (wordsIt != words.end()) {
|
||||
wordWidths.push_back(measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt));
|
||||
|
||||
std::advance(wordsIt, 1);
|
||||
std::advance(wordStylesIt, 1);
|
||||
for (size_t i = 0; i < words.size(); ++i) {
|
||||
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i]));
|
||||
}
|
||||
|
||||
return wordWidths;
|
||||
}
|
||||
|
||||
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
|
||||
const int spaceWidth, std::vector<uint16_t>& wordWidths) {
|
||||
const int spaceWidth, std::vector<uint16_t>& wordWidths,
|
||||
std::vector<bool>& continuesVec) {
|
||||
if (words.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
||||
const int firstLineIndent =
|
||||
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
||||
? blockStyle.textIndent
|
||||
: 0;
|
||||
|
||||
// Ensure any word that would overflow even as the first entry on a line is split using fallback hyphenation.
|
||||
for (size_t i = 0; i < wordWidths.size(); ++i) {
|
||||
while (wordWidths[i] > pageWidth) {
|
||||
if (!hyphenateWordAtIndex(i, pageWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
|
||||
// First word needs to fit in reduced width if there's an indent
|
||||
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||
while (wordWidths[i] > effectiveWidth) {
|
||||
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -130,22 +141,31 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
ans[totalWordCount - 1] = totalWordCount - 1;
|
||||
|
||||
for (int i = totalWordCount - 2; i >= 0; --i) {
|
||||
int currlen = -spaceWidth;
|
||||
int currlen = 0;
|
||||
dp[i] = MAX_COST;
|
||||
|
||||
for (size_t j = i; j < totalWordCount; ++j) {
|
||||
// Current line length: previous width + space + current word width
|
||||
currlen += wordWidths[j] + spaceWidth;
|
||||
// First line has reduced width due to text-indent
|
||||
const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||
|
||||
if (currlen > pageWidth) {
|
||||
for (size_t j = i; j < totalWordCount; ++j) {
|
||||
// Add space before word j, unless it's the first word on the line or a continuation
|
||||
const int gap = j > static_cast<size_t>(i) && !continuesVec[j] ? spaceWidth : 0;
|
||||
currlen += wordWidths[j] + gap;
|
||||
|
||||
if (currlen > effectivePageWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Cannot break after word j if the next word attaches to it (continuation group)
|
||||
if (j + 1 < totalWordCount && continuesVec[j + 1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int cost;
|
||||
if (j == totalWordCount - 1) {
|
||||
cost = 0; // Last line
|
||||
} else {
|
||||
const int remainingSpace = pageWidth - currlen;
|
||||
const int remainingSpace = effectivePageWidth - currlen;
|
||||
// Use long long for the square to prevent overflow
|
||||
const long long cost_ll = static_cast<long long>(remainingSpace) * remainingSpace + dp[j + 1];
|
||||
|
||||
@@ -200,7 +220,11 @@ void ParsedText::applyParagraphIndent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) {
|
||||
if (blockStyle.textIndentDefined) {
|
||||
// CSS text-indent is explicitly set (even if 0) - don't use fallback EmSpace
|
||||
// The actual indent positioning is handled in extractLine()
|
||||
} else if (blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left) {
|
||||
// No CSS text-indent defined - use EmSpace fallback for visual indent
|
||||
words.front().insert(0, "\xe2\x80\x83");
|
||||
}
|
||||
}
|
||||
@@ -208,29 +232,41 @@ void ParsedText::applyParagraphIndent() {
|
||||
// Builds break indices while opportunistically splitting the word that would overflow the current line.
|
||||
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
|
||||
const int pageWidth, const int spaceWidth,
|
||||
std::vector<uint16_t>& wordWidths) {
|
||||
std::vector<uint16_t>& wordWidths,
|
||||
std::vector<bool>& continuesVec) {
|
||||
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
||||
const int firstLineIndent =
|
||||
blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
||||
? blockStyle.textIndent
|
||||
: 0;
|
||||
|
||||
std::vector<size_t> lineBreakIndices;
|
||||
size_t currentIndex = 0;
|
||||
bool isFirstLine = true;
|
||||
|
||||
while (currentIndex < wordWidths.size()) {
|
||||
const size_t lineStart = currentIndex;
|
||||
int lineWidth = 0;
|
||||
|
||||
// First line has reduced width due to text-indent
|
||||
const int effectivePageWidth = isFirstLine ? pageWidth - firstLineIndent : pageWidth;
|
||||
|
||||
// Consume as many words as possible for current line, splitting when prefixes fit
|
||||
while (currentIndex < wordWidths.size()) {
|
||||
const bool isFirstWord = currentIndex == lineStart;
|
||||
const int spacing = isFirstWord ? 0 : spaceWidth;
|
||||
const int spacing = isFirstWord || continuesVec[currentIndex] ? 0 : spaceWidth;
|
||||
const int candidateWidth = spacing + wordWidths[currentIndex];
|
||||
|
||||
// Word fits on current line
|
||||
if (lineWidth + candidateWidth <= pageWidth) {
|
||||
if (lineWidth + candidateWidth <= effectivePageWidth) {
|
||||
lineWidth += candidateWidth;
|
||||
++currentIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word would overflow — try to split based on hyphenation points
|
||||
const int availableWidth = pageWidth - lineWidth - spacing;
|
||||
const int availableWidth = effectivePageWidth - lineWidth - spacing;
|
||||
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
|
||||
|
||||
if (availableWidth > 0 &&
|
||||
@@ -249,7 +285,14 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't break before a continuation word (e.g., orphaned "?" after "question").
|
||||
// Backtrack to the start of the continuation group so the whole group moves to the next line.
|
||||
while (currentIndex > lineStart + 1 && currentIndex < wordWidths.size() && continuesVec[currentIndex]) {
|
||||
--currentIndex;
|
||||
}
|
||||
|
||||
lineBreakIndices.push_back(currentIndex);
|
||||
isFirstLine = false;
|
||||
}
|
||||
|
||||
return lineBreakIndices;
|
||||
@@ -265,14 +308,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get iterators to target word and style.
|
||||
auto wordIt = words.begin();
|
||||
auto styleIt = wordStyles.begin();
|
||||
std::advance(wordIt, wordIndex);
|
||||
std::advance(styleIt, wordIndex);
|
||||
|
||||
const std::string& word = *wordIt;
|
||||
const auto style = *styleIt;
|
||||
const std::string& word = words[wordIndex];
|
||||
const auto style = wordStyles[wordIndex];
|
||||
|
||||
// Collect candidate breakpoints (byte offsets and hyphen requirements).
|
||||
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
|
||||
@@ -309,16 +346,20 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
|
||||
// Split the word at the selected breakpoint and append a hyphen if required.
|
||||
std::string remainder = word.substr(chosenOffset);
|
||||
wordIt->resize(chosenOffset);
|
||||
words[wordIndex].resize(chosenOffset);
|
||||
if (chosenNeedsHyphen) {
|
||||
wordIt->push_back('-');
|
||||
words[wordIndex].push_back('-');
|
||||
}
|
||||
|
||||
// Insert the remainder word (with matching style) directly after the prefix.
|
||||
auto insertWordIt = std::next(wordIt);
|
||||
auto insertStyleIt = std::next(styleIt);
|
||||
words.insert(insertWordIt, remainder);
|
||||
wordStyles.insert(insertStyleIt, style);
|
||||
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
|
||||
words.insert(words.begin() + wordIndex + 1, remainder);
|
||||
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
|
||||
|
||||
// The remainder inherits whatever continuation status the original word had with the word after it.
|
||||
const bool originalContinuedToNext = wordContinues[wordIndex];
|
||||
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
|
||||
wordContinues[wordIndex] = false;
|
||||
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
|
||||
|
||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
||||
@@ -328,55 +369,74 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
||||
}
|
||||
|
||||
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
|
||||
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::vector<uint16_t>& wordWidths, const std::vector<bool>& continuesVec,
|
||||
const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
|
||||
const size_t lineBreak = lineBreakIndices[breakIndex];
|
||||
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
|
||||
const size_t lineWordCount = lineBreak - lastBreakAt;
|
||||
|
||||
// Calculate total word width for this line
|
||||
// Calculate first line indent (only for left/justified text without extra paragraph spacing)
|
||||
const bool isFirstLine = breakIndex == 0;
|
||||
const int firstLineIndent =
|
||||
isFirstLine && blockStyle.textIndent > 0 && !extraParagraphSpacing &&
|
||||
(blockStyle.alignment == CssTextAlign::Justify || blockStyle.alignment == CssTextAlign::Left)
|
||||
? blockStyle.textIndent
|
||||
: 0;
|
||||
|
||||
// Calculate total word width for this line and count actual word gaps
|
||||
// (continuation words attach to previous word with no gap)
|
||||
int lineWordWidthSum = 0;
|
||||
for (size_t i = lastBreakAt; i < lineBreak; i++) {
|
||||
lineWordWidthSum += wordWidths[i];
|
||||
size_t actualGapCount = 0;
|
||||
|
||||
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||
lineWordWidthSum += wordWidths[lastBreakAt + wordIdx];
|
||||
// Count gaps: each word after the first creates a gap, unless it's a continuation
|
||||
if (wordIdx > 0 && !continuesVec[lastBreakAt + wordIdx]) {
|
||||
actualGapCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate spacing
|
||||
const int spareSpace = pageWidth - lineWordWidthSum;
|
||||
// Calculate spacing (account for indent reducing effective page width on first line)
|
||||
const int effectivePageWidth = pageWidth - firstLineIndent;
|
||||
const int spareSpace = effectivePageWidth - lineWordWidthSum;
|
||||
|
||||
int spacing = spaceWidth;
|
||||
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
|
||||
|
||||
if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) {
|
||||
spacing = spareSpace / (lineWordCount - 1);
|
||||
// For justified text, calculate spacing based on actual gap count
|
||||
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1) {
|
||||
spacing = spareSpace / static_cast<int>(actualGapCount);
|
||||
}
|
||||
|
||||
// Calculate initial x position
|
||||
uint16_t xpos = 0;
|
||||
if (style == TextBlock::RIGHT_ALIGN) {
|
||||
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
|
||||
} else if (style == TextBlock::CENTER_ALIGN) {
|
||||
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
|
||||
// Calculate initial x position (first line starts at indent for left/justified text)
|
||||
auto xpos = static_cast<uint16_t>(firstLineIndent);
|
||||
if (blockStyle.alignment == CssTextAlign::Right) {
|
||||
xpos = spareSpace - static_cast<int>(actualGapCount) * spaceWidth;
|
||||
} else if (blockStyle.alignment == CssTextAlign::Center) {
|
||||
xpos = (spareSpace - static_cast<int>(actualGapCount) * spaceWidth) / 2;
|
||||
}
|
||||
|
||||
// Pre-calculate X positions for words
|
||||
std::list<uint16_t> lineXPos;
|
||||
for (size_t i = lastBreakAt; i < lineBreak; i++) {
|
||||
const uint16_t currentWordWidth = wordWidths[i];
|
||||
// Continuation words attach to the previous word with no space before them
|
||||
std::vector<uint16_t> lineXPos;
|
||||
lineXPos.reserve(lineWordCount);
|
||||
|
||||
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
|
||||
|
||||
lineXPos.push_back(xpos);
|
||||
xpos += currentWordWidth + spacing;
|
||||
|
||||
// Add spacing after this word, unless the next word is a continuation
|
||||
const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1];
|
||||
|
||||
xpos += currentWordWidth + (nextIsContinuation ? 0 : spacing);
|
||||
}
|
||||
|
||||
// Iterators always start at the beginning as we are moving content with splice below
|
||||
auto wordEndIt = words.begin();
|
||||
auto wordStyleEndIt = wordStyles.begin();
|
||||
std::advance(wordEndIt, lineWordCount);
|
||||
std::advance(wordStyleEndIt, lineWordCount);
|
||||
|
||||
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
||||
std::list<std::string> lineWords;
|
||||
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
|
||||
std::list<EpdFontFamily::Style> lineWordStyles;
|
||||
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
||||
// Build line data by moving from the original vectors using index range
|
||||
std::vector<std::string> lineWords(std::make_move_iterator(words.begin() + lastBreakAt),
|
||||
std::make_move_iterator(words.begin() + lineBreak));
|
||||
std::vector<EpdFontFamily::Style> lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak);
|
||||
|
||||
for (auto& word : lineWords) {
|
||||
if (containsSoftHyphen(word)) {
|
||||
@@ -384,5 +444,6 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
||||
}
|
||||
}
|
||||
|
||||
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style));
|
||||
}
|
||||
processLine(
|
||||
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), blockStyle));
|
||||
}
|
||||
|
||||
@@ -3,43 +3,45 @@
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <functional>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "blocks/BlockStyle.h"
|
||||
#include "blocks/TextBlock.h"
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
class ParsedText {
|
||||
std::list<std::string> words;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
TextBlock::Style style;
|
||||
std::vector<std::string> words;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||
BlockStyle blockStyle;
|
||||
bool extraParagraphSpacing;
|
||||
bool hyphenationEnabled;
|
||||
|
||||
void applyParagraphIndent();
|
||||
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, int spaceWidth,
|
||||
std::vector<uint16_t>& wordWidths);
|
||||
std::vector<uint16_t>& wordWidths, std::vector<bool>& continuesVec);
|
||||
std::vector<size_t> computeHyphenatedLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth,
|
||||
int spaceWidth, std::vector<uint16_t>& wordWidths);
|
||||
int spaceWidth, std::vector<uint16_t>& wordWidths,
|
||||
std::vector<bool>& continuesVec);
|
||||
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
||||
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
|
||||
const std::vector<size_t>& lineBreakIndices,
|
||||
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||
|
||||
public:
|
||||
explicit ParsedText(const TextBlock::Style style, const bool extraParagraphSpacing,
|
||||
const bool hyphenationEnabled = false)
|
||||
: style(style), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
|
||||
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
|
||||
const BlockStyle& blockStyle = BlockStyle())
|
||||
: blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
|
||||
~ParsedText() = default;
|
||||
|
||||
void addWord(std::string word, EpdFontFamily::Style fontStyle);
|
||||
void setStyle(const TextBlock::Style style) { this->style = style; }
|
||||
TextBlock::Style getStyle() const { return style; }
|
||||
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
|
||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||
BlockStyle& getBlockStyle() { return blockStyle; }
|
||||
size_t size() const { return words.size(); }
|
||||
bool isEmpty() const { return words.empty(); }
|
||||
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "Section.h"
|
||||
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "Page.h"
|
||||
@@ -8,24 +9,24 @@
|
||||
#include "parsers/ChapterHtmlSlimParser.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 10;
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 12;
|
||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) +
|
||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||
sizeof(uint32_t);
|
||||
} // namespace
|
||||
|
||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
if (!file) {
|
||||
Serial.printf("[%lu] [SCT] File not open for writing page %d\n", millis(), pageCount);
|
||||
LOG_ERR("SCT", "File not open for writing page %d", pageCount);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint32_t position = file.position();
|
||||
if (!page->serialize(file)) {
|
||||
Serial.printf("[%lu] [SCT] Failed to serialize page %d\n", millis(), pageCount);
|
||||
LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
|
||||
return 0;
|
||||
}
|
||||
Serial.printf("[%lu] [SCT] Page %d processed\n", millis(), pageCount);
|
||||
LOG_DBG("SCT", "Page %d processed", pageCount);
|
||||
|
||||
pageCount++;
|
||||
return position;
|
||||
@@ -33,15 +34,16 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||
|
||||
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled) {
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||
const bool embeddedStyle) {
|
||||
if (!file) {
|
||||
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
|
||||
LOG_DBG("SCT", "File not open for writing header");
|
||||
return;
|
||||
}
|
||||
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
||||
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
||||
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
|
||||
sizeof(uint32_t),
|
||||
sizeof(embeddedStyle) + sizeof(uint32_t),
|
||||
"Header size mismatch");
|
||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||
serialization::writePod(file, fontId);
|
||||
@@ -51,14 +53,15 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
||||
serialization::writePod(file, viewportWidth);
|
||||
serialization::writePod(file, viewportHeight);
|
||||
serialization::writePod(file, hyphenationEnabled);
|
||||
serialization::writePod(file, embeddedStyle);
|
||||
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
|
||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset
|
||||
}
|
||||
|
||||
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled) {
|
||||
if (!SdMan.openFileForRead("SCT", filePath, file)) {
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle) {
|
||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -68,7 +71,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
serialization::readPod(file, version);
|
||||
if (version != SECTION_FILE_VERSION) {
|
||||
file.close();
|
||||
Serial.printf("[%lu] [SCT] Deserialization failed: Unknown version %u\n", millis(), version);
|
||||
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
@@ -79,6 +82,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
bool fileExtraParagraphSpacing;
|
||||
uint8_t fileParagraphAlignment;
|
||||
bool fileHyphenationEnabled;
|
||||
bool fileEmbeddedStyle;
|
||||
serialization::readPod(file, fileFontId);
|
||||
serialization::readPod(file, fileLineCompression);
|
||||
serialization::readPod(file, fileExtraParagraphSpacing);
|
||||
@@ -86,13 +90,14 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
serialization::readPod(file, fileViewportWidth);
|
||||
serialization::readPod(file, fileViewportHeight);
|
||||
serialization::readPod(file, fileHyphenationEnabled);
|
||||
serialization::readPod(file, fileEmbeddedStyle);
|
||||
|
||||
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
||||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
||||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
|
||||
hyphenationEnabled != fileHyphenationEnabled) {
|
||||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) {
|
||||
file.close();
|
||||
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
|
||||
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
@@ -100,39 +105,37 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
||||
|
||||
serialization::readPod(file, pageCount);
|
||||
file.close();
|
||||
Serial.printf("[%lu] [SCT] Deserialization succeeded: %d pages\n", millis(), pageCount);
|
||||
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
||||
bool Section::clearCache() const {
|
||||
if (!SdMan.exists(filePath.c_str())) {
|
||||
Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis());
|
||||
if (!Storage.exists(filePath.c_str())) {
|
||||
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!SdMan.remove(filePath.c_str())) {
|
||||
Serial.printf("[%lu] [SCT] Failed to clear cache\n", millis());
|
||||
if (!Storage.remove(filePath.c_str())) {
|
||||
LOG_ERR("SCT", "Failed to clear cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [SCT] Cache cleared successfully\n", millis());
|
||||
LOG_DBG("SCT", "Cache cleared successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||
const std::function<void()>& progressSetupFn,
|
||||
const std::function<void(int)>& progressFn) {
|
||||
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
|
||||
const std::function<void()>& popupFn) {
|
||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||
|
||||
// Create cache directory if it doesn't exist
|
||||
{
|
||||
const auto sectionsDir = epub->getCachePath() + "/sections";
|
||||
SdMan.mkdir(sectionsDir.c_str());
|
||||
Storage.mkdir(sectionsDir.c_str());
|
||||
}
|
||||
|
||||
// Retry logic for SD card timing issues
|
||||
@@ -140,17 +143,17 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
uint32_t fileSize = 0;
|
||||
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
||||
if (attempt > 0) {
|
||||
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1);
|
||||
LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
|
||||
delay(50); // Brief delay before retry
|
||||
}
|
||||
|
||||
// Remove any incomplete file from previous attempt before retrying
|
||||
if (SdMan.exists(tmpHtmlPath.c_str())) {
|
||||
SdMan.remove(tmpHtmlPath.c_str());
|
||||
if (Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
}
|
||||
|
||||
FsFile tmpHtml;
|
||||
if (!SdMan.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||
continue;
|
||||
}
|
||||
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
||||
@@ -158,44 +161,39 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
tmpHtml.close();
|
||||
|
||||
// If streaming failed, remove the incomplete file immediately
|
||||
if (!success && SdMan.exists(tmpHtmlPath.c_str())) {
|
||||
SdMan.remove(tmpHtmlPath.c_str());
|
||||
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis());
|
||||
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis());
|
||||
LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
|
||||
LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
|
||||
|
||||
// Only show progress bar for larger chapters where rendering overhead is worth it
|
||||
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
|
||||
progressSetupFn();
|
||||
}
|
||||
|
||||
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
|
||||
if (!Storage.openFileForWrite("SCT", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled);
|
||||
viewportHeight, hyphenationEnabled, embeddedStyle);
|
||||
std::vector<uint32_t> lut = {};
|
||||
|
||||
ChapterHtmlSlimParser visitor(
|
||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||
viewportHeight, hyphenationEnabled,
|
||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||
progressFn);
|
||||
embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr);
|
||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||
success = visitor.parseAndBuildPages();
|
||||
|
||||
SdMan.remove(tmpHtmlPath.c_str());
|
||||
Storage.remove(tmpHtmlPath.c_str());
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis());
|
||||
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
||||
file.close();
|
||||
SdMan.remove(filePath.c_str());
|
||||
Storage.remove(filePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -211,9 +209,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
}
|
||||
|
||||
if (hasFailedLutRecords) {
|
||||
Serial.printf("[%lu] [SCT] Failed to write LUT due to invalid page positions\n", millis());
|
||||
LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
|
||||
file.close();
|
||||
SdMan.remove(filePath.c_str());
|
||||
Storage.remove(filePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -226,7 +224,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
||||
}
|
||||
|
||||
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
||||
if (!SdMan.openFileForRead("SCT", filePath, file)) {
|
||||
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ class Section {
|
||||
FsFile file;
|
||||
|
||||
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled);
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
|
||||
bool embeddedStyle);
|
||||
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||
|
||||
public:
|
||||
@@ -29,11 +30,10 @@ class Section {
|
||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||
~Section() = default;
|
||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled);
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle);
|
||||
bool clearCache() const;
|
||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
|
||||
const std::function<void()>& progressSetupFn = nullptr,
|
||||
const std::function<void(int)>& progressFn = nullptr);
|
||||
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
|
||||
const std::function<void()>& popupFn = nullptr);
|
||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||
};
|
||||
|
||||
97
lib/Epub/Epub/blocks/BlockStyle.h
Normal file
97
lib/Epub/Epub/blocks/BlockStyle.h
Normal file
@@ -0,0 +1,97 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "Epub/css/CssStyle.h"
|
||||
|
||||
/**
|
||||
* BlockStyle - Block-level styling properties
|
||||
*/
|
||||
struct BlockStyle {
|
||||
CssTextAlign alignment = CssTextAlign::Justify;
|
||||
|
||||
// Spacing (in pixels)
|
||||
int16_t marginTop = 0;
|
||||
int16_t marginBottom = 0;
|
||||
int16_t marginLeft = 0;
|
||||
int16_t marginRight = 0;
|
||||
int16_t paddingTop = 0; // treated same as margin for rendering
|
||||
int16_t paddingBottom = 0; // treated same as margin for rendering
|
||||
int16_t paddingLeft = 0; // treated same as margin for rendering
|
||||
int16_t paddingRight = 0; // treated same as margin for rendering
|
||||
int16_t textIndent = 0;
|
||||
bool textIndentDefined = false; // true if text-indent was explicitly set in CSS
|
||||
bool textAlignDefined = false; // true if text-align was explicitly set in CSS
|
||||
|
||||
// Combined horizontal insets (margin + padding)
|
||||
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
|
||||
[[nodiscard]] int16_t rightInset() const { return marginRight + paddingRight; }
|
||||
[[nodiscard]] int16_t totalHorizontalInset() const { return leftInset() + rightInset(); }
|
||||
|
||||
// Combine with another block style. Useful for parent -> child styles, where the child style should be
|
||||
// applied on top of the parent's style to get the combined style.
|
||||
BlockStyle getCombinedBlockStyle(const BlockStyle& child) const {
|
||||
BlockStyle combinedBlockStyle;
|
||||
|
||||
combinedBlockStyle.marginTop = static_cast<int16_t>(child.marginTop + marginTop);
|
||||
combinedBlockStyle.marginBottom = static_cast<int16_t>(child.marginBottom + marginBottom);
|
||||
combinedBlockStyle.marginLeft = static_cast<int16_t>(child.marginLeft + marginLeft);
|
||||
combinedBlockStyle.marginRight = static_cast<int16_t>(child.marginRight + marginRight);
|
||||
|
||||
combinedBlockStyle.paddingTop = static_cast<int16_t>(child.paddingTop + paddingTop);
|
||||
combinedBlockStyle.paddingBottom = static_cast<int16_t>(child.paddingBottom + paddingBottom);
|
||||
combinedBlockStyle.paddingLeft = static_cast<int16_t>(child.paddingLeft + paddingLeft);
|
||||
combinedBlockStyle.paddingRight = static_cast<int16_t>(child.paddingRight + paddingRight);
|
||||
// Text indent: use child's if defined
|
||||
if (child.textIndentDefined) {
|
||||
combinedBlockStyle.textIndent = child.textIndent;
|
||||
combinedBlockStyle.textIndentDefined = true;
|
||||
} else {
|
||||
combinedBlockStyle.textIndent = textIndent;
|
||||
combinedBlockStyle.textIndentDefined = textIndentDefined;
|
||||
}
|
||||
// Text align: use child's if defined
|
||||
if (child.textAlignDefined) {
|
||||
combinedBlockStyle.alignment = child.alignment;
|
||||
combinedBlockStyle.textAlignDefined = true;
|
||||
} else {
|
||||
combinedBlockStyle.alignment = alignment;
|
||||
combinedBlockStyle.textAlignDefined = textAlignDefined;
|
||||
}
|
||||
return combinedBlockStyle;
|
||||
}
|
||||
|
||||
// Create a BlockStyle from CSS style properties, resolving CssLength values to pixels
|
||||
// emSize is the current font line height, used for em/rem unit conversion
|
||||
// paragraphAlignment is the user's paragraphAlignment setting preference
|
||||
static BlockStyle fromCssStyle(const CssStyle& cssStyle, const float emSize, const CssTextAlign paragraphAlignment,
|
||||
const uint16_t viewportWidth = 0) {
|
||||
BlockStyle blockStyle;
|
||||
const float vw = viewportWidth;
|
||||
// Resolve all CssLength values to pixels using the current font's em size and viewport width
|
||||
blockStyle.marginTop = cssStyle.marginTop.toPixelsInt16(emSize, vw);
|
||||
blockStyle.marginBottom = cssStyle.marginBottom.toPixelsInt16(emSize, vw);
|
||||
blockStyle.marginLeft = cssStyle.marginLeft.toPixelsInt16(emSize, vw);
|
||||
blockStyle.marginRight = cssStyle.marginRight.toPixelsInt16(emSize, vw);
|
||||
|
||||
blockStyle.paddingTop = cssStyle.paddingTop.toPixelsInt16(emSize, vw);
|
||||
blockStyle.paddingBottom = cssStyle.paddingBottom.toPixelsInt16(emSize, vw);
|
||||
blockStyle.paddingLeft = cssStyle.paddingLeft.toPixelsInt16(emSize, vw);
|
||||
blockStyle.paddingRight = cssStyle.paddingRight.toPixelsInt16(emSize, vw);
|
||||
|
||||
// For textIndent: if it's a percentage we can't resolve (no viewport width),
|
||||
// leave textIndentDefined=false so the EmSpace fallback in applyParagraphIndent() is used
|
||||
if (cssStyle.hasTextIndent() && cssStyle.textIndent.isResolvable(vw)) {
|
||||
blockStyle.textIndent = cssStyle.textIndent.toPixelsInt16(emSize, vw);
|
||||
blockStyle.textIndentDefined = true;
|
||||
}
|
||||
blockStyle.textAlignDefined = cssStyle.hasTextAlign();
|
||||
// User setting overrides CSS, unless "Book's Style" alignment setting is selected
|
||||
if (paragraphAlignment == CssTextAlign::None) {
|
||||
blockStyle.alignment = blockStyle.textAlignDefined ? cssStyle.textAlign : CssTextAlign::Justify;
|
||||
} else {
|
||||
blockStyle.alignment = paragraphAlignment;
|
||||
}
|
||||
return blockStyle;
|
||||
}
|
||||
};
|
||||
@@ -1,33 +1,50 @@
|
||||
#include "TextBlock.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
||||
// Validate iterator bounds before rendering
|
||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
|
||||
Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(),
|
||||
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
|
||||
LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", (uint32_t)words.size(),
|
||||
(uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
|
||||
return;
|
||||
}
|
||||
|
||||
auto wordIt = words.begin();
|
||||
auto wordStylesIt = wordStyles.begin();
|
||||
auto wordXposIt = wordXpos.begin();
|
||||
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt);
|
||||
const int wordX = wordXpos[i] + x;
|
||||
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
||||
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
|
||||
|
||||
std::advance(wordIt, 1);
|
||||
std::advance(wordStylesIt, 1);
|
||||
std::advance(wordXposIt, 1);
|
||||
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||
const std::string& w = words[i];
|
||||
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
|
||||
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
||||
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
||||
|
||||
int startX = wordX;
|
||||
int underlineWidth = fullWordWidth;
|
||||
|
||||
// if word starts with em-space ("\xe2\x80\x83"), account for the additional indent before drawing the line
|
||||
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
||||
static_cast<uint8_t>(w[2]) == 0x83) {
|
||||
const char* visiblePtr = w.c_str() + 3;
|
||||
const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str());
|
||||
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
||||
startX = wordX + prefixWidth;
|
||||
underlineWidth = visibleWidth;
|
||||
}
|
||||
|
||||
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool TextBlock::serialize(FsFile& file) const {
|
||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
|
||||
Serial.printf("[%lu] [TXB] Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(),
|
||||
words.size(), wordXpos.size(), wordStyles.size());
|
||||
LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", words.size(),
|
||||
wordXpos.size(), wordStyles.size());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -37,25 +54,36 @@ bool TextBlock::serialize(FsFile& file) const {
|
||||
for (auto x : wordXpos) serialization::writePod(file, x);
|
||||
for (auto s : wordStyles) serialization::writePod(file, s);
|
||||
|
||||
// Block style
|
||||
serialization::writePod(file, style);
|
||||
// Style (alignment + margins/padding/indent)
|
||||
serialization::writePod(file, blockStyle.alignment);
|
||||
serialization::writePod(file, blockStyle.textAlignDefined);
|
||||
serialization::writePod(file, blockStyle.marginTop);
|
||||
serialization::writePod(file, blockStyle.marginBottom);
|
||||
serialization::writePod(file, blockStyle.marginLeft);
|
||||
serialization::writePod(file, blockStyle.marginRight);
|
||||
serialization::writePod(file, blockStyle.paddingTop);
|
||||
serialization::writePod(file, blockStyle.paddingBottom);
|
||||
serialization::writePod(file, blockStyle.paddingLeft);
|
||||
serialization::writePod(file, blockStyle.paddingRight);
|
||||
serialization::writePod(file, blockStyle.textIndent);
|
||||
serialization::writePod(file, blockStyle.textIndentDefined);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||
uint16_t wc;
|
||||
std::list<std::string> words;
|
||||
std::list<uint16_t> wordXpos;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
Style style;
|
||||
std::vector<std::string> words;
|
||||
std::vector<uint16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
// Word count
|
||||
serialization::readPod(file, wc);
|
||||
|
||||
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
|
||||
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
||||
if (wc > 10000) {
|
||||
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
|
||||
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -67,8 +95,20 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||
for (auto& x : wordXpos) serialization::readPod(file, x);
|
||||
for (auto& s : wordStyles) serialization::readPod(file, s);
|
||||
|
||||
// Block style
|
||||
serialization::readPod(file, style);
|
||||
// Style (alignment + margins/padding/indent)
|
||||
serialization::readPod(file, blockStyle.alignment);
|
||||
serialization::readPod(file, blockStyle.textAlignDefined);
|
||||
serialization::readPod(file, blockStyle.marginTop);
|
||||
serialization::readPod(file, blockStyle.marginBottom);
|
||||
serialization::readPod(file, blockStyle.marginLeft);
|
||||
serialization::readPod(file, blockStyle.marginRight);
|
||||
serialization::readPod(file, blockStyle.paddingTop);
|
||||
serialization::readPod(file, blockStyle.paddingBottom);
|
||||
serialization::readPod(file, blockStyle.paddingLeft);
|
||||
serialization::readPod(file, blockStyle.paddingRight);
|
||||
serialization::readPod(file, blockStyle.textIndent);
|
||||
serialization::readPod(file, blockStyle.textIndentDefined);
|
||||
|
||||
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style));
|
||||
return std::unique_ptr<TextBlock>(
|
||||
new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), blockStyle));
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
#pragma once
|
||||
#include <EpdFontFamily.h>
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Block.h"
|
||||
#include "BlockStyle.h"
|
||||
|
||||
// Represents a line of text on a page
|
||||
class TextBlock final : public Block {
|
||||
public:
|
||||
enum Style : uint8_t {
|
||||
JUSTIFIED = 0,
|
||||
LEFT_ALIGN = 1,
|
||||
CENTER_ALIGN = 2,
|
||||
RIGHT_ALIGN = 3,
|
||||
};
|
||||
|
||||
private:
|
||||
std::list<std::string> words;
|
||||
std::list<uint16_t> wordXpos;
|
||||
std::list<EpdFontFamily::Style> wordStyles;
|
||||
Style style;
|
||||
std::vector<std::string> words;
|
||||
std::vector<uint16_t> wordXpos;
|
||||
std::vector<EpdFontFamily::Style> wordStyles;
|
||||
BlockStyle blockStyle;
|
||||
|
||||
public:
|
||||
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
||||
std::list<EpdFontFamily::Style> word_styles, const Style style)
|
||||
: words(std::move(words)), wordXpos(std::move(word_xpos)), wordStyles(std::move(word_styles)), style(style) {}
|
||||
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
|
||||
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
|
||||
: words(std::move(words)),
|
||||
wordXpos(std::move(word_xpos)),
|
||||
wordStyles(std::move(word_styles)),
|
||||
blockStyle(blockStyle) {}
|
||||
~TextBlock() override = default;
|
||||
void setStyle(const Style style) { this->style = style; }
|
||||
Style getStyle() const { return style; }
|
||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||
const std::vector<std::string>& getWords() const { return words; }
|
||||
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
|
||||
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
||||
bool isEmpty() override { return words.empty(); }
|
||||
void layout(GfxRenderer& renderer) override {};
|
||||
// given a renderer works out where to break the words into lines
|
||||
|
||||
699
lib/Epub/Epub/css/CssParser.cpp
Normal file
699
lib/Epub/Epub/css/CssParser.cpp
Normal file
@@ -0,0 +1,699 @@
|
||||
#include "CssParser.h"
|
||||
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace {
|
||||
|
||||
// Buffer size for reading CSS files
|
||||
constexpr size_t READ_BUFFER_SIZE = 512;
|
||||
|
||||
// Maximum CSS file size we'll process (prevent memory issues)
|
||||
constexpr size_t MAX_CSS_SIZE = 64 * 1024;
|
||||
|
||||
// Check if character is CSS whitespace
|
||||
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
|
||||
|
||||
// Read entire file into string (with size limit)
|
||||
std::string readFileContent(FsFile& file) {
|
||||
std::string content;
|
||||
content.reserve(std::min(static_cast<size_t>(file.size()), MAX_CSS_SIZE));
|
||||
|
||||
char buffer[READ_BUFFER_SIZE];
|
||||
while (file.available() && content.size() < MAX_CSS_SIZE) {
|
||||
const int bytesRead = file.read(buffer, sizeof(buffer));
|
||||
if (bytesRead <= 0) break;
|
||||
content.append(buffer, bytesRead);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// Remove CSS comments (/* ... */) from content
|
||||
std::string stripComments(const std::string& css) {
|
||||
std::string result;
|
||||
result.reserve(css.size());
|
||||
|
||||
size_t pos = 0;
|
||||
while (pos < css.size()) {
|
||||
// Look for start of comment
|
||||
if (pos + 1 < css.size() && css[pos] == '/' && css[pos + 1] == '*') {
|
||||
// Find end of comment
|
||||
const size_t endPos = css.find("*/", pos + 2);
|
||||
if (endPos == std::string::npos) {
|
||||
// Unterminated comment - skip rest of file
|
||||
break;
|
||||
}
|
||||
pos = endPos + 2;
|
||||
} else {
|
||||
result.push_back(css[pos]);
|
||||
++pos;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Skip @-rules (like @media, @import, @font-face)
|
||||
// Returns position after the @-rule
|
||||
size_t skipAtRule(const std::string& css, const size_t start) {
|
||||
// Find the end - either semicolon (simple @-rule) or matching brace
|
||||
size_t pos = start + 1; // Skip the '@'
|
||||
|
||||
// Skip identifier
|
||||
while (pos < css.size() && (std::isalnum(css[pos]) || css[pos] == '-')) {
|
||||
++pos;
|
||||
}
|
||||
|
||||
// Look for { or ;
|
||||
int braceDepth = 0;
|
||||
while (pos < css.size()) {
|
||||
const char c = css[pos];
|
||||
if (c == '{') {
|
||||
++braceDepth;
|
||||
} else if (c == '}') {
|
||||
--braceDepth;
|
||||
if (braceDepth == 0) {
|
||||
return pos + 1;
|
||||
}
|
||||
} else if (c == ';' && braceDepth == 0) {
|
||||
return pos + 1;
|
||||
}
|
||||
++pos;
|
||||
}
|
||||
return css.size();
|
||||
}
|
||||
|
||||
// Extract next rule from CSS content
|
||||
// Returns true if a rule was found, with selector and body filled
|
||||
bool extractNextRule(const std::string& css, size_t& pos, std::string& selector, std::string& body) {
|
||||
selector.clear();
|
||||
body.clear();
|
||||
|
||||
// Skip whitespace and @-rules until we find a regular rule
|
||||
while (pos < css.size()) {
|
||||
// Skip whitespace
|
||||
while (pos < css.size() && isCssWhitespace(css[pos])) {
|
||||
++pos;
|
||||
}
|
||||
|
||||
if (pos >= css.size()) return false;
|
||||
|
||||
// Handle @-rules iteratively (avoids recursion/stack overflow)
|
||||
if (css[pos] == '@') {
|
||||
pos = skipAtRule(css, pos);
|
||||
continue; // Try again after skipping the @-rule
|
||||
}
|
||||
|
||||
break; // Found start of a regular rule
|
||||
}
|
||||
|
||||
if (pos >= css.size()) return false;
|
||||
|
||||
// Find opening brace
|
||||
const size_t bracePos = css.find('{', pos);
|
||||
if (bracePos == std::string::npos) return false;
|
||||
|
||||
// Extract selector (everything before the brace)
|
||||
selector = css.substr(pos, bracePos - pos);
|
||||
|
||||
// Find matching closing brace
|
||||
int depth = 1;
|
||||
const size_t bodyStart = bracePos + 1;
|
||||
size_t bodyEnd = bodyStart;
|
||||
|
||||
while (bodyEnd < css.size() && depth > 0) {
|
||||
if (css[bodyEnd] == '{')
|
||||
++depth;
|
||||
else if (css[bodyEnd] == '}')
|
||||
--depth;
|
||||
++bodyEnd;
|
||||
}
|
||||
|
||||
// Extract body (between braces)
|
||||
if (bodyEnd > bodyStart) {
|
||||
body = css.substr(bodyStart, bodyEnd - bodyStart - 1);
|
||||
}
|
||||
|
||||
pos = bodyEnd;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// String utilities implementation
|
||||
|
||||
std::string CssParser::normalized(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size());
|
||||
|
||||
bool inSpace = true; // Start true to skip leading space
|
||||
for (const char c : s) {
|
||||
if (isCssWhitespace(c)) {
|
||||
if (!inSpace) {
|
||||
result.push_back(' ');
|
||||
inSpace = true;
|
||||
}
|
||||
} else {
|
||||
result.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
|
||||
inSpace = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing space
|
||||
if (!result.empty() && result.back() == ' ') {
|
||||
result.pop_back();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
|
||||
std::vector<std::string> parts;
|
||||
size_t start = 0;
|
||||
|
||||
for (size_t i = 0; i <= s.size(); ++i) {
|
||||
if (i == s.size() || s[i] == delimiter) {
|
||||
std::string part = s.substr(start, i - start);
|
||||
std::string trimmed = normalized(part);
|
||||
if (!trimmed.empty()) {
|
||||
parts.push_back(trimmed);
|
||||
}
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
std::vector<std::string> CssParser::splitWhitespace(const std::string& s) {
|
||||
std::vector<std::string> parts;
|
||||
size_t start = 0;
|
||||
bool inWord = false;
|
||||
|
||||
for (size_t i = 0; i <= s.size(); ++i) {
|
||||
const bool isSpace = i == s.size() || isCssWhitespace(s[i]);
|
||||
if (isSpace && inWord) {
|
||||
parts.push_back(s.substr(start, i - start));
|
||||
inWord = false;
|
||||
} else if (!isSpace && !inWord) {
|
||||
start = i;
|
||||
inWord = true;
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Property value interpreters
|
||||
|
||||
CssTextAlign CssParser::interpretAlignment(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
|
||||
if (v == "left" || v == "start") return CssTextAlign::Left;
|
||||
if (v == "right" || v == "end") return CssTextAlign::Right;
|
||||
if (v == "center") return CssTextAlign::Center;
|
||||
if (v == "justify") return CssTextAlign::Justify;
|
||||
|
||||
return CssTextAlign::Left;
|
||||
}
|
||||
|
||||
CssFontStyle CssParser::interpretFontStyle(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
|
||||
if (v == "italic" || v == "oblique") return CssFontStyle::Italic;
|
||||
return CssFontStyle::Normal;
|
||||
}
|
||||
|
||||
CssFontWeight CssParser::interpretFontWeight(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
|
||||
// Named values
|
||||
if (v == "bold" || v == "bolder") return CssFontWeight::Bold;
|
||||
if (v == "normal" || v == "lighter") return CssFontWeight::Normal;
|
||||
|
||||
// Numeric values: 100-900
|
||||
// CSS spec: 400 = normal, 700 = bold
|
||||
// We use: 0-400 = normal, 700+ = bold, 500-600 = normal (conservative)
|
||||
char* endPtr = nullptr;
|
||||
const long numericWeight = std::strtol(v.c_str(), &endPtr, 10);
|
||||
|
||||
// If we parsed a number and consumed the whole string
|
||||
if (endPtr != v.c_str() && *endPtr == '\0') {
|
||||
return numericWeight >= 700 ? CssFontWeight::Bold : CssFontWeight::Normal;
|
||||
}
|
||||
|
||||
return CssFontWeight::Normal;
|
||||
}
|
||||
|
||||
CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
|
||||
// text-decoration can have multiple space-separated values
|
||||
if (v.find("underline") != std::string::npos) {
|
||||
return CssTextDecoration::Underline;
|
||||
}
|
||||
return CssTextDecoration::None;
|
||||
}
|
||||
|
||||
CssLength CssParser::interpretLength(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) return CssLength{};
|
||||
|
||||
// Find where the number ends
|
||||
size_t unitStart = v.size();
|
||||
for (size_t i = 0; i < v.size(); ++i) {
|
||||
const char c = v[i];
|
||||
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
|
||||
unitStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string numPart = v.substr(0, unitStart);
|
||||
const std::string unitPart = v.substr(unitStart);
|
||||
|
||||
// Parse numeric value
|
||||
char* endPtr = nullptr;
|
||||
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||
if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed
|
||||
|
||||
// Determine unit type (preserve for deferred resolution)
|
||||
auto unit = CssUnit::Pixels;
|
||||
if (unitPart == "em") {
|
||||
unit = CssUnit::Em;
|
||||
} else if (unitPart == "rem") {
|
||||
unit = CssUnit::Rem;
|
||||
} else if (unitPart == "pt") {
|
||||
unit = CssUnit::Points;
|
||||
} else if (unitPart == "%") {
|
||||
unit = CssUnit::Percent;
|
||||
}
|
||||
// px and unitless default to Pixels
|
||||
|
||||
return CssLength{numericValue, unit};
|
||||
}
|
||||
|
||||
int8_t CssParser::interpretSpacing(const std::string& val) {
|
||||
const std::string v = normalized(val);
|
||||
if (v.empty()) return 0;
|
||||
|
||||
// For spacing, we convert to "lines" (discrete units for e-ink)
|
||||
// 1em ≈ 1 line, percentages based on ~30 lines per page
|
||||
|
||||
float multiplier = 0.0f;
|
||||
size_t unitStart = v.size();
|
||||
|
||||
for (size_t i = 0; i < v.size(); ++i) {
|
||||
const char c = v[i];
|
||||
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
|
||||
unitStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const std::string numPart = v.substr(0, unitStart);
|
||||
const std::string unitPart = v.substr(unitStart);
|
||||
|
||||
if (unitPart == "em" || unitPart == "rem") {
|
||||
multiplier = 1.0f; // 1em = 1 line
|
||||
} else if (unitPart == "%") {
|
||||
multiplier = 0.3f; // ~30 lines per page, so 10% = 3 lines
|
||||
} else {
|
||||
return 0; // Unsupported unit for spacing
|
||||
}
|
||||
|
||||
char* endPtr = nullptr;
|
||||
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||
|
||||
if (endPtr == numPart.c_str()) return 0;
|
||||
|
||||
int lines = static_cast<int>(numericValue * multiplier);
|
||||
|
||||
// Clamp to reasonable range (0-2 lines)
|
||||
if (lines < 0) lines = 0;
|
||||
if (lines > 2) lines = 2;
|
||||
|
||||
return static_cast<int8_t>(lines);
|
||||
}
|
||||
|
||||
// Declaration parsing
|
||||
|
||||
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||
CssStyle style;
|
||||
|
||||
// Split declarations by semicolon
|
||||
const auto declarations = splitOnChar(declBlock, ';');
|
||||
|
||||
for (const auto& decl : declarations) {
|
||||
// Find colon separator
|
||||
const size_t colonPos = decl.find(':');
|
||||
if (colonPos == std::string::npos || colonPos == 0) continue;
|
||||
|
||||
std::string propName = normalized(decl.substr(0, colonPos));
|
||||
std::string propValue = normalized(decl.substr(colonPos + 1));
|
||||
|
||||
if (propName.empty() || propValue.empty()) continue;
|
||||
|
||||
// Match property and set value
|
||||
if (propName == "text-align") {
|
||||
style.textAlign = interpretAlignment(propValue);
|
||||
style.defined.textAlign = 1;
|
||||
} else if (propName == "font-style") {
|
||||
style.fontStyle = interpretFontStyle(propValue);
|
||||
style.defined.fontStyle = 1;
|
||||
} else if (propName == "font-weight") {
|
||||
style.fontWeight = interpretFontWeight(propValue);
|
||||
style.defined.fontWeight = 1;
|
||||
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
|
||||
style.textDecoration = interpretDecoration(propValue);
|
||||
style.defined.textDecoration = 1;
|
||||
} else if (propName == "text-indent") {
|
||||
style.textIndent = interpretLength(propValue);
|
||||
style.defined.textIndent = 1;
|
||||
} else if (propName == "margin-top") {
|
||||
style.marginTop = interpretLength(propValue);
|
||||
style.defined.marginTop = 1;
|
||||
} else if (propName == "margin-bottom") {
|
||||
style.marginBottom = interpretLength(propValue);
|
||||
style.defined.marginBottom = 1;
|
||||
} else if (propName == "margin-left") {
|
||||
style.marginLeft = interpretLength(propValue);
|
||||
style.defined.marginLeft = 1;
|
||||
} else if (propName == "margin-right") {
|
||||
style.marginRight = interpretLength(propValue);
|
||||
style.defined.marginRight = 1;
|
||||
} else if (propName == "margin") {
|
||||
// Shorthand: 1-4 values for top, right, bottom, left
|
||||
const auto values = splitWhitespace(propValue);
|
||||
if (!values.empty()) {
|
||||
style.marginTop = interpretLength(values[0]);
|
||||
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
|
||||
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
|
||||
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
|
||||
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
||||
}
|
||||
} else if (propName == "padding-top") {
|
||||
style.paddingTop = interpretLength(propValue);
|
||||
style.defined.paddingTop = 1;
|
||||
} else if (propName == "padding-bottom") {
|
||||
style.paddingBottom = interpretLength(propValue);
|
||||
style.defined.paddingBottom = 1;
|
||||
} else if (propName == "padding-left") {
|
||||
style.paddingLeft = interpretLength(propValue);
|
||||
style.defined.paddingLeft = 1;
|
||||
} else if (propName == "padding-right") {
|
||||
style.paddingRight = interpretLength(propValue);
|
||||
style.defined.paddingRight = 1;
|
||||
} else if (propName == "padding") {
|
||||
// Shorthand: 1-4 values for top, right, bottom, left
|
||||
const auto values = splitWhitespace(propValue);
|
||||
if (!values.empty()) {
|
||||
style.paddingTop = interpretLength(values[0]);
|
||||
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
|
||||
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
|
||||
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
|
||||
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
|
||||
style.defined.paddingLeft = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
// Rule processing
|
||||
|
||||
void CssParser::processRuleBlock(const std::string& selectorGroup, const std::string& declarations) {
|
||||
const CssStyle style = parseDeclarations(declarations);
|
||||
|
||||
// Only store if any properties were set
|
||||
if (!style.defined.anySet()) return;
|
||||
|
||||
// Handle comma-separated selectors
|
||||
const auto selectors = splitOnChar(selectorGroup, ',');
|
||||
|
||||
for (const auto& sel : selectors) {
|
||||
// Normalize the selector
|
||||
std::string key = normalized(sel);
|
||||
if (key.empty()) continue;
|
||||
|
||||
// Store or merge with existing
|
||||
auto it = rulesBySelector_.find(key);
|
||||
if (it != rulesBySelector_.end()) {
|
||||
it->second.applyOver(style);
|
||||
} else {
|
||||
rulesBySelector_[key] = style;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main parsing entry point
|
||||
|
||||
bool CssParser::loadFromStream(FsFile& source) {
|
||||
if (!source) {
|
||||
LOG_ERR("CSS", "Cannot read from invalid file");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const std::string content = readFileContent(source);
|
||||
if (content.empty()) {
|
||||
return true; // Empty file is valid
|
||||
}
|
||||
|
||||
// Remove comments
|
||||
const std::string cleaned = stripComments(content);
|
||||
|
||||
// Parse rules
|
||||
size_t pos = 0;
|
||||
std::string selector, body;
|
||||
|
||||
while (extractNextRule(cleaned, pos, selector, body)) {
|
||||
processRuleBlock(selector, body);
|
||||
}
|
||||
|
||||
LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Style resolution
|
||||
|
||||
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
|
||||
CssStyle result;
|
||||
const std::string tag = normalized(tagName);
|
||||
|
||||
// 1. Apply element-level style (lowest priority)
|
||||
const auto tagIt = rulesBySelector_.find(tag);
|
||||
if (tagIt != rulesBySelector_.end()) {
|
||||
result.applyOver(tagIt->second);
|
||||
}
|
||||
|
||||
// 2. Apply class styles (medium priority)
|
||||
if (!classAttr.empty()) {
|
||||
const auto classes = splitWhitespace(classAttr);
|
||||
|
||||
for (const auto& cls : classes) {
|
||||
std::string classKey = "." + normalized(cls);
|
||||
|
||||
auto classIt = rulesBySelector_.find(classKey);
|
||||
if (classIt != rulesBySelector_.end()) {
|
||||
result.applyOver(classIt->second);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Apply element.class styles (higher priority)
|
||||
for (const auto& cls : classes) {
|
||||
std::string combinedKey = tag + "." + normalized(cls);
|
||||
|
||||
auto combinedIt = rulesBySelector_.find(combinedKey);
|
||||
if (combinedIt != rulesBySelector_.end()) {
|
||||
result.applyOver(combinedIt->second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Inline style parsing (static - doesn't need rule database)
|
||||
|
||||
CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return parseDeclarations(styleValue); }
|
||||
|
||||
// Cache serialization
|
||||
|
||||
// Cache format version - increment when format changes
|
||||
constexpr uint8_t CSS_CACHE_VERSION = 2;
|
||||
|
||||
bool CssParser::saveToCache(FsFile& file) const {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write version
|
||||
file.write(CSS_CACHE_VERSION);
|
||||
|
||||
// Write rule count
|
||||
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
|
||||
file.write(reinterpret_cast<const uint8_t*>(&ruleCount), sizeof(ruleCount));
|
||||
|
||||
// Write each rule: selector string + CssStyle fields
|
||||
for (const auto& pair : rulesBySelector_) {
|
||||
// Write selector string (length-prefixed)
|
||||
const auto selectorLen = static_cast<uint16_t>(pair.first.size());
|
||||
file.write(reinterpret_cast<const uint8_t*>(&selectorLen), sizeof(selectorLen));
|
||||
file.write(reinterpret_cast<const uint8_t*>(pair.first.data()), selectorLen);
|
||||
|
||||
// Write CssStyle fields (all are POD types)
|
||||
const CssStyle& style = pair.second;
|
||||
file.write(static_cast<uint8_t>(style.textAlign));
|
||||
file.write(static_cast<uint8_t>(style.fontStyle));
|
||||
file.write(static_cast<uint8_t>(style.fontWeight));
|
||||
file.write(static_cast<uint8_t>(style.textDecoration));
|
||||
|
||||
// Write CssLength fields (value + unit)
|
||||
auto writeLength = [&file](const CssLength& len) {
|
||||
file.write(reinterpret_cast<const uint8_t*>(&len.value), sizeof(len.value));
|
||||
file.write(static_cast<uint8_t>(len.unit));
|
||||
};
|
||||
|
||||
writeLength(style.textIndent);
|
||||
writeLength(style.marginTop);
|
||||
writeLength(style.marginBottom);
|
||||
writeLength(style.marginLeft);
|
||||
writeLength(style.marginRight);
|
||||
writeLength(style.paddingTop);
|
||||
writeLength(style.paddingBottom);
|
||||
writeLength(style.paddingLeft);
|
||||
writeLength(style.paddingRight);
|
||||
|
||||
// Write defined flags as uint16_t
|
||||
uint16_t definedBits = 0;
|
||||
if (style.defined.textAlign) definedBits |= 1 << 0;
|
||||
if (style.defined.fontStyle) definedBits |= 1 << 1;
|
||||
if (style.defined.fontWeight) definedBits |= 1 << 2;
|
||||
if (style.defined.textDecoration) definedBits |= 1 << 3;
|
||||
if (style.defined.textIndent) definedBits |= 1 << 4;
|
||||
if (style.defined.marginTop) definedBits |= 1 << 5;
|
||||
if (style.defined.marginBottom) definedBits |= 1 << 6;
|
||||
if (style.defined.marginLeft) definedBits |= 1 << 7;
|
||||
if (style.defined.marginRight) definedBits |= 1 << 8;
|
||||
if (style.defined.paddingTop) definedBits |= 1 << 9;
|
||||
if (style.defined.paddingBottom) definedBits |= 1 << 10;
|
||||
if (style.defined.paddingLeft) definedBits |= 1 << 11;
|
||||
if (style.defined.paddingRight) definedBits |= 1 << 12;
|
||||
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
|
||||
}
|
||||
|
||||
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CssParser::loadFromCache(FsFile& file) {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear existing rules
|
||||
clear();
|
||||
|
||||
// Read and verify version
|
||||
uint8_t version = 0;
|
||||
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
|
||||
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read rule count
|
||||
uint16_t ruleCount = 0;
|
||||
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read each rule
|
||||
for (uint16_t i = 0; i < ruleCount; ++i) {
|
||||
// Read selector string
|
||||
uint16_t selectorLen = 0;
|
||||
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string selector;
|
||||
selector.resize(selectorLen);
|
||||
if (file.read(&selector[0], selectorLen) != selectorLen) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read CssStyle fields
|
||||
CssStyle style;
|
||||
uint8_t enumVal;
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
style.textAlign = static_cast<CssTextAlign>(enumVal);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
style.fontStyle = static_cast<CssFontStyle>(enumVal);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
style.fontWeight = static_cast<CssFontWeight>(enumVal);
|
||||
|
||||
if (file.read(&enumVal, 1) != 1) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
||||
|
||||
// Read CssLength fields
|
||||
auto readLength = [&file](CssLength& len) -> bool {
|
||||
if (file.read(&len.value, sizeof(len.value)) != sizeof(len.value)) {
|
||||
return false;
|
||||
}
|
||||
uint8_t unitVal;
|
||||
if (file.read(&unitVal, 1) != 1) {
|
||||
return false;
|
||||
}
|
||||
len.unit = static_cast<CssUnit>(unitVal);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
|
||||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read defined flags
|
||||
uint16_t definedBits = 0;
|
||||
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
|
||||
rulesBySelector_.clear();
|
||||
return false;
|
||||
}
|
||||
style.defined.textAlign = (definedBits & 1 << 0) != 0;
|
||||
style.defined.fontStyle = (definedBits & 1 << 1) != 0;
|
||||
style.defined.fontWeight = (definedBits & 1 << 2) != 0;
|
||||
style.defined.textDecoration = (definedBits & 1 << 3) != 0;
|
||||
style.defined.textIndent = (definedBits & 1 << 4) != 0;
|
||||
style.defined.marginTop = (definedBits & 1 << 5) != 0;
|
||||
style.defined.marginBottom = (definedBits & 1 << 6) != 0;
|
||||
style.defined.marginLeft = (definedBits & 1 << 7) != 0;
|
||||
style.defined.marginRight = (definedBits & 1 << 8) != 0;
|
||||
style.defined.paddingTop = (definedBits & 1 << 9) != 0;
|
||||
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
|
||||
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
|
||||
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
|
||||
|
||||
rulesBySelector_[selector] = style;
|
||||
}
|
||||
|
||||
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
|
||||
return true;
|
||||
}
|
||||
114
lib/Epub/Epub/css/CssParser.h
Normal file
114
lib/Epub/Epub/css/CssParser.h
Normal file
@@ -0,0 +1,114 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "CssStyle.h"
|
||||
|
||||
/**
|
||||
* Lightweight CSS parser for EPUB stylesheets
|
||||
*
|
||||
* Parses CSS files and extracts styling information relevant for e-ink display.
|
||||
* Uses a two-phase approach: first tokenizes the CSS content, then builds
|
||||
* a rule database that can be queried during HTML parsing.
|
||||
*
|
||||
* Supported selectors:
|
||||
* - Element selectors: p, div, h1, etc.
|
||||
* - Class selectors: .classname
|
||||
* - Combined: element.classname
|
||||
* - Grouped: selector1, selector2 { }
|
||||
*
|
||||
* Not supported (silently ignored):
|
||||
* - Descendant/child selectors
|
||||
* - Pseudo-classes and pseudo-elements
|
||||
* - Media queries (content is skipped)
|
||||
* - @import, @font-face, etc.
|
||||
*/
|
||||
class CssParser {
|
||||
public:
|
||||
CssParser() = default;
|
||||
~CssParser() = default;
|
||||
|
||||
// Non-copyable
|
||||
CssParser(const CssParser&) = delete;
|
||||
CssParser& operator=(const CssParser&) = delete;
|
||||
|
||||
/**
|
||||
* Load and parse CSS from a file stream.
|
||||
* Can be called multiple times to accumulate rules from multiple stylesheets.
|
||||
* @param source Open file handle to read from
|
||||
* @return true if parsing completed (even if no rules found)
|
||||
*/
|
||||
bool loadFromStream(FsFile& source);
|
||||
|
||||
/**
|
||||
* Look up the style for an HTML element, considering tag name and class attributes.
|
||||
* Applies CSS cascade: element style < class style < element.class style
|
||||
*
|
||||
* @param tagName The HTML element name (e.g., "p", "div")
|
||||
* @param classAttr The class attribute value (may contain multiple space-separated classes)
|
||||
* @return Combined style with all applicable rules merged
|
||||
*/
|
||||
[[nodiscard]] CssStyle resolveStyle(const std::string& tagName, const std::string& classAttr) const;
|
||||
|
||||
/**
|
||||
* Parse an inline style attribute string.
|
||||
* @param styleValue The value of a style="" attribute
|
||||
* @return Parsed style properties
|
||||
*/
|
||||
[[nodiscard]] static CssStyle parseInlineStyle(const std::string& styleValue);
|
||||
|
||||
/**
|
||||
* Check if any rules have been loaded
|
||||
*/
|
||||
[[nodiscard]] bool empty() const { return rulesBySelector_.empty(); }
|
||||
|
||||
/**
|
||||
* Get count of loaded rule sets
|
||||
*/
|
||||
[[nodiscard]] size_t ruleCount() const { return rulesBySelector_.size(); }
|
||||
|
||||
/**
|
||||
* Clear all loaded rules
|
||||
*/
|
||||
void clear() { rulesBySelector_.clear(); }
|
||||
|
||||
/**
|
||||
* Save parsed CSS rules to a cache file.
|
||||
* @param file Open file handle to write to
|
||||
* @return true if cache was written successfully
|
||||
*/
|
||||
bool saveToCache(FsFile& file) const;
|
||||
|
||||
/**
|
||||
* Load CSS rules from a cache file.
|
||||
* Clears any existing rules before loading.
|
||||
* @param file Open file handle to read from
|
||||
* @return true if cache was loaded successfully
|
||||
*/
|
||||
bool loadFromCache(FsFile& file);
|
||||
|
||||
private:
|
||||
// Storage: maps normalized selector -> style properties
|
||||
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
||||
|
||||
// Internal parsing helpers
|
||||
void processRuleBlock(const std::string& selectorGroup, const std::string& declarations);
|
||||
static CssStyle parseDeclarations(const std::string& declBlock);
|
||||
|
||||
// Individual property value parsers
|
||||
static CssTextAlign interpretAlignment(const std::string& val);
|
||||
static CssFontStyle interpretFontStyle(const std::string& val);
|
||||
static CssFontWeight interpretFontWeight(const std::string& val);
|
||||
static CssTextDecoration interpretDecoration(const std::string& val);
|
||||
static CssLength interpretLength(const std::string& val);
|
||||
static int8_t interpretSpacing(const std::string& val);
|
||||
|
||||
// String utilities
|
||||
static std::string normalized(const std::string& s);
|
||||
static std::vector<std::string> splitOnChar(const std::string& s, char delimiter);
|
||||
static std::vector<std::string> splitWhitespace(const std::string& s);
|
||||
};
|
||||
202
lib/Epub/Epub/css/CssStyle.h
Normal file
202
lib/Epub/Epub/css/CssStyle.h
Normal file
@@ -0,0 +1,202 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Matches order of PARAGRAPH_ALIGNMENT in CrossPointSettings
|
||||
enum class CssTextAlign : uint8_t { Justify = 0, Left = 1, Center = 2, Right = 3, None = 4 };
|
||||
enum class CssUnit : uint8_t { Pixels = 0, Em = 1, Rem = 2, Points = 3, Percent = 4 };
|
||||
|
||||
// Represents a CSS length value with its unit, allowing deferred resolution to pixels
|
||||
struct CssLength {
|
||||
float value = 0.0f;
|
||||
CssUnit unit = CssUnit::Pixels;
|
||||
|
||||
CssLength() = default;
|
||||
CssLength(const float v, const CssUnit u) : value(v), unit(u) {}
|
||||
|
||||
// Convenience constructor for pixel values (most common case)
|
||||
explicit CssLength(const float pixels) : value(pixels) {}
|
||||
|
||||
// Returns true if this length can be resolved to pixels with the given context.
|
||||
// Percentage units require a non-zero containerWidth to resolve.
|
||||
[[nodiscard]] bool isResolvable(const float containerWidth = 0) const {
|
||||
return unit != CssUnit::Percent || containerWidth > 0;
|
||||
}
|
||||
|
||||
// Resolve to pixels given the current em size (font line height)
|
||||
// containerWidth is needed for percentage units (e.g. viewport width)
|
||||
[[nodiscard]] float toPixels(const float emSize, const float containerWidth = 0) const {
|
||||
switch (unit) {
|
||||
case CssUnit::Em:
|
||||
case CssUnit::Rem:
|
||||
return value * emSize;
|
||||
case CssUnit::Points:
|
||||
return value * 1.33f; // Approximate pt to px conversion
|
||||
case CssUnit::Percent:
|
||||
return value * containerWidth / 100.0f;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve to int16_t pixels (for BlockStyle fields)
|
||||
[[nodiscard]] int16_t toPixelsInt16(const float emSize, const float containerWidth = 0) const {
|
||||
return static_cast<int16_t>(toPixels(emSize, containerWidth));
|
||||
}
|
||||
};
|
||||
|
||||
// Font style options matching CSS font-style property
|
||||
enum class CssFontStyle : uint8_t { Normal = 0, Italic = 1 };
|
||||
|
||||
// Font weight options - CSS supports 100-900, we simplify to normal/bold
|
||||
enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 };
|
||||
|
||||
// Text decoration options
|
||||
enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 };
|
||||
|
||||
// Bitmask for tracking which properties have been explicitly set
|
||||
struct CssPropertyFlags {
|
||||
uint16_t textAlign : 1;
|
||||
uint16_t fontStyle : 1;
|
||||
uint16_t fontWeight : 1;
|
||||
uint16_t textDecoration : 1;
|
||||
uint16_t textIndent : 1;
|
||||
uint16_t marginTop : 1;
|
||||
uint16_t marginBottom : 1;
|
||||
uint16_t marginLeft : 1;
|
||||
uint16_t marginRight : 1;
|
||||
uint16_t paddingTop : 1;
|
||||
uint16_t paddingBottom : 1;
|
||||
uint16_t paddingLeft : 1;
|
||||
uint16_t paddingRight : 1;
|
||||
|
||||
CssPropertyFlags()
|
||||
: textAlign(0),
|
||||
fontStyle(0),
|
||||
fontWeight(0),
|
||||
textDecoration(0),
|
||||
textIndent(0),
|
||||
marginTop(0),
|
||||
marginBottom(0),
|
||||
marginLeft(0),
|
||||
marginRight(0),
|
||||
paddingTop(0),
|
||||
paddingBottom(0),
|
||||
paddingLeft(0),
|
||||
paddingRight(0) {}
|
||||
|
||||
[[nodiscard]] bool anySet() const {
|
||||
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
|
||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
|
||||
marginTop = marginBottom = marginLeft = marginRight = 0;
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Represents a collection of CSS style properties
|
||||
// Only stores properties relevant to e-ink text rendering
|
||||
// Length values are stored as CssLength (value + unit) for deferred resolution
|
||||
struct CssStyle {
|
||||
CssTextAlign textAlign = CssTextAlign::Left;
|
||||
CssFontStyle fontStyle = CssFontStyle::Normal;
|
||||
CssFontWeight fontWeight = CssFontWeight::Normal;
|
||||
CssTextDecoration textDecoration = CssTextDecoration::None;
|
||||
|
||||
CssLength textIndent; // First-line indent (deferred resolution)
|
||||
CssLength marginTop; // Vertical spacing before block
|
||||
CssLength marginBottom; // Vertical spacing after block
|
||||
CssLength marginLeft; // Horizontal spacing left of block
|
||||
CssLength marginRight; // Horizontal spacing right of block
|
||||
CssLength paddingTop; // Padding before
|
||||
CssLength paddingBottom; // Padding after
|
||||
CssLength paddingLeft; // Padding left
|
||||
CssLength paddingRight; // Padding right
|
||||
|
||||
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
||||
|
||||
// Apply properties from another style, only overwriting if the other style
|
||||
// has that property explicitly defined
|
||||
void applyOver(const CssStyle& base) {
|
||||
if (base.hasTextAlign()) {
|
||||
textAlign = base.textAlign;
|
||||
defined.textAlign = 1;
|
||||
}
|
||||
if (base.hasFontStyle()) {
|
||||
fontStyle = base.fontStyle;
|
||||
defined.fontStyle = 1;
|
||||
}
|
||||
if (base.hasFontWeight()) {
|
||||
fontWeight = base.fontWeight;
|
||||
defined.fontWeight = 1;
|
||||
}
|
||||
if (base.hasTextDecoration()) {
|
||||
textDecoration = base.textDecoration;
|
||||
defined.textDecoration = 1;
|
||||
}
|
||||
if (base.hasTextIndent()) {
|
||||
textIndent = base.textIndent;
|
||||
defined.textIndent = 1;
|
||||
}
|
||||
if (base.hasMarginTop()) {
|
||||
marginTop = base.marginTop;
|
||||
defined.marginTop = 1;
|
||||
}
|
||||
if (base.hasMarginBottom()) {
|
||||
marginBottom = base.marginBottom;
|
||||
defined.marginBottom = 1;
|
||||
}
|
||||
if (base.hasMarginLeft()) {
|
||||
marginLeft = base.marginLeft;
|
||||
defined.marginLeft = 1;
|
||||
}
|
||||
if (base.hasMarginRight()) {
|
||||
marginRight = base.marginRight;
|
||||
defined.marginRight = 1;
|
||||
}
|
||||
if (base.hasPaddingTop()) {
|
||||
paddingTop = base.paddingTop;
|
||||
defined.paddingTop = 1;
|
||||
}
|
||||
if (base.hasPaddingBottom()) {
|
||||
paddingBottom = base.paddingBottom;
|
||||
defined.paddingBottom = 1;
|
||||
}
|
||||
if (base.hasPaddingLeft()) {
|
||||
paddingLeft = base.paddingLeft;
|
||||
defined.paddingLeft = 1;
|
||||
}
|
||||
if (base.hasPaddingRight()) {
|
||||
paddingRight = base.paddingRight;
|
||||
defined.paddingRight = 1;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
|
||||
[[nodiscard]] bool hasFontStyle() const { return defined.fontStyle; }
|
||||
[[nodiscard]] bool hasFontWeight() const { return defined.fontWeight; }
|
||||
[[nodiscard]] bool hasTextDecoration() const { return defined.textDecoration; }
|
||||
[[nodiscard]] bool hasTextIndent() const { return defined.textIndent; }
|
||||
[[nodiscard]] bool hasMarginTop() const { return defined.marginTop; }
|
||||
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
|
||||
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
|
||||
[[nodiscard]] bool hasMarginRight() const { return defined.marginRight; }
|
||||
[[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; }
|
||||
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
|
||||
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
||||
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
||||
|
||||
void reset() {
|
||||
textAlign = CssTextAlign::Left;
|
||||
fontStyle = CssFontStyle::Normal;
|
||||
fontWeight = CssFontWeight::Normal;
|
||||
textDecoration = CssTextDecoration::None;
|
||||
textIndent = CssLength{};
|
||||
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
||||
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
||||
defined.clearAll();
|
||||
}
|
||||
};
|
||||
76
lib/Epub/Epub/htmlEntities.cpp
Normal file
76
lib/Epub/Epub/htmlEntities.cpp
Normal file
@@ -0,0 +1,76 @@
|
||||
// from
|
||||
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
|
||||
|
||||
#include "htmlEntities.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
struct EntityPair {
|
||||
const char* key;
|
||||
const char* value;
|
||||
};
|
||||
|
||||
static const EntityPair ENTITY_LOOKUP[] = {
|
||||
{""", "\""}, {"⁄", "⁄"}, {"&", "&"}, {"<", "<"}, {">", ">"},
|
||||
{"À", "À"}, {"Á", "Á"}, {"Â", "Â"}, {"Ã", "Ã"}, {"Ä", "Ä"},
|
||||
{"Å", "Å"}, {"Æ", "Æ"}, {"Ç", "Ç"}, {"È", "È"}, {"É", "É"},
|
||||
{"Ê", "Ê"}, {"Ë", "Ë"}, {"Ì", "Ì"}, {"Í", "Í"}, {"Î", "Î"},
|
||||
{"Ï", "Ï"}, {"Ð", "Ð"}, {"Ñ", "Ñ"}, {"Ò", "Ò"}, {"Ó", "Ó"},
|
||||
{"Ô", "Ô"}, {"Õ", "Õ"}, {"Ö", "Ö"}, {"Ø", "Ø"}, {"Ù", "Ù"},
|
||||
{"Ú", "Ú"}, {"Û", "Û"}, {"Ü", "Ü"}, {"Ý", "Ý"}, {"Þ", "Þ"},
|
||||
{"ß", "ß"}, {"à", "à"}, {"á", "á"}, {"â", "â"}, {"ã", "ã"},
|
||||
{"ä", "ä"}, {"å", "å"}, {"æ", "æ"}, {"ç", "ç"}, {"è", "è"},
|
||||
{"é", "é"}, {"ê", "ê"}, {"ë", "ë"}, {"ì", "ì"}, {"í", "í"},
|
||||
{"î", "î"}, {"ï", "ï"}, {"ð", "ð"}, {"ñ", "ñ"}, {"ò", "ò"},
|
||||
{"ó", "ó"}, {"ô", "ô"}, {"õ", "õ"}, {"ö", "ö"}, {"ø", "ø"},
|
||||
{"ù", "ù"}, {"ú", "ú"}, {"û", "û"}, {"ü", "ü"}, {"ý", "ý"},
|
||||
{"þ", "þ"}, {"ÿ", "ÿ"}, {" ", "\xC2\xA0"}, {"¡", "¡"}, {"¢", "¢"},
|
||||
{"£", "£"}, {"¤", "¤"}, {"¥", "¥"}, {"¦", "¦"}, {"§", "§"},
|
||||
{"¨", "¨"}, {"©", "©"}, {"ª", "ª"}, {"«", "«"}, {"¬", "¬"},
|
||||
{"­", ""}, {"®", "®"}, {"¯", "¯"}, {"°", "°"}, {"±", "±"},
|
||||
{"²", "²"}, {"³", "³"}, {"´", "´"}, {"µ", "µ"}, {"¶", "¶"},
|
||||
{"¸", "¸"}, {"¹", "¹"}, {"º", "º"}, {"»", "»"}, {"¼", "¼"},
|
||||
{"½", "½"}, {"¾", "¾"}, {"¿", "¿"}, {"×", "×"}, {"÷", "÷"},
|
||||
{"∀", "∀"}, {"∂", "∂"}, {"∃", "∃"}, {"∅", "∅"}, {"∇", "∇"},
|
||||
{"∈", "∈"}, {"∉", "∉"}, {"∋", "∋"}, {"∏", "∏"}, {"∑", "∑"},
|
||||
{"−", "−"}, {"∗", "∗"}, {"√", "√"}, {"∝", "∝"}, {"∞", "∞"},
|
||||
{"∠", "∠"}, {"∧", "∧"}, {"∨", "∨"}, {"∩", "∩"}, {"∪", "∪"},
|
||||
{"∫", "∫"}, {"∴", "∴"}, {"∼", "∼"}, {"≅", "≅"}, {"≈", "≈"},
|
||||
{"≠", "≠"}, {"≡", "≡"}, {"≤", "≤"}, {"≥", "≥"}, {"⊂", "⊂"},
|
||||
{"⊃", "⊃"}, {"⊄", "⊄"}, {"⊆", "⊆"}, {"⊇", "⊇"}, {"⊕", "⊕"},
|
||||
{"⊗", "⊗"}, {"⊥", "⊥"}, {"⋅", "⋅"}, {"Α", "Α"}, {"Β", "Β"},
|
||||
{"Γ", "Γ"}, {"Δ", "Δ"}, {"Ε", "Ε"}, {"Ζ", "Ζ"}, {"Η", "Η"},
|
||||
{"Θ", "Θ"}, {"Ι", "Ι"}, {"Κ", "Κ"}, {"Λ", "Λ"}, {"Μ", "Μ"},
|
||||
{"Ν", "Ν"}, {"Ξ", "Ξ"}, {"Ο", "Ο"}, {"Π", "Π"}, {"Ρ", "Ρ"},
|
||||
{"Σ", "Σ"}, {"Τ", "Τ"}, {"Υ", "Υ"}, {"Φ", "Φ"}, {"Χ", "Χ"},
|
||||
{"Ψ", "Ψ"}, {"Ω", "Ω"}, {"α", "α"}, {"β", "β"}, {"γ", "γ"},
|
||||
{"δ", "δ"}, {"ε", "ε"}, {"ζ", "ζ"}, {"η", "η"}, {"θ", "θ"},
|
||||
{"ι", "ι"}, {"κ", "κ"}, {"λ", "λ"}, {"μ", "μ"}, {"ν", "ν"},
|
||||
{"ξ", "ξ"}, {"ο", "ο"}, {"π", "π"}, {"ρ", "ρ"}, {"ς", "ς"},
|
||||
{"σ", "σ"}, {"τ", "τ"}, {"υ", "υ"}, {"φ", "φ"}, {"χ", "χ"},
|
||||
{"ψ", "ψ"}, {"ω", "ω"}, {"ϑ", "ϑ"}, {"ϒ", "ϒ"}, {"ϖ", "ϖ"},
|
||||
{"Œ", "Œ"}, {"œ", "œ"}, {"Š", "Š"}, {"š", "š"}, {"Ÿ", "Ÿ"},
|
||||
{"ƒ", "ƒ"}, {"ˆ", "ˆ"}, {"˜", "˜"}, {" ", " "}, {" ", " "},
|
||||
{" ", " "}, {"‌", ""}, {"‍", ""}, {"‎", ""}, {"‏", ""},
|
||||
{"–", "–"}, {"—", "—"}, {"‘", "‘"}, {"’", "’"}, {"‚", "‚"},
|
||||
{"“", "“"}, {"”", "”"}, {"„", "„"}, {"†", "†"}, {"‡", "‡"},
|
||||
{"•", "•"}, {"…", "…"}, {"‰", "‰"}, {"′", "′"}, {"″", "″"},
|
||||
{"‹", "‹"}, {"›", "›"}, {"‾", "‾"}, {"€", "€"}, {"™", "™"},
|
||||
{"←", "←"}, {"↑", "↑"}, {"→", "→"}, {"↓", "↓"}, {"↔", "↔"},
|
||||
{"↵", "↵"}, {"⌈", "⌈"}, {"⌉", "⌉"}, {"⌊", "⌊"}, {"⌋", "⌋"},
|
||||
{"◊", "◊"}, {"♠", "♠"}, {"♣", "♣"}, {"♥", "♥"}, {"♦", "♦"}};
|
||||
|
||||
static const size_t ENTITY_LOOKUP_COUNT = sizeof(ENTITY_LOOKUP) / sizeof(ENTITY_LOOKUP[0]);
|
||||
|
||||
// Lookup a single HTML entity and return its UTF-8 value
|
||||
const char* lookupHtmlEntity(const char* entity, int len) {
|
||||
for (size_t i = 0; i < ENTITY_LOOKUP_COUNT; i++) {
|
||||
const char* key = ENTITY_LOOKUP[i].key;
|
||||
const size_t keyLen = strlen(key);
|
||||
if (static_cast<size_t>(len) == keyLen && memcmp(entity, key, keyLen) == 0) {
|
||||
return ENTITY_LOOKUP[i].value;
|
||||
}
|
||||
}
|
||||
|
||||
return nullptr; // Entity not found
|
||||
}
|
||||
9
lib/Epub/Epub/htmlEntities.h
Normal file
9
lib/Epub/Epub/htmlEntities.h
Normal file
@@ -0,0 +1,9 @@
|
||||
// from
|
||||
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
// Lookup a single HTML entity (including & and ;) and return its UTF-8 value
|
||||
// Returns nullptr if entity is not found
|
||||
const char* lookupHtmlEntity(const char* entity, int len);
|
||||
@@ -1,45 +1,84 @@
|
||||
#include "LanguageRegistry.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
#include "HyphenationCommon.h"
|
||||
#ifndef OMIT_HYPH_DE
|
||||
#include "generated/hyph-de.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_EN
|
||||
#include "generated/hyph-en.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_ES
|
||||
#include "generated/hyph-es.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_FR
|
||||
#include "generated/hyph-fr.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_IT
|
||||
#include "generated/hyph-it.trie.h"
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_RU
|
||||
#include "generated/hyph-ru.trie.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
#ifndef OMIT_HYPH_EN
|
||||
// English hyphenation patterns (3/3 minimum prefix/suffix length)
|
||||
LanguageHyphenator englishHyphenator(en_us_patterns, isLatinLetter, toLowerLatin, 3, 3);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_FR
|
||||
LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_DE
|
||||
LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_RU
|
||||
LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_ES
|
||||
LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_IT
|
||||
LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
|
||||
#endif
|
||||
|
||||
using EntryArray = std::array<LanguageEntry, 5>;
|
||||
|
||||
const EntryArray& entries() {
|
||||
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator},
|
||||
{"french", "fr", &frenchHyphenator},
|
||||
{"german", "de", &germanHyphenator},
|
||||
{"russian", "ru", &russianHyphenator},
|
||||
{"spanish", "es", &spanishHyphenator}}};
|
||||
return kEntries;
|
||||
const LanguageEntryView entries() {
|
||||
static const std::vector<LanguageEntry> kEntries = {
|
||||
#ifndef OMIT_HYPH_EN
|
||||
{"english", "en", &englishHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_FR
|
||||
{"french", "fr", &frenchHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_DE
|
||||
{"german", "de", &germanHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_RU
|
||||
{"russian", "ru", &russianHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_ES
|
||||
{"spanish", "es", &spanishHyphenator},
|
||||
#endif
|
||||
#ifndef OMIT_HYPH_IT
|
||||
{"italian", "it", &italianHyphenator},
|
||||
#endif
|
||||
};
|
||||
static const LanguageEntryView view{kEntries.data(), kEntries.size()};
|
||||
return view;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const LanguageHyphenator* getLanguageHyphenatorForPrimaryTag(const std::string& primaryTag) {
|
||||
const auto& allEntries = entries();
|
||||
const auto allEntries = entries();
|
||||
const auto it = std::find_if(allEntries.begin(), allEntries.end(),
|
||||
[&primaryTag](const LanguageEntry& entry) { return primaryTag == entry.primaryTag; });
|
||||
return (it != allEntries.end()) ? it->hyphenator : nullptr;
|
||||
}
|
||||
|
||||
LanguageEntryView getLanguageEntries() {
|
||||
const auto& allEntries = entries();
|
||||
return LanguageEntryView{allEntries.data(), allEntries.size()};
|
||||
return entries();
|
||||
}
|
||||
|
||||
113
lib/Epub/Epub/hyphenation/generated/hyph-it.trie.h
Normal file
113
lib/Epub/Epub/hyphenation/generated/hyph-it.trie.h
Normal file
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "../SerializedHyphenationTrie.h"
|
||||
|
||||
// Auto-generated by generate_hyphenation_trie.py. Do not edit manually.
|
||||
alignas(4) constexpr uint8_t it_trie_data[] = {
|
||||
0x00, 0x00, 0x05, 0xC4, 0x17, 0x0C, 0x33, 0x35, 0x0C, 0x29, 0x22, 0x0D, 0x3E, 0x0B, 0x47, 0x20,
|
||||
0x0D, 0x16, 0x0B, 0x34, 0x0D, 0x21, 0x0C, 0x3D, 0x1F, 0x0C, 0x2A, 0x17, 0x2A, 0x0B, 0x02, 0x0C,
|
||||
0x01, 0x02, 0x16, 0x02, 0x0D, 0x0C, 0x0C, 0x0D, 0x03, 0x0C, 0x01, 0x0C, 0x0E, 0x0D, 0x04, 0x02,
|
||||
0x0B, 0xA0, 0x00, 0x42, 0x21, 0x6E, 0xFD, 0xA0, 0x00, 0x72, 0x21, 0x6E, 0xFD, 0xA1, 0x00, 0x61,
|
||||
0x6D, 0xFD, 0x21, 0x69, 0xFB, 0x21, 0x74, 0xFD, 0x22, 0x70, 0x6E, 0xEC, 0xFD, 0xA0, 0x00, 0x91,
|
||||
0x21, 0x6F, 0xFD, 0x21, 0x69, 0xFD, 0xA0, 0x00, 0xA2, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0xA0,
|
||||
0x00, 0xC2, 0x21, 0x6D, 0xFD, 0x21, 0x75, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0xA0, 0x00,
|
||||
0xE1, 0x21, 0x6F, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x74, 0xFD, 0x21, 0x6E, 0xFD, 0xA3, 0x01, 0x11,
|
||||
0x61, 0x69, 0x6F, 0xDF, 0xEE, 0xFD, 0xA0, 0x00, 0xF2, 0x21, 0x65, 0xFD, 0x21, 0x6E, 0xFD, 0x21,
|
||||
0x69, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0xA1, 0x01, 0x11, 0x69, 0xFD, 0xA0, 0x01, 0x12,
|
||||
0x21, 0x75, 0xFD, 0x21, 0x65, 0xFD, 0x21, 0x78, 0xFD, 0xA0, 0x01, 0x32, 0x21, 0x6B, 0xFD, 0x21,
|
||||
0x6E, 0xFD, 0xA0, 0x00, 0x71, 0x21, 0x65, 0xFD, 0x22, 0x61, 0x65, 0xF7, 0xFD, 0x21, 0x72, 0xFB,
|
||||
0xA0, 0x01, 0x52, 0x21, 0x61, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0x21, 0x69, 0xFD, 0xA0,
|
||||
0x01, 0x71, 0x21, 0x6F, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0xA0, 0x00,
|
||||
0x61, 0x21, 0x6F, 0xFD, 0x21, 0x74, 0xFD, 0x41, 0x70, 0xFF, 0x50, 0x21, 0x6F, 0xFC, 0x21, 0x74,
|
||||
0xFD, 0x22, 0x70, 0x72, 0xF3, 0xFD, 0x21, 0x61, 0xE8, 0x21, 0x72, 0xFD, 0xA0, 0x00, 0xF1, 0x22,
|
||||
0x6C, 0x72, 0xFD, 0xFD, 0x21, 0x69, 0xE3, 0x21, 0x6C, 0xFD, 0x41, 0x65, 0xFF, 0x43, 0xA0, 0x01,
|
||||
0x11, 0x25, 0x61, 0x68, 0x6F, 0x72, 0x73, 0xE8, 0xEE, 0xF6, 0xF9, 0xFD, 0xA0, 0x01, 0x82, 0x21,
|
||||
0x72, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x69, 0xFD, 0x21, 0x65, 0xFD, 0xA0, 0x01,
|
||||
0xA2, 0x21, 0x65, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0x41, 0x75, 0xFF, 0x4C, 0x42, 0x6C,
|
||||
0x72, 0xFF, 0xFC, 0xFF, 0x48, 0x21, 0x62, 0xF9, 0x22, 0x68, 0x75, 0xEF, 0xFD, 0x47, 0x63, 0x64,
|
||||
0x6C, 0x6E, 0x70, 0x72, 0x74, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF,
|
||||
0x5C, 0xFF, 0x5C, 0x21, 0x73, 0xEA, 0x21, 0x6E, 0xFD, 0x21, 0x61, 0xFD, 0xA1, 0x01, 0x11, 0x72,
|
||||
0xFD, 0x41, 0x6E, 0xFF, 0x15, 0x21, 0x67, 0xFC, 0xA0, 0x01, 0xC2, 0x21, 0x74, 0xFD, 0x21, 0x6C,
|
||||
0xFD, 0x22, 0x61, 0x65, 0xF4, 0xFD, 0x52, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x6C, 0x6E, 0x6F,
|
||||
0x70, 0x72, 0x73, 0x74, 0x77, 0x68, 0x6A, 0x6B, 0x7A, 0xFE, 0xC2, 0xFE, 0xCD, 0xFE, 0xF7, 0xFF,
|
||||
0x12, 0xFF, 0x20, 0xFF, 0x37, 0xFF, 0x46, 0xFF, 0x55, 0xFF, 0x6B, 0xFF, 0x8B, 0xFF, 0xA5, 0xFF,
|
||||
0xC2, 0xFF, 0xE6, 0xFF, 0xFB, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xA0, 0x01, 0xE2,
|
||||
0xA0, 0x00, 0xD1, 0x24, 0x61, 0x65, 0x6F, 0x75, 0xFD, 0xFD, 0xFD, 0xFD, 0x21, 0x6F, 0xF4, 0x21,
|
||||
0x61, 0xF1, 0xA0, 0x01, 0xE1, 0x21, 0x2E, 0xFD, 0x24, 0x69, 0x75, 0x79, 0x74, 0xEB, 0xF4, 0xF7,
|
||||
0xFD, 0x21, 0x75, 0xDF, 0xA0, 0x00, 0x51, 0x22, 0x69, 0x77, 0xFA, 0xFD, 0x21, 0x69, 0xD7, 0xAE,
|
||||
0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0x6C, 0x72, 0x2E, 0x27,
|
||||
0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xF5, 0xF5, 0xE3, 0xE3, 0x22, 0x2E,
|
||||
0x27, 0xC4, 0xC7, 0xC6, 0x00, 0x51, 0x68, 0x2E, 0x27, 0x62, 0x72, 0x6E, 0xFF, 0xBF, 0xFF, 0xBF,
|
||||
0xFF, 0xFB, 0xFF, 0xBF, 0xFE, 0xFB, 0xFF, 0xBF, 0xD0, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6B,
|
||||
0x6D, 0x6E, 0x71, 0x73, 0x74, 0x7A, 0x68, 0x6C, 0x72, 0x2E, 0x27, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
|
||||
0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
|
||||
0xAA, 0xFF, 0xEB, 0xFF, 0xBC, 0xFF, 0xBC, 0xFF, 0xAA, 0xFF, 0xAA, 0xCE, 0x02, 0x01, 0x62, 0x64,
|
||||
0x67, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFF, 0x77, 0xFF, 0x77,
|
||||
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x89, 0xFF, 0x77, 0xFF, 0x77,
|
||||
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xCA, 0x02, 0x01, 0x62, 0x67, 0x66, 0x6E, 0x6C,
|
||||
0x72, 0x73, 0x74, 0x2E, 0x27, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x5C, 0xFF,
|
||||
0x5C, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xA0, 0x02, 0x12, 0xA1, 0x00, 0x51, 0x74,
|
||||
0xFD, 0xD1, 0x02, 0x01, 0x62, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74,
|
||||
0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0xFB, 0xFF,
|
||||
0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF,
|
||||
0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0x41, 0x70, 0xFD, 0x4D, 0xCB, 0x02, 0x01, 0x62, 0x64,
|
||||
0x68, 0x69, 0x6C, 0x6D, 0x6E, 0x72, 0x76, 0x2E, 0x27, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFF,
|
||||
0xFC, 0xFE, 0xF9, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xC2,
|
||||
0x02, 0x01, 0x2E, 0x27, 0xFE, 0xC3, 0xFE, 0xC3, 0xCB, 0x02, 0x01, 0x67, 0x66, 0x68, 0x6B, 0x6C,
|
||||
0x6D, 0x72, 0x73, 0x74, 0x2E, 0x27, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xCC,
|
||||
0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xA0, 0x02, 0x33, 0x42,
|
||||
0x2E, 0x27, 0xFE, 0x93, 0xFE, 0x93, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6A,
|
||||
0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFE, 0x8C,
|
||||
0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF6, 0xFE, 0x8C, 0xFE, 0x9E, 0xFE, 0x9E, 0xFE, 0x8C, 0xFE, 0x8C,
|
||||
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C,
|
||||
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF9, 0xCF, 0x02, 0x01, 0x62, 0x63, 0x66, 0x6C, 0x6D,
|
||||
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
|
||||
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
|
||||
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xA0, 0x02, 0x62, 0xA1, 0x01, 0xE1, 0x6E, 0xFD,
|
||||
0x21, 0x72, 0xF8, 0x21, 0x65, 0xFD, 0xA1, 0x01, 0xE1, 0x66, 0xFD, 0x41, 0x74, 0xFE, 0x07, 0x21,
|
||||
0x69, 0xFC, 0x21, 0x65, 0xFD, 0xD3, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6B, 0x6C, 0x6D,
|
||||
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x7A, 0x68, 0x2E, 0x27, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
|
||||
0xFD, 0xFD, 0xFD, 0xFF, 0xE6, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
|
||||
0xFD, 0xFD, 0xFD, 0xFF, 0xF1, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFF, 0xFD, 0xFD, 0xFD, 0xFD,
|
||||
0xFD, 0xA0, 0x02, 0x82, 0xA1, 0x01, 0xE1, 0x65, 0xFD, 0x21, 0x63, 0xF8, 0xA1, 0x01, 0xE1, 0x69,
|
||||
0xFD, 0xCB, 0x02, 0x01, 0x64, 0x68, 0x6C, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x7A, 0x2E, 0x27, 0xFD,
|
||||
0xB1, 0xFD, 0xC3, 0xFD, 0xC3, 0xFF, 0xF3, 0xFD, 0xB1, 0xFD, 0xC3, 0xFF, 0xFB, 0xFD, 0xB1, 0xFD,
|
||||
0xB1, 0xFD, 0xB1, 0xFD, 0xB1, 0xC3, 0x02, 0x01, 0x71, 0x2E, 0x27, 0xFD, 0x8D, 0xFD, 0x8D, 0xFD,
|
||||
0x8D, 0xA0, 0x02, 0x53, 0xA1, 0x01, 0xE1, 0x73, 0xFD, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66,
|
||||
0x68, 0x67, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x78, 0x77, 0x7A, 0x2E,
|
||||
0x27, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x8B, 0xFD, 0x79, 0xFD, 0x79, 0xFD,
|
||||
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFF, 0xFB, 0xFD,
|
||||
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0x43, 0x6D, 0x2E, 0x27, 0xFD,
|
||||
0x37, 0xFD, 0x37, 0xFD, 0x37, 0xA0, 0x02, 0xC2, 0xA1, 0x02, 0x32, 0x6D, 0xFD, 0x41, 0x6E, 0xFE,
|
||||
0x8F, 0x4B, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0xFD, 0x21, 0xFD,
|
||||
0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD,
|
||||
0x21, 0xFD, 0x21, 0xA0, 0x02, 0xE1, 0x22, 0x2E, 0x27, 0xFD, 0xFD, 0xC7, 0x02, 0xA2, 0x68, 0x73,
|
||||
0x70, 0x74, 0x7A, 0x2E, 0x27, 0xFF, 0xC0, 0xFF, 0xCD, 0xFF, 0xD2, 0xFF, 0xD6, 0xFC, 0xF7, 0xFF,
|
||||
0xF8, 0xFF, 0xFB, 0xC1, 0x00, 0x51, 0x2E, 0xFC, 0xDF, 0x41, 0x68, 0xFF, 0x18, 0xA1, 0x00, 0x51,
|
||||
0x63, 0xFC, 0xC1, 0x01, 0xE1, 0x73, 0xFE, 0xB6, 0xC2, 0x00, 0x51, 0x6B, 0x73, 0xFC, 0xCA, 0xFC,
|
||||
0x06, 0xD2, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73,
|
||||
0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1,
|
||||
0xFF, 0xE2, 0xFC, 0xD3, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xD3, 0xFF, 0xEC, 0xFF, 0xF1,
|
||||
0xFC, 0xC1, 0xFC, 0xC1, 0xFF, 0xF7, 0xFC, 0xC1, 0xFE, 0x2E, 0xC6, 0x02, 0x01, 0x63, 0x6C, 0x72,
|
||||
0x76, 0x2E, 0x27, 0xFC, 0x88, 0xFC, 0x9A, 0xFC, 0x9A, 0xFC, 0x88, 0xFC, 0x88, 0xFD, 0xF5, 0x41,
|
||||
0x72, 0xFB, 0xAF, 0xA0, 0x02, 0xF2, 0xC5, 0x02, 0x01, 0x68, 0x61, 0x79, 0x2E, 0x27, 0xFC, 0x7E,
|
||||
0xFF, 0xF9, 0xFF, 0xFD, 0xFC, 0x6C, 0xFC, 0x6C, 0xCA, 0x02, 0x01, 0x62, 0x63, 0x66, 0x68, 0x6D,
|
||||
0x70, 0x74, 0x77, 0x2E, 0x27, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC,
|
||||
0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0x42, 0x6F, 0x69, 0xFC, 0x48, 0xFC, 0x27,
|
||||
0xCB, 0x02, 0x01, 0x62, 0x64, 0x6C, 0x6E, 0x70, 0x74, 0x73, 0x76, 0x7A, 0x2E, 0x27, 0xFC, 0x32,
|
||||
0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32,
|
||||
0xFC, 0x32, 0xFD, 0x9F, 0x5A, 0x2E, 0x27, 0x61, 0x65, 0x6F, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68,
|
||||
0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x78, 0x79, 0x7A, 0xFB,
|
||||
0xC2, 0xFB, 0xF9, 0xFC, 0x14, 0xFC, 0x23, 0xFC, 0x28, 0xFC, 0x2B, 0xFC, 0x64, 0xFC, 0x97, 0xFC,
|
||||
0xC4, 0xFC, 0xED, 0xFD, 0x27, 0xFD, 0x4B, 0xFD, 0x54, 0xFD, 0x82, 0xFD, 0xC4, 0xFE, 0x11, 0xFE,
|
||||
0x5D, 0xFE, 0x81, 0xFE, 0x95, 0xFF, 0x17, 0xFF, 0x4D, 0xFF, 0x86, 0xFF, 0xA2, 0xFF, 0xB4, 0xFF,
|
||||
0xD5, 0xFF, 0xDC,
|
||||
};
|
||||
|
||||
constexpr SerializedHyphenationPatterns it_patterns = {
|
||||
it_trie_data,
|
||||
sizeof(it_trie_data),
|
||||
};
|
||||
@@ -1,17 +1,18 @@
|
||||
#include "ChapterHtmlSlimParser.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <expat.h>
|
||||
|
||||
#include "../Page.h"
|
||||
#include "../htmlEntities.h"
|
||||
|
||||
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
|
||||
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
|
||||
|
||||
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it
|
||||
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
||||
// Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
|
||||
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
|
||||
|
||||
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
||||
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
|
||||
@@ -22,6 +23,9 @@ constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
|
||||
const char* ITALIC_TAGS[] = {"i", "em"};
|
||||
constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]);
|
||||
|
||||
const char* UNDERLINE_TAGS[] = {"u", "ins"};
|
||||
constexpr int NUM_UNDERLINE_TAGS = sizeof(UNDERLINE_TAGS) / sizeof(UNDERLINE_TAGS[0]);
|
||||
|
||||
const char* IMAGE_TAGS[] = {"img"};
|
||||
constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]);
|
||||
|
||||
@@ -40,35 +44,74 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isHeaderOrBlock(const char* name) {
|
||||
return matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
||||
}
|
||||
|
||||
// Update effective bold/italic/underline based on block style and inline style stack
|
||||
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
|
||||
// Start with block-level styles
|
||||
effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold;
|
||||
effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic;
|
||||
effectiveUnderline =
|
||||
currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
|
||||
// Apply inline style stack in order
|
||||
for (const auto& entry : inlineStyleStack) {
|
||||
if (entry.hasBold) {
|
||||
effectiveBold = entry.bold;
|
||||
}
|
||||
if (entry.hasItalic) {
|
||||
effectiveItalic = entry.italic;
|
||||
}
|
||||
if (entry.hasUnderline) {
|
||||
effectiveUnderline = entry.underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flush the contents of partWordBuffer to currentTextBlock
|
||||
void ChapterHtmlSlimParser::flushPartWordBuffer() {
|
||||
// determine font style
|
||||
// Determine font style from depth-based tracking and CSS effective style
|
||||
const bool isBold = boldUntilDepth < depth || effectiveBold;
|
||||
const bool isItalic = italicUntilDepth < depth || effectiveItalic;
|
||||
const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline;
|
||||
|
||||
// Combine style flags using bitwise OR
|
||||
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
||||
if (boldUntilDepth < depth && italicUntilDepth < depth) {
|
||||
fontStyle = EpdFontFamily::BOLD_ITALIC;
|
||||
} else if (boldUntilDepth < depth) {
|
||||
fontStyle = EpdFontFamily::BOLD;
|
||||
} else if (italicUntilDepth < depth) {
|
||||
fontStyle = EpdFontFamily::ITALIC;
|
||||
if (isBold) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::BOLD);
|
||||
}
|
||||
if (isItalic) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::ITALIC);
|
||||
}
|
||||
if (isUnderline) {
|
||||
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::UNDERLINE);
|
||||
}
|
||||
|
||||
// flush the buffer
|
||||
partWordBuffer[partWordBufferIndex] = '\0';
|
||||
currentTextBlock->addWord(partWordBuffer, fontStyle);
|
||||
currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
|
||||
partWordBufferIndex = 0;
|
||||
nextWordContinues = false;
|
||||
}
|
||||
|
||||
// start a new text block if needed
|
||||
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) {
|
||||
void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
|
||||
nextWordContinues = false; // New block = new paragraph, no continuation
|
||||
if (currentTextBlock) {
|
||||
// already have a text block running and it is empty - just reuse it
|
||||
if (currentTextBlock->isEmpty()) {
|
||||
currentTextBlock->setStyle(style);
|
||||
// Merge with existing block style to accumulate CSS styling from parent block elements.
|
||||
// This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the
|
||||
// div's margin should be preserved, even though it has no direct text content.
|
||||
currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle));
|
||||
return;
|
||||
}
|
||||
|
||||
makePages();
|
||||
}
|
||||
currentTextBlock.reset(new ParsedText(style, extraParagraphSpacing, hyphenationEnabled));
|
||||
currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle));
|
||||
}
|
||||
|
||||
void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
||||
@@ -80,13 +123,30 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract class and style attributes for CSS processing
|
||||
std::string classAttr;
|
||||
std::string styleAttr;
|
||||
if (atts != nullptr) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], "class") == 0) {
|
||||
classAttr = atts[i + 1];
|
||||
} else if (strcmp(atts[i], "style") == 0) {
|
||||
styleAttr = atts[i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto centeredBlockStyle = BlockStyle();
|
||||
centeredBlockStyle.textAlignDefined = true;
|
||||
centeredBlockStyle.alignment = CssTextAlign::Center;
|
||||
|
||||
// Special handling for tables - show placeholder text instead of dropping silently
|
||||
if (strcmp(name, "table") == 0) {
|
||||
// Add placeholder text
|
||||
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
|
||||
self->startNewTextBlock(centeredBlockStyle);
|
||||
|
||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
||||
// Advance depth before processing character data (like you would for a element with text)
|
||||
// Advance depth before processing character data (like you would for an element with text)
|
||||
self->depth += 1;
|
||||
self->characterData(userData, "[Table omitted]", strlen("[Table omitted]"));
|
||||
|
||||
@@ -109,11 +169,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str());
|
||||
LOG_DBG("EHP", "Image alt: %s", alt.c_str());
|
||||
|
||||
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
|
||||
self->startNewTextBlock(centeredBlockStyle);
|
||||
self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
||||
// Advance depth before processing character data (like you would for a element with text)
|
||||
// Advance depth before processing character data (like you would for an element with text)
|
||||
self->depth += 1;
|
||||
self->characterData(userData, alt.c_str(), alt.length());
|
||||
|
||||
@@ -141,43 +201,139 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
}
|
||||
}
|
||||
|
||||
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
|
||||
self->startNewTextBlock(TextBlock::CENTER_ALIGN);
|
||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||
self->depth += 1;
|
||||
return;
|
||||
// Compute CSS style for this element
|
||||
CssStyle cssStyle;
|
||||
if (self->cssParser) {
|
||||
// Get combined tag + class styles
|
||||
cssStyle = self->cssParser->resolveStyle(name, classAttr);
|
||||
// Merge inline style (highest priority)
|
||||
if (!styleAttr.empty()) {
|
||||
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
|
||||
cssStyle.applyOver(inlineStyle);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
|
||||
const float emSize = static_cast<float>(self->renderer.getLineHeight(self->fontId)) * self->lineCompression;
|
||||
const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle(
|
||||
cssStyle, emSize, static_cast<CssTextAlign>(self->paragraphAlignment), self->viewportWidth);
|
||||
|
||||
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
|
||||
self->currentCssStyle = cssStyle;
|
||||
auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center, self->viewportWidth);
|
||||
headerBlockStyle.textAlignDefined = true;
|
||||
if (self->embeddedStyle && cssStyle.hasTextAlign()) {
|
||||
headerBlockStyle.alignment = cssStyle.textAlign;
|
||||
}
|
||||
self->startNewTextBlock(headerBlockStyle);
|
||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||
self->updateEffectiveInlineStyle();
|
||||
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
|
||||
if (strcmp(name, "br") == 0) {
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
||||
self->flushPartWordBuffer();
|
||||
}
|
||||
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
||||
self->depth += 1;
|
||||
return;
|
||||
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
|
||||
} else {
|
||||
self->currentCssStyle = cssStyle;
|
||||
self->startNewTextBlock(userAlignmentBlockStyle);
|
||||
self->updateEffectiveInlineStyle();
|
||||
|
||||
if (strcmp(name, "li") == 0) {
|
||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||
}
|
||||
}
|
||||
|
||||
self->startNewTextBlock(static_cast<TextBlock::Style>(self->paragraphAlignment));
|
||||
if (strcmp(name, "li") == 0) {
|
||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||
// Push inline style entry for underline tag
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth; // Track depth for matching pop
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = true;
|
||||
if (cssStyle.hasFontWeight()) {
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
}
|
||||
if (cssStyle.hasFontStyle()) {
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||
self->depth += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
||||
// Push inline style entry for bold tag
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth; // Track depth for matching pop
|
||||
entry.hasBold = true;
|
||||
entry.bold = true;
|
||||
if (cssStyle.hasFontStyle()) {
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||
self->depth += 1;
|
||||
return;
|
||||
// Push inline style entry for italic tag
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth; // Track depth for matching pop
|
||||
entry.hasItalic = true;
|
||||
entry.italic = true;
|
||||
if (cssStyle.hasFontWeight()) {
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
} else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) {
|
||||
// Handle span and other inline elements for CSS styling
|
||||
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
||||
// Flush buffer before style change so preceding text gets current style
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
StyleStackEntry entry;
|
||||
entry.depth = self->depth; // Track depth for matching pop
|
||||
if (cssStyle.hasFontWeight()) {
|
||||
entry.hasBold = true;
|
||||
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
|
||||
}
|
||||
if (cssStyle.hasFontStyle()) {
|
||||
entry.hasItalic = true;
|
||||
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
|
||||
}
|
||||
if (cssStyle.hasTextDecoration()) {
|
||||
entry.hasUnderline = true;
|
||||
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
|
||||
}
|
||||
self->inlineStyleStack.push_back(entry);
|
||||
self->updateEffectiveInlineStyle();
|
||||
}
|
||||
}
|
||||
|
||||
// Unprocessed tag, just increasing depth and continue forward
|
||||
@@ -198,10 +354,34 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
}
|
||||
// Whitespace is a real word boundary — reset continuation state
|
||||
self->nextWordContinues = false;
|
||||
// Skip the whitespace char
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect U+00A0 (non-breaking space): UTF-8 encoding is 0xC2 0xA0
|
||||
// Render a visible space without allowing a line break around it.
|
||||
if (static_cast<uint8_t>(s[i]) == 0xC2 && i + 1 < len && static_cast<uint8_t>(s[i + 1]) == 0xA0) {
|
||||
// Flush any pending text so style is applied correctly.
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
self->flushPartWordBuffer();
|
||||
}
|
||||
|
||||
// Add a standalone space that attaches to the previous word.
|
||||
self->partWordBuffer[0] = ' ';
|
||||
self->partWordBuffer[1] = '\0';
|
||||
self->partWordBufferIndex = 1;
|
||||
self->nextWordContinues = true; // Attach space to previous word (no break).
|
||||
self->flushPartWordBuffer();
|
||||
|
||||
// Ensure the next real word attaches to this space (no break).
|
||||
self->nextWordContinues = true;
|
||||
|
||||
i++; // Skip the second byte (0xA0)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF
|
||||
const XML_Char FEFF_BYTE_1 = static_cast<XML_Char>(0xEF);
|
||||
const XML_Char FEFF_BYTE_2 = static_cast<XML_Char>(0xBB);
|
||||
@@ -229,28 +409,60 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
||||
// memory.
|
||||
// Spotted when reading Intermezzo, there are some really long text blocks in there.
|
||||
if (self->currentTextBlock->size() > 750) {
|
||||
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis());
|
||||
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
|
||||
self->currentTextBlock->layoutAndExtractLines(
|
||||
self->renderer, self->fontId, self->viewportWidth,
|
||||
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
|
||||
}
|
||||
}
|
||||
|
||||
void XMLCALL ChapterHtmlSlimParser::defaultHandlerExpand(void* userData, const XML_Char* s, const int len) {
|
||||
// Check if this looks like an entity reference (&...;)
|
||||
if (len >= 3 && s[0] == '&' && s[len - 1] == ';') {
|
||||
const char* utf8Value = lookupHtmlEntity(s, len);
|
||||
if (utf8Value != nullptr) {
|
||||
// Known entity: expand to its UTF-8 value
|
||||
characterData(userData, utf8Value, strlen(utf8Value));
|
||||
return;
|
||||
}
|
||||
// Unknown entity: preserve original &...; sequence
|
||||
characterData(userData, s, len);
|
||||
return;
|
||||
}
|
||||
// Not an entity we recognize - skip it
|
||||
}
|
||||
|
||||
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
|
||||
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
|
||||
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
// Only flush out part word buffer if we're closing a block tag or are at the top of the HTML file.
|
||||
// We don't want to flush out content when closing inline tags like <span>.
|
||||
// Currently this also flushes out on closing <b> and <i> tags, but they are line tags so that shouldn't happen,
|
||||
// text styling needs to be overhauled to fix it.
|
||||
const bool shouldBreakText =
|
||||
matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) || matches(name, HEADER_TAGS, NUM_HEADER_TAGS) ||
|
||||
matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
|
||||
strcmp(name, "table") == 0 || matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
|
||||
// Check if any style state will change after we decrement depth
|
||||
// If so, we MUST flush the partWordBuffer with the CURRENT style first
|
||||
// Note: depth hasn't been decremented yet, so we check against (depth - 1)
|
||||
const bool willPopStyleStack =
|
||||
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
|
||||
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
|
||||
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
|
||||
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
|
||||
|
||||
if (shouldBreakText) {
|
||||
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
|
||||
const bool headerOrBlockTag = isHeaderOrBlock(name);
|
||||
|
||||
// Flush buffer with current style BEFORE any style changes
|
||||
if (self->partWordBufferIndex > 0) {
|
||||
// Flush if style will change OR if we're closing a block/structural element
|
||||
const bool isInlineTag = !headerOrBlockTag && strcmp(name, "table") != 0 &&
|
||||
!matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1;
|
||||
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
|
||||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
|
||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || strcmp(name, "table") == 0 ||
|
||||
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
|
||||
|
||||
if (shouldFlush) {
|
||||
self->flushPartWordBuffer();
|
||||
// If closing an inline element, the next word fragment continues the same visual word
|
||||
if (isInlineTag) {
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,38 +473,67 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
||||
self->skipUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Leaving bold
|
||||
// Leaving bold tag
|
||||
if (self->boldUntilDepth == self->depth) {
|
||||
self->boldUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Leaving italic
|
||||
// Leaving italic tag
|
||||
if (self->italicUntilDepth == self->depth) {
|
||||
self->italicUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Leaving underline tag
|
||||
if (self->underlineUntilDepth == self->depth) {
|
||||
self->underlineUntilDepth = INT_MAX;
|
||||
}
|
||||
|
||||
// Pop from inline style stack if we pushed an entry at this depth
|
||||
// This handles all inline elements: b, i, u, span, etc.
|
||||
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
|
||||
self->inlineStyleStack.pop_back();
|
||||
self->updateEffectiveInlineStyle();
|
||||
}
|
||||
|
||||
// Clear block style when leaving header or block elements
|
||||
if (headerOrBlockTag) {
|
||||
self->currentCssStyle.reset();
|
||||
self->updateEffectiveInlineStyle();
|
||||
}
|
||||
}
|
||||
|
||||
bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
startNewTextBlock((TextBlock::Style)this->paragraphAlignment);
|
||||
auto paragraphAlignmentBlockStyle = BlockStyle();
|
||||
paragraphAlignmentBlockStyle.textAlignDefined = true;
|
||||
// Resolve None sentinel to Justify for initial block (no CSS context yet)
|
||||
const auto align = (this->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
|
||||
? CssTextAlign::Justify
|
||||
: static_cast<CssTextAlign>(this->paragraphAlignment);
|
||||
paragraphAlignmentBlockStyle.alignment = align;
|
||||
startNewTextBlock(paragraphAlignmentBlockStyle);
|
||||
|
||||
const XML_Parser parser = XML_ParserCreate(nullptr);
|
||||
int done;
|
||||
|
||||
if (!parser) {
|
||||
Serial.printf("[%lu] [EHP] Couldn't allocate memory for parser\n", millis());
|
||||
LOG_ERR("EHP", "Couldn't allocate memory for parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle HTML entities (like ) that aren't in XML spec or DTD
|
||||
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
|
||||
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
|
||||
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("EHP", filepath, file)) {
|
||||
if (!Storage.openFileForRead("EHP", filepath, file)) {
|
||||
XML_ParserFree(parser);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get file size for progress calculation
|
||||
const size_t totalSize = file.size();
|
||||
size_t bytesRead = 0;
|
||||
int lastProgress = -1;
|
||||
// Get file size to decide whether to show indexing popup.
|
||||
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
|
||||
popupFn();
|
||||
}
|
||||
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
@@ -301,7 +542,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
do {
|
||||
void* const buf = XML_GetBuffer(parser, 1024);
|
||||
if (!buf) {
|
||||
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis());
|
||||
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -313,7 +554,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
const size_t len = file.read(buf, 1024);
|
||||
|
||||
if (len == 0 && file.available() > 0) {
|
||||
Serial.printf("[%lu] [EHP] File read error\n", millis());
|
||||
LOG_ERR("EHP", "File read error");
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -322,22 +563,11 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update progress (call every 10% change to avoid too frequent updates)
|
||||
// Only show progress for larger chapters where rendering overhead is worth it
|
||||
bytesRead += len;
|
||||
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
|
||||
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
|
||||
if (lastProgress / 10 != progress / 10) {
|
||||
lastProgress = progress;
|
||||
progressFn(progress);
|
||||
}
|
||||
}
|
||||
|
||||
done = file.available() == 0;
|
||||
|
||||
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
|
||||
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -373,13 +603,15 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
|
||||
currentPageNextY = 0;
|
||||
}
|
||||
|
||||
currentPage->elements.push_back(std::make_shared<PageLine>(line, 0, currentPageNextY));
|
||||
// Apply horizontal left inset (margin + padding) as x position offset
|
||||
const int16_t xOffset = line->getBlockStyle().leftInset();
|
||||
currentPage->elements.push_back(std::make_shared<PageLine>(line, xOffset, currentPageNextY));
|
||||
currentPageNextY += lineHeight;
|
||||
}
|
||||
|
||||
void ChapterHtmlSlimParser::makePages() {
|
||||
if (!currentTextBlock) {
|
||||
Serial.printf("[%lu] [EHP] !! No text block to make pages for !!\n", millis());
|
||||
LOG_ERR("EHP", "!! No text block to make pages for !!");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -389,10 +621,34 @@ void ChapterHtmlSlimParser::makePages() {
|
||||
}
|
||||
|
||||
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
|
||||
|
||||
// Apply top spacing before the paragraph (stored in pixels)
|
||||
const BlockStyle& blockStyle = currentTextBlock->getBlockStyle();
|
||||
if (blockStyle.marginTop > 0) {
|
||||
currentPageNextY += blockStyle.marginTop;
|
||||
}
|
||||
if (blockStyle.paddingTop > 0) {
|
||||
currentPageNextY += blockStyle.paddingTop;
|
||||
}
|
||||
|
||||
// Calculate effective width accounting for horizontal margins/padding
|
||||
const int horizontalInset = blockStyle.totalHorizontalInset();
|
||||
const uint16_t effectiveWidth =
|
||||
(horizontalInset < viewportWidth) ? static_cast<uint16_t>(viewportWidth - horizontalInset) : viewportWidth;
|
||||
|
||||
currentTextBlock->layoutAndExtractLines(
|
||||
renderer, fontId, viewportWidth,
|
||||
renderer, fontId, effectiveWidth,
|
||||
[this](const std::shared_ptr<TextBlock>& textBlock) { addLineToPage(textBlock); });
|
||||
// Extra paragraph spacing if enabled
|
||||
|
||||
// Apply bottom spacing after the paragraph (stored in pixels)
|
||||
if (blockStyle.marginBottom > 0) {
|
||||
currentPageNextY += blockStyle.marginBottom;
|
||||
}
|
||||
if (blockStyle.paddingBottom > 0) {
|
||||
currentPageNextY += blockStyle.paddingBottom;
|
||||
}
|
||||
|
||||
// Extra paragraph spacing if enabled (default behavior)
|
||||
if (extraParagraphSpacing) {
|
||||
currentPageNextY += lineHeight / 2;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#include "../ParsedText.h"
|
||||
#include "../blocks/TextBlock.h"
|
||||
#include "../css/CssParser.h"
|
||||
#include "../css/CssStyle.h"
|
||||
|
||||
class Page;
|
||||
class GfxRenderer;
|
||||
@@ -18,15 +20,17 @@ class ChapterHtmlSlimParser {
|
||||
const std::string& filepath;
|
||||
GfxRenderer& renderer;
|
||||
std::function<void(std::unique_ptr<Page>)> completePageFn;
|
||||
std::function<void(int)> progressFn; // Progress callback (0-100)
|
||||
std::function<void()> popupFn; // Popup callback
|
||||
int depth = 0;
|
||||
int skipUntilDepth = INT_MAX;
|
||||
int boldUntilDepth = INT_MAX;
|
||||
int italicUntilDepth = INT_MAX;
|
||||
int underlineUntilDepth = INT_MAX;
|
||||
// buffer for building up words from characters, will auto break if longer than this
|
||||
// leave one char at end for null pointer
|
||||
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
|
||||
int partWordBufferIndex = 0;
|
||||
bool nextWordContinues = false; // true when next flushed word attaches to previous (inline element boundary)
|
||||
std::unique_ptr<ParsedText> currentTextBlock = nullptr;
|
||||
std::unique_ptr<Page> currentPage = nullptr;
|
||||
int16_t currentPageNextY = 0;
|
||||
@@ -37,13 +41,30 @@ class ChapterHtmlSlimParser {
|
||||
uint16_t viewportWidth;
|
||||
uint16_t viewportHeight;
|
||||
bool hyphenationEnabled;
|
||||
const CssParser* cssParser;
|
||||
bool embeddedStyle;
|
||||
|
||||
void startNewTextBlock(TextBlock::Style style);
|
||||
// Style tracking (replaces depth-based approach)
|
||||
struct StyleStackEntry {
|
||||
int depth = 0;
|
||||
bool hasBold = false, bold = false;
|
||||
bool hasItalic = false, italic = false;
|
||||
bool hasUnderline = false, underline = false;
|
||||
};
|
||||
std::vector<StyleStackEntry> inlineStyleStack;
|
||||
CssStyle currentCssStyle;
|
||||
bool effectiveBold = false;
|
||||
bool effectiveItalic = false;
|
||||
bool effectiveUnderline = false;
|
||||
|
||||
void updateEffectiveInlineStyle();
|
||||
void startNewTextBlock(const BlockStyle& blockStyle);
|
||||
void flushPartWordBuffer();
|
||||
void makePages();
|
||||
// XML callbacks
|
||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
|
||||
static void XMLCALL defaultHandlerExpand(void* userData, const XML_Char* s, int len);
|
||||
static void XMLCALL endElement(void* userData, const XML_Char* name);
|
||||
|
||||
public:
|
||||
@@ -52,7 +73,9 @@ class ChapterHtmlSlimParser {
|
||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
||||
const uint16_t viewportHeight, const bool hyphenationEnabled,
|
||||
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
|
||||
const std::function<void(int)>& progressFn = nullptr)
|
||||
const bool embeddedStyle, const std::function<void()>& popupFn = nullptr,
|
||||
const CssParser* cssParser = nullptr)
|
||||
|
||||
: filepath(filepath),
|
||||
renderer(renderer),
|
||||
fontId(fontId),
|
||||
@@ -63,7 +86,10 @@ class ChapterHtmlSlimParser {
|
||||
viewportHeight(viewportHeight),
|
||||
hyphenationEnabled(hyphenationEnabled),
|
||||
completePageFn(completePageFn),
|
||||
progressFn(progressFn) {}
|
||||
popupFn(popupFn),
|
||||
cssParser(cssParser),
|
||||
embeddedStyle(embeddedStyle) {}
|
||||
|
||||
~ChapterHtmlSlimParser() = default;
|
||||
bool parseAndBuildPages();
|
||||
void addLineToPage(std::shared_ptr<TextBlock> line);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#include "ContainerParser.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
bool ContainerParser::setup() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
Serial.printf("[%lu] [CTR] Couldn't allocate memory for parser\n", millis());
|
||||
LOG_ERR("CTR", "Couldn't allocate memory for parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ size_t ContainerParser::write(const uint8_t* buffer, const size_t size) {
|
||||
while (remainingInBuffer > 0) {
|
||||
void* const buf = XML_GetBuffer(parser, 1024);
|
||||
if (!buf) {
|
||||
Serial.printf("[%lu] [CTR] Couldn't allocate buffer\n", millis());
|
||||
LOG_DBG("CTR", "Couldn't allocate buffer");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ size_t ContainerParser::write(const uint8_t* buffer, const size_t size) {
|
||||
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)));
|
||||
LOG_ERR("CTR", "Parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
#include "ContentOpfParser.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include "../BookMetadataCache.h"
|
||||
|
||||
namespace {
|
||||
constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
|
||||
constexpr char MEDIA_TYPE_CSS[] = "text/css";
|
||||
constexpr char itemCacheFile[] = "/.items.bin";
|
||||
} // namespace
|
||||
|
||||
bool ContentOpfParser::setup() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
Serial.printf("[%lu] [COF] Couldn't allocate memory for parser\n", millis());
|
||||
LOG_DBG("COF", "Couldn't allocate memory for parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -35,8 +36,8 @@ ContentOpfParser::~ContentOpfParser() {
|
||||
if (tempItemStore) {
|
||||
tempItemStore.close();
|
||||
}
|
||||
if (SdMan.exists((cachePath + itemCacheFile).c_str())) {
|
||||
SdMan.remove((cachePath + itemCacheFile).c_str());
|
||||
if (Storage.exists((cachePath + itemCacheFile).c_str())) {
|
||||
Storage.remove((cachePath + itemCacheFile).c_str());
|
||||
}
|
||||
itemIndex.clear();
|
||||
itemIndex.shrink_to_fit();
|
||||
@@ -55,7 +56,7 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
|
||||
void* const buf = XML_GetBuffer(parser, 1024);
|
||||
|
||||
if (!buf) {
|
||||
Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis());
|
||||
LOG_ERR("COF", "Couldn't allocate memory for buffer");
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -68,8 +69,8 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
|
||||
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)));
|
||||
LOG_DBG("COF", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -117,20 +118,16 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
|
||||
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
|
||||
self->state = IN_MANIFEST;
|
||||
if (!SdMan.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
||||
Serial.printf(
|
||||
"[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n",
|
||||
millis());
|
||||
if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
||||
LOG_ERR("COF", "Couldn't open temp items file for writing. This is probably going to be a fatal error.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
|
||||
self->state = IN_SPINE;
|
||||
if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
||||
Serial.printf(
|
||||
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
|
||||
millis());
|
||||
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
||||
LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
|
||||
}
|
||||
|
||||
// Sort item index for binary search if we have enough items
|
||||
@@ -139,7 +136,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen);
|
||||
});
|
||||
self->useItemIndex = true;
|
||||
Serial.printf("[%lu] [COF] Using fast index for %zu manifest items\n", millis(), self->itemIndex.size());
|
||||
LOG_DBG("COF", "Using fast index for %zu manifest items", self->itemIndex.size());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -147,11 +144,9 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
|
||||
self->state = IN_GUIDE;
|
||||
// TODO Remove print
|
||||
Serial.printf("[%lu] [COF] Entering guide state.\n", millis());
|
||||
if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
||||
Serial.printf(
|
||||
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
|
||||
millis());
|
||||
LOG_DBG("COF", "Entering guide state.");
|
||||
if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
|
||||
LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -213,17 +208,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
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());
|
||||
LOG_DBG("COF", "Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s", href.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Collect CSS files
|
||||
if (mediaType == MEDIA_TYPE_CSS) {
|
||||
self->cssFiles.push_back(href);
|
||||
}
|
||||
|
||||
// EPUB 3: Check for nav document (properties contains "nav")
|
||||
if (!properties.empty() && self->tocNavPath.empty()) {
|
||||
// Properties is space-separated, check if "nav" is present as a word
|
||||
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
|
||||
self->tocNavPath = href;
|
||||
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str());
|
||||
LOG_DBG("COF", "Found EPUB 3 nav document: %s", href.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// EPUB 3: Check for cover image (properties contains "cover-image")
|
||||
if (!properties.empty() && self->coverItemHref.empty()) {
|
||||
if (properties == "cover-image" || properties.find("cover-image ") == 0 ||
|
||||
properties.find(" cover-image") != std::string::npos) {
|
||||
self->coverItemHref = href;
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -296,7 +303,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
if (type == "text" || type == "start") {
|
||||
continue;
|
||||
} else {
|
||||
Serial.printf("[%lu] [COF] Skipping non-text reference in guide: %s\n", millis(), type.c_str());
|
||||
LOG_DBG("COF", "Skipping non-text reference in guide: %s", type.c_str());
|
||||
break;
|
||||
}
|
||||
} else if (strcmp(atts[i], "href") == 0) {
|
||||
@@ -304,7 +311,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
||||
}
|
||||
}
|
||||
if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) {
|
||||
Serial.printf("[%lu] [COF] Found %s reference in guide: %s.\n", millis(), type.c_str(), textHref.c_str());
|
||||
LOG_DBG("COF", "Found %s reference in guide: %s.", type.c_str(), textHref.c_str());
|
||||
self->textReferenceHref = textHref;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -64,6 +64,7 @@ class ContentOpfParser final : public Print {
|
||||
std::string tocNavPath; // EPUB 3 nav document path
|
||||
std::string coverItemHref;
|
||||
std::string textReferenceHref;
|
||||
std::vector<std::string> cssFiles; // CSS stylesheet paths
|
||||
|
||||
explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize,
|
||||
BookMetadataCache* cache)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#include "TocNavParser.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include "../BookMetadataCache.h"
|
||||
|
||||
bool TocNavParser::setup() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
Serial.printf("[%lu] [NAV] Couldn't allocate memory for parser\n", millis());
|
||||
LOG_DBG("NAV", "Couldn't allocate memory for parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
|
||||
while (remainingInBuffer > 0) {
|
||||
void* const buf = XML_GetBuffer(parser, 1024);
|
||||
if (!buf) {
|
||||
Serial.printf("[%lu] [NAV] Couldn't allocate memory for buffer\n", millis());
|
||||
LOG_DBG("NAV", "Couldn't allocate memory for buffer");
|
||||
XML_StopParser(parser, XML_FALSE);
|
||||
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -52,8 +52,8 @@ size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
|
||||
memcpy(buf, currentBufferPos, toRead);
|
||||
|
||||
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
|
||||
Serial.printf("[%lu] [NAV] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
LOG_DBG("NAV", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
XML_StopParser(parser, XML_FALSE);
|
||||
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -88,7 +88,7 @@ void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, co
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
|
||||
self->state = IN_NAV_TOC;
|
||||
Serial.printf("[%lu] [NAV] Found nav toc element\n", millis());
|
||||
LOG_DBG("NAV", "Found nav toc element");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
|
||||
|
||||
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
|
||||
self->state = IN_BODY;
|
||||
Serial.printf("[%lu] [NAV] Finished parsing nav toc\n", millis());
|
||||
LOG_DBG("NAV", "Finished parsing nav toc");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#include "TocNcxParser.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include "../BookMetadataCache.h"
|
||||
|
||||
bool TocNcxParser::setup() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
Serial.printf("[%lu] [TOC] Couldn't allocate memory for parser\n", millis());
|
||||
LOG_DBG("TOC", "Couldn't allocate memory for parser");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t 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());
|
||||
LOG_DBG("TOC", "Couldn't allocate memory for buffer");
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
@@ -52,8 +52,8 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
|
||||
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)));
|
||||
LOG_DBG("TOC", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
|
||||
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
|
||||
@@ -104,3 +104,20 @@ uint8_t quantize1bit(int gray, int x, int y) {
|
||||
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
|
||||
return (gray >= adjustedThreshold) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Noise dithering for gradient fills - always uses hash-based noise regardless of global dithering config.
|
||||
// Produces smooth-looking gradients on the 4-level e-ink display.
|
||||
uint8_t quantizeNoiseDither(int gray, int x, int y) {
|
||||
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
|
||||
hash = (hash ^ (hash >> 13)) * 1274126177u;
|
||||
const int threshold = static_cast<int>(hash >> 24);
|
||||
|
||||
const int scaled = gray * 3;
|
||||
if (scaled < 255) {
|
||||
return (scaled + threshold >= 255) ? 1 : 0;
|
||||
} else if (scaled < 510) {
|
||||
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
|
||||
} else {
|
||||
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ uint8_t quantize(int gray, int x, int y);
|
||||
uint8_t quantizeSimple(int gray);
|
||||
uint8_t quantize1bit(int gray, int x, int y);
|
||||
int adjustPixel(int gray);
|
||||
uint8_t quantizeNoiseDither(int gray, int x, int y);
|
||||
|
||||
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
|
||||
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <EInkDisplay.h>
|
||||
#include <EpdFontFamily.h>
|
||||
#include <HalDisplay.h>
|
||||
|
||||
#include <map>
|
||||
|
||||
#include "Bitmap.h"
|
||||
|
||||
// Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels
|
||||
// 0 = transparent, 1-16 = gray levels (white to black)
|
||||
enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = 0x0A, Black = 0x10 };
|
||||
|
||||
class GfxRenderer {
|
||||
public:
|
||||
enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB };
|
||||
@@ -21,22 +25,28 @@ class GfxRenderer {
|
||||
|
||||
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,
|
||||
static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
|
||||
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE,
|
||||
"BW buffer chunking does not line up with display buffer size");
|
||||
|
||||
EInkDisplay& einkDisplay;
|
||||
HalDisplay& display;
|
||||
RenderMode renderMode;
|
||||
Orientation orientation;
|
||||
bool fadingFix;
|
||||
uint8_t* frameBuffer = nullptr;
|
||||
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
||||
std::map<int, EpdFontFamily> fontMap;
|
||||
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
|
||||
EpdFontFamily::Style style) const;
|
||||
void freeBwBufferChunks();
|
||||
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
|
||||
template <Color color>
|
||||
void drawPixelDither(int x, int y) const;
|
||||
template <Color color>
|
||||
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
|
||||
|
||||
public:
|
||||
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
|
||||
explicit GfxRenderer(HalDisplay& halDisplay)
|
||||
: display(halDisplay), renderMode(BW), orientation(Portrait), fadingFix(false) {}
|
||||
~GfxRenderer() { freeBwBufferChunks(); }
|
||||
|
||||
static constexpr int VIEWABLE_MARGIN_TOP = 9;
|
||||
@@ -45,27 +55,44 @@ class GfxRenderer {
|
||||
static constexpr int VIEWABLE_MARGIN_LEFT = 3;
|
||||
|
||||
// Setup
|
||||
void begin(); // must be called right after display.begin()
|
||||
void insertFont(int fontId, EpdFontFamily font);
|
||||
|
||||
// Orientation control (affects logical width/height and coordinate transforms)
|
||||
void setOrientation(const Orientation o) { orientation = o; }
|
||||
Orientation getOrientation() const { return orientation; }
|
||||
|
||||
// Fading fix control
|
||||
void setFadingFix(const bool enabled) { fadingFix = enabled; }
|
||||
|
||||
// Screen ops
|
||||
int getScreenWidth() const;
|
||||
int getScreenHeight() const;
|
||||
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const;
|
||||
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||
void displayWindow(int x, int y, int width, int height) const;
|
||||
// void displayWindow(int x, int y, int width, int height) const;
|
||||
void invertScreen() const;
|
||||
void clearScreen(uint8_t color = 0xFF) const;
|
||||
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
|
||||
|
||||
// Drawing
|
||||
void drawPixel(int x, int y, bool state = true) const;
|
||||
void drawPixelGray(int x, int y, uint8_t val2bit) const;
|
||||
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
|
||||
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const;
|
||||
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;
|
||||
void drawRect(int x, int y, int width, int height, bool state = true) const;
|
||||
void drawRect(int x, int y, int width, int height, int lineWidth, bool state) const;
|
||||
void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const;
|
||||
void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool roundTopLeft,
|
||||
bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, bool state) const;
|
||||
void fillRect(int x, int y, int width, int height, bool state = true) const;
|
||||
void fillRectDither(int x, int y, int width, int height, Color color) const;
|
||||
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, Color color) const;
|
||||
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
|
||||
bool roundBottomLeft, bool roundBottomRight, Color color) const;
|
||||
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||
void drawIcon(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, float cropX = 0,
|
||||
float cropY = 0) const;
|
||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||
@@ -78,22 +105,19 @@ class GfxRenderer {
|
||||
void drawText(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
int getSpaceWidth(int fontId) const;
|
||||
int getTextAdvanceX(int fontId, const char* text) const;
|
||||
int getFontAscenderSize(int fontId) const;
|
||||
int getLineHeight(int fontId) const;
|
||||
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
|
||||
// UI Components
|
||||
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4);
|
||||
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
|
||||
|
||||
private:
|
||||
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
|
||||
// Helpers for drawing rotated text (for side buttons)
|
||||
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
void drawTextRotated90CCW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
int getTextHeight(int fontId) const;
|
||||
|
||||
public:
|
||||
// Grayscale functions
|
||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||
void copyGrayscaleLsbBuffers() const;
|
||||
@@ -106,6 +130,4 @@ class GfxRenderer {
|
||||
// Low level functions
|
||||
uint8_t* getFrameBuffer() const;
|
||||
static size_t getBufferSize();
|
||||
void grayscaleRevert() const;
|
||||
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "JpegToBmpConverter.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <picojpeg.h>
|
||||
|
||||
#include <cstdio>
|
||||
@@ -201,8 +201,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
|
||||
// Internal implementation with configurable target size and bit depth
|
||||
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
|
||||
bool oneBit, bool crop) {
|
||||
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
|
||||
targetWidth, targetHeight);
|
||||
LOG_DBG("JPG", "Converting JPEG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight);
|
||||
|
||||
// Setup context for picojpeg callback
|
||||
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
|
||||
@@ -211,12 +210,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
pjpeg_image_info_t imageInfo;
|
||||
const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||
if (status != 0) {
|
||||
Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status);
|
||||
LOG_ERR("JPG", "JPEG decode init failed with error code: %d", status);
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width,
|
||||
imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
|
||||
LOG_DBG("JPG", "JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d", imageInfo.m_width, imageInfo.m_height,
|
||||
imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
|
||||
|
||||
// Safety limits to prevent memory issues on ESP32
|
||||
constexpr int MAX_IMAGE_WIDTH = 2048;
|
||||
@@ -224,8 +223,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
constexpr int MAX_MCU_ROW_BYTES = 65536;
|
||||
|
||||
if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
|
||||
Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width,
|
||||
imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
|
||||
LOG_DBG("JPG", "Image too large (%dx%d), max supported: %dx%d", imageInfo.m_width, imageInfo.m_height,
|
||||
MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -262,8 +261,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
|
||||
needsScaling = true;
|
||||
|
||||
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width,
|
||||
imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight);
|
||||
LOG_DBG("JPG", "Pre-scaling %dx%d -> %dx%d (fit to %dx%d)", imageInfo.m_width, imageInfo.m_height, outWidth,
|
||||
outHeight, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
// Write BMP header with output dimensions
|
||||
@@ -282,7 +281,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
// Allocate row buffer
|
||||
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
|
||||
if (!rowBuffer) {
|
||||
Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis());
|
||||
LOG_ERR("JPG", "Failed to allocate row buffer");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -293,15 +292,14 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
|
||||
// Validate MCU row buffer size before allocation
|
||||
if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
|
||||
Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels,
|
||||
MAX_MCU_ROW_BYTES);
|
||||
LOG_DBG("JPG", "MCU row buffer too large (%d bytes), max: %d", mcuRowPixels, MAX_MCU_ROW_BYTES);
|
||||
free(rowBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
|
||||
if (!mcuRowBuffer) {
|
||||
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels);
|
||||
LOG_ERR("JPG", "Failed to allocate MCU row buffer (%d bytes)", mcuRowPixels);
|
||||
free(rowBuffer);
|
||||
return false;
|
||||
}
|
||||
@@ -349,10 +347,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
const unsigned char mcuStatus = pjpeg_decode_mcu();
|
||||
if (mcuStatus != 0) {
|
||||
if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
|
||||
Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY);
|
||||
LOG_ERR("JPG", "Unexpected end of blocks at MCU (%d, %d)", mcuX, mcuY);
|
||||
} else {
|
||||
Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY,
|
||||
mcuStatus);
|
||||
LOG_ERR("JPG", "JPEG decode MCU failed at (%d, %d) with error code: %d", mcuX, mcuY, mcuStatus);
|
||||
}
|
||||
free(mcuRowBuffer);
|
||||
free(rowBuffer);
|
||||
@@ -549,7 +546,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
||||
free(mcuRowBuffer);
|
||||
free(rowBuffer);
|
||||
|
||||
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis());
|
||||
LOG_DBG("JPG", "Successfully converted JPEG to BMP");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -567,5 +564,5 @@ bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bm
|
||||
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
|
||||
bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth,
|
||||
int targetMaxHeight) {
|
||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true);
|
||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true, true);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <MD5Builder.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
// Initialize the static instance
|
||||
@@ -32,10 +32,10 @@ void KOReaderCredentialStore::obfuscate(std::string& data) const {
|
||||
|
||||
bool KOReaderCredentialStore::saveToFile() const {
|
||||
// Make sure the directory exists
|
||||
SdMan.mkdir("/.crosspoint");
|
||||
Storage.mkdir("/.crosspoint");
|
||||
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForWrite("KRS", KOREADER_FILE, file)) {
|
||||
if (!Storage.openFileForWrite("KRS", KOREADER_FILE, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ bool KOReaderCredentialStore::saveToFile() const {
|
||||
|
||||
// Write username (plaintext - not particularly sensitive)
|
||||
serialization::writeString(file, username);
|
||||
Serial.printf("[%lu] [KRS] Saving username: %s\n", millis(), username.c_str());
|
||||
LOG_DBG("KRS", "Saving username: %s", username.c_str());
|
||||
|
||||
// Write password (obfuscated)
|
||||
std::string obfuscatedPwd = password;
|
||||
@@ -58,14 +58,14 @@ bool KOReaderCredentialStore::saveToFile() const {
|
||||
serialization::writePod(file, static_cast<uint8_t>(matchMethod));
|
||||
|
||||
file.close();
|
||||
Serial.printf("[%lu] [KRS] Saved KOReader credentials to file\n", millis());
|
||||
LOG_DBG("KRS", "Saved KOReader credentials to file");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool KOReaderCredentialStore::loadFromFile() {
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("KRS", KOREADER_FILE, file)) {
|
||||
Serial.printf("[%lu] [KRS] No credentials file found\n", millis());
|
||||
if (!Storage.openFileForRead("KRS", KOREADER_FILE, file)) {
|
||||
LOG_DBG("KRS", "No credentials file found");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ bool KOReaderCredentialStore::loadFromFile() {
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version != KOREADER_FILE_VERSION) {
|
||||
Serial.printf("[%lu] [KRS] Unknown file version: %u\n", millis(), version);
|
||||
LOG_DBG("KRS", "Unknown file version: %u", version);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
@@ -110,14 +110,14 @@ bool KOReaderCredentialStore::loadFromFile() {
|
||||
}
|
||||
|
||||
file.close();
|
||||
Serial.printf("[%lu] [KRS] Loaded KOReader credentials for user: %s\n", millis(), username.c_str());
|
||||
LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", username.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) {
|
||||
username = user;
|
||||
password = pass;
|
||||
Serial.printf("[%lu] [KRS] Set credentials for user: %s\n", millis(), user.c_str());
|
||||
LOG_DBG("KRS", "Set credentials for user: %s", user.c_str());
|
||||
}
|
||||
|
||||
std::string KOReaderCredentialStore::getMd5Password() const {
|
||||
@@ -140,12 +140,12 @@ void KOReaderCredentialStore::clearCredentials() {
|
||||
username.clear();
|
||||
password.clear();
|
||||
saveToFile();
|
||||
Serial.printf("[%lu] [KRS] Cleared KOReader credentials\n", millis());
|
||||
LOG_DBG("KRS", "Cleared KOReader credentials");
|
||||
}
|
||||
|
||||
void KOReaderCredentialStore::setServerUrl(const std::string& url) {
|
||||
serverUrl = url;
|
||||
Serial.printf("[%lu] [KRS] Set server URL: %s\n", millis(), url.empty() ? "(default)" : url.c_str());
|
||||
LOG_DBG("KRS", "Set server URL: %s", url.empty() ? "(default)" : url.c_str());
|
||||
}
|
||||
|
||||
std::string KOReaderCredentialStore::getBaseUrl() const {
|
||||
@@ -163,6 +163,5 @@ std::string KOReaderCredentialStore::getBaseUrl() const {
|
||||
|
||||
void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) {
|
||||
matchMethod = method;
|
||||
Serial.printf("[%lu] [KRS] Set match method: %s\n", millis(),
|
||||
method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
|
||||
LOG_DBG("KRS", "Set match method: %s", method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#include "KOReaderDocumentId.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <MD5Builder.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
namespace {
|
||||
// Extract filename from path (everything after last '/')
|
||||
@@ -27,7 +27,7 @@ std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePat
|
||||
md5.calculate();
|
||||
|
||||
std::string result = md5.toString().c_str();
|
||||
Serial.printf("[%lu] [KODoc] Filename hash: %s (from '%s')\n", millis(), result.c_str(), filename.c_str());
|
||||
LOG_DBG("KODoc", "Filename hash: %s (from '%s')", result.c_str(), filename.c_str());
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -43,13 +43,13 @@ size_t KOReaderDocumentId::getOffset(int i) {
|
||||
|
||||
std::string KOReaderDocumentId::calculate(const std::string& filePath) {
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("KODoc", filePath, file)) {
|
||||
Serial.printf("[%lu] [KODoc] Failed to open file: %s\n", millis(), filePath.c_str());
|
||||
if (!Storage.openFileForRead("KODoc", filePath, file)) {
|
||||
LOG_DBG("KODoc", "Failed to open file: %s", filePath.c_str());
|
||||
return "";
|
||||
}
|
||||
|
||||
const size_t fileSize = file.fileSize();
|
||||
Serial.printf("[%lu] [KODoc] Calculating hash for file: %s (size: %zu)\n", millis(), filePath.c_str(), fileSize);
|
||||
LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize);
|
||||
|
||||
// Initialize MD5 builder
|
||||
MD5Builder md5;
|
||||
@@ -70,7 +70,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
|
||||
|
||||
// Seek to offset
|
||||
if (!file.seekSet(offset)) {
|
||||
Serial.printf("[%lu] [KODoc] Failed to seek to offset %zu\n", millis(), offset);
|
||||
LOG_DBG("KODoc", "Failed to seek to offset %zu", offset);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
|
||||
md5.calculate();
|
||||
std::string result = md5.toString().c_str();
|
||||
|
||||
Serial.printf("[%lu] [KODoc] Hash calculated: %s (from %zu bytes)\n", millis(), result.c_str(), totalBytesRead);
|
||||
LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
|
||||
@@ -19,6 +19,10 @@ void addAuthHeaders(HTTPClient& http) {
|
||||
http.addHeader("Accept", "application/vnd.koreader.v1+json");
|
||||
http.addHeader("x-auth-user", KOREADER_STORE.getUsername().c_str());
|
||||
http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password().c_str());
|
||||
|
||||
// HTTP Basic Auth (RFC 7617) header. This is needed to support koreader sync server embedded in Calibre Web Automated
|
||||
// (https://github.com/crocodilestick/Calibre-Web-Automated/blob/main/cps/progress_syncing/protocols/kosync.py)
|
||||
http.setAuthorization(KOREADER_STORE.getUsername().c_str(), KOREADER_STORE.getPassword().c_str());
|
||||
}
|
||||
|
||||
bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; }
|
||||
@@ -26,12 +30,12 @@ bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0;
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
|
||||
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
|
||||
Serial.printf("[%lu] [KOSync] Authenticating: %s\n", millis(), url.c_str());
|
||||
LOG_DBG("KOSync", "Authenticating: %s", url.c_str());
|
||||
|
||||
HTTPClient http;
|
||||
std::unique_ptr<WiFiClientSecure> secureClient;
|
||||
@@ -49,7 +53,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||
const int httpCode = http.GET();
|
||||
http.end();
|
||||
|
||||
Serial.printf("[%lu] [KOSync] Auth response: %d\n", millis(), httpCode);
|
||||
LOG_DBG("KOSync", "Auth response: %d", httpCode);
|
||||
|
||||
if (httpCode == 200) {
|
||||
return OK;
|
||||
@@ -64,12 +68,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
||||
KOReaderProgress& outProgress) {
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
|
||||
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
|
||||
Serial.printf("[%lu] [KOSync] Getting progress: %s\n", millis(), url.c_str());
|
||||
LOG_DBG("KOSync", "Getting progress: %s", url.c_str());
|
||||
|
||||
HTTPClient http;
|
||||
std::unique_ptr<WiFiClientSecure> secureClient;
|
||||
@@ -95,7 +99,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
||||
const DeserializationError error = deserializeJson(doc, responseBody);
|
||||
|
||||
if (error) {
|
||||
Serial.printf("[%lu] [KOSync] JSON parse failed: %s\n", millis(), error.c_str());
|
||||
LOG_ERR("KOSync", "JSON parse failed: %s", error.c_str());
|
||||
return JSON_ERROR;
|
||||
}
|
||||
|
||||
@@ -106,14 +110,13 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
||||
outProgress.deviceId = doc["device_id"].as<std::string>();
|
||||
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
||||
|
||||
Serial.printf("[%lu] [KOSync] Got progress: %.2f%% at %s\n", millis(), outProgress.percentage * 100,
|
||||
outProgress.progress.c_str());
|
||||
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
||||
return OK;
|
||||
}
|
||||
|
||||
http.end();
|
||||
|
||||
Serial.printf("[%lu] [KOSync] Get progress response: %d\n", millis(), httpCode);
|
||||
LOG_DBG("KOSync", "Get progress response: %d", httpCode);
|
||||
|
||||
if (httpCode == 401) {
|
||||
return AUTH_FAILED;
|
||||
@@ -127,12 +130,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) {
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
|
||||
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
|
||||
Serial.printf("[%lu] [KOSync] Updating progress: %s\n", millis(), url.c_str());
|
||||
LOG_DBG("KOSync", "Updating progress: %s", url.c_str());
|
||||
|
||||
HTTPClient http;
|
||||
std::unique_ptr<WiFiClientSecure> secureClient;
|
||||
@@ -159,12 +162,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
|
||||
std::string body;
|
||||
serializeJson(doc, body);
|
||||
|
||||
Serial.printf("[%lu] [KOSync] Request body: %s\n", millis(), body.c_str());
|
||||
LOG_DBG("KOSync", "Request body: %s", body.c_str());
|
||||
|
||||
const int httpCode = http.PUT(body.c_str());
|
||||
http.end();
|
||||
|
||||
Serial.printf("[%lu] [KOSync] Update progress response: %d\n", millis(), httpCode);
|
||||
LOG_DBG("KOSync", "Update progress response: %d", httpCode);
|
||||
|
||||
if (httpCode == 200 || httpCode == 202) {
|
||||
return OK;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "ProgressMapper.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
@@ -23,8 +23,8 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
|
||||
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
|
||||
const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown";
|
||||
|
||||
Serial.printf("[%lu] [ProgressMapper] CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s\n", millis(),
|
||||
chapterName.c_str(), pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str());
|
||||
LOG_DBG("ProgressMapper", "CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s", chapterName.c_str(),
|
||||
pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str());
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -76,8 +76,8 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [ProgressMapper] KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d\n", millis(),
|
||||
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
|
||||
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
|
||||
koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
47
lib/Logging/Logging.cpp
Normal file
47
lib/Logging/Logging.cpp
Normal file
@@ -0,0 +1,47 @@
|
||||
#include "Logging.h"
|
||||
|
||||
// Since logging can take a large amount of flash, we want to make the format string as short as possible.
|
||||
// This logPrintf prepend the timestamp, level and origin to the user-provided message, so that the user only needs to
|
||||
// provide the format string for the message itself.
|
||||
void logPrintf(const char* level, const char* origin, const char* format, ...) {
|
||||
if (!logSerial) {
|
||||
return; // Serial not initialized, skip logging
|
||||
}
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
char buf[256];
|
||||
char* c = buf;
|
||||
// add the timestamp
|
||||
{
|
||||
unsigned long ms = millis();
|
||||
int len = snprintf(c, sizeof(buf), "[%lu] ", ms);
|
||||
if (len < 0) {
|
||||
return; // encoding error, skip logging
|
||||
}
|
||||
c += len;
|
||||
}
|
||||
// add the level
|
||||
{
|
||||
const char* p = level;
|
||||
size_t remaining = sizeof(buf) - (c - buf);
|
||||
while (*p && remaining > 1) {
|
||||
*c++ = *p++;
|
||||
remaining--;
|
||||
}
|
||||
if (remaining > 1) {
|
||||
*c++ = ' ';
|
||||
}
|
||||
}
|
||||
// add the origin
|
||||
{
|
||||
int len = snprintf(c, sizeof(buf) - (c - buf), "[%s] ", origin);
|
||||
if (len < 0) {
|
||||
return; // encoding error, skip logging
|
||||
}
|
||||
c += len;
|
||||
}
|
||||
// add the user message
|
||||
vsnprintf(c, sizeof(buf) - (c - buf), format, args);
|
||||
va_end(args);
|
||||
logSerial.print(buf);
|
||||
}
|
||||
71
lib/Logging/Logging.h
Normal file
71
lib/Logging/Logging.h
Normal file
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
/*
|
||||
Define ENABLE_SERIAL_LOG to enable logging
|
||||
Can be set in platformio.ini build_flags or as a compile definition
|
||||
|
||||
Define LOG_LEVEL to control log verbosity:
|
||||
0 = ERR only
|
||||
1 = ERR + INF
|
||||
2 = ERR + INF + DBG
|
||||
If not defined, defaults to 0
|
||||
|
||||
If you have a legitimate need for raw Serial access (e.g., binary data,
|
||||
special formatting), use the underlying logSerial object directly:
|
||||
logSerial.printf("Special case: %d\n", value);
|
||||
logSerial.write(binaryData, length);
|
||||
|
||||
The logSerial reference (defined below) points to the real Serial object and
|
||||
won't trigger deprecation warnings.
|
||||
*/
|
||||
|
||||
#ifndef LOG_LEVEL
|
||||
#define LOG_LEVEL 0
|
||||
#endif
|
||||
|
||||
static HWCDC& logSerial = Serial;
|
||||
|
||||
void logPrintf(const char* level, const char* origin, const char* format, ...);
|
||||
|
||||
#ifdef ENABLE_SERIAL_LOG
|
||||
#if LOG_LEVEL >= 0
|
||||
#define LOG_ERR(origin, format, ...) logPrintf("[ERR]", origin, format "\n", ##__VA_ARGS__)
|
||||
#else
|
||||
#define LOG_ERR(origin, format, ...)
|
||||
#endif
|
||||
|
||||
#if LOG_LEVEL >= 1
|
||||
#define LOG_INF(origin, format, ...) logPrintf("[INF]", origin, format "\n", ##__VA_ARGS__)
|
||||
#else
|
||||
#define LOG_INF(origin, format, ...)
|
||||
#endif
|
||||
|
||||
#if LOG_LEVEL >= 2
|
||||
#define LOG_DBG(origin, format, ...) logPrintf("[DBG]", origin, format "\n", ##__VA_ARGS__)
|
||||
#else
|
||||
#define LOG_DBG(origin, format, ...)
|
||||
#endif
|
||||
#else
|
||||
#define LOG_DBG(origin, format, ...)
|
||||
#define LOG_ERR(origin, format, ...)
|
||||
#define LOG_INF(origin, format, ...)
|
||||
#endif
|
||||
|
||||
class MySerialImpl : public Print {
|
||||
public:
|
||||
void begin(unsigned long baud) { logSerial.begin(baud); }
|
||||
|
||||
// Support boolean conversion for compatibility with code like:
|
||||
// if (Serial) or while (!Serial)
|
||||
operator bool() const { return logSerial; }
|
||||
|
||||
__attribute__((deprecated("Use LOG_* macro instead"))) size_t printf(const char* format, ...);
|
||||
size_t write(uint8_t b) override;
|
||||
size_t write(const uint8_t* buffer, size_t size) override;
|
||||
void flush() override;
|
||||
static MySerialImpl instance;
|
||||
};
|
||||
|
||||
#define Serial MySerialImpl::instance
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "OpdsParser.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
@@ -8,7 +8,7 @@ OpdsParser::OpdsParser() {
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
errorOccured = true;
|
||||
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis());
|
||||
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
void* const buf = XML_GetBuffer(parser, chunkSize);
|
||||
if (!buf) {
|
||||
errorOccured = true;
|
||||
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis());
|
||||
LOG_DBG("OPDS", "Couldn't allocate memory for buffer");
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
return length;
|
||||
@@ -53,8 +53,8 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
|
||||
|
||||
if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) {
|
||||
errorOccured = true;
|
||||
Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
return length;
|
||||
|
||||
27
lib/PlaceholderCover/BookIcon.h
Normal file
27
lib/PlaceholderCover/BookIcon.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Book icon: 48x48, 1-bit packed (MSB first)
|
||||
// 0 = black, 1 = white (same format as Logo120.h)
|
||||
static constexpr int BOOK_ICON_WIDTH = 48;
|
||||
static constexpr int BOOK_ICON_HEIGHT = 48;
|
||||
static const uint8_t BookIcon[] = {
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00,
|
||||
0x00, 0x1f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x00, 0x01, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1c, 0x00, 0x01,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1c, 0x00, 0x00, 0x1f, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f,
|
||||
0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff,
|
||||
0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f,
|
||||
0xff, 0xff, 0xff, 0x9f, 0xfc, 0x1f, 0xff, 0xff, 0xff, 0x9f, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f,
|
||||
0xfc, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00,
|
||||
0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
};
|
||||
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
480
lib/PlaceholderCover/PlaceholderCoverGenerator.cpp
Normal file
@@ -0,0 +1,480 @@
|
||||
#include "PlaceholderCoverGenerator.h"
|
||||
|
||||
#include <EpdFont.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
// Include the UI fonts directly for self-contained placeholder rendering.
|
||||
// These are 1-bit bitmap fonts compiled from Ubuntu TTF.
|
||||
#include "builtinFonts/ubuntu_10_regular.h"
|
||||
#include "builtinFonts/ubuntu_12_bold.h"
|
||||
|
||||
// Book icon bitmap (48x48 1-bit, generated by scripts/generate_book_icon.py)
|
||||
#include "BookIcon.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// BMP writing helpers (same format as JpegToBmpConverter)
|
||||
inline void write16(Print& out, const uint16_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
inline void write32(Print& out, const uint32_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
out.write((value >> 16) & 0xFF);
|
||||
out.write((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
inline void write32Signed(Print& out, const int32_t value) {
|
||||
out.write(value & 0xFF);
|
||||
out.write((value >> 8) & 0xFF);
|
||||
out.write((value >> 16) & 0xFF);
|
||||
out.write((value >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) {
|
||||
const int bytesPerRow = (width + 31) / 32 * 4;
|
||||
const int imageSize = bytesPerRow * height;
|
||||
const uint32_t fileSize = 62 + imageSize;
|
||||
|
||||
// BMP File Header (14 bytes)
|
||||
bmpOut.write('B');
|
||||
bmpOut.write('M');
|
||||
write32(bmpOut, fileSize);
|
||||
write32(bmpOut, 0); // Reserved
|
||||
write32(bmpOut, 62); // Offset to pixel data
|
||||
|
||||
// DIB Header (BITMAPINFOHEADER - 40 bytes)
|
||||
write32(bmpOut, 40);
|
||||
write32Signed(bmpOut, width);
|
||||
write32Signed(bmpOut, -height); // Negative = top-down
|
||||
write16(bmpOut, 1); // Color planes
|
||||
write16(bmpOut, 1); // Bits per pixel
|
||||
write32(bmpOut, 0); // BI_RGB
|
||||
write32(bmpOut, imageSize);
|
||||
write32(bmpOut, 2835); // xPixelsPerMeter
|
||||
write32(bmpOut, 2835); // yPixelsPerMeter
|
||||
write32(bmpOut, 2); // colorsUsed
|
||||
write32(bmpOut, 2); // colorsImportant
|
||||
|
||||
// Palette: index 0 = black, index 1 = white
|
||||
const uint8_t palette[8] = {
|
||||
0x00, 0x00, 0x00, 0x00, // Black
|
||||
0xFF, 0xFF, 0xFF, 0x00 // White
|
||||
};
|
||||
for (const uint8_t b : palette) {
|
||||
bmpOut.write(b);
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-bit pixel buffer that can render text, icons, and shapes, then write as BMP.
|
||||
class PixelBuffer {
|
||||
public:
|
||||
PixelBuffer(int width, int height) : width(width), height(height) {
|
||||
bytesPerRow = (width + 31) / 32 * 4;
|
||||
bufferSize = bytesPerRow * height;
|
||||
buffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||
if (buffer) {
|
||||
memset(buffer, 0xFF, bufferSize); // White background
|
||||
}
|
||||
}
|
||||
|
||||
~PixelBuffer() {
|
||||
if (buffer) {
|
||||
free(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
bool isValid() const { return buffer != nullptr; }
|
||||
|
||||
/// Set a pixel to black.
|
||||
void setBlack(int x, int y) {
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) return;
|
||||
const int byteIndex = y * bytesPerRow + x / 8;
|
||||
const uint8_t bitMask = 0x80 >> (x % 8);
|
||||
buffer[byteIndex] &= ~bitMask;
|
||||
}
|
||||
|
||||
/// Set a scaled "pixel" (scale x scale block) to black.
|
||||
void setBlackScaled(int x, int y, int scale) {
|
||||
for (int dy = 0; dy < scale; dy++) {
|
||||
for (int dx = 0; dx < scale; dx++) {
|
||||
setBlack(x + dx, y + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a filled rectangle in black.
|
||||
void fillRect(int x, int y, int w, int h) {
|
||||
for (int row = y; row < y + h && row < height; row++) {
|
||||
for (int col = x; col < x + w && col < width; col++) {
|
||||
setBlack(col, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a rectangular border in black.
|
||||
void drawBorder(int x, int y, int w, int h, int thickness) {
|
||||
fillRect(x, y, w, thickness); // Top
|
||||
fillRect(x, y + h - thickness, w, thickness); // Bottom
|
||||
fillRect(x, y, thickness, h); // Left
|
||||
fillRect(x + w - thickness, y, thickness, h); // Right
|
||||
}
|
||||
|
||||
/// Draw a horizontal line in black with configurable thickness.
|
||||
void drawHLine(int x, int y, int length, int thickness = 1) {
|
||||
fillRect(x, y, length, thickness);
|
||||
}
|
||||
|
||||
/// Render a single glyph at (cursorX, baselineY) with integer scaling. Returns advance in X (scaled).
|
||||
int renderGlyph(const EpdFontData* font, uint32_t codepoint, int cursorX, int baselineY, int scale = 1) {
|
||||
const EpdFont fontObj(font);
|
||||
const EpdGlyph* glyph = fontObj.getGlyph(codepoint);
|
||||
if (!glyph) {
|
||||
glyph = fontObj.getGlyph(REPLACEMENT_GLYPH);
|
||||
}
|
||||
if (!glyph) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const uint8_t* bitmap = &font->bitmap[glyph->dataOffset];
|
||||
const int glyphW = glyph->width;
|
||||
const int glyphH = glyph->height;
|
||||
|
||||
for (int gy = 0; gy < glyphH; gy++) {
|
||||
const int screenY = baselineY - glyph->top * scale + gy * scale;
|
||||
for (int gx = 0; gx < glyphW; gx++) {
|
||||
const int pixelPos = gy * glyphW + gx;
|
||||
const int screenX = cursorX + glyph->left * scale + gx * scale;
|
||||
|
||||
bool isSet = false;
|
||||
if (font->is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPos / 4];
|
||||
const uint8_t bitIndex = (3 - pixelPos % 4) * 2;
|
||||
const uint8_t val = 3 - ((byte >> bitIndex) & 0x3);
|
||||
isSet = (val < 3);
|
||||
} else {
|
||||
const uint8_t byte = bitmap[pixelPos / 8];
|
||||
const uint8_t bitIndex = 7 - (pixelPos % 8);
|
||||
isSet = ((byte >> bitIndex) & 1);
|
||||
}
|
||||
|
||||
if (isSet) {
|
||||
setBlackScaled(screenX, screenY, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return glyph->advanceX * scale;
|
||||
}
|
||||
|
||||
/// Render a UTF-8 string at (x, y) where y is the top of the text line, with integer scaling.
|
||||
void drawText(const EpdFontData* font, int x, int y, const char* text, int scale = 1) {
|
||||
const int baselineY = y + font->ascender * scale;
|
||||
int cursorX = x;
|
||||
uint32_t cp;
|
||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||
cursorX += renderGlyph(font, cp, cursorX, baselineY, scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a 1-bit icon bitmap (MSB first, 0=black, 1=white) with integer scaling.
|
||||
void drawIcon(const uint8_t* icon, int iconW, int iconH, int x, int y, int scale = 1) {
|
||||
const int bytesPerIconRow = iconW / 8;
|
||||
for (int iy = 0; iy < iconH; iy++) {
|
||||
for (int ix = 0; ix < iconW; ix++) {
|
||||
const int byteIdx = iy * bytesPerIconRow + ix / 8;
|
||||
const uint8_t bitMask = 0x80 >> (ix % 8);
|
||||
// In the icon data: 0 = black (drawn), 1 = white (skip)
|
||||
if (!(icon[byteIdx] & bitMask)) {
|
||||
const int sx = x + ix * scale;
|
||||
const int sy = y + iy * scale;
|
||||
setBlackScaled(sx, sy, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the pixel buffer to a file as a 1-bit BMP.
|
||||
bool writeBmp(Print& out) const {
|
||||
if (!buffer) return false;
|
||||
writeBmpHeader1bit(out, width, height);
|
||||
out.write(buffer, bufferSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
int getWidth() const { return width; }
|
||||
int getHeight() const { return height; }
|
||||
|
||||
private:
|
||||
int width;
|
||||
int height;
|
||||
int bytesPerRow;
|
||||
size_t bufferSize;
|
||||
uint8_t* buffer;
|
||||
};
|
||||
|
||||
/// Measure the width of a UTF-8 string in pixels (at 1x scale).
|
||||
int measureTextWidth(const EpdFontData* font, const char* text) {
|
||||
const EpdFont fontObj(font);
|
||||
int w = 0, h = 0;
|
||||
fontObj.getTextDimensions(text, &w, &h);
|
||||
return w;
|
||||
}
|
||||
|
||||
/// Get the advance width of a single character.
|
||||
int getCharAdvance(const EpdFontData* font, uint32_t cp) {
|
||||
const EpdFont fontObj(font);
|
||||
const EpdGlyph* glyph = fontObj.getGlyph(cp);
|
||||
if (!glyph) return 0;
|
||||
return glyph->advanceX;
|
||||
}
|
||||
|
||||
/// Split a string into words (splitting on spaces).
|
||||
std::vector<std::string> splitWords(const std::string& text) {
|
||||
std::vector<std::string> words;
|
||||
std::string current;
|
||||
for (size_t i = 0; i < text.size(); i++) {
|
||||
if (text[i] == ' ') {
|
||||
if (!current.empty()) {
|
||||
words.push_back(current);
|
||||
current.clear();
|
||||
}
|
||||
} else {
|
||||
current += text[i];
|
||||
}
|
||||
}
|
||||
if (!current.empty()) {
|
||||
words.push_back(current);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
/// Word-wrap text into lines that fit within maxWidth pixels at the given scale.
|
||||
std::vector<std::string> wrapText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
|
||||
std::vector<std::string> lines;
|
||||
const auto words = splitWords(text);
|
||||
if (words.empty()) return lines;
|
||||
|
||||
const int spaceWidth = getCharAdvance(font, ' ') * scale;
|
||||
std::string currentLine;
|
||||
int currentWidth = 0;
|
||||
|
||||
for (const auto& word : words) {
|
||||
const int wordWidth = measureTextWidth(font, word.c_str()) * scale;
|
||||
|
||||
if (currentLine.empty()) {
|
||||
currentLine = word;
|
||||
currentWidth = wordWidth;
|
||||
} else if (currentWidth + spaceWidth + wordWidth <= maxWidth) {
|
||||
currentLine += " " + word;
|
||||
currentWidth += spaceWidth + wordWidth;
|
||||
} else {
|
||||
lines.push_back(currentLine);
|
||||
currentLine = word;
|
||||
currentWidth = wordWidth;
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLine.empty()) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// Truncate a string with "..." if it exceeds maxWidth pixels at the given scale.
|
||||
std::string truncateText(const EpdFontData* font, const std::string& text, int maxWidth, int scale = 1) {
|
||||
if (measureTextWidth(font, text.c_str()) * scale <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string truncated = text;
|
||||
const char* ellipsis = "...";
|
||||
const int ellipsisWidth = measureTextWidth(font, ellipsis) * scale;
|
||||
|
||||
while (!truncated.empty()) {
|
||||
utf8RemoveLastChar(truncated);
|
||||
if (measureTextWidth(font, truncated.c_str()) * scale + ellipsisWidth <= maxWidth) {
|
||||
return truncated + ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
return ellipsis;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PlaceholderCoverGenerator::generate(const std::string& outputPath, const std::string& title,
|
||||
const std::string& author, int width, int height) {
|
||||
LOG_DBG("PHC", "Generating placeholder cover %dx%d: \"%s\" by \"%s\"", width, height, title.c_str(), author.c_str());
|
||||
|
||||
const EpdFontData* titleFont = &ubuntu_12_bold;
|
||||
const EpdFontData* authorFont = &ubuntu_10_regular;
|
||||
|
||||
PixelBuffer buf(width, height);
|
||||
if (!buf.isValid()) {
|
||||
LOG_ERR("PHC", "Failed to allocate %dx%d pixel buffer (%d bytes)", width, height,
|
||||
(width + 31) / 32 * 4 * height);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Proportional layout constants based on cover dimensions.
|
||||
// The device bezel covers ~2-3px on each edge, so we pad inward from the edge.
|
||||
const int edgePadding = std::max(3, width / 48); // ~10px at 480w, ~3px at 136w
|
||||
const int borderWidth = std::max(2, width / 96); // ~5px at 480w, ~2px at 136w
|
||||
const int innerPadding = std::max(4, width / 32); // ~15px at 480w, ~4px at 136w
|
||||
|
||||
// Text scaling: 2x for full-size covers, 1x for thumbnails
|
||||
const int titleScale = (height >= 600) ? 2 : 1;
|
||||
const int authorScale = (height >= 600) ? 2 : 1; // Author also larger on full covers
|
||||
// Icon: 2x for full cover, 1x for medium thumb, skip for small
|
||||
const int iconScale = (height >= 600) ? 2 : (height >= 350 ? 1 : 0);
|
||||
|
||||
// Draw border inset from edge
|
||||
buf.drawBorder(edgePadding, edgePadding, width - 2 * edgePadding, height - 2 * edgePadding, borderWidth);
|
||||
|
||||
// Content area (inside border + inner padding)
|
||||
const int contentX = edgePadding + borderWidth + innerPadding;
|
||||
const int contentY = edgePadding + borderWidth + innerPadding;
|
||||
const int contentW = width - 2 * contentX;
|
||||
const int contentH = height - 2 * contentY;
|
||||
|
||||
if (contentW <= 0 || contentH <= 0) {
|
||||
LOG_ERR("PHC", "Cover too small for content (%dx%d)", width, height);
|
||||
FsFile file;
|
||||
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
|
||||
return false;
|
||||
}
|
||||
buf.writeBmp(file);
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Layout zones ---
|
||||
// Title zone: top 2/3 of content area (icon + title)
|
||||
// Author zone: bottom 1/3 of content area
|
||||
const int titleZoneH = contentH * 2 / 3;
|
||||
const int authorZoneH = contentH - titleZoneH;
|
||||
const int authorZoneY = contentY + titleZoneH;
|
||||
|
||||
// --- Separator line at the zone boundary ---
|
||||
const int separatorWidth = contentW / 3;
|
||||
const int separatorX = contentX + (contentW - separatorWidth) / 2;
|
||||
buf.drawHLine(separatorX, authorZoneY, separatorWidth);
|
||||
|
||||
// --- Icon dimensions (needed for title text wrapping) ---
|
||||
const int iconW = (iconScale > 0) ? BOOK_ICON_WIDTH * iconScale : 0;
|
||||
const int iconGap = (iconScale > 0) ? std::max(8, width / 40) : 0; // Gap between icon and title text
|
||||
const int titleTextW = contentW - iconW - iconGap; // Title wraps in narrower area beside icon
|
||||
|
||||
// --- Prepare title text (wraps within the area to the right of the icon) ---
|
||||
const std::string displayTitle = title.empty() ? "Untitled" : title;
|
||||
auto titleLines = wrapText(titleFont, displayTitle, titleTextW, titleScale);
|
||||
|
||||
constexpr int MAX_TITLE_LINES = 5;
|
||||
if (static_cast<int>(titleLines.size()) > MAX_TITLE_LINES) {
|
||||
titleLines.resize(MAX_TITLE_LINES);
|
||||
titleLines.back() = truncateText(titleFont, titleLines.back(), titleTextW, titleScale);
|
||||
}
|
||||
|
||||
// --- Prepare author text (multi-line, max 3 lines) ---
|
||||
std::vector<std::string> authorLines;
|
||||
if (!author.empty()) {
|
||||
authorLines = wrapText(authorFont, author, contentW, authorScale);
|
||||
constexpr int MAX_AUTHOR_LINES = 3;
|
||||
if (static_cast<int>(authorLines.size()) > MAX_AUTHOR_LINES) {
|
||||
authorLines.resize(MAX_AUTHOR_LINES);
|
||||
authorLines.back() = truncateText(authorFont, authorLines.back(), contentW, authorScale);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Calculate title zone layout (icon LEFT of title) ---
|
||||
// Tighter line spacing so 2-3 title lines fit within the icon height
|
||||
const int titleLineH = titleFont->advanceY * titleScale * 3 / 4;
|
||||
const int iconH = (iconScale > 0) ? BOOK_ICON_HEIGHT * iconScale : 0;
|
||||
const int numTitleLines = static_cast<int>(titleLines.size());
|
||||
// Visual height: distance from top of first line to bottom of last line's glyphs.
|
||||
// Use ascender (not full advanceY) for the last line since trailing line-gap isn't visible.
|
||||
const int titleVisualH = (numTitleLines > 0)
|
||||
? (numTitleLines - 1) * titleLineH + titleFont->ascender * titleScale
|
||||
: 0;
|
||||
const int titleBlockH = std::max(iconH, titleVisualH); // Taller of icon or text
|
||||
|
||||
int titleStartY = contentY + (titleZoneH - titleBlockH) / 2;
|
||||
if (titleStartY < contentY) {
|
||||
titleStartY = contentY;
|
||||
}
|
||||
|
||||
// If title fits within icon height, center it vertically against the icon.
|
||||
// Otherwise top-align so extra lines overflow below.
|
||||
const int iconY = titleStartY;
|
||||
const int titleTextY = (iconH > 0 && titleVisualH <= iconH)
|
||||
? titleStartY + (iconH - titleVisualH) / 2
|
||||
: titleStartY;
|
||||
|
||||
// --- Horizontal centering: measure the widest title line, then center icon+gap+text block ---
|
||||
int maxTitleLineW = 0;
|
||||
for (const auto& line : titleLines) {
|
||||
const int w = measureTextWidth(titleFont, line.c_str()) * titleScale;
|
||||
if (w > maxTitleLineW) maxTitleLineW = w;
|
||||
}
|
||||
const int titleBlockW = iconW + iconGap + maxTitleLineW;
|
||||
const int titleBlockX = contentX + (contentW - titleBlockW) / 2;
|
||||
|
||||
// --- Draw icon ---
|
||||
if (iconScale > 0) {
|
||||
buf.drawIcon(BookIcon, BOOK_ICON_WIDTH, BOOK_ICON_HEIGHT, titleBlockX, iconY, iconScale);
|
||||
}
|
||||
|
||||
// --- Draw title lines (to the right of the icon) ---
|
||||
const int titleTextX = titleBlockX + iconW + iconGap;
|
||||
int currentY = titleTextY;
|
||||
for (const auto& line : titleLines) {
|
||||
buf.drawText(titleFont, titleTextX, currentY, line.c_str(), titleScale);
|
||||
currentY += titleLineH;
|
||||
}
|
||||
|
||||
// --- Draw author lines (centered vertically in bottom 1/3, centered horizontally) ---
|
||||
if (!authorLines.empty()) {
|
||||
const int authorLineH = authorFont->advanceY * authorScale;
|
||||
const int authorBlockH = static_cast<int>(authorLines.size()) * authorLineH;
|
||||
int authorStartY = authorZoneY + (authorZoneH - authorBlockH) / 2;
|
||||
if (authorStartY < authorZoneY + 4) {
|
||||
authorStartY = authorZoneY + 4; // Small gap below separator
|
||||
}
|
||||
|
||||
for (const auto& line : authorLines) {
|
||||
const int lineWidth = measureTextWidth(authorFont, line.c_str()) * authorScale;
|
||||
const int lineX = contentX + (contentW - lineWidth) / 2;
|
||||
buf.drawText(authorFont, lineX, authorStartY, line.c_str(), authorScale);
|
||||
authorStartY += authorLineH;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Write to file ---
|
||||
FsFile file;
|
||||
if (!Storage.openFileForWrite("PHC", outputPath, file)) {
|
||||
LOG_ERR("PHC", "Failed to open output file: %s", outputPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool success = buf.writeBmp(file);
|
||||
file.close();
|
||||
|
||||
if (success) {
|
||||
LOG_DBG("PHC", "Placeholder cover written to %s", outputPath.c_str());
|
||||
} else {
|
||||
LOG_ERR("PHC", "Failed to write placeholder BMP");
|
||||
Storage.remove(outputPath.c_str());
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal file
14
lib/PlaceholderCover/PlaceholderCoverGenerator.h
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
/// Generates simple 1-bit BMP placeholder covers with title/author text
|
||||
/// for books that have no embedded cover image.
|
||||
class PlaceholderCoverGenerator {
|
||||
public:
|
||||
/// Generate a placeholder cover BMP with title and author text.
|
||||
/// The BMP is written to outputPath as a 1-bit black-and-white image.
|
||||
/// Returns true if the file was written successfully.
|
||||
static bool generate(const std::string& outputPath, const std::string& title, const std::string& author, int width,
|
||||
int height);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
#pragma once
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <JpegToBmpConverter.h>
|
||||
#include <Logging.h>
|
||||
|
||||
Txt::Txt(std::string path, std::string cacheBasePath)
|
||||
: filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) {
|
||||
@@ -15,14 +16,14 @@ bool Txt::load() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!SdMan.exists(filepath.c_str())) {
|
||||
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str());
|
||||
if (!Storage.exists(filepath.c_str())) {
|
||||
LOG_ERR("TXT", "File does not exist: %s", filepath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("TXT", filepath, file)) {
|
||||
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str());
|
||||
if (!Storage.openFileForRead("TXT", filepath, file)) {
|
||||
LOG_ERR("TXT", "Failed to open file: %s", filepath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ bool Txt::load() {
|
||||
file.close();
|
||||
|
||||
loaded = true;
|
||||
Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize);
|
||||
LOG_DBG("TXT", "Loaded TXT file: %s (%zu bytes)", filepath.c_str(), fileSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -48,11 +49,11 @@ std::string Txt::getTitle() const {
|
||||
}
|
||||
|
||||
void Txt::setupCacheDir() const {
|
||||
if (!SdMan.exists(cacheBasePath.c_str())) {
|
||||
SdMan.mkdir(cacheBasePath.c_str());
|
||||
if (!Storage.exists(cacheBasePath.c_str())) {
|
||||
Storage.mkdir(cacheBasePath.c_str());
|
||||
}
|
||||
if (!SdMan.exists(cachePath.c_str())) {
|
||||
SdMan.mkdir(cachePath.c_str());
|
||||
if (!Storage.exists(cachePath.c_str())) {
|
||||
Storage.mkdir(cachePath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +74,8 @@ std::string Txt::findCoverImage() const {
|
||||
// First priority: look for image with same name as txt file (e.g., mybook.jpg)
|
||||
for (const auto& ext : extensions) {
|
||||
std::string coverPath = folder + "/" + baseName + ext;
|
||||
if (SdMan.exists(coverPath.c_str())) {
|
||||
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str());
|
||||
if (Storage.exists(coverPath.c_str())) {
|
||||
LOG_DBG("TXT", "Found matching cover image: %s", coverPath.c_str());
|
||||
return coverPath;
|
||||
}
|
||||
}
|
||||
@@ -84,8 +85,8 @@ std::string Txt::findCoverImage() const {
|
||||
for (const auto& name : coverNames) {
|
||||
for (const auto& ext : extensions) {
|
||||
std::string coverPath = folder + "/" + std::string(name) + ext;
|
||||
if (SdMan.exists(coverPath.c_str())) {
|
||||
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str());
|
||||
if (Storage.exists(coverPath.c_str())) {
|
||||
LOG_DBG("TXT", "Found fallback cover image: %s", coverPath.c_str());
|
||||
return coverPath;
|
||||
}
|
||||
}
|
||||
@@ -96,15 +97,18 @@ std::string Txt::findCoverImage() const {
|
||||
|
||||
std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
||||
|
||||
std::string Txt::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
||||
std::string Txt::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||
|
||||
bool Txt::generateCoverBmp() const {
|
||||
// Already generated, return true
|
||||
if (SdMan.exists(getCoverBmpPath().c_str())) {
|
||||
if (Storage.exists(getCoverBmpPath().c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string coverImagePath = findCoverImage();
|
||||
if (coverImagePath.empty()) {
|
||||
Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis());
|
||||
LOG_DBG("TXT", "No cover image found for TXT file");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -120,12 +124,12 @@ bool Txt::generateCoverBmp() const {
|
||||
|
||||
if (isBmp) {
|
||||
// Copy BMP file to cache
|
||||
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis());
|
||||
LOG_DBG("TXT", "Copying BMP cover image to cache");
|
||||
FsFile src, dst;
|
||||
if (!SdMan.openFileForRead("TXT", coverImagePath, src)) {
|
||||
if (!Storage.openFileForRead("TXT", coverImagePath, src)) {
|
||||
return false;
|
||||
}
|
||||
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
|
||||
if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
|
||||
src.close();
|
||||
return false;
|
||||
}
|
||||
@@ -136,18 +140,18 @@ bool Txt::generateCoverBmp() const {
|
||||
}
|
||||
src.close();
|
||||
dst.close();
|
||||
Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis());
|
||||
LOG_DBG("TXT", "Copied BMP cover to cache");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isJpg) {
|
||||
// Convert JPG/JPEG to BMP (same approach as Epub)
|
||||
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis());
|
||||
LOG_DBG("TXT", "Generating BMP from JPG cover image");
|
||||
FsFile coverJpg, coverBmp;
|
||||
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) {
|
||||
if (!Storage.openFileForRead("TXT", coverImagePath, coverJpg)) {
|
||||
return false;
|
||||
}
|
||||
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
|
||||
if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
@@ -156,16 +160,16 @@ bool Txt::generateCoverBmp() const {
|
||||
coverBmp.close();
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis());
|
||||
SdMan.remove(getCoverBmpPath().c_str());
|
||||
LOG_ERR("TXT", "Failed to generate BMP from JPG cover image");
|
||||
Storage.remove(getCoverBmpPath().c_str());
|
||||
} else {
|
||||
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis());
|
||||
LOG_DBG("TXT", "Generated BMP from JPG cover image");
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// PNG files are not supported (would need a PNG decoder)
|
||||
Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis());
|
||||
LOG_ERR("TXT", "Cover image format not supported (only BMP/JPG/JPEG)");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -175,7 +179,7 @@ bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("TXT", filepath, file)) {
|
||||
if (!Storage.openFileForRead("TXT", filepath, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -28,6 +28,10 @@ class Txt {
|
||||
[[nodiscard]] bool generateCoverBmp() const;
|
||||
[[nodiscard]] std::string findCoverImage() const;
|
||||
|
||||
// Thumbnail paths (matching Epub/Xtc pattern for home screen covers)
|
||||
[[nodiscard]] std::string getThumbBmpPath() const;
|
||||
[[nodiscard]] std::string getThumbBmpPath(int height) const;
|
||||
|
||||
// Read content from file
|
||||
[[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const;
|
||||
};
|
||||
|
||||
@@ -29,3 +29,20 @@ uint32_t utf8NextCodepoint(const unsigned char** string) {
|
||||
|
||||
return cp;
|
||||
}
|
||||
|
||||
size_t utf8RemoveLastChar(std::string& str) {
|
||||
if (str.empty()) return 0;
|
||||
size_t pos = str.size() - 1;
|
||||
while (pos > 0 && (static_cast<unsigned char>(str[pos]) & 0xC0) == 0x80) {
|
||||
--pos;
|
||||
}
|
||||
str.resize(pos);
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Truncate string by removing N UTF-8 characters from the end
|
||||
void utf8TruncateChars(std::string& str, const size_t numChars) {
|
||||
for (size_t i = 0; i < numChars && !str.empty(); ++i) {
|
||||
utf8RemoveLastChar(str);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <string>
|
||||
#define REPLACEMENT_GLYPH 0xFFFD
|
||||
|
||||
uint32_t utf8NextCodepoint(const unsigned char** string);
|
||||
// Remove the last UTF-8 codepoint from a std::string and return the new size.
|
||||
size_t utf8RemoveLastChar(std::string& str);
|
||||
// Truncate string by removing N UTF-8 codepoints from the end.
|
||||
void utf8TruncateChars(std::string& str, size_t numChars);
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
|
||||
#include "Xtc.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
bool Xtc::load() {
|
||||
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str());
|
||||
LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str());
|
||||
|
||||
// Initialize parser
|
||||
parser.reset(new xtc::XtcParser());
|
||||
@@ -19,43 +19,43 @@ bool Xtc::load() {
|
||||
// Open XTC file
|
||||
xtc::XtcError err = parser->open(filepath.c_str());
|
||||
if (err != xtc::XtcError::OK) {
|
||||
Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err));
|
||||
LOG_ERR("XTC", "Failed to load: %s", xtc::errorToString(err));
|
||||
parser.reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
loaded = true;
|
||||
Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount());
|
||||
LOG_DBG("XTC", "Loaded XTC: %s (%lu pages)", filepath.c_str(), parser->getPageCount());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Xtc::clearCache() const {
|
||||
if (!SdMan.exists(cachePath.c_str())) {
|
||||
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis());
|
||||
if (!Storage.exists(cachePath.c_str())) {
|
||||
LOG_DBG("XTC", "Cache does not exist, no action needed");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!SdMan.removeDir(cachePath.c_str())) {
|
||||
Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis());
|
||||
if (!Storage.removeDir(cachePath.c_str())) {
|
||||
LOG_ERR("XTC", "Failed to clear cache");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis());
|
||||
LOG_DBG("XTC", "Cache cleared successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
void Xtc::setupCacheDir() const {
|
||||
if (SdMan.exists(cachePath.c_str())) {
|
||||
if (Storage.exists(cachePath.c_str())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create directories recursively
|
||||
for (size_t i = 1; i < cachePath.length(); i++) {
|
||||
if (cachePath[i] == '/') {
|
||||
SdMan.mkdir(cachePath.substr(0, i).c_str());
|
||||
Storage.mkdir(cachePath.substr(0, i).c_str());
|
||||
}
|
||||
}
|
||||
SdMan.mkdir(cachePath.c_str());
|
||||
Storage.mkdir(cachePath.c_str());
|
||||
}
|
||||
|
||||
std::string Xtc::getTitle() const {
|
||||
@@ -114,17 +114,17 @@ std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
|
||||
|
||||
bool Xtc::generateCoverBmp() const {
|
||||
// Already generated
|
||||
if (SdMan.exists(getCoverBmpPath().c_str())) {
|
||||
if (Storage.exists(getCoverBmpPath().c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!loaded || !parser) {
|
||||
Serial.printf("[%lu] [XTC] Cannot generate cover BMP, file not loaded\n", millis());
|
||||
LOG_ERR("XTC", "Cannot generate cover BMP, file not loaded");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parser->getPageCount() == 0) {
|
||||
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis());
|
||||
LOG_ERR("XTC", "No pages in XTC file");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ bool Xtc::generateCoverBmp() const {
|
||||
// Get first page info for cover
|
||||
xtc::PageInfo pageInfo;
|
||||
if (!parser->getPageInfo(0, pageInfo)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis());
|
||||
LOG_DBG("XTC", "Failed to get first page info");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -152,22 +152,22 @@ bool Xtc::generateCoverBmp() const {
|
||||
}
|
||||
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
|
||||
if (!pageBuffer) {
|
||||
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
|
||||
LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load first page (cover)
|
||||
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
|
||||
if (bytesRead == 0) {
|
||||
Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis());
|
||||
LOG_ERR("XTC", "Failed to load cover page");
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create BMP file
|
||||
FsFile coverBmp;
|
||||
if (!SdMan.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis());
|
||||
if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
|
||||
LOG_DBG("XTC", "Failed to create cover BMP file");
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
}
|
||||
@@ -297,25 +297,26 @@ bool Xtc::generateCoverBmp() const {
|
||||
coverBmp.close();
|
||||
free(pageBuffer);
|
||||
|
||||
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str());
|
||||
LOG_DBG("XTC", "Generated cover BMP: %s", getCoverBmpPath().c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
|
||||
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
|
||||
std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
|
||||
|
||||
bool Xtc::generateThumbBmp() const {
|
||||
bool Xtc::generateThumbBmp(int height) const {
|
||||
// Already generated
|
||||
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
||||
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!loaded || !parser) {
|
||||
Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis());
|
||||
LOG_ERR("XTC", "Cannot generate thumb BMP, file not loaded");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parser->getPageCount() == 0) {
|
||||
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis());
|
||||
LOG_ERR("XTC", "No pages in XTC file");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -325,7 +326,7 @@ bool Xtc::generateThumbBmp() const {
|
||||
// Get first page info for cover
|
||||
xtc::PageInfo pageInfo;
|
||||
if (!parser->getPageInfo(0, pageInfo)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis());
|
||||
LOG_DBG("XTC", "Failed to get first page info");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -333,13 +334,13 @@ bool Xtc::generateThumbBmp() const {
|
||||
const uint8_t bitDepth = parser->getBitDepth();
|
||||
|
||||
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
|
||||
constexpr int THUMB_TARGET_WIDTH = 240;
|
||||
constexpr int THUMB_TARGET_HEIGHT = 400;
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
|
||||
// Calculate scale factor
|
||||
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
|
||||
float scaleY = static_cast<float>(THUMB_TARGET_HEIGHT) / pageInfo.height;
|
||||
float scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||
float scale = (scaleX > scaleY) ? scaleX : scaleY; // for cropping
|
||||
|
||||
// Only scale down, never up
|
||||
if (scale >= 1.0f) {
|
||||
@@ -347,8 +348,8 @@ bool Xtc::generateThumbBmp() const {
|
||||
// Copy cover.bmp to thumb.bmp
|
||||
if (generateCoverBmp()) {
|
||||
FsFile src, dst;
|
||||
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
|
||||
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
|
||||
if (Storage.openFileForRead("XTC", getCoverBmpPath(), src)) {
|
||||
if (Storage.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
|
||||
uint8_t buffer[512];
|
||||
while (src.available()) {
|
||||
size_t bytesRead = src.read(buffer, sizeof(buffer));
|
||||
@@ -358,8 +359,8 @@ bool Xtc::generateThumbBmp() const {
|
||||
}
|
||||
src.close();
|
||||
}
|
||||
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
|
||||
return SdMan.exists(getThumbBmpPath().c_str());
|
||||
LOG_DBG("XTC", "Copied cover to thumb (no scaling needed)");
|
||||
return Storage.exists(getThumbBmpPath(height).c_str());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -367,8 +368,8 @@ bool Xtc::generateThumbBmp() const {
|
||||
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
|
||||
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
|
||||
|
||||
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width,
|
||||
pageInfo.height, thumbWidth, thumbHeight, scale);
|
||||
LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth,
|
||||
thumbHeight, scale);
|
||||
|
||||
// Allocate buffer for page data
|
||||
size_t bitmapSize;
|
||||
@@ -379,22 +380,22 @@ bool Xtc::generateThumbBmp() const {
|
||||
}
|
||||
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
|
||||
if (!pageBuffer) {
|
||||
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize);
|
||||
LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load first page (cover)
|
||||
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
|
||||
if (bytesRead == 0) {
|
||||
Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis());
|
||||
LOG_ERR("XTC", "Failed to load cover page for thumb");
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
|
||||
FsFile thumbBmp;
|
||||
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
|
||||
if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) {
|
||||
LOG_DBG("XTC", "Failed to create thumb BMP file");
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
}
|
||||
@@ -557,8 +558,7 @@ bool Xtc::generateThumbBmp() const {
|
||||
thumbBmp.close();
|
||||
free(pageBuffer);
|
||||
|
||||
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
|
||||
getThumbBmpPath().c_str());
|
||||
LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ class Xtc {
|
||||
bool generateCoverBmp() const;
|
||||
// Thumbnail support (for Continue Reading card)
|
||||
std::string getThumbBmpPath() const;
|
||||
bool generateThumbBmp() const;
|
||||
std::string getThumbBmpPath(int height) const;
|
||||
bool generateThumbBmp(int height) const;
|
||||
|
||||
// Page access
|
||||
uint32_t getPageCount() const;
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
#include "XtcParser.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
@@ -34,7 +34,7 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
}
|
||||
|
||||
// Open file
|
||||
if (!SdMan.openFileForRead("XTC", filepath, m_file)) {
|
||||
if (!Storage.openFileForRead("XTC", filepath, m_file)) {
|
||||
m_lastError = XtcError::FILE_NOT_FOUND;
|
||||
return m_lastError;
|
||||
}
|
||||
@@ -42,7 +42,7 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
// Read header
|
||||
m_lastError = readHeader();
|
||||
if (m_lastError != XtcError::OK) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read header: %s\n", millis(), errorToString(m_lastError));
|
||||
LOG_DBG("XTC", "Failed to read header: %s", errorToString(m_lastError));
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
@@ -51,13 +51,13 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
if (m_header.hasMetadata) {
|
||||
m_lastError = readTitle();
|
||||
if (m_lastError != XtcError::OK) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read title: %s\n", millis(), errorToString(m_lastError));
|
||||
LOG_DBG("XTC", "Failed to read title: %s", errorToString(m_lastError));
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
m_lastError = readAuthor();
|
||||
if (m_lastError != XtcError::OK) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read author: %s\n", millis(), errorToString(m_lastError));
|
||||
LOG_DBG("XTC", "Failed to read author: %s", errorToString(m_lastError));
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
// Read page table
|
||||
m_lastError = readPageTable();
|
||||
if (m_lastError != XtcError::OK) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read page table: %s\n", millis(), errorToString(m_lastError));
|
||||
LOG_DBG("XTC", "Failed to read page table: %s", errorToString(m_lastError));
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
@@ -74,14 +74,13 @@ XtcError XtcParser::open(const char* filepath) {
|
||||
// Read chapters if present
|
||||
m_lastError = readChapters();
|
||||
if (m_lastError != XtcError::OK) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read chapters: %s\n", millis(), errorToString(m_lastError));
|
||||
LOG_DBG("XTC", "Failed to read chapters: %s", errorToString(m_lastError));
|
||||
m_file.close();
|
||||
return m_lastError;
|
||||
}
|
||||
|
||||
m_isOpen = true;
|
||||
Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount,
|
||||
m_defaultWidth, m_defaultHeight);
|
||||
LOG_DBG("XTC", "Opened file: %s (%u pages, %dx%d)", filepath, m_header.pageCount, m_defaultWidth, m_defaultHeight);
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
@@ -106,8 +105,7 @@ XtcError XtcParser::readHeader() {
|
||||
|
||||
// Verify magic number (accept both XTC and XTCH)
|
||||
if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
|
||||
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic,
|
||||
XTC_MAGIC, XTCH_MAGIC);
|
||||
LOG_DBG("XTC", "Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)", m_header.magic, XTC_MAGIC, XTCH_MAGIC);
|
||||
return XtcError::INVALID_MAGIC;
|
||||
}
|
||||
|
||||
@@ -120,7 +118,7 @@ XtcError XtcParser::readHeader() {
|
||||
const bool validVersion = m_header.versionMajor == 1 && m_header.versionMinor == 0 ||
|
||||
m_header.versionMajor == 0 && m_header.versionMinor == 1;
|
||||
if (!validVersion) {
|
||||
Serial.printf("[%lu] [XTC] Unsupported version: %u.%u\n", millis(), m_header.versionMajor, m_header.versionMinor);
|
||||
LOG_DBG("XTC", "Unsupported version: %u.%u", m_header.versionMajor, m_header.versionMinor);
|
||||
return XtcError::INVALID_VERSION;
|
||||
}
|
||||
|
||||
@@ -129,9 +127,9 @@ XtcError XtcParser::readHeader() {
|
||||
return XtcError::CORRUPTED_HEADER;
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u.%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic,
|
||||
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.versionMajor, m_header.versionMinor,
|
||||
m_header.pageCount, m_bitDepth);
|
||||
LOG_DBG("XTC", "Header: magic=0x%08X (%s), ver=%u.%u, pages=%u, bitDepth=%u", m_header.magic,
|
||||
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.versionMajor, m_header.versionMinor,
|
||||
m_header.pageCount, m_bitDepth);
|
||||
|
||||
return XtcError::OK;
|
||||
}
|
||||
@@ -146,7 +144,7 @@ XtcError XtcParser::readTitle() {
|
||||
m_file.read(titleBuf, sizeof(titleBuf) - 1);
|
||||
m_title = titleBuf;
|
||||
|
||||
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
|
||||
LOG_DBG("XTC", "Title: %s", m_title.c_str());
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
@@ -161,19 +159,19 @@ XtcError XtcParser::readAuthor() {
|
||||
m_file.read(authorBuf, sizeof(authorBuf) - 1);
|
||||
m_author = authorBuf;
|
||||
|
||||
Serial.printf("[%lu] [XTC] Author: %s\n", millis(), m_author.c_str());
|
||||
LOG_DBG("XTC", "Author: %s", m_author.c_str());
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
XtcError XtcParser::readPageTable() {
|
||||
if (m_header.pageTableOffset == 0) {
|
||||
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());
|
||||
LOG_DBG("XTC", "Page table offset is 0, cannot read");
|
||||
return XtcError::CORRUPTED_HEADER;
|
||||
}
|
||||
|
||||
// Seek to page table
|
||||
if (!m_file.seek(m_header.pageTableOffset)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset);
|
||||
LOG_DBG("XTC", "Failed to seek to page table at %llu", m_header.pageTableOffset);
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
@@ -184,7 +182,7 @@ XtcError XtcParser::readPageTable() {
|
||||
PageTableEntry entry;
|
||||
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
|
||||
if (bytesRead != sizeof(PageTableEntry)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i);
|
||||
LOG_DBG("XTC", "Failed to read page table entry %u", i);
|
||||
return XtcError::READ_ERROR;
|
||||
}
|
||||
|
||||
@@ -201,7 +199,7 @@ XtcError XtcParser::readPageTable() {
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [XTC] Read %u page table entries\n", millis(), m_header.pageCount);
|
||||
LOG_DBG("XTC", "Read %u page table entries", m_header.pageCount);
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
@@ -307,7 +305,7 @@ XtcError XtcParser::readChapters() {
|
||||
}
|
||||
|
||||
m_hasChapters = !m_chapters.empty();
|
||||
Serial.printf("[%lu] [XTC] Chapters: %u\n", millis(), static_cast<unsigned int>(m_chapters.size()));
|
||||
LOG_DBG("XTC", "Chapters: %u", static_cast<unsigned int>(m_chapters.size()));
|
||||
return XtcError::OK;
|
||||
}
|
||||
|
||||
@@ -334,7 +332,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
|
||||
|
||||
// Seek to page data
|
||||
if (!m_file.seek(page.offset)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset);
|
||||
LOG_DBG("XTC", "Failed to seek to page %u at offset %lu", pageIndex, page.offset);
|
||||
m_lastError = XtcError::READ_ERROR;
|
||||
return 0;
|
||||
}
|
||||
@@ -343,7 +341,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
|
||||
XtgPageHeader pageHeader;
|
||||
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
|
||||
if (headerRead != sizeof(XtgPageHeader)) {
|
||||
Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex);
|
||||
LOG_DBG("XTC", "Failed to read page header for page %u", pageIndex);
|
||||
m_lastError = XtcError::READ_ERROR;
|
||||
return 0;
|
||||
}
|
||||
@@ -351,8 +349,8 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
|
||||
// Verify page magic (XTG for 1-bit, XTH for 2-bit)
|
||||
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
|
||||
if (pageHeader.magic != expectedMagic) {
|
||||
Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex,
|
||||
pageHeader.magic, expectedMagic);
|
||||
LOG_DBG("XTC", "Invalid page magic for page %u: 0x%08X (expected 0x%08X)", pageIndex, pageHeader.magic,
|
||||
expectedMagic);
|
||||
m_lastError = XtcError::INVALID_MAGIC;
|
||||
return 0;
|
||||
}
|
||||
@@ -370,7 +368,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
|
||||
|
||||
// Check buffer size
|
||||
if (bufferSize < bitmapSize) {
|
||||
Serial.printf("[%lu] [XTC] Buffer too small: need %u, have %u\n", millis(), bitmapSize, bufferSize);
|
||||
LOG_DBG("XTC", "Buffer too small: need %u, have %u", bitmapSize, bufferSize);
|
||||
m_lastError = XtcError::MEMORY_ERROR;
|
||||
return 0;
|
||||
}
|
||||
@@ -378,7 +376,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
|
||||
// Read bitmap data
|
||||
size_t bytesRead = m_file.read(buffer, bitmapSize);
|
||||
if (bytesRead != bitmapSize) {
|
||||
Serial.printf("[%lu] [XTC] Page read error: expected %u, got %u\n", millis(), bitmapSize, bytesRead);
|
||||
LOG_DBG("XTC", "Page read error: expected %u, got %u", bitmapSize, bytesRead);
|
||||
m_lastError = XtcError::READ_ERROR;
|
||||
return 0;
|
||||
}
|
||||
@@ -444,7 +442,7 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
|
||||
|
||||
bool XtcParser::isValidXtcFile(const char* filepath) {
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("XTC", filepath, file)) {
|
||||
if (!Storage.openFileForRead("XTC", filepath, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "ZipFile.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <miniz.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -10,7 +10,7 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t*
|
||||
// Setup inflator
|
||||
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||
if (!inflator) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for inflator");
|
||||
return false;
|
||||
}
|
||||
memset(inflator, 0, sizeof(tinfl_decompressor));
|
||||
@@ -23,7 +23,7 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t*
|
||||
free(inflator);
|
||||
|
||||
if (status != TINFL_STATUS_DONE) {
|
||||
Serial.printf("[%lu] [ZIP] tinfl_decompress() failed with status %d\n", millis(), status);
|
||||
LOG_ERR("ZIP", "tinfl_decompress() failed with status %d", status);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -195,13 +195,13 @@ long ZipFile::getDataOffset(const FileStatSlim& fileStat) {
|
||||
}
|
||||
|
||||
if (read != localHeaderSize) {
|
||||
Serial.printf("[%lu] [ZIP] Something went wrong reading the local header\n", millis());
|
||||
LOG_ERR("ZIP", "Something went wrong reading the local header");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (pLocalHeader[0] + (pLocalHeader[1] << 8) + (pLocalHeader[2] << 16) + (pLocalHeader[3] << 24) !=
|
||||
0x04034b50 /* MZ_ZIP_LOCAL_DIR_HEADER_SIG */) {
|
||||
Serial.printf("[%lu] [ZIP] Not a valid zip file header\n", millis());
|
||||
LOG_ERR("ZIP", "Not a valid zip file header");
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ bool ZipFile::loadZipDetails() {
|
||||
|
||||
const size_t fileSize = file.size();
|
||||
if (fileSize < 22) {
|
||||
Serial.printf("[%lu] [ZIP] File too small to be a valid zip\n", millis());
|
||||
LOG_ERR("ZIP", "File too small to be a valid zip");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -234,7 +234,7 @@ bool ZipFile::loadZipDetails() {
|
||||
const int scanRange = fileSize > 1024 ? 1024 : fileSize;
|
||||
const auto buffer = static_cast<uint8_t*>(malloc(scanRange));
|
||||
if (!buffer) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for EOCD scan buffer\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for EOCD scan buffer");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -255,7 +255,7 @@ bool ZipFile::loadZipDetails() {
|
||||
}
|
||||
|
||||
if (foundOffset == -1) {
|
||||
Serial.printf("[%lu] [ZIP] EOCD signature not found in zip file\n", millis());
|
||||
LOG_ERR("ZIP", "EOCD signature not found in zip file");
|
||||
free(buffer);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
@@ -279,7 +279,7 @@ bool ZipFile::loadZipDetails() {
|
||||
}
|
||||
|
||||
bool ZipFile::open() {
|
||||
if (!SdMan.openFileForRead("ZIP", filePath, file)) {
|
||||
if (!Storage.openFileForRead("ZIP", filePath, file)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -407,7 +407,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize;
|
||||
const auto data = static_cast<uint8_t*>(malloc(dataSize));
|
||||
if (data == nullptr) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for output buffer (%zu bytes)\n", millis(), dataSize);
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for output buffer (%zu bytes)", dataSize);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -422,7 +422,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
}
|
||||
|
||||
if (dataRead != inflatedDataSize) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to read data\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to read data");
|
||||
free(data);
|
||||
return nullptr;
|
||||
}
|
||||
@@ -432,7 +432,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
// Read out deflated content from file
|
||||
const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize));
|
||||
if (deflatedData == nullptr) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for decompression buffer\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -445,7 +445,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
}
|
||||
|
||||
if (dataRead != deflatedDataSize) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to read data, expected %d got %d\n", millis(), deflatedDataSize, dataRead);
|
||||
LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead);
|
||||
free(deflatedData);
|
||||
free(data);
|
||||
return nullptr;
|
||||
@@ -455,14 +455,14 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
free(deflatedData);
|
||||
|
||||
if (!success) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to inflate file\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to inflate file");
|
||||
free(data);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Continue out of block with data set
|
||||
} else {
|
||||
Serial.printf("[%lu] [ZIP] Unsupported compression method\n", millis());
|
||||
LOG_ERR("ZIP", "Unsupported compression method");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -498,7 +498,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
// no deflation, just read content
|
||||
const auto buffer = static_cast<uint8_t*>(malloc(chunkSize));
|
||||
if (!buffer) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for buffer\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for buffer");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -509,7 +509,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
while (remaining > 0) {
|
||||
const size_t dataRead = file.read(buffer, remaining < chunkSize ? remaining : chunkSize);
|
||||
if (dataRead == 0) {
|
||||
Serial.printf("[%lu] [ZIP] Could not read more bytes\n", millis());
|
||||
LOG_ERR("ZIP", "Could not read more bytes");
|
||||
free(buffer);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
@@ -532,7 +532,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
// Setup inflator
|
||||
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||
if (!inflator) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for inflator");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -544,7 +544,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
// Setup file read buffer
|
||||
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
|
||||
if (!fileReadBuffer) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer");
|
||||
free(inflator);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
@@ -554,7 +554,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
|
||||
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
|
||||
if (!outputBuffer) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to allocate memory for dictionary");
|
||||
free(inflator);
|
||||
free(fileReadBuffer);
|
||||
if (!wasOpen) {
|
||||
@@ -605,7 +605,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
if (outBytes > 0) {
|
||||
processedOutputBytes += outBytes;
|
||||
if (out.write(outputBuffer + outputCursor, outBytes) != outBytes) {
|
||||
Serial.printf("[%lu] [ZIP] Failed to write all output bytes to stream\n", millis());
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -619,7 +619,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
}
|
||||
|
||||
if (status < 0) {
|
||||
Serial.printf("[%lu] [ZIP] tinfl_decompress() failed with status %d\n", millis(), status);
|
||||
LOG_ERR("ZIP", "tinfl_decompress() failed with status %d", status);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -630,8 +630,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
}
|
||||
|
||||
if (status == TINFL_STATUS_DONE) {
|
||||
Serial.printf("[%lu] [ZIP] Decompressed %d bytes into %d bytes\n", millis(), deflatedDataSize,
|
||||
inflatedDataSize);
|
||||
LOG_ERR("ZIP", "Decompressed %d bytes into %d bytes", deflatedDataSize, inflatedDataSize);
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -643,7 +642,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
}
|
||||
|
||||
// If we get here, EOF reached without TINFL_STATUS_DONE
|
||||
Serial.printf("[%lu] [ZIP] Unexpected EOF\n", millis());
|
||||
LOG_ERR("ZIP", "Unexpected EOF");
|
||||
if (!wasOpen) {
|
||||
close();
|
||||
}
|
||||
@@ -657,6 +656,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
close();
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [ZIP] Unsupported compression method\n", millis());
|
||||
LOG_ERR("ZIP", "Unsupported compression method");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#pragma once
|
||||
#include <SdFat.h>
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
53
lib/hal/HalDisplay.cpp
Normal file
53
lib/hal/HalDisplay.cpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#include <HalDisplay.h>
|
||||
#include <HalGPIO.h>
|
||||
|
||||
#define SD_SPI_MISO 7
|
||||
|
||||
HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {}
|
||||
|
||||
HalDisplay::~HalDisplay() {}
|
||||
|
||||
void HalDisplay::begin() { einkDisplay.begin(); }
|
||||
|
||||
void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); }
|
||||
|
||||
void HalDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||
bool fromProgmem) const {
|
||||
einkDisplay.drawImage(imageData, x, y, w, h, fromProgmem);
|
||||
}
|
||||
|
||||
EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) {
|
||||
switch (mode) {
|
||||
case HalDisplay::FULL_REFRESH:
|
||||
return EInkDisplay::FULL_REFRESH;
|
||||
case HalDisplay::HALF_REFRESH:
|
||||
return EInkDisplay::HALF_REFRESH;
|
||||
case HalDisplay::FAST_REFRESH:
|
||||
default:
|
||||
return EInkDisplay::FAST_REFRESH;
|
||||
}
|
||||
}
|
||||
|
||||
void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
||||
}
|
||||
|
||||
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
|
||||
}
|
||||
|
||||
void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
|
||||
|
||||
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||
|
||||
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
||||
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||
}
|
||||
|
||||
void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); }
|
||||
|
||||
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
|
||||
|
||||
void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); }
|
||||
|
||||
void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); }
|
||||
52
lib/hal/HalDisplay.h
Normal file
52
lib/hal/HalDisplay.h
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <EInkDisplay.h>
|
||||
|
||||
class HalDisplay {
|
||||
public:
|
||||
// Constructor with pin configuration
|
||||
HalDisplay();
|
||||
|
||||
// Destructor
|
||||
~HalDisplay();
|
||||
|
||||
// Refresh modes
|
||||
enum RefreshMode {
|
||||
FULL_REFRESH, // Full refresh with complete waveform
|
||||
HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed
|
||||
FAST_REFRESH // Fast refresh using custom LUT
|
||||
};
|
||||
|
||||
// Initialize the display hardware and driver
|
||||
void begin();
|
||||
|
||||
// Display dimensions
|
||||
static constexpr uint16_t DISPLAY_WIDTH = EInkDisplay::DISPLAY_WIDTH;
|
||||
static constexpr uint16_t DISPLAY_HEIGHT = EInkDisplay::DISPLAY_HEIGHT;
|
||||
static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8;
|
||||
static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT;
|
||||
|
||||
// Frame buffer operations
|
||||
void clearScreen(uint8_t color = 0xFF) const;
|
||||
void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||
bool fromProgmem = false) const;
|
||||
|
||||
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
|
||||
// Power management
|
||||
void deepSleep();
|
||||
|
||||
// Access to frame buffer
|
||||
uint8_t* getFrameBuffer() const;
|
||||
|
||||
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
|
||||
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
|
||||
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
|
||||
void cleanupGrayscaleBuffers(const uint8_t* bwBuffer);
|
||||
|
||||
void displayGrayBuffer(bool turnOffScreen = false);
|
||||
|
||||
private:
|
||||
EInkDisplay einkDisplay;
|
||||
};
|
||||
45
lib/hal/HalGPIO.cpp
Normal file
45
lib/hal/HalGPIO.cpp
Normal file
@@ -0,0 +1,45 @@
|
||||
#include <HalGPIO.h>
|
||||
#include <SPI.h>
|
||||
|
||||
void HalGPIO::begin() {
|
||||
inputMgr.begin();
|
||||
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
||||
pinMode(UART0_RXD, INPUT);
|
||||
}
|
||||
|
||||
void HalGPIO::update() { inputMgr.update(); }
|
||||
|
||||
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }
|
||||
|
||||
bool HalGPIO::wasPressed(uint8_t buttonIndex) const { return inputMgr.wasPressed(buttonIndex); }
|
||||
|
||||
bool HalGPIO::wasAnyPressed() const { return inputMgr.wasAnyPressed(); }
|
||||
|
||||
bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleased(buttonIndex); }
|
||||
|
||||
bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
|
||||
|
||||
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||
|
||||
bool HalGPIO::isUsbConnected() const {
|
||||
// U0RXD/GPIO20 reads HIGH when USB is connected
|
||||
return digitalRead(UART0_RXD) == HIGH;
|
||||
}
|
||||
|
||||
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
||||
const bool usbConnected = isUsbConnected();
|
||||
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
||||
const auto resetReason = esp_reset_reason();
|
||||
|
||||
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
|
||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
|
||||
return WakeupReason::PowerButton;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
|
||||
return WakeupReason::AfterFlash;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) {
|
||||
return WakeupReason::AfterUSBPower;
|
||||
}
|
||||
return WakeupReason::Other;
|
||||
}
|
||||
56
lib/hal/HalGPIO.h
Normal file
56
lib/hal/HalGPIO.h
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <BatteryMonitor.h>
|
||||
#include <InputManager.h>
|
||||
|
||||
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
|
||||
#define EPD_SCLK 8 // SPI Clock
|
||||
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
|
||||
#define EPD_CS 21 // Chip Select
|
||||
#define EPD_DC 4 // Data/Command
|
||||
#define EPD_RST 5 // Reset
|
||||
#define EPD_BUSY 6 // Busy
|
||||
|
||||
#define SPI_MISO 7 // SPI MISO, shared between SD card and display (Master In Slave Out)
|
||||
|
||||
#define BAT_GPIO0 0 // Battery voltage
|
||||
|
||||
#define UART0_RXD 20 // Used for USB connection detection
|
||||
|
||||
class HalGPIO {
|
||||
#if CROSSPOINT_EMULATED == 0
|
||||
InputManager inputMgr;
|
||||
#endif
|
||||
|
||||
public:
|
||||
HalGPIO() = default;
|
||||
|
||||
// Start button GPIO and setup SPI for screen and SD card
|
||||
void begin();
|
||||
|
||||
// Button input methods
|
||||
void update();
|
||||
bool isPressed(uint8_t buttonIndex) const;
|
||||
bool wasPressed(uint8_t buttonIndex) const;
|
||||
bool wasAnyPressed() const;
|
||||
bool wasReleased(uint8_t buttonIndex) const;
|
||||
bool wasAnyReleased() const;
|
||||
unsigned long getHeldTime() const;
|
||||
|
||||
// Check if USB is connected
|
||||
bool isUsbConnected() const;
|
||||
|
||||
enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other };
|
||||
|
||||
WakeupReason getWakeupReason() const;
|
||||
|
||||
// Button indices
|
||||
static constexpr uint8_t BTN_BACK = 0;
|
||||
static constexpr uint8_t BTN_CONFIRM = 1;
|
||||
static constexpr uint8_t BTN_LEFT = 2;
|
||||
static constexpr uint8_t BTN_RIGHT = 3;
|
||||
static constexpr uint8_t BTN_UP = 4;
|
||||
static constexpr uint8_t BTN_DOWN = 5;
|
||||
static constexpr uint8_t BTN_POWER = 6;
|
||||
};
|
||||
49
lib/hal/HalPowerManager.cpp
Normal file
49
lib/hal/HalPowerManager.cpp
Normal file
@@ -0,0 +1,49 @@
|
||||
#include "HalPowerManager.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <esp_sleep.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
|
||||
void HalPowerManager::begin() {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
normalFreq = getCpuFrequencyMhz();
|
||||
}
|
||||
|
||||
void HalPowerManager::setPowerSaving(bool enabled) {
|
||||
if (normalFreq <= 0) {
|
||||
return; // invalid state
|
||||
}
|
||||
if (enabled && !isLowPower) {
|
||||
LOG_DBG("PWR", "Going to low-power mode");
|
||||
if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) {
|
||||
LOG_ERR("PWR", "Failed to set low-power CPU frequency");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!enabled && isLowPower) {
|
||||
LOG_DBG("PWR", "Restoring normal CPU frequency");
|
||||
if (!setCpuFrequencyMhz(normalFreq)) {
|
||||
LOG_ERR("PWR", "Failed to restore normal CPU frequency");
|
||||
return;
|
||||
}
|
||||
}
|
||||
isLowPower = enabled;
|
||||
}
|
||||
|
||||
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
||||
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||
delay(50);
|
||||
gpio.update();
|
||||
}
|
||||
// Arm the wakeup trigger *after* the button is released
|
||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
// Enter Deep Sleep
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
int HalPowerManager::getBatteryPercentage() const {
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
return battery.readPercentage();
|
||||
}
|
||||
27
lib/hal/HalPowerManager.h
Normal file
27
lib/hal/HalPowerManager.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <BatteryMonitor.h>
|
||||
#include <InputManager.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
|
||||
class HalPowerManager {
|
||||
int normalFreq = 0; // MHz
|
||||
bool isLowPower = false;
|
||||
|
||||
public:
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
|
||||
|
||||
void begin();
|
||||
|
||||
// Control CPU frequency for power saving
|
||||
void setPowerSaving(bool enabled);
|
||||
|
||||
// Setup wake up GPIO and enter deep sleep
|
||||
void startDeepSleep(HalGPIO& gpio) const;
|
||||
|
||||
// Get battery percentage (range 0-100)
|
||||
int getBatteryPercentage() const;
|
||||
};
|
||||
65
lib/hal/HalStorage.cpp
Normal file
65
lib/hal/HalStorage.cpp
Normal file
@@ -0,0 +1,65 @@
|
||||
#include "HalStorage.h"
|
||||
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#define SDCard SDCardManager::getInstance()
|
||||
|
||||
HalStorage HalStorage::instance;
|
||||
|
||||
HalStorage::HalStorage() {}
|
||||
|
||||
bool HalStorage::begin() { return SDCard.begin(); }
|
||||
|
||||
bool HalStorage::ready() const { return SDCard.ready(); }
|
||||
|
||||
std::vector<String> HalStorage::listFiles(const char* path, int maxFiles) { return SDCard.listFiles(path, maxFiles); }
|
||||
|
||||
String HalStorage::readFile(const char* path) { return SDCard.readFile(path); }
|
||||
|
||||
bool HalStorage::readFileToStream(const char* path, Print& out, size_t chunkSize) {
|
||||
return SDCard.readFileToStream(path, out, chunkSize);
|
||||
}
|
||||
|
||||
size_t HalStorage::readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes) {
|
||||
return SDCard.readFileToBuffer(path, buffer, bufferSize, maxBytes);
|
||||
}
|
||||
|
||||
bool HalStorage::writeFile(const char* path, const String& content) { return SDCard.writeFile(path, content); }
|
||||
|
||||
bool HalStorage::ensureDirectoryExists(const char* path) { return SDCard.ensureDirectoryExists(path); }
|
||||
|
||||
FsFile HalStorage::open(const char* path, const oflag_t oflag) { return SDCard.open(path, oflag); }
|
||||
|
||||
bool HalStorage::mkdir(const char* path, const bool pFlag) { return SDCard.mkdir(path, pFlag); }
|
||||
|
||||
bool HalStorage::exists(const char* path) { return SDCard.exists(path); }
|
||||
|
||||
bool HalStorage::remove(const char* path) { return SDCard.remove(path); }
|
||||
|
||||
bool HalStorage::rmdir(const char* path) { return SDCard.rmdir(path); }
|
||||
|
||||
bool HalStorage::openFileForRead(const char* moduleName, const char* path, FsFile& file) {
|
||||
return SDCard.openFileForRead(moduleName, path, file);
|
||||
}
|
||||
|
||||
bool HalStorage::openFileForRead(const char* moduleName, const std::string& path, FsFile& file) {
|
||||
return openFileForRead(moduleName, path.c_str(), file);
|
||||
}
|
||||
|
||||
bool HalStorage::openFileForRead(const char* moduleName, const String& path, FsFile& file) {
|
||||
return openFileForRead(moduleName, path.c_str(), file);
|
||||
}
|
||||
|
||||
bool HalStorage::openFileForWrite(const char* moduleName, const char* path, FsFile& file) {
|
||||
return SDCard.openFileForWrite(moduleName, path, file);
|
||||
}
|
||||
|
||||
bool HalStorage::openFileForWrite(const char* moduleName, const std::string& path, FsFile& file) {
|
||||
return openFileForWrite(moduleName, path.c_str(), file);
|
||||
}
|
||||
|
||||
bool HalStorage::openFileForWrite(const char* moduleName, const String& path, FsFile& file) {
|
||||
return openFileForWrite(moduleName, path.c_str(), file);
|
||||
}
|
||||
|
||||
bool HalStorage::removeDir(const char* path) { return SDCard.removeDir(path); }
|
||||
54
lib/hal/HalStorage.h
Normal file
54
lib/hal/HalStorage.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
class HalStorage {
|
||||
public:
|
||||
HalStorage();
|
||||
bool begin();
|
||||
bool ready() const;
|
||||
std::vector<String> listFiles(const char* path = "/", int maxFiles = 200);
|
||||
// Read the entire file at `path` into a String. Returns empty string on failure.
|
||||
String readFile(const char* path);
|
||||
// Low-memory helpers:
|
||||
// Stream the file contents to a `Print` (e.g. `Serial`, or any `Print`-derived object).
|
||||
// Returns true on success, false on failure.
|
||||
bool readFileToStream(const char* path, Print& out, size_t chunkSize = 256);
|
||||
// Read up to `bufferSize-1` bytes into `buffer`, null-terminating it. Returns bytes read.
|
||||
size_t readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes = 0);
|
||||
// Write a string to `path` on the SD card. Overwrites existing file.
|
||||
// Returns true on success.
|
||||
bool writeFile(const char* path, const String& content);
|
||||
// Ensure a directory exists, creating it if necessary. Returns true on success.
|
||||
bool ensureDirectoryExists(const char* path);
|
||||
|
||||
FsFile open(const char* path, const oflag_t oflag = O_RDONLY);
|
||||
bool mkdir(const char* path, const bool pFlag = true);
|
||||
bool exists(const char* path);
|
||||
bool remove(const char* path);
|
||||
bool rmdir(const char* path);
|
||||
|
||||
bool openFileForRead(const char* moduleName, const char* path, FsFile& file);
|
||||
bool openFileForRead(const char* moduleName, const std::string& path, FsFile& file);
|
||||
bool openFileForRead(const char* moduleName, const String& path, FsFile& file);
|
||||
bool openFileForWrite(const char* moduleName, const char* path, FsFile& file);
|
||||
bool openFileForWrite(const char* moduleName, const std::string& path, FsFile& file);
|
||||
bool openFileForWrite(const char* moduleName, const String& path, FsFile& file);
|
||||
bool removeDir(const char* path);
|
||||
|
||||
static HalStorage& getInstance() { return instance; }
|
||||
|
||||
private:
|
||||
static HalStorage instance;
|
||||
|
||||
bool initialized = false;
|
||||
};
|
||||
|
||||
#define Storage HalStorage::getInstance()
|
||||
|
||||
// Downstream code must use Storage instead of SdMan
|
||||
#ifdef SdMan
|
||||
#undef SdMan
|
||||
#endif
|
||||
Submodule open-x4-sdk updated: bd4e670750...c8ce3949b3
@@ -2,7 +2,7 @@
|
||||
default_envs = default
|
||||
|
||||
[crosspoint]
|
||||
version = 0.16.0
|
||||
version = 1.0.0
|
||||
|
||||
[base]
|
||||
platform = espressif32 @ 6.12.0
|
||||
@@ -54,9 +54,47 @@ extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||
|
||||
|
||||
[env:mod]
|
||||
extends = base
|
||||
extra_scripts =
|
||||
${base.extra_scripts}
|
||||
pre:scripts/inject_mod_version.py
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DOMIT_OPENDYSLEXIC
|
||||
-DOMIT_HYPH_DE
|
||||
-DOMIT_HYPH_ES
|
||||
-DOMIT_HYPH_FR
|
||||
-DOMIT_HYPH_IT
|
||||
-DOMIT_HYPH_RU
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for mod builds
|
||||
|
||||
[env:gh_release]
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=0 ; Set log level to error for release builds
|
||||
|
||||
[env:gh_release_rc]
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
||||
|
||||
[env:slim]
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
|
||||
; serial output is disabled in slim builds to save space
|
||||
-UENABLE_SERIAL_LOG
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import gzip
|
||||
|
||||
SRC_DIR = "src"
|
||||
|
||||
@@ -40,12 +41,34 @@ for root, _, files in os.walk(SRC_DIR):
|
||||
|
||||
# minified = regex.sub("\g<1>", html_content)
|
||||
minified = minify_html(html_content)
|
||||
|
||||
# Compress with gzip (compresslevel 9 is maximum compression)
|
||||
# IMPORTANT: we don't use brotli because Firefox doesn't support brotli with insecured context (only supported on HTTPS)
|
||||
compressed = gzip.compress(minified.encode('utf-8'), compresslevel=9)
|
||||
|
||||
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')
|
||||
h.write(f"#include <cstddef>\n\n")
|
||||
|
||||
# Write the compressed data as a byte array
|
||||
h.write(f"constexpr char {base_name}[] PROGMEM = {{\n")
|
||||
|
||||
# Write bytes in rows of 16
|
||||
for i in range(0, len(compressed), 16):
|
||||
chunk = compressed[i:i+16]
|
||||
hex_values = ', '.join(f'0x{b:02x}' for b in chunk)
|
||||
h.write(f" {hex_values},\n")
|
||||
|
||||
h.write(f"}};\n\n")
|
||||
h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n")
|
||||
h.write(f"constexpr size_t {base_name}OriginalSize = {len(minified)};\n")
|
||||
|
||||
print(f"Generated: {header_path}")
|
||||
print(f" Original: {len(html_content)} bytes")
|
||||
print(f" Minified: {len(minified)} bytes ({100*len(minified)/len(html_content):.1f}%)")
|
||||
print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(html_content):.1f}%)")
|
||||
|
||||
|
||||
508
scripts/debugging_monitor.py
Executable file
508
scripts/debugging_monitor.py
Executable file
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ESP32 Serial Monitor with Memory Graph
|
||||
|
||||
This script provides a comprehensive real-time serial monitor for ESP32 devices with
|
||||
integrated memory usage graphing capabilities. It reads serial output, parses memory
|
||||
information, and displays it in both console and graphical form.
|
||||
|
||||
Features:
|
||||
- Real-time serial output monitoring with color-coded log levels
|
||||
- Interactive memory usage graphing with matplotlib
|
||||
- Command input interface for sending commands to the ESP32 device
|
||||
- Screenshot capture and processing (1-bit black/white format)
|
||||
- Graceful shutdown handling with Ctrl-C signal processing
|
||||
- Configurable filtering and suppression of log messages
|
||||
- Thread-safe operation with coordinated shutdown events
|
||||
|
||||
Usage:
|
||||
python debugging_monitor.py [port] [options]
|
||||
|
||||
The script will open a matplotlib window showing memory usage over time and provide
|
||||
an interactive command prompt for sending commands to the device. Press Ctrl-C or
|
||||
close the graph window to exit gracefully.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import platform
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
|
||||
# Try to import potentially missing packages
|
||||
PACKAGE_MAPPING: dict[str, str] = {
|
||||
"serial": "pyserial",
|
||||
"colorama": "colorama",
|
||||
"matplotlib": "matplotlib",
|
||||
"PIL": "Pillow",
|
||||
}
|
||||
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
import serial
|
||||
from colorama import Fore, Style, init
|
||||
from matplotlib import animation
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
except ImportError as e:
|
||||
ERROR_MSG = str(e).lower()
|
||||
missing_packages = [pkg for mod, pkg in PACKAGE_MAPPING.items() if mod in ERROR_MSG]
|
||||
|
||||
if not missing_packages:
|
||||
# Fallback if mapping doesn't cover
|
||||
missing_packages = ["pyserial", "colorama", "matplotlib"]
|
||||
|
||||
print("\n" + "!" * 50)
|
||||
print(f" Error: Required package(s) not installed: {', '.join(missing_packages)}")
|
||||
print("!" * 50)
|
||||
|
||||
print("\nTo fix this, please run the following command in your terminal:\n")
|
||||
INSTALL_CMD = "pip install " if sys.platform.startswith("win") else "pip3 install "
|
||||
print(f" {INSTALL_CMD}{' '.join(missing_packages)}")
|
||||
|
||||
print("\nExiting...")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Global Variables for Data Sharing ---
|
||||
# Store last 50 data points
|
||||
MAX_POINTS = 50
|
||||
time_data: deque[str] = deque(maxlen=MAX_POINTS)
|
||||
free_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
|
||||
total_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
|
||||
data_lock: threading.Lock = threading.Lock() # Prevent reading while writing
|
||||
|
||||
# Global shutdown flag
|
||||
shutdown_event = threading.Event()
|
||||
|
||||
# Initialize colors
|
||||
init(autoreset=True)
|
||||
|
||||
# Color mapping for log lines
|
||||
COLOR_KEYWORDS: dict[str, list[str]] = {
|
||||
Fore.RED: ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"],
|
||||
Fore.CYAN: ["[MEM]", "FREE:"],
|
||||
Fore.MAGENTA: [
|
||||
"[GFX]",
|
||||
"[ERS]",
|
||||
"DISPLAY",
|
||||
"RAM WRITE",
|
||||
"RAM COMPLETE",
|
||||
"REFRESH",
|
||||
"POWERING ON",
|
||||
"FRAME BUFFER",
|
||||
"LUT",
|
||||
],
|
||||
Fore.GREEN: [
|
||||
"[EBP]",
|
||||
"[BMC]",
|
||||
"[ZIP]",
|
||||
"[PARSER]",
|
||||
"[EHP]",
|
||||
"LOADING EPUB",
|
||||
"CACHE",
|
||||
"DECOMPRESSED",
|
||||
"PARSING",
|
||||
],
|
||||
Fore.YELLOW: ["[ACT]", "ENTERING ACTIVITY", "EXITING ACTIVITY"],
|
||||
Fore.BLUE: ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"],
|
||||
Fore.LIGHTYELLOW_EX: [
|
||||
"[CPS]",
|
||||
"SETTINGS",
|
||||
"[CLEAR_CACHE]",
|
||||
"[CHAP]",
|
||||
"[OPDS]",
|
||||
"[COF]",
|
||||
],
|
||||
Fore.LIGHTBLACK_EX: [
|
||||
"ESP-ROM",
|
||||
"BUILD:",
|
||||
"RST:",
|
||||
"BOOT:",
|
||||
"SPIWP:",
|
||||
"MODE:",
|
||||
"LOAD:",
|
||||
"ENTRY",
|
||||
"[SD]",
|
||||
"STARTING CROSSPOINT",
|
||||
"VERSION",
|
||||
],
|
||||
Fore.LIGHTCYAN_EX: ["[RBS]"],
|
||||
Fore.LIGHTMAGENTA_EX: [
|
||||
"[KRS]",
|
||||
"EINKDISPLAY:",
|
||||
"STATIC FRAME",
|
||||
"INITIALIZING",
|
||||
"SPI INITIALIZED",
|
||||
"GPIO PINS",
|
||||
"RESETTING",
|
||||
"SSD1677",
|
||||
"E-INK",
|
||||
],
|
||||
Fore.LIGHTGREEN_EX: ["[FNS]", "FOOTNOTE"],
|
||||
}
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
"""Handle SIGINT (Ctrl-C) by setting the shutdown event."""
|
||||
# frame parameter is required by signal handler signature but not used
|
||||
del frame # Explicitly mark as unused to satisfy linters
|
||||
print(f"\n{Fore.YELLOW}Received signal {signum}. Shutting down...{Style.RESET_ALL}")
|
||||
shutdown_event.set()
|
||||
plt.close("all")
|
||||
|
||||
|
||||
# pylint: disable=R0912
|
||||
def get_color_for_line(line: str) -> str:
|
||||
"""
|
||||
Classify log lines by type and assign appropriate colors.
|
||||
"""
|
||||
line_upper = line.upper()
|
||||
for color, keywords in COLOR_KEYWORDS.items():
|
||||
if any(keyword in line_upper for keyword in keywords):
|
||||
return color
|
||||
return Fore.WHITE
|
||||
|
||||
|
||||
def parse_memory_line(line: str) -> tuple[int | None, int | None]:
|
||||
"""
|
||||
Extracts Free and Total bytes from the specific log line.
|
||||
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
|
||||
"""
|
||||
# Regex to find 'Free: <digits>' and 'Total: <digits>'
|
||||
match = re.search(r"Free:\s*(\d+).*Total:\s*(\d+)", line)
|
||||
if match:
|
||||
try:
|
||||
free_bytes = int(match.group(1))
|
||||
total_bytes = int(match.group(2))
|
||||
return free_bytes, total_bytes
|
||||
except ValueError:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def serial_worker(ser, kwargs: dict[str, str]) -> None:
|
||||
"""
|
||||
Runs in a background thread. Handles reading serial data, printing to console,
|
||||
updating memory usage data for graphing, and processing screenshot data.
|
||||
Monitors the global shutdown event for graceful termination.
|
||||
"""
|
||||
print(f"{Fore.CYAN}--- Opening serial port ---{Style.RESET_ALL}")
|
||||
filter_keyword = kwargs.get("filter", "").lower()
|
||||
suppress = kwargs.get("suppress", "").lower()
|
||||
if filter_keyword and suppress and filter_keyword == suppress:
|
||||
print(
|
||||
f"{Fore.YELLOW}Warning: Filter and Suppress keywords are the same. "
|
||||
f"This may result in no output.{Style.RESET_ALL}"
|
||||
)
|
||||
if filter_keyword:
|
||||
print(
|
||||
f"{Fore.YELLOW}Filtering lines to only show those containing: "
|
||||
f"'{filter_keyword}'{Style.RESET_ALL}"
|
||||
)
|
||||
if suppress:
|
||||
print(
|
||||
f"{Fore.YELLOW}Suppressing lines containing: '{suppress}'{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
expecting_screenshot = False
|
||||
screenshot_size = 0
|
||||
screenshot_data = b""
|
||||
|
||||
try:
|
||||
while not shutdown_event.is_set():
|
||||
if expecting_screenshot:
|
||||
data = ser.read(screenshot_size - len(screenshot_data))
|
||||
if not data:
|
||||
continue
|
||||
screenshot_data += data
|
||||
if len(screenshot_data) == screenshot_size:
|
||||
if Image:
|
||||
img = Image.frombytes("1", (800, 480), screenshot_data)
|
||||
# We need to rotate the image because the raw data is in landscape mode
|
||||
img = img.transpose(Image.ROTATE_270)
|
||||
img.save("screenshot.bmp")
|
||||
print(
|
||||
f"{Fore.GREEN}Screenshot saved to screenshot.bmp{Style.RESET_ALL}"
|
||||
)
|
||||
else:
|
||||
with open("screenshot.raw", "wb") as f:
|
||||
f.write(screenshot_data)
|
||||
print(
|
||||
f"{Fore.GREEN}Screenshot saved to screenshot.raw (PIL not available){Style.RESET_ALL}"
|
||||
)
|
||||
expecting_screenshot = False
|
||||
screenshot_data = b""
|
||||
else:
|
||||
try:
|
||||
raw_data = ser.readline().decode("utf-8", errors="replace")
|
||||
|
||||
if not raw_data:
|
||||
continue
|
||||
|
||||
clean_line = raw_data.strip()
|
||||
if not clean_line:
|
||||
continue
|
||||
|
||||
if clean_line.startswith("SCREENSHOT_START:"):
|
||||
screenshot_size = int(clean_line.split(":")[1])
|
||||
expecting_screenshot = True
|
||||
continue
|
||||
elif clean_line == "SCREENSHOT_END":
|
||||
continue # ignore
|
||||
|
||||
# Add PC timestamp
|
||||
pc_time = datetime.now().strftime("%H:%M:%S")
|
||||
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
|
||||
|
||||
# Check for Memory Line
|
||||
if "[MEM]" in formatted_line:
|
||||
free_val, total_val = parse_memory_line(formatted_line)
|
||||
if free_val is not None and total_val is not None:
|
||||
with data_lock:
|
||||
time_data.append(pc_time)
|
||||
free_mem_data.append(free_val / 1024) # Convert to KB
|
||||
total_mem_data.append(total_val / 1024) # Convert to KB
|
||||
# Apply filters
|
||||
if filter_keyword and filter_keyword not in formatted_line.lower():
|
||||
continue
|
||||
if suppress and suppress in formatted_line.lower():
|
||||
continue
|
||||
# Print to console
|
||||
line_color = get_color_for_line(formatted_line)
|
||||
print(f"{line_color}{formatted_line}")
|
||||
|
||||
except (OSError, UnicodeDecodeError):
|
||||
print(
|
||||
f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}"
|
||||
)
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
# If thread is killed violently (e.g. main exit), silence errors
|
||||
pass
|
||||
finally:
|
||||
pass # ser closed in main
|
||||
|
||||
|
||||
def input_worker(ser) -> None:
|
||||
"""
|
||||
Runs in a background thread. Handles user input to send commands to the ESP32 device.
|
||||
Monitors the global shutdown event for graceful termination on Ctrl-C.
|
||||
"""
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
cmd = input("Command: ")
|
||||
ser.write(f"CMD:{cmd}\n".encode())
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
|
||||
|
||||
def update_graph(frame) -> list: # pylint: disable=unused-argument
|
||||
"""
|
||||
Called by Matplotlib animation to redraw the memory usage chart.
|
||||
Monitors the global shutdown event and closes the plot when shutdown is requested.
|
||||
"""
|
||||
if shutdown_event.is_set():
|
||||
plt.close("all")
|
||||
return []
|
||||
|
||||
with data_lock:
|
||||
if not time_data:
|
||||
return []
|
||||
|
||||
# Convert deques to lists for plotting
|
||||
x = list(time_data)
|
||||
y_free = list(free_mem_data)
|
||||
y_total = list(total_mem_data)
|
||||
|
||||
plt.cla() # Clear axis
|
||||
|
||||
# Plot Total RAM
|
||||
plt.plot(x, y_total, label="Total RAM (KB)", color="red", linestyle="--")
|
||||
|
||||
# Plot Free RAM
|
||||
plt.plot(x, y_free, label="Free RAM (KB)", color="green", marker="o")
|
||||
|
||||
# Fill area under Free RAM
|
||||
plt.fill_between(x, y_free, color="green", alpha=0.1)
|
||||
|
||||
plt.title("ESP32 Memory Monitor")
|
||||
plt.ylabel("Memory (KB)")
|
||||
plt.xlabel("Time")
|
||||
plt.legend(loc="upper left")
|
||||
plt.grid(True, linestyle=":", alpha=0.6)
|
||||
|
||||
# Rotate date labels
|
||||
plt.xticks(rotation=45, ha="right")
|
||||
plt.tight_layout()
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def get_auto_detected_port() -> list[str]:
|
||||
"""
|
||||
Attempts to auto-detect the serial port for the ESP32 device.
|
||||
Returns a list of all detected ports.
|
||||
If no suitable port is found, the list will be empty.
|
||||
Darwin/Linux logic by jonasdiemer
|
||||
"""
|
||||
port_list = []
|
||||
system = platform.system()
|
||||
# Code for darwin (macOS), linux, and windows
|
||||
if system in ("Darwin", "Linux"):
|
||||
pattern = "/dev/tty.usbmodem*" if system == "Darwin" else "/dev/ttyACM*"
|
||||
port_list = sorted(glob.glob(pattern))
|
||||
elif system == "Windows":
|
||||
from serial.tools import list_ports
|
||||
|
||||
# Be careful with this pattern list - it should be specific
|
||||
# enough to avoid picking up unrelated devices, but broad enough
|
||||
# to catch all common USB-serial adapters used with ESP32
|
||||
# Caveat: localized versions of Windows may have different descriptions,
|
||||
# so we also check for specific VID:PID (but that may not cover all clones)
|
||||
pattern_list = ["CP210x", "CH340", "USB Serial"]
|
||||
found_ports = list_ports.comports()
|
||||
port_list = [
|
||||
port.device
|
||||
for port in found_ports
|
||||
if any(pat in port.description for pat in pattern_list)
|
||||
or port.hwid.startswith(
|
||||
"USB VID:PID=303A:1001"
|
||||
) # Add specific VID:PID for XTEINK X4
|
||||
]
|
||||
|
||||
return port_list
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Main entry point for the ESP32 monitor application.
|
||||
|
||||
Sets up argument parsing, initializes serial communication, starts background threads
|
||||
for serial monitoring and command input, and launches the memory usage graph.
|
||||
Implements graceful shutdown handling with signal processing for clean termination.
|
||||
|
||||
Features:
|
||||
- Serial port monitoring with color-coded output
|
||||
- Real-time memory usage graphing
|
||||
- Interactive command interface
|
||||
- Screenshot capture capability
|
||||
- Graceful shutdown on Ctrl-C or window close
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface"
|
||||
)
|
||||
default_baudrate = 115200
|
||||
parser.add_argument(
|
||||
"port",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Serial port (leave empty for autodetection)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=default_baudrate,
|
||||
help=f"Baud rate (default: {default_baudrate})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--filter",
|
||||
type=str,
|
||||
default="",
|
||||
help="Only display lines containing this keyword (case-insensitive)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--suppress",
|
||||
type=str,
|
||||
default="",
|
||||
help="Suppress lines containing this keyword (case-insensitive)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
port = args.port
|
||||
if port is None:
|
||||
port_list = get_auto_detected_port()
|
||||
if len(port_list) == 1:
|
||||
port = port_list[0]
|
||||
print(f"{Fore.CYAN}Auto-detected serial port: {port}{Style.RESET_ALL}")
|
||||
elif len(port_list) > 1:
|
||||
print(f"{Fore.YELLOW}Multiple serial ports found:{Style.RESET_ALL}")
|
||||
for p in port_list:
|
||||
print(f" - {p}")
|
||||
print(
|
||||
f"{Fore.YELLOW}Please specify the desired port as a command-line argument.{Style.RESET_ALL}"
|
||||
)
|
||||
if port is None:
|
||||
print(f"{Fore.RED}Error: No suitable serial port found.{Style.RESET_ALL}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
ser = serial.Serial(port, args.baud, timeout=0.1)
|
||||
ser.dtr = False
|
||||
ser.rts = False
|
||||
except serial.SerialException as e:
|
||||
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
# Set up signal handler for graceful shutdown
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# 1. Start the Serial Reader in a separate thread
|
||||
# Daemon=True means this thread dies when the main program closes
|
||||
myargs = vars(args) # Convert Namespace to dict for easier passing
|
||||
t = threading.Thread(target=serial_worker, args=(ser, myargs), daemon=True)
|
||||
t.start()
|
||||
|
||||
# Start input thread
|
||||
input_thread = threading.Thread(target=input_worker, args=(ser,), daemon=True)
|
||||
input_thread.start()
|
||||
|
||||
# 2. Set up the Graph (Main Thread)
|
||||
try:
|
||||
import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel
|
||||
|
||||
default_styles = (
|
||||
"light_background",
|
||||
"ggplot",
|
||||
"seaborn",
|
||||
"dark_background",
|
||||
)
|
||||
styles = list(mplstyle.available)
|
||||
for default_style in default_styles:
|
||||
if default_style in styles:
|
||||
print(
|
||||
f"\n{Fore.CYAN}--- Using Matplotlib style: {default_style} ---{Style.RESET_ALL}"
|
||||
)
|
||||
mplstyle.use(default_style)
|
||||
break
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
fig = plt.figure(figsize=(10, 6))
|
||||
|
||||
# Update graph every 1000ms
|
||||
_ = animation.FuncAnimation(
|
||||
fig, update_graph, interval=1000, cache_frame_data=False
|
||||
)
|
||||
|
||||
try:
|
||||
print(
|
||||
f"{Fore.YELLOW}Starting Graph Window... (Close window or press Ctrl-C to exit){Style.RESET_ALL}"
|
||||
)
|
||||
plt.show()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
|
||||
finally:
|
||||
shutdown_event.set() # Ensure all threads know to stop
|
||||
plt.close("all") # Force close any lingering plot windows
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
123
scripts/generate_book_icon.py
Normal file
123
scripts/generate_book_icon.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a 1-bit book icon bitmap as a C header for PlaceholderCoverGenerator.
|
||||
|
||||
The icon is a simplified closed book with a spine on the left and 3 text lines.
|
||||
Output format matches Logo120.h: MSB-first packed 1-bit, 0=black, 1=white.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import sys
|
||||
|
||||
|
||||
def generate_book_icon(size=48):
|
||||
"""Create a book icon at the given size."""
|
||||
img = Image.new("1", (size, size), 1) # White background
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Scale helper
|
||||
s = size / 48.0
|
||||
|
||||
# Book body (main rectangle, leaving room for spine and pages)
|
||||
body_left = int(6 * s)
|
||||
body_top = int(2 * s)
|
||||
body_right = int(42 * s)
|
||||
body_bottom = int(40 * s)
|
||||
|
||||
# Draw book body outline (2px thick)
|
||||
for i in range(int(2 * s)):
|
||||
draw.rectangle(
|
||||
[body_left + i, body_top + i, body_right - i, body_bottom - i], outline=0
|
||||
)
|
||||
|
||||
# Spine (thicker left edge)
|
||||
spine_width = int(4 * s)
|
||||
draw.rectangle([body_left, body_top, body_left + spine_width, body_bottom], fill=0)
|
||||
|
||||
# Pages at the bottom (slight offset from body)
|
||||
pages_top = body_bottom
|
||||
pages_bottom = int(44 * s)
|
||||
draw.rectangle(
|
||||
[body_left + int(2 * s), pages_top, body_right - int(1 * s), pages_bottom],
|
||||
outline=0,
|
||||
)
|
||||
# Page edges (a few lines)
|
||||
for i in range(3):
|
||||
y = pages_top + int((i + 1) * 1 * s)
|
||||
if y < pages_bottom:
|
||||
draw.line(
|
||||
[body_left + int(3 * s), y, body_right - int(2 * s), y], fill=0
|
||||
)
|
||||
|
||||
# Text lines on the book cover
|
||||
text_left = body_left + spine_width + int(4 * s)
|
||||
text_right = body_right - int(4 * s)
|
||||
line_thickness = max(1, int(1.5 * s))
|
||||
|
||||
text_lines_y = [int(12 * s), int(18 * s), int(24 * s)]
|
||||
text_widths = [1.0, 0.7, 0.85] # Relative widths for visual interest
|
||||
|
||||
for y, w_ratio in zip(text_lines_y, text_widths):
|
||||
line_right = text_left + int((text_right - text_left) * w_ratio)
|
||||
for t in range(line_thickness):
|
||||
draw.line([text_left, y + t, line_right, y + t], fill=0)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def image_to_c_array(img, name="BookIcon"):
|
||||
"""Convert a 1-bit PIL image to a C header array."""
|
||||
width, height = img.size
|
||||
pixels = img.load()
|
||||
|
||||
bytes_per_row = width // 8
|
||||
data = []
|
||||
|
||||
for y in range(height):
|
||||
for bx in range(bytes_per_row):
|
||||
byte = 0
|
||||
for bit in range(8):
|
||||
x = bx * 8 + bit
|
||||
if x < width:
|
||||
# 1 = white, 0 = black (matching Logo120.h convention)
|
||||
if pixels[x, y]:
|
||||
byte |= 1 << (7 - bit)
|
||||
data.append(byte)
|
||||
|
||||
# Format as C header
|
||||
lines = []
|
||||
lines.append("#pragma once")
|
||||
lines.append("#include <cstdint>")
|
||||
lines.append("")
|
||||
lines.append(f"// Book icon: {width}x{height}, 1-bit packed (MSB first)")
|
||||
lines.append(f"// 0 = black, 1 = white (same format as Logo120.h)")
|
||||
lines.append(f"static constexpr int BOOK_ICON_WIDTH = {width};")
|
||||
lines.append(f"static constexpr int BOOK_ICON_HEIGHT = {height};")
|
||||
lines.append(f"static const uint8_t {name}[] = {{")
|
||||
|
||||
# Format data in rows of 16 bytes
|
||||
for i in range(0, len(data), 16):
|
||||
chunk = data[i : i + 16]
|
||||
hex_str = ", ".join(f"0x{b:02x}" for b in chunk)
|
||||
lines.append(f" {hex_str},")
|
||||
|
||||
lines.append("};")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
size = int(sys.argv[1]) if len(sys.argv) > 1 else 48
|
||||
img = generate_book_icon(size)
|
||||
|
||||
# Save preview PNG
|
||||
preview_path = f"mod/book_icon_{size}x{size}.png"
|
||||
img.resize((size * 4, size * 4), Image.NEAREST).save(preview_path)
|
||||
print(f"Preview saved to {preview_path}", file=sys.stderr)
|
||||
|
||||
# Generate C header
|
||||
header = image_to_c_array(img, "BookIcon")
|
||||
output_path = "lib/PlaceholderCover/BookIcon.h"
|
||||
with open(output_path, "w") as f:
|
||||
f.write(header)
|
||||
print(f"C header saved to {output_path}", file=sys.stderr)
|
||||
15
scripts/inject_mod_version.py
Normal file
15
scripts/inject_mod_version.py
Normal file
@@ -0,0 +1,15 @@
|
||||
Import("env")
|
||||
import subprocess
|
||||
|
||||
config = env.GetProjectConfig()
|
||||
version = config.get("crosspoint", "version")
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
git_hash = result.stdout.strip()
|
||||
|
||||
env.Append(
|
||||
BUILD_FLAGS=[f'-DCROSSPOINT_VERSION=\\"{version}-mod+{git_hash}\\"']
|
||||
)
|
||||
179
scripts/preview_placeholder_cover.py
Normal file
179
scripts/preview_placeholder_cover.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a preview of the placeholder cover layout at full cover size (480x800).
|
||||
This mirrors the C++ PlaceholderCoverGenerator layout logic for visual verification.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Reuse the book icon generator
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from generate_book_icon import generate_book_icon
|
||||
|
||||
|
||||
def create_preview(width=480, height=800, title="The Great Gatsby", author="F. Scott Fitzgerald"):
|
||||
img = Image.new("1", (width, height), 1) # White
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Proportional layout constants
|
||||
edge_padding = max(3, width // 48) # ~10px at 480w
|
||||
border_width = max(2, width // 96) # ~5px at 480w
|
||||
inner_padding = max(4, width // 32) # ~15px at 480w
|
||||
|
||||
title_scale = 2 if height >= 600 else 1
|
||||
author_scale = 2 if height >= 600 else 1 # Author also larger on full covers
|
||||
icon_scale = 2 if height >= 600 else (1 if height >= 350 else 0)
|
||||
|
||||
# Draw border inset from edge
|
||||
bx = edge_padding
|
||||
by = edge_padding
|
||||
bw = width - 2 * edge_padding
|
||||
bh = height - 2 * edge_padding
|
||||
for i in range(border_width):
|
||||
draw.rectangle([bx + i, by + i, bx + bw - 1 - i, by + bh - 1 - i], outline=0)
|
||||
|
||||
# Content area
|
||||
content_x = edge_padding + border_width + inner_padding
|
||||
content_y = edge_padding + border_width + inner_padding
|
||||
content_w = width - 2 * content_x
|
||||
content_h = height - 2 * content_y
|
||||
|
||||
# Zones
|
||||
title_zone_h = content_h * 2 // 3
|
||||
author_zone_h = content_h - title_zone_h
|
||||
author_zone_y = content_y + title_zone_h
|
||||
|
||||
# Separator
|
||||
sep_w = content_w // 3
|
||||
sep_x = content_x + (content_w - sep_w) // 2
|
||||
draw.line([sep_x, author_zone_y, sep_x + sep_w, author_zone_y], fill=0)
|
||||
|
||||
# Use a basic font for the preview (won't match exact Ubuntu metrics, but shows layout)
|
||||
try:
|
||||
title_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12 * title_scale)
|
||||
author_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10 * author_scale)
|
||||
except (OSError, IOError):
|
||||
title_font = ImageFont.load_default()
|
||||
author_font = ImageFont.load_default()
|
||||
|
||||
# Icon dimensions (needed for title text wrapping)
|
||||
icon_w_px = 48 * icon_scale if icon_scale > 0 else 0
|
||||
icon_h_px = 48 * icon_scale if icon_scale > 0 else 0
|
||||
icon_gap = max(8, width // 40) if icon_scale > 0 else 0
|
||||
title_text_w = content_w - icon_w_px - icon_gap # Title wraps in narrower area beside icon
|
||||
|
||||
# Wrap title (within the narrower area to the right of the icon)
|
||||
title_lines = []
|
||||
words = title.split()
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test, font=title_font)
|
||||
if bbox[2] - bbox[0] <= title_text_w:
|
||||
current_line = test
|
||||
else:
|
||||
if current_line:
|
||||
title_lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
title_lines.append(current_line)
|
||||
title_lines = title_lines[:5]
|
||||
|
||||
# Line spacing: 75% of advanceY (tighter so 2-3 lines fit within icon height)
|
||||
title_line_h = 29 * title_scale * 3 // 4 # Based on C++ ubuntu_12_bold advanceY
|
||||
|
||||
# Measure actual single-line height from the PIL font for accurate centering
|
||||
sample_bbox = draw.textbbox((0, 0), "Ag", font=title_font) # Tall + descender chars
|
||||
single_line_visual_h = sample_bbox[3] - sample_bbox[1]
|
||||
|
||||
# Visual height: line spacing between lines + actual height of last line's glyphs
|
||||
num_title_lines = len(title_lines)
|
||||
title_visual_h = (num_title_lines - 1) * title_line_h + single_line_visual_h if num_title_lines > 0 else 0
|
||||
title_block_h = max(icon_h_px, title_visual_h)
|
||||
|
||||
title_start_y = content_y + (title_zone_h - title_block_h) // 2
|
||||
if title_start_y < content_y:
|
||||
title_start_y = content_y
|
||||
|
||||
# If title fits within icon height, center it vertically against the icon.
|
||||
# Otherwise top-align so extra lines overflow below.
|
||||
icon_y = title_start_y
|
||||
if icon_h_px > 0 and title_visual_h <= icon_h_px:
|
||||
title_text_y = title_start_y + (icon_h_px - title_visual_h) // 2
|
||||
else:
|
||||
title_text_y = title_start_y
|
||||
|
||||
# Horizontal centering: measure widest title line, center icon+gap+text block
|
||||
max_title_line_w = 0
|
||||
for line in title_lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=title_font)
|
||||
w = bbox[2] - bbox[0]
|
||||
if w > max_title_line_w:
|
||||
max_title_line_w = w
|
||||
title_block_w = icon_w_px + icon_gap + max_title_line_w
|
||||
title_block_x = content_x + (content_w - title_block_w) // 2
|
||||
|
||||
# Draw icon
|
||||
if icon_scale > 0:
|
||||
icon_img = generate_book_icon(48)
|
||||
scaled_icon = icon_img.resize((icon_w_px, icon_h_px), Image.NEAREST)
|
||||
for iy in range(scaled_icon.height):
|
||||
for ix in range(scaled_icon.width):
|
||||
if not scaled_icon.getpixel((ix, iy)):
|
||||
img.putpixel((title_block_x + ix, icon_y + iy), 0)
|
||||
|
||||
# Draw title (to the right of the icon)
|
||||
title_text_x = title_block_x + icon_w_px + icon_gap
|
||||
current_y = title_text_y
|
||||
for line in title_lines:
|
||||
draw.text((title_text_x, current_y), line, fill=0, font=title_font)
|
||||
current_y += title_line_h
|
||||
|
||||
# Wrap author
|
||||
author_lines = []
|
||||
words = author.split()
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test, font=author_font)
|
||||
if bbox[2] - bbox[0] <= content_w:
|
||||
current_line = test
|
||||
else:
|
||||
if current_line:
|
||||
author_lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
author_lines.append(current_line)
|
||||
author_lines = author_lines[:3]
|
||||
|
||||
# Draw author centered in bottom 1/3
|
||||
author_line_h = 24 * author_scale # Ubuntu 10 regular advanceY ~24
|
||||
author_block_h = len(author_lines) * author_line_h
|
||||
author_start_y = author_zone_y + (author_zone_h - author_block_h) // 2
|
||||
|
||||
for line in author_lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=author_font)
|
||||
line_w = bbox[2] - bbox[0]
|
||||
line_x = content_x + (content_w - line_w) // 2
|
||||
draw.text((line_x, author_start_y), line, fill=0, font=author_font)
|
||||
author_start_y += author_line_h
|
||||
|
||||
return img
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Full cover
|
||||
img = create_preview(480, 800, "A Really Long Book Title That Should Wrap", "Jane Doe")
|
||||
img.save("mod/preview_cover_480x800.png")
|
||||
print("Saved mod/preview_cover_480x800.png", file=sys.stderr)
|
||||
|
||||
# Medium thumbnail
|
||||
img2 = create_preview(240, 400, "A Really Long Book Title That Should Wrap", "Jane Doe")
|
||||
img2.save("mod/preview_thumb_240x400.png")
|
||||
print("Saved mod/preview_thumb_240x400.png", file=sys.stderr)
|
||||
|
||||
# Small thumbnail
|
||||
img3 = create_preview(136, 226, "A Really Long Book Title", "Jane Doe")
|
||||
img3.save("mod/preview_thumb_136x226.png")
|
||||
print("Saved mod/preview_thumb_136x226.png", file=sys.stderr)
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstring>
|
||||
@@ -22,16 +22,67 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// Increment this when adding new persisted settings fields
|
||||
constexpr uint8_t SETTINGS_COUNT = 23;
|
||||
constexpr uint8_t SETTINGS_COUNT = 31;
|
||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||
|
||||
// Validate front button mapping to ensure each hardware button is unique.
|
||||
// If duplicates are detected, reset to the default physical order to prevent invalid mappings.
|
||||
void validateFrontButtonMapping(CrossPointSettings& settings) {
|
||||
// Snapshot the logical->hardware mapping so we can compare for duplicates.
|
||||
const uint8_t mapping[] = {settings.frontButtonBack, settings.frontButtonConfirm, settings.frontButtonLeft,
|
||||
settings.frontButtonRight};
|
||||
for (size_t i = 0; i < 4; i++) {
|
||||
for (size_t j = i + 1; j < 4; j++) {
|
||||
if (mapping[i] == mapping[j]) {
|
||||
// Duplicate detected: restore the default physical order (Back, Confirm, Left, Right).
|
||||
settings.frontButtonBack = CrossPointSettings::FRONT_HW_BACK;
|
||||
settings.frontButtonConfirm = CrossPointSettings::FRONT_HW_CONFIRM;
|
||||
settings.frontButtonLeft = CrossPointSettings::FRONT_HW_LEFT;
|
||||
settings.frontButtonRight = CrossPointSettings::FRONT_HW_RIGHT;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert legacy front button layout into explicit logical->hardware mapping.
|
||||
void applyLegacyFrontButtonLayout(CrossPointSettings& settings) {
|
||||
switch (static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(settings.frontButtonLayout)) {
|
||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
||||
settings.frontButtonBack = CrossPointSettings::FRONT_HW_LEFT;
|
||||
settings.frontButtonConfirm = CrossPointSettings::FRONT_HW_RIGHT;
|
||||
settings.frontButtonLeft = CrossPointSettings::FRONT_HW_BACK;
|
||||
settings.frontButtonRight = CrossPointSettings::FRONT_HW_CONFIRM;
|
||||
break;
|
||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
||||
settings.frontButtonBack = CrossPointSettings::FRONT_HW_CONFIRM;
|
||||
settings.frontButtonConfirm = CrossPointSettings::FRONT_HW_LEFT;
|
||||
settings.frontButtonLeft = CrossPointSettings::FRONT_HW_BACK;
|
||||
settings.frontButtonRight = CrossPointSettings::FRONT_HW_RIGHT;
|
||||
break;
|
||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
||||
settings.frontButtonBack = CrossPointSettings::FRONT_HW_BACK;
|
||||
settings.frontButtonConfirm = CrossPointSettings::FRONT_HW_CONFIRM;
|
||||
settings.frontButtonLeft = CrossPointSettings::FRONT_HW_RIGHT;
|
||||
settings.frontButtonRight = CrossPointSettings::FRONT_HW_LEFT;
|
||||
break;
|
||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
||||
default:
|
||||
settings.frontButtonBack = CrossPointSettings::FRONT_HW_BACK;
|
||||
settings.frontButtonConfirm = CrossPointSettings::FRONT_HW_CONFIRM;
|
||||
settings.frontButtonLeft = CrossPointSettings::FRONT_HW_LEFT;
|
||||
settings.frontButtonRight = CrossPointSettings::FRONT_HW_RIGHT;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool CrossPointSettings::saveToFile() const {
|
||||
// Make sure the directory exists
|
||||
SdMan.mkdir("/.crosspoint");
|
||||
Storage.mkdir("/.crosspoint");
|
||||
|
||||
FsFile outputFile;
|
||||
if (!SdMan.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
|
||||
if (!Storage.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -42,7 +93,7 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writePod(outputFile, shortPwrBtn);
|
||||
serialization::writePod(outputFile, statusBar);
|
||||
serialization::writePod(outputFile, orientation);
|
||||
serialization::writePod(outputFile, frontButtonLayout);
|
||||
serialization::writePod(outputFile, frontButtonLayout); // legacy
|
||||
serialization::writePod(outputFile, sideButtonLayout);
|
||||
serialization::writePod(outputFile, fontFamily);
|
||||
serialization::writePod(outputFile, fontSize);
|
||||
@@ -60,23 +111,31 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writeString(outputFile, std::string(opdsUsername));
|
||||
serialization::writeString(outputFile, std::string(opdsPassword));
|
||||
serialization::writePod(outputFile, sleepScreenCoverFilter);
|
||||
serialization::writePod(outputFile, uiTheme);
|
||||
serialization::writePod(outputFile, frontButtonBack);
|
||||
serialization::writePod(outputFile, frontButtonConfirm);
|
||||
serialization::writePod(outputFile, frontButtonLeft);
|
||||
serialization::writePod(outputFile, frontButtonRight);
|
||||
serialization::writePod(outputFile, fadingFix);
|
||||
serialization::writePod(outputFile, embeddedStyle);
|
||||
serialization::writePod(outputFile, sleepScreenLetterboxFill);
|
||||
// New fields added at end for backward compatibility
|
||||
outputFile.close();
|
||||
|
||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||
LOG_DBG("CPS", "Settings saved to file");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CrossPointSettings::loadFromFile() {
|
||||
FsFile inputFile;
|
||||
if (!SdMan.openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
|
||||
if (!Storage.openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
if (version != SETTINGS_FILE_VERSION) {
|
||||
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version);
|
||||
LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version);
|
||||
inputFile.close();
|
||||
return false;
|
||||
}
|
||||
@@ -86,6 +145,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
|
||||
// load settings that exist (support older files with fewer fields)
|
||||
uint8_t settingsRead = 0;
|
||||
// Track whether remap fields were present in the settings file.
|
||||
bool frontButtonMappingRead = false;
|
||||
do {
|
||||
readAndValidate(inputFile, sleepScreen, SLEEP_SCREEN_MODE_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
@@ -97,7 +158,7 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, orientation, ORIENTATION_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, frontButtonLayout, FRONT_BUTTON_LAYOUT_COUNT);
|
||||
readAndValidate(inputFile, frontButtonLayout, FRONT_BUTTON_LAYOUT_COUNT); // legacy
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sideButtonLayout, SIDE_BUTTON_LAYOUT_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
@@ -148,18 +209,43 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, uiTheme);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, frontButtonBack, FRONT_BUTTON_HARDWARE_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, frontButtonConfirm, FRONT_BUTTON_HARDWARE_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, frontButtonLeft, FRONT_BUTTON_HARDWARE_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, frontButtonRight, FRONT_BUTTON_HARDWARE_COUNT);
|
||||
frontButtonMappingRead = true;
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, fadingFix);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, embeddedStyle);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{ uint8_t _ignore; serialization::readPod(inputFile, _ignore); } // legacy: sleepScreenGradientDir
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
// New fields added at end for backward compatibility
|
||||
} while (false);
|
||||
|
||||
if (frontButtonMappingRead) {
|
||||
validateFrontButtonMapping(*this);
|
||||
} else {
|
||||
applyLegacyFrontButtonLayout(*this);
|
||||
}
|
||||
|
||||
inputFile.close();
|
||||
Serial.printf("[%lu] [CPS] Settings loaded from file\n", millis());
|
||||
LOG_DBG("CPS", "Settings loaded from file");
|
||||
return true;
|
||||
}
|
||||
|
||||
float CrossPointSettings::getReaderLineCompression() const {
|
||||
switch (fontFamily) {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
case BOOKERLY:
|
||||
default:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.95f;
|
||||
@@ -169,6 +255,8 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.1f;
|
||||
}
|
||||
#endif // OMIT_BOOKERLY
|
||||
#ifndef OMIT_NOTOSANS
|
||||
case NOTOSANS:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
@@ -179,6 +267,8 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.0f;
|
||||
}
|
||||
#endif // OMIT_NOTOSANS
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
case OPENDYSLEXIC:
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
@@ -189,6 +279,30 @@ float CrossPointSettings::getReaderLineCompression() const {
|
||||
case WIDE:
|
||||
return 1.0f;
|
||||
}
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
default:
|
||||
// Fallback: use Bookerly-style compression, or Noto Sans if Bookerly is omitted
|
||||
#if !defined(OMIT_BOOKERLY)
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.95f;
|
||||
case NORMAL:
|
||||
default:
|
||||
return 1.0f;
|
||||
case WIDE:
|
||||
return 1.1f;
|
||||
}
|
||||
#else
|
||||
switch (lineSpacing) {
|
||||
case TIGHT:
|
||||
return 0.90f;
|
||||
case NORMAL:
|
||||
default:
|
||||
return 0.95f;
|
||||
case WIDE:
|
||||
return 1.0f;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,8 +340,8 @@ int CrossPointSettings::getRefreshFrequency() const {
|
||||
|
||||
int CrossPointSettings::getReaderFontId() const {
|
||||
switch (fontFamily) {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
case BOOKERLY:
|
||||
default:
|
||||
switch (fontSize) {
|
||||
case SMALL:
|
||||
return BOOKERLY_12_FONT_ID;
|
||||
@@ -239,6 +353,8 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
case EXTRA_LARGE:
|
||||
return BOOKERLY_18_FONT_ID;
|
||||
}
|
||||
#endif // OMIT_BOOKERLY
|
||||
#ifndef OMIT_NOTOSANS
|
||||
case NOTOSANS:
|
||||
switch (fontSize) {
|
||||
case SMALL:
|
||||
@@ -251,6 +367,8 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
case EXTRA_LARGE:
|
||||
return NOTOSANS_18_FONT_ID;
|
||||
}
|
||||
#endif // OMIT_NOTOSANS
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
case OPENDYSLEXIC:
|
||||
switch (fontSize) {
|
||||
case SMALL:
|
||||
@@ -263,5 +381,17 @@ int CrossPointSettings::getReaderFontId() const {
|
||||
case EXTRA_LARGE:
|
||||
return OPENDYSLEXIC_14_FONT_ID;
|
||||
}
|
||||
#endif // OMIT_OPENDYSLEXIC
|
||||
default:
|
||||
// Fallback to first available font family at medium size
|
||||
#if !defined(OMIT_BOOKERLY)
|
||||
return BOOKERLY_14_FONT_ID;
|
||||
#elif !defined(OMIT_NOTOSANS)
|
||||
return NOTOSANS_14_FONT_ID;
|
||||
#elif !defined(OMIT_OPENDYSLEXIC)
|
||||
return OPENDYSLEXIC_10_FONT_ID;
|
||||
#else
|
||||
#error "At least one font family must be available"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,15 @@ class CrossPointSettings {
|
||||
CrossPointSettings(const CrossPointSettings&) = delete;
|
||||
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
|
||||
|
||||
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4, SLEEP_SCREEN_MODE_COUNT };
|
||||
enum SLEEP_SCREEN_MODE {
|
||||
DARK = 0,
|
||||
LIGHT = 1,
|
||||
CUSTOM = 2,
|
||||
COVER = 3,
|
||||
BLANK = 4,
|
||||
COVER_CUSTOM = 5,
|
||||
SLEEP_SCREEN_MODE_COUNT
|
||||
};
|
||||
enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT };
|
||||
enum SLEEP_SCREEN_COVER_FILTER {
|
||||
NO_FILTER = 0,
|
||||
@@ -23,14 +31,21 @@ class CrossPointSettings {
|
||||
INVERTED_BLACK_AND_WHITE = 2,
|
||||
SLEEP_SCREEN_COVER_FILTER_COUNT
|
||||
};
|
||||
enum SLEEP_SCREEN_LETTERBOX_FILL {
|
||||
LETTERBOX_DITHERED = 0,
|
||||
LETTERBOX_SOLID = 1,
|
||||
LETTERBOX_NONE = 2,
|
||||
SLEEP_SCREEN_LETTERBOX_FILL_COUNT
|
||||
};
|
||||
|
||||
// Status bar display type enum
|
||||
enum STATUS_BAR_MODE {
|
||||
NONE = 0,
|
||||
NO_PROGRESS = 1,
|
||||
FULL = 2,
|
||||
FULL_WITH_PROGRESS_BAR = 3,
|
||||
ONLY_PROGRESS_BAR = 4,
|
||||
BOOK_PROGRESS_BAR = 3,
|
||||
ONLY_BOOK_PROGRESS_BAR = 4,
|
||||
CHAPTER_PROGRESS_BAR = 5,
|
||||
STATUS_BAR_MODE_COUNT
|
||||
};
|
||||
|
||||
@@ -42,7 +57,7 @@ class CrossPointSettings {
|
||||
ORIENTATION_COUNT
|
||||
};
|
||||
|
||||
// Front button layout options
|
||||
// Front button layout options (legacy)
|
||||
// Default: Back, Confirm, Left, Right
|
||||
// Swapped: Left, Right, Back, Confirm
|
||||
enum FRONT_BUTTON_LAYOUT {
|
||||
@@ -53,6 +68,15 @@ class CrossPointSettings {
|
||||
FRONT_BUTTON_LAYOUT_COUNT
|
||||
};
|
||||
|
||||
// Front button hardware identifiers (for remapping)
|
||||
enum FRONT_BUTTON_HARDWARE {
|
||||
FRONT_HW_BACK = 0,
|
||||
FRONT_HW_CONFIRM = 1,
|
||||
FRONT_HW_LEFT = 2,
|
||||
FRONT_HW_RIGHT = 3,
|
||||
FRONT_BUTTON_HARDWARE_COUNT
|
||||
};
|
||||
|
||||
// Side button layout options
|
||||
// Default: Previous, Next
|
||||
// Swapped: Next, Previous
|
||||
@@ -68,6 +92,7 @@ class CrossPointSettings {
|
||||
LEFT_ALIGN = 1,
|
||||
CENTER_ALIGN = 2,
|
||||
RIGHT_ALIGN = 3,
|
||||
BOOK_STYLE = 4,
|
||||
PARAGRAPH_ALIGNMENT_COUNT
|
||||
};
|
||||
|
||||
@@ -97,12 +122,17 @@ class CrossPointSettings {
|
||||
// Hide battery percentage
|
||||
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
|
||||
|
||||
// UI Theme
|
||||
enum UI_THEME { CLASSIC = 0, LYRA = 1 };
|
||||
|
||||
// Sleep screen settings
|
||||
uint8_t sleepScreen = DARK;
|
||||
// Sleep screen cover mode settings
|
||||
uint8_t sleepScreenCoverMode = FIT;
|
||||
// Sleep screen cover filter
|
||||
uint8_t sleepScreenCoverFilter = NO_FILTER;
|
||||
// Sleep screen letterbox fill mode (Dithered / Solid / None)
|
||||
uint8_t sleepScreenLetterboxFill = LETTERBOX_DITHERED;
|
||||
// Status bar settings
|
||||
uint8_t statusBar = FULL;
|
||||
// Text rendering settings
|
||||
@@ -113,9 +143,15 @@ class CrossPointSettings {
|
||||
// EPUB reading orientation settings
|
||||
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
|
||||
uint8_t orientation = PORTRAIT;
|
||||
// Button layouts
|
||||
// Button layouts (front layout retained for migration only)
|
||||
uint8_t frontButtonLayout = BACK_CONFIRM_LEFT_RIGHT;
|
||||
uint8_t sideButtonLayout = PREV_NEXT;
|
||||
// Front button remap (logical -> hardware)
|
||||
// Used by MappedInputManager to translate logical buttons into physical front buttons.
|
||||
uint8_t frontButtonBack = FRONT_HW_BACK;
|
||||
uint8_t frontButtonConfirm = FRONT_HW_CONFIRM;
|
||||
uint8_t frontButtonLeft = FRONT_HW_LEFT;
|
||||
uint8_t frontButtonRight = FRONT_HW_RIGHT;
|
||||
// Reader font settings
|
||||
uint8_t fontFamily = BOOKERLY;
|
||||
uint8_t fontSize = MEDIUM;
|
||||
@@ -137,6 +173,12 @@ class CrossPointSettings {
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press chapter skip on side buttons
|
||||
uint8_t longPressChapterSkip = 1;
|
||||
// UI Theme
|
||||
uint8_t uiTheme = LYRA;
|
||||
// Sunlight fading compensation
|
||||
uint8_t fadingFix = 0;
|
||||
// Use book's embedded CSS styles for EPUB rendering (1 = enabled, 0 = disabled)
|
||||
uint8_t embeddedStyle = 1;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#include "CrossPointState.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t STATE_FILE_VERSION = 2;
|
||||
constexpr uint8_t STATE_FILE_VERSION = 4;
|
||||
constexpr char STATE_FILE[] = "/.crosspoint/state.bin";
|
||||
} // namespace
|
||||
|
||||
@@ -13,27 +13,29 @@ CrossPointState CrossPointState::instance;
|
||||
|
||||
bool CrossPointState::saveToFile() const {
|
||||
FsFile outputFile;
|
||||
if (!SdMan.openFileForWrite("CPS", STATE_FILE, outputFile)) {
|
||||
if (!Storage.openFileForWrite("CPS", STATE_FILE, outputFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
serialization::writePod(outputFile, STATE_FILE_VERSION);
|
||||
serialization::writeString(outputFile, openEpubPath);
|
||||
serialization::writePod(outputFile, lastSleepImage);
|
||||
serialization::writePod(outputFile, readerActivityLoadCount);
|
||||
serialization::writePod(outputFile, lastSleepFromReader);
|
||||
outputFile.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CrossPointState::loadFromFile() {
|
||||
FsFile inputFile;
|
||||
if (!SdMan.openFileForRead("CPS", STATE_FILE, inputFile)) {
|
||||
if (!Storage.openFileForRead("CPS", STATE_FILE, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
if (version > STATE_FILE_VERSION) {
|
||||
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version);
|
||||
LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version);
|
||||
inputFile.close();
|
||||
return false;
|
||||
}
|
||||
@@ -45,6 +47,16 @@ bool CrossPointState::loadFromFile() {
|
||||
lastSleepImage = 0;
|
||||
}
|
||||
|
||||
if (version >= 3) {
|
||||
serialization::readPod(inputFile, readerActivityLoadCount);
|
||||
}
|
||||
|
||||
if (version >= 4) {
|
||||
serialization::readPod(inputFile, lastSleepFromReader);
|
||||
} else {
|
||||
lastSleepFromReader = false;
|
||||
}
|
||||
|
||||
inputFile.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ class CrossPointState {
|
||||
public:
|
||||
std::string openEpubPath;
|
||||
uint8_t lastSleepImage;
|
||||
uint8_t readerActivityLoadCount = 0;
|
||||
bool lastSleepFromReader = false;
|
||||
~CrossPointState() = default;
|
||||
|
||||
// Get singleton instance
|
||||
|
||||
@@ -5,90 +5,105 @@
|
||||
namespace {
|
||||
using ButtonIndex = uint8_t;
|
||||
|
||||
struct FrontLayoutMap {
|
||||
ButtonIndex back;
|
||||
ButtonIndex confirm;
|
||||
ButtonIndex left;
|
||||
ButtonIndex right;
|
||||
};
|
||||
|
||||
struct SideLayoutMap {
|
||||
ButtonIndex pageBack;
|
||||
ButtonIndex pageForward;
|
||||
};
|
||||
|
||||
// Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT.
|
||||
constexpr FrontLayoutMap kFrontLayouts[] = {
|
||||
{InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_RIGHT},
|
||||
{InputManager::BTN_LEFT, InputManager::BTN_RIGHT, InputManager::BTN_BACK, InputManager::BTN_CONFIRM},
|
||||
{InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_BACK, InputManager::BTN_RIGHT},
|
||||
{InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_RIGHT, InputManager::BTN_LEFT},
|
||||
};
|
||||
|
||||
// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT.
|
||||
constexpr SideLayoutMap kSideLayouts[] = {
|
||||
{InputManager::BTN_UP, InputManager::BTN_DOWN},
|
||||
{InputManager::BTN_DOWN, InputManager::BTN_UP},
|
||||
{HalGPIO::BTN_UP, HalGPIO::BTN_DOWN},
|
||||
{HalGPIO::BTN_DOWN, HalGPIO::BTN_UP},
|
||||
};
|
||||
} // namespace
|
||||
|
||||
bool MappedInputManager::mapButton(const Button button, bool (InputManager::*fn)(uint8_t) const) const {
|
||||
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
|
||||
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
|
||||
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
|
||||
const auto& front = kFrontLayouts[frontLayout];
|
||||
const auto& side = kSideLayouts[sideLayout];
|
||||
|
||||
switch (button) {
|
||||
case Button::Back:
|
||||
return (inputManager.*fn)(front.back);
|
||||
// Logical Back maps to user-configured front button.
|
||||
return (gpio.*fn)(SETTINGS.frontButtonBack);
|
||||
case Button::Confirm:
|
||||
return (inputManager.*fn)(front.confirm);
|
||||
// Logical Confirm maps to user-configured front button.
|
||||
return (gpio.*fn)(SETTINGS.frontButtonConfirm);
|
||||
case Button::Left:
|
||||
return (inputManager.*fn)(front.left);
|
||||
// Logical Left maps to user-configured front button.
|
||||
return (gpio.*fn)(SETTINGS.frontButtonLeft);
|
||||
case Button::Right:
|
||||
return (inputManager.*fn)(front.right);
|
||||
// Logical Right maps to user-configured front button.
|
||||
return (gpio.*fn)(SETTINGS.frontButtonRight);
|
||||
case Button::Up:
|
||||
return (inputManager.*fn)(InputManager::BTN_UP);
|
||||
// Side buttons remain fixed for Up/Down.
|
||||
return (gpio.*fn)(HalGPIO::BTN_UP);
|
||||
case Button::Down:
|
||||
return (inputManager.*fn)(InputManager::BTN_DOWN);
|
||||
// Side buttons remain fixed for Up/Down.
|
||||
return (gpio.*fn)(HalGPIO::BTN_DOWN);
|
||||
case Button::Power:
|
||||
return (inputManager.*fn)(InputManager::BTN_POWER);
|
||||
// Power button bypasses remapping.
|
||||
return (gpio.*fn)(HalGPIO::BTN_POWER);
|
||||
case Button::PageBack:
|
||||
return (inputManager.*fn)(side.pageBack);
|
||||
// Reader page navigation uses side buttons and can be swapped via settings.
|
||||
return (gpio.*fn)(side.pageBack);
|
||||
case Button::PageForward:
|
||||
return (inputManager.*fn)(side.pageForward);
|
||||
// Reader page navigation uses side buttons and can be swapped via settings.
|
||||
return (gpio.*fn)(side.pageForward);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &InputManager::wasPressed); }
|
||||
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
||||
|
||||
bool MappedInputManager::wasReleased(const Button button) const {
|
||||
return mapButton(button, &InputManager::wasReleased);
|
||||
}
|
||||
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
||||
|
||||
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &InputManager::isPressed); }
|
||||
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
||||
|
||||
bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); }
|
||||
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||
|
||||
bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); }
|
||||
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||
|
||||
unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); }
|
||||
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
||||
|
||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||
const char* next) const {
|
||||
const auto layout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
|
||||
// Build the label order based on the configured hardware mapping.
|
||||
auto labelForHardware = [&](uint8_t hw) -> const char* {
|
||||
// Compare against configured logical roles and return the matching label.
|
||||
if (hw == SETTINGS.frontButtonBack) {
|
||||
return back;
|
||||
}
|
||||
if (hw == SETTINGS.frontButtonConfirm) {
|
||||
return confirm;
|
||||
}
|
||||
if (hw == SETTINGS.frontButtonLeft) {
|
||||
return previous;
|
||||
}
|
||||
if (hw == SETTINGS.frontButtonRight) {
|
||||
return next;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
switch (layout) {
|
||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
||||
return {previous, next, back, confirm};
|
||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
||||
return {previous, back, confirm, next};
|
||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
||||
return {back, confirm, next, previous};
|
||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
||||
default:
|
||||
return {back, confirm, previous, next};
|
||||
}
|
||||
return {labelForHardware(HalGPIO::BTN_BACK), labelForHardware(HalGPIO::BTN_CONFIRM),
|
||||
labelForHardware(HalGPIO::BTN_LEFT), labelForHardware(HalGPIO::BTN_RIGHT)};
|
||||
}
|
||||
|
||||
int MappedInputManager::getPressedFrontButton() const {
|
||||
// Scan the raw front buttons in hardware order.
|
||||
// This bypasses remapping so the remap activity can capture physical presses.
|
||||
if (gpio.wasPressed(HalGPIO::BTN_BACK)) {
|
||||
return HalGPIO::BTN_BACK;
|
||||
}
|
||||
if (gpio.wasPressed(HalGPIO::BTN_CONFIRM)) {
|
||||
return HalGPIO::BTN_CONFIRM;
|
||||
}
|
||||
if (gpio.wasPressed(HalGPIO::BTN_LEFT)) {
|
||||
return HalGPIO::BTN_LEFT;
|
||||
}
|
||||
if (gpio.wasPressed(HalGPIO::BTN_RIGHT)) {
|
||||
return HalGPIO::BTN_RIGHT;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <InputManager.h>
|
||||
#include <HalGPIO.h>
|
||||
|
||||
class MappedInputManager {
|
||||
public:
|
||||
@@ -13,8 +13,9 @@ class MappedInputManager {
|
||||
const char* btn4;
|
||||
};
|
||||
|
||||
explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {}
|
||||
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
|
||||
|
||||
void update() const { gpio.update(); }
|
||||
bool wasPressed(Button button) const;
|
||||
bool wasReleased(Button button) const;
|
||||
bool isPressed(Button button) const;
|
||||
@@ -22,9 +23,11 @@ class MappedInputManager {
|
||||
bool wasAnyReleased() const;
|
||||
unsigned long getHeldTime() const;
|
||||
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
|
||||
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||
int getPressedFrontButton() const;
|
||||
|
||||
private:
|
||||
InputManager& inputManager;
|
||||
HalGPIO& gpio;
|
||||
|
||||
bool mapButton(Button button, bool (InputManager::*fn)(uint8_t) const) const;
|
||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||
};
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
#include "RecentBooksStore.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Epub.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 1;
|
||||
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3;
|
||||
constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin";
|
||||
constexpr int MAX_RECENT_BOOKS = 10;
|
||||
} // namespace
|
||||
|
||||
RecentBooksStore RecentBooksStore::instance;
|
||||
|
||||
void RecentBooksStore::addBook(const std::string& path) {
|
||||
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath) {
|
||||
// Remove existing entry if present
|
||||
auto it = std::find(recentBooks.begin(), recentBooks.end(), path);
|
||||
auto it =
|
||||
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
|
||||
if (it != recentBooks.end()) {
|
||||
recentBooks.erase(it);
|
||||
}
|
||||
|
||||
// Add to front
|
||||
recentBooks.insert(recentBooks.begin(), path);
|
||||
recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath});
|
||||
|
||||
// Trim to max size
|
||||
if (recentBooks.size() > MAX_RECENT_BOOKS) {
|
||||
@@ -32,12 +38,25 @@ void RecentBooksStore::addBook(const std::string& path) {
|
||||
saveToFile();
|
||||
}
|
||||
|
||||
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath) {
|
||||
auto it =
|
||||
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
|
||||
if (it != recentBooks.end()) {
|
||||
RecentBook& book = *it;
|
||||
book.title = title;
|
||||
book.author = author;
|
||||
book.coverBmpPath = coverBmpPath;
|
||||
saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
bool RecentBooksStore::saveToFile() const {
|
||||
// Make sure the directory exists
|
||||
SdMan.mkdir("/.crosspoint");
|
||||
Storage.mkdir("/.crosspoint");
|
||||
|
||||
FsFile outputFile;
|
||||
if (!SdMan.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) {
|
||||
if (!Storage.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -46,41 +65,99 @@ bool RecentBooksStore::saveToFile() const {
|
||||
serialization::writePod(outputFile, count);
|
||||
|
||||
for (const auto& book : recentBooks) {
|
||||
serialization::writeString(outputFile, book);
|
||||
serialization::writeString(outputFile, book.path);
|
||||
serialization::writeString(outputFile, book.title);
|
||||
serialization::writeString(outputFile, book.author);
|
||||
serialization::writeString(outputFile, book.coverBmpPath);
|
||||
}
|
||||
|
||||
outputFile.close();
|
||||
Serial.printf("[%lu] [RBS] Recent books saved to file (%d entries)\n", millis(), count);
|
||||
LOG_DBG("RBS", "Recent books saved to file (%d entries)", count);
|
||||
return true;
|
||||
}
|
||||
|
||||
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||
std::string lastBookFileName = "";
|
||||
const size_t lastSlash = path.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
lastBookFileName = path.substr(lastSlash + 1);
|
||||
}
|
||||
|
||||
LOG_DBG("RBS", "Loading recent book: %s", path.c_str());
|
||||
|
||||
// If epub, try to load the metadata for title/author and cover
|
||||
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
|
||||
Epub epub(path, "/.crosspoint");
|
||||
epub.load(false);
|
||||
return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()};
|
||||
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
|
||||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
|
||||
// Handle XTC file
|
||||
Xtc xtc(path, "/.crosspoint");
|
||||
if (xtc.load()) {
|
||||
return RecentBook{path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()};
|
||||
}
|
||||
} else if (StringUtils::checkFileExtension(lastBookFileName, ".txt") ||
|
||||
StringUtils::checkFileExtension(lastBookFileName, ".md")) {
|
||||
return RecentBook{path, lastBookFileName, "", ""};
|
||||
}
|
||||
return RecentBook{path, "", "", ""};
|
||||
}
|
||||
|
||||
bool RecentBooksStore::loadFromFile() {
|
||||
FsFile inputFile;
|
||||
if (!SdMan.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) {
|
||||
if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
if (version != RECENT_BOOKS_FILE_VERSION) {
|
||||
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version);
|
||||
inputFile.close();
|
||||
return false;
|
||||
}
|
||||
if (version == 1 || version == 2) {
|
||||
// Old version, just read paths
|
||||
uint8_t count;
|
||||
serialization::readPod(inputFile, count);
|
||||
recentBooks.clear();
|
||||
recentBooks.reserve(count);
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
std::string path;
|
||||
serialization::readString(inputFile, path);
|
||||
|
||||
uint8_t count;
|
||||
serialization::readPod(inputFile, count);
|
||||
// load book to get missing data
|
||||
RecentBook book = getDataFromBook(path);
|
||||
if (book.title.empty() && book.author.empty() && version == 2) {
|
||||
// Fall back to loading what we can from the store
|
||||
std::string title, author;
|
||||
serialization::readString(inputFile, title);
|
||||
serialization::readString(inputFile, author);
|
||||
recentBooks.push_back({path, title, author, ""});
|
||||
} else {
|
||||
recentBooks.push_back(book);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version);
|
||||
inputFile.close();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
uint8_t count;
|
||||
serialization::readPod(inputFile, count);
|
||||
|
||||
recentBooks.clear();
|
||||
recentBooks.reserve(count);
|
||||
recentBooks.clear();
|
||||
recentBooks.reserve(count);
|
||||
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
std::string path;
|
||||
serialization::readString(inputFile, path);
|
||||
recentBooks.push_back(path);
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
std::string path, title, author, coverBmpPath;
|
||||
serialization::readString(inputFile, path);
|
||||
serialization::readString(inputFile, title);
|
||||
serialization::readString(inputFile, author);
|
||||
serialization::readString(inputFile, coverBmpPath);
|
||||
recentBooks.push_back({path, title, author, coverBmpPath});
|
||||
}
|
||||
}
|
||||
|
||||
inputFile.close();
|
||||
Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), count);
|
||||
LOG_DBG("RBS", "Recent books loaded from file (%d entries)", recentBooks.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct RecentBook {
|
||||
std::string path;
|
||||
std::string title;
|
||||
std::string author;
|
||||
std::string coverBmpPath;
|
||||
|
||||
bool operator==(const RecentBook& other) const { return path == other.path; }
|
||||
};
|
||||
|
||||
class RecentBooksStore {
|
||||
// Static instance
|
||||
static RecentBooksStore instance;
|
||||
|
||||
std::vector<std::string> recentBooks;
|
||||
std::vector<RecentBook> recentBooks;
|
||||
|
||||
public:
|
||||
~RecentBooksStore() = default;
|
||||
@@ -14,11 +23,15 @@ class RecentBooksStore {
|
||||
// Get singleton instance
|
||||
static RecentBooksStore& getInstance() { return instance; }
|
||||
|
||||
// Add a book path to the recent list (moves to front if already exists)
|
||||
void addBook(const std::string& path);
|
||||
// Add a book to the recent list (moves to front if already exists)
|
||||
void addBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath);
|
||||
|
||||
// Get the list of recent book paths (most recent first)
|
||||
const std::vector<std::string>& getBooks() const { return recentBooks; }
|
||||
void updateBook(const std::string& path, const std::string& title, const std::string& author,
|
||||
const std::string& coverBmpPath);
|
||||
|
||||
// Get the list of recent books (most recent first)
|
||||
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
|
||||
|
||||
// Get the count of recent books
|
||||
int getCount() const { return static_cast<int>(recentBooks.size()); }
|
||||
@@ -26,6 +39,7 @@ class RecentBooksStore {
|
||||
bool saveToFile() const;
|
||||
|
||||
bool loadFromFile();
|
||||
RecentBook getDataFromBook(std::string path) const;
|
||||
};
|
||||
|
||||
// Helper macro to access recent books store
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#include "ScreenComponents.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top,
|
||||
const bool showPercentage) {
|
||||
// Left aligned battery icon and percentage
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
|
||||
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str());
|
||||
|
||||
// 1 column on left, 2 columns on right, 5 columns of battery body
|
||||
constexpr int batteryWidth = 15;
|
||||
constexpr int batteryHeight = 12;
|
||||
const int x = left;
|
||||
const int y = top + 6;
|
||||
|
||||
// Top line
|
||||
renderer.drawLine(x + 1, y, x + batteryWidth - 3, y);
|
||||
// Bottom line
|
||||
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1);
|
||||
// Left line
|
||||
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
|
||||
// Battery end
|
||||
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
|
||||
renderer.drawPixel(x + batteryWidth - 1, y + 3);
|
||||
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
|
||||
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5);
|
||||
|
||||
// The +1 is to round up, so that we always fill at least one pixel
|
||||
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
|
||||
if (filledWidth > batteryWidth - 5) {
|
||||
filledWidth = batteryWidth - 5; // Ensure we don't overflow
|
||||
}
|
||||
|
||||
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
||||
}
|
||||
|
||||
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
|
||||
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
|
||||
&vieweableMarginLeft);
|
||||
|
||||
const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight;
|
||||
const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BOOK_PROGRESS_BAR_HEIGHT;
|
||||
const int barWidth = progressBarMaxWidth * bookProgress / 100;
|
||||
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true);
|
||||
}
|
||||
|
||||
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs) {
|
||||
constexpr int tabPadding = 20; // Horizontal padding between tabs
|
||||
constexpr int leftMargin = 20; // Left margin for first tab
|
||||
constexpr int underlineHeight = 2; // Height of selection underline
|
||||
constexpr int underlineGap = 4; // Gap between text and underline
|
||||
|
||||
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
const int tabBarHeight = lineHeight + underlineGap + underlineHeight;
|
||||
|
||||
int currentX = leftMargin;
|
||||
|
||||
for (const auto& tab : tabs) {
|
||||
const int textWidth =
|
||||
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
|
||||
|
||||
// Draw tab label
|
||||
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
|
||||
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
|
||||
|
||||
// Draw underline for selected tab
|
||||
if (tab.selected) {
|
||||
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight);
|
||||
}
|
||||
|
||||
currentX += textWidth + tabPadding;
|
||||
}
|
||||
|
||||
return tabBarHeight;
|
||||
}
|
||||
|
||||
void ScreenComponents::drawScrollIndicator(const GfxRenderer& renderer, const int currentPage, const int totalPages,
|
||||
const int contentTop, const int contentHeight) {
|
||||
if (totalPages <= 1) {
|
||||
return; // No need for indicator if only one page
|
||||
}
|
||||
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
constexpr int indicatorWidth = 20;
|
||||
constexpr int arrowSize = 6;
|
||||
constexpr int margin = 15; // Offset from right edge
|
||||
|
||||
const int centerX = screenWidth - indicatorWidth / 2 - margin;
|
||||
const int indicatorTop = contentTop + 60; // Offset to avoid overlapping side button hints
|
||||
const int indicatorBottom = contentTop + contentHeight - 30;
|
||||
|
||||
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
|
||||
for (int i = 0; i < arrowSize; ++i) {
|
||||
const int lineWidth = 1 + i * 2;
|
||||
const int startX = centerX - i;
|
||||
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
|
||||
}
|
||||
|
||||
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
|
||||
for (int i = 0; i < arrowSize; ++i) {
|
||||
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
|
||||
const int startX = centerX - (arrowSize - 1 - i);
|
||||
renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1,
|
||||
indicatorBottom - arrowSize + 1 + i);
|
||||
}
|
||||
|
||||
// Draw page fraction in the middle (e.g., "1/3")
|
||||
const std::string pageText = std::to_string(currentPage) + "/" + std::to_string(totalPages);
|
||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageText.c_str());
|
||||
const int textX = centerX - textWidth / 2;
|
||||
const int textY = (indicatorTop + indicatorBottom) / 2 - renderer.getLineHeight(SMALL_FONT_ID) / 2;
|
||||
|
||||
renderer.drawText(SMALL_FONT_ID, textX, textY, pageText.c_str());
|
||||
}
|
||||
|
||||
void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width,
|
||||
const int height, const size_t current, const size_t total) {
|
||||
if (total == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use 64-bit arithmetic to avoid overflow for large files
|
||||
const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total);
|
||||
|
||||
// Draw outline
|
||||
renderer.drawRect(x, y, width, height);
|
||||
|
||||
// Draw filled portion
|
||||
const int fillWidth = (width - 4) * percent / 100;
|
||||
if (fillWidth > 0) {
|
||||
renderer.fillRect(x + 2, y + 2, fillWidth, height - 4);
|
||||
}
|
||||
|
||||
// Draw percentage text centered below bar
|
||||
const std::string percentText = std::to_string(percent) + "%";
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str());
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
class GfxRenderer;
|
||||
|
||||
struct TabInfo {
|
||||
const char* label;
|
||||
bool selected;
|
||||
};
|
||||
|
||||
class ScreenComponents {
|
||||
public:
|
||||
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
|
||||
|
||||
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
|
||||
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
|
||||
|
||||
// Draw a horizontal tab bar with underline indicator for selected tab
|
||||
// Returns the height of the tab bar (for positioning content below)
|
||||
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);
|
||||
|
||||
// Draw a scroll/page indicator on the right side of the screen
|
||||
// Shows up/down arrows and current page fraction (e.g., "1/3")
|
||||
static void drawScrollIndicator(const GfxRenderer& renderer, int currentPage, int totalPages, int contentTop,
|
||||
int contentHeight);
|
||||
|
||||
/**
|
||||
* Draw a progress bar with percentage text.
|
||||
* @param renderer The graphics renderer
|
||||
* @param x Left position of the bar
|
||||
* @param y Top position of the bar
|
||||
* @param width Width of the bar
|
||||
* @param height Height of the bar
|
||||
* @param current Current progress value
|
||||
* @param total Total value for 100% progress
|
||||
*/
|
||||
static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current,
|
||||
size_t total);
|
||||
};
|
||||
141
src/SettingsList.h
Normal file
141
src/SettingsList.h
Normal file
@@ -0,0 +1,141 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "activities/settings/SettingsActivity.h"
|
||||
|
||||
// Compile-time table of available font families and their enum values.
|
||||
// Used by the DynamicEnum getter/setter to map between list indices and stored FONT_FAMILY values.
|
||||
struct FontFamilyMapping {
|
||||
const char* name;
|
||||
uint8_t value;
|
||||
};
|
||||
inline constexpr FontFamilyMapping kFontFamilyMappings[] = {
|
||||
#ifndef OMIT_BOOKERLY
|
||||
{"Bookerly", CrossPointSettings::BOOKERLY},
|
||||
#endif
|
||||
#ifndef OMIT_NOTOSANS
|
||||
{"Noto Sans", CrossPointSettings::NOTOSANS},
|
||||
#endif
|
||||
#ifndef OMIT_OPENDYSLEXIC
|
||||
{"Open Dyslexic", CrossPointSettings::OPENDYSLEXIC},
|
||||
#endif
|
||||
};
|
||||
inline constexpr size_t kFontFamilyMappingCount = sizeof(kFontFamilyMappings) / sizeof(kFontFamilyMappings[0]);
|
||||
static_assert(kFontFamilyMappingCount > 0, "At least one font family must be available");
|
||||
|
||||
// Shared settings list used by both the device settings UI and the web settings API.
|
||||
// Each entry has a key (for JSON API) and category (for grouping).
|
||||
// ACTION-type entries and entries without a key are device-only.
|
||||
inline std::vector<SettingInfo> getSettingsList() {
|
||||
// Build font family options from the compile-time mapping table
|
||||
std::vector<std::string> fontFamilyOptions;
|
||||
for (size_t i = 0; i < kFontFamilyMappingCount; i++) {
|
||||
fontFamilyOptions.push_back(kFontFamilyMappings[i].name);
|
||||
}
|
||||
|
||||
return {
|
||||
// --- Display ---
|
||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
|
||||
{"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}, "sleepScreen", "Display"),
|
||||
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"},
|
||||
"sleepScreenCoverMode", "Display"),
|
||||
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
|
||||
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
|
||||
SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill,
|
||||
{"Dithered", "Solid", "None"}, "sleepScreenLetterboxFill", "Display"),
|
||||
SettingInfo::Enum(
|
||||
"Status Bar", &CrossPointSettings::statusBar,
|
||||
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},
|
||||
"statusBar", "Display"),
|
||||
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"},
|
||||
"hideBatteryPercentage", "Display"),
|
||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}, "refreshFrequency", "Display"),
|
||||
SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}, "uiTheme", "Display"),
|
||||
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"),
|
||||
|
||||
// --- Reader ---
|
||||
SettingInfo::DynamicEnum(
|
||||
"Font Family", std::move(fontFamilyOptions),
|
||||
[]() -> uint8_t {
|
||||
for (uint8_t i = 0; i < kFontFamilyMappingCount; i++) {
|
||||
if (kFontFamilyMappings[i].value == SETTINGS.fontFamily) return i;
|
||||
}
|
||||
return 0; // fallback to first available family
|
||||
},
|
||||
[](uint8_t idx) {
|
||||
if (idx < kFontFamilyMappingCount) {
|
||||
SETTINGS.fontFamily = kFontFamilyMappings[idx].value;
|
||||
}
|
||||
},
|
||||
"fontFamily", "Reader"),
|
||||
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize",
|
||||
"Reader"),
|
||||
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing",
|
||||
"Reader"),
|
||||
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}, "screenMargin", "Reader"),
|
||||
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
|
||||
{"Justify", "Left", "Center", "Right", "Book's Style"}, "paragraphAlignment", "Reader"),
|
||||
SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle, "embeddedStyle", "Reader"),
|
||||
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", "Reader"),
|
||||
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
|
||||
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}, "orientation", "Reader"),
|
||||
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing,
|
||||
"extraParagraphSpacing", "Reader"),
|
||||
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing, "textAntiAliasing", "Reader"),
|
||||
|
||||
// --- Controls ---
|
||||
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||
{"Prev, Next", "Next, Prev"}, "sideButtonLayout", "Controls"),
|
||||
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip",
|
||||
"Controls"),
|
||||
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"},
|
||||
"shortPwrBtn", "Controls"),
|
||||
|
||||
// --- System ---
|
||||
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
|
||||
{"1 min", "5 min", "10 min", "15 min", "30 min"}, "sleepTimeout", "System"),
|
||||
|
||||
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
|
||||
SettingInfo::DynamicString(
|
||||
"KOReader Username", [] { return KOREADER_STORE.getUsername(); },
|
||||
[](const std::string& v) {
|
||||
KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword());
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"koUsername", "KOReader Sync"),
|
||||
SettingInfo::DynamicString(
|
||||
"KOReader Password", [] { return KOREADER_STORE.getPassword(); },
|
||||
[](const std::string& v) {
|
||||
KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v);
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"koPassword", "KOReader Sync"),
|
||||
SettingInfo::DynamicString(
|
||||
"Sync Server URL", [] { return KOREADER_STORE.getServerUrl(); },
|
||||
[](const std::string& v) {
|
||||
KOREADER_STORE.setServerUrl(v);
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"koServerUrl", "KOReader Sync"),
|
||||
SettingInfo::DynamicEnum(
|
||||
"Document Matching", {"Filename", "Binary"},
|
||||
[] { return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod()); },
|
||||
[](uint8_t v) {
|
||||
KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(v));
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"koMatchMethod", "KOReader Sync"),
|
||||
|
||||
// --- OPDS Browser (web-only, uses CrossPointSettings char arrays) ---
|
||||
SettingInfo::String("OPDS Server URL", SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), "opdsServerUrl",
|
||||
"OPDS Browser"),
|
||||
SettingInfo::String("OPDS Username", SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername",
|
||||
"OPDS Browser"),
|
||||
SettingInfo::String("OPDS Password", SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword",
|
||||
"OPDS Browser"),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#include "WifiCredentialStore.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
// Initialize the static instance
|
||||
@@ -9,7 +9,7 @@ WifiCredentialStore WifiCredentialStore::instance;
|
||||
|
||||
namespace {
|
||||
// File format version
|
||||
constexpr uint8_t WIFI_FILE_VERSION = 1;
|
||||
constexpr uint8_t WIFI_FILE_VERSION = 2; // Increased version
|
||||
|
||||
// WiFi credentials file path
|
||||
constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin";
|
||||
@@ -21,7 +21,7 @@ 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());
|
||||
LOG_DBG("WCS", "Obfuscating/deobfuscating %zu bytes", data.size());
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH];
|
||||
}
|
||||
@@ -29,23 +29,23 @@ void WifiCredentialStore::obfuscate(std::string& data) const {
|
||||
|
||||
bool WifiCredentialStore::saveToFile() const {
|
||||
// Make sure the directory exists
|
||||
SdMan.mkdir("/.crosspoint");
|
||||
Storage.mkdir("/.crosspoint");
|
||||
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForWrite("WCS", WIFI_FILE, file)) {
|
||||
if (!Storage.openFileForWrite("WCS", WIFI_FILE, file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write header
|
||||
serialization::writePod(file, WIFI_FILE_VERSION);
|
||||
serialization::writeString(file, lastConnectedSsid); // Save last connected SSID
|
||||
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());
|
||||
LOG_DBG("WCS", "Saving SSID: %s, password length: %zu", cred.ssid.c_str(), cred.password.size());
|
||||
|
||||
// Write password (obfuscated)
|
||||
std::string obfuscatedPwd = cred.password;
|
||||
@@ -54,25 +54,31 @@ bool WifiCredentialStore::saveToFile() const {
|
||||
}
|
||||
|
||||
file.close();
|
||||
Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size());
|
||||
LOG_DBG("WCS", "Saved %zu WiFi credentials to file", credentials.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WifiCredentialStore::loadFromFile() {
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForRead("WCS", WIFI_FILE, file)) {
|
||||
if (!Storage.openFileForRead("WCS", WIFI_FILE, file)) {
|
||||
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);
|
||||
if (version > WIFI_FILE_VERSION) {
|
||||
LOG_DBG("WCS", "Unknown file version: %u", version);
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (version >= 2) {
|
||||
serialization::readString(file, lastConnectedSsid);
|
||||
} else {
|
||||
lastConnectedSsid.clear();
|
||||
}
|
||||
|
||||
// Read credential count
|
||||
uint8_t count;
|
||||
serialization::readPod(file, count);
|
||||
@@ -87,16 +93,15 @@ bool WifiCredentialStore::loadFromFile() {
|
||||
|
||||
// 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());
|
||||
LOG_DBG("WCS", "Loaded SSID: %s, obfuscated password length: %zu", 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());
|
||||
LOG_DBG("WCS", "After deobfuscation, password length: %zu", cred.password.size());
|
||||
|
||||
credentials.push_back(cred);
|
||||
}
|
||||
|
||||
file.close();
|
||||
Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size());
|
||||
LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -106,19 +111,19 @@ bool WifiCredentialStore::addCredential(const std::string& ssid, const std::stri
|
||||
[&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
|
||||
if (cred != credentials.end()) {
|
||||
cred->password = password;
|
||||
Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str());
|
||||
LOG_DBG("WCS", "Updated credentials for: %s", 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);
|
||||
LOG_DBG("WCS", "Cannot add more networks, limit of %zu reached", 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());
|
||||
LOG_DBG("WCS", "Added credentials for: %s", ssid.c_str());
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
@@ -127,7 +132,10 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) {
|
||||
[&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
|
||||
if (cred != credentials.end()) {
|
||||
credentials.erase(cred);
|
||||
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
|
||||
LOG_DBG("WCS", "Removed credentials for: %s", ssid.c_str());
|
||||
if (ssid == lastConnectedSsid) {
|
||||
clearLastConnectedSsid();
|
||||
}
|
||||
return saveToFile();
|
||||
}
|
||||
return false; // Not found
|
||||
@@ -146,8 +154,25 @@ const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssi
|
||||
|
||||
bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
|
||||
|
||||
void WifiCredentialStore::setLastConnectedSsid(const std::string& ssid) {
|
||||
if (lastConnectedSsid != ssid) {
|
||||
lastConnectedSsid = ssid;
|
||||
saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
const std::string& WifiCredentialStore::getLastConnectedSsid() const { return lastConnectedSsid; }
|
||||
|
||||
void WifiCredentialStore::clearLastConnectedSsid() {
|
||||
if (!lastConnectedSsid.empty()) {
|
||||
lastConnectedSsid.clear();
|
||||
saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
void WifiCredentialStore::clearAll() {
|
||||
credentials.clear();
|
||||
lastConnectedSsid.clear();
|
||||
saveToFile();
|
||||
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis());
|
||||
LOG_DBG("WCS", "Cleared all WiFi credentials");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class WifiCredentialStore {
|
||||
private:
|
||||
static WifiCredentialStore instance;
|
||||
std::vector<WifiCredential> credentials;
|
||||
std::string lastConnectedSsid;
|
||||
|
||||
static constexpr size_t MAX_NETWORKS = 8;
|
||||
|
||||
@@ -48,6 +49,11 @@ class WifiCredentialStore {
|
||||
// Check if a network is saved
|
||||
bool hasSavedCredential(const std::string& ssid) const;
|
||||
|
||||
// Last connected network
|
||||
void setLastConnectedSsid(const std::string& ssid);
|
||||
const std::string& getLastConnectedSsid() const;
|
||||
void clearLastConnectedSsid();
|
||||
|
||||
// Clear all credentials
|
||||
void clearAll();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -18,9 +18,10 @@ class Activity {
|
||||
explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {}
|
||||
virtual ~Activity() = default;
|
||||
virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); }
|
||||
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }
|
||||
virtual void onEnter() { LOG_DBG("ACT", "Entering activity: %s", name.c_str()); }
|
||||
virtual void onExit() { LOG_DBG("ACT", "Exiting activity: %s", name.c_str()); }
|
||||
virtual void loop() {}
|
||||
virtual bool skipLoopDelay() { return false; }
|
||||
virtual bool preventAutoSleep() { return false; }
|
||||
virtual bool isReaderActivity() const { return false; }
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "fontIds.h"
|
||||
#include "images/CrossLarge.h"
|
||||
#include "images/Logo120.h"
|
||||
|
||||
void BootActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
@@ -12,7 +12,7 @@ void BootActivity::onEnter() {
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
|
||||
renderer.drawImage(Logo120, (pageWidth - 120) / 2, (pageHeight - 120) / 2, 120, 120);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING");
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION);
|
||||
|
||||
@@ -2,52 +2,368 @@
|
||||
|
||||
#include <Epub.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Serialization.h>
|
||||
#include <Txt.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "util/BookSettings.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "images/CrossLarge.h"
|
||||
#include "images/Logo120.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Number of source pixels along the image edge to average for the dominant color
|
||||
constexpr int EDGE_SAMPLE_DEPTH = 20;
|
||||
|
||||
// Map a 2-bit quantized pixel value to an 8-bit grayscale value
|
||||
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
|
||||
|
||||
// Letterbox fill data: one average gray value per edge (top/bottom or left/right).
|
||||
struct LetterboxFillData {
|
||||
uint8_t avgA = 128; // average gray of edge A (top or left)
|
||||
uint8_t avgB = 128; // average gray of edge B (bottom or right)
|
||||
int letterboxA = 0; // pixel size of the first letterbox area (top or left)
|
||||
int letterboxB = 0; // pixel size of the second letterbox area (bottom or right)
|
||||
bool horizontal = false; // true = top/bottom letterbox, false = left/right
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
// Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255.
|
||||
uint8_t snapToEinkLevel(uint8_t gray) {
|
||||
// Thresholds at midpoints: 42, 127, 212
|
||||
if (gray < 43) return 0;
|
||||
if (gray < 128) return 85;
|
||||
if (gray < 213) return 170;
|
||||
return 255;
|
||||
}
|
||||
|
||||
// 4x4 Bayer ordered dithering matrix, values 0-255.
|
||||
// Produces a structured halftone pattern for 4-level quantization.
|
||||
// clang-format off
|
||||
constexpr uint8_t BAYER_4X4[4][4] = {
|
||||
{ 0, 128, 32, 160},
|
||||
{192, 64, 224, 96},
|
||||
{ 48, 176, 16, 144},
|
||||
{240, 112, 208, 80}
|
||||
};
|
||||
// clang-format on
|
||||
|
||||
// Ordered (Bayer) dithering for 4-level e-ink display.
|
||||
// Maps an 8-bit gray value to a 2-bit level (0-3) using the Bayer matrix
|
||||
// to produce a structured, repeating halftone pattern.
|
||||
uint8_t quantizeBayerDither(int gray, int x, int y) {
|
||||
const int threshold = BAYER_4X4[y & 3][x & 3];
|
||||
const int scaled = gray * 3;
|
||||
if (scaled < 255) {
|
||||
return (scaled + threshold >= 255) ? 1 : 0;
|
||||
} else if (scaled < 510) {
|
||||
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
|
||||
} else {
|
||||
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether a gray value would produce a dithered mix that crosses the
|
||||
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
|
||||
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
|
||||
// creating a high-frequency checkerboard that causes e-ink display crosstalk
|
||||
// and washes out adjacent content during HALF_REFRESH.
|
||||
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
|
||||
bool bayerCrossesBwBoundary(uint8_t gray) {
|
||||
return gray > 170 && gray < 255;
|
||||
}
|
||||
|
||||
// Hash-based block dithering for BW-boundary gray values (171-254).
|
||||
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
|
||||
// determined by a deterministic spatial hash. The proportion of level-3 blocks
|
||||
// approximates the target gray. Unlike Bayer, the pattern is irregular
|
||||
// (noise-like), making it much less visually obvious at the same block size.
|
||||
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
|
||||
// identical levels across BW, LSB, and MSB render passes.
|
||||
static constexpr int BW_DITHER_BLOCK = 2;
|
||||
|
||||
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
|
||||
const int bx = x / BW_DITHER_BLOCK;
|
||||
const int by = y / BW_DITHER_BLOCK;
|
||||
// Fast mixing hash (splitmix32-inspired)
|
||||
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
|
||||
h ^= h >> 16;
|
||||
h *= 0x45d9f3bu;
|
||||
h ^= h >> 16;
|
||||
// Proportion of level-3 blocks needed to approximate the target gray
|
||||
const float ratio = (avg - 170.0f) / 85.0f;
|
||||
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
|
||||
return (h < threshold) ? 3 : 2;
|
||||
}
|
||||
|
||||
// --- Edge average cache ---
|
||||
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
|
||||
constexpr uint8_t EDGE_CACHE_VERSION = 2;
|
||||
|
||||
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) {
|
||||
FsFile file;
|
||||
if (!Storage.openFileForRead("SLP", path, file)) return false;
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(file, version);
|
||||
if (version != EDGE_CACHE_VERSION) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t cachedW, cachedH;
|
||||
serialization::readPod(file, cachedW);
|
||||
serialization::readPod(file, cachedH);
|
||||
if (cachedW != static_cast<uint16_t>(screenWidth) || cachedH != static_cast<uint16_t>(screenHeight)) {
|
||||
file.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t horizontal;
|
||||
serialization::readPod(file, horizontal);
|
||||
data.horizontal = (horizontal != 0);
|
||||
|
||||
serialization::readPod(file, data.avgA);
|
||||
serialization::readPod(file, data.avgB);
|
||||
|
||||
int16_t lbA, lbB;
|
||||
serialization::readPod(file, lbA);
|
||||
serialization::readPod(file, lbB);
|
||||
data.letterboxA = lbA;
|
||||
data.letterboxB = lbB;
|
||||
|
||||
file.close();
|
||||
data.valid = true;
|
||||
LOG_DBG("SLP", "Loaded edge cache from %s (avgA=%d, avgB=%d)", path.c_str(), data.avgA, data.avgB);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
|
||||
if (!data.valid) return false;
|
||||
|
||||
FsFile file;
|
||||
if (!Storage.openFileForWrite("SLP", path, file)) return false;
|
||||
|
||||
serialization::writePod(file, EDGE_CACHE_VERSION);
|
||||
serialization::writePod(file, static_cast<uint16_t>(screenWidth));
|
||||
serialization::writePod(file, static_cast<uint16_t>(screenHeight));
|
||||
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
|
||||
serialization::writePod(file, data.avgA);
|
||||
serialization::writePod(file, data.avgB);
|
||||
serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
|
||||
serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
|
||||
file.close();
|
||||
|
||||
LOG_DBG("SLP", "Saved edge cache to %s", path.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges.
|
||||
// Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers.
|
||||
// After sampling the bitmap is rewound via rewindToData().
|
||||
LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
|
||||
float scale, float cropX, float cropY) {
|
||||
LetterboxFillData data;
|
||||
|
||||
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
|
||||
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
|
||||
const int visibleWidth = bitmap.getWidth() - 2 * cropPixX;
|
||||
const int visibleHeight = bitmap.getHeight() - 2 * cropPixY;
|
||||
|
||||
if (visibleWidth <= 0 || visibleHeight <= 0) return data;
|
||||
|
||||
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
|
||||
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
|
||||
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
|
||||
if (!outputRow || !rowBytes) {
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return data;
|
||||
}
|
||||
|
||||
if (imgY > 0) {
|
||||
// Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows
|
||||
data.horizontal = true;
|
||||
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
|
||||
data.letterboxA = imgY;
|
||||
data.letterboxB = pageHeight - imgY - scaledHeight;
|
||||
if (data.letterboxB < 0) data.letterboxB = 0;
|
||||
|
||||
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
|
||||
uint64_t sumTop = 0, sumBot = 0;
|
||||
int countTop = 0, countBot = 0;
|
||||
|
||||
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||||
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||||
const int outY = logicalY - cropPixY;
|
||||
|
||||
const bool inTop = (outY < sampleRows);
|
||||
const bool inBot = (outY >= visibleHeight - sampleRows);
|
||||
if (!inTop && !inBot) continue;
|
||||
|
||||
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||
const uint8_t gray = val2bitToGray(val);
|
||||
if (inTop) {
|
||||
sumTop += gray;
|
||||
countTop++;
|
||||
}
|
||||
if (inBot) {
|
||||
sumBot += gray;
|
||||
countBot++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
|
||||
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
|
||||
data.valid = true;
|
||||
} else if (imgX > 0) {
|
||||
// Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
|
||||
data.horizontal = false;
|
||||
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
|
||||
data.letterboxA = imgX;
|
||||
data.letterboxB = pageWidth - imgX - scaledWidth;
|
||||
if (data.letterboxB < 0) data.letterboxB = 0;
|
||||
|
||||
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
|
||||
uint64_t sumLeft = 0, sumRight = 0;
|
||||
int countLeft = 0, countRight = 0;
|
||||
|
||||
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||||
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||||
|
||||
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
|
||||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||
sumLeft += val2bitToGray(val);
|
||||
countLeft++;
|
||||
}
|
||||
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||
sumRight += val2bitToGray(val);
|
||||
countRight++;
|
||||
}
|
||||
}
|
||||
|
||||
data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
|
||||
data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
|
||||
data.valid = true;
|
||||
}
|
||||
|
||||
bitmap.rewindToData();
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Draw letterbox fill in the areas around the cover image.
|
||||
// DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color.
|
||||
// SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill.
|
||||
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
|
||||
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
|
||||
if (!data.valid) return;
|
||||
|
||||
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
|
||||
|
||||
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
|
||||
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
|
||||
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
|
||||
//
|
||||
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
|
||||
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
|
||||
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
|
||||
// crosstalk, and the irregular hash pattern is much less visible than a regular
|
||||
// Bayer grid at the same block size.
|
||||
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
|
||||
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
|
||||
|
||||
// For solid mode: snap to nearest e-ink level
|
||||
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
|
||||
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
|
||||
|
||||
if (data.horizontal) {
|
||||
if (data.letterboxA > 0) {
|
||||
for (int y = 0; y < data.letterboxA; y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid) lv = levelA;
|
||||
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
|
||||
else lv = quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
if (data.letterboxB > 0) {
|
||||
const int start = renderer.getScreenHeight() - data.letterboxB;
|
||||
for (int y = start; y < renderer.getScreenHeight(); y++)
|
||||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||
uint8_t lv;
|
||||
if (isSolid) lv = levelB;
|
||||
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
|
||||
else lv = quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (data.letterboxA > 0) {
|
||||
for (int x = 0; x < data.letterboxA; x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid) lv = levelA;
|
||||
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
|
||||
else lv = quantizeBayerDither(data.avgA, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
if (data.letterboxB > 0) {
|
||||
const int start = renderer.getScreenWidth() - data.letterboxB;
|
||||
for (int x = start; x < renderer.getScreenWidth(); x++)
|
||||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||
uint8_t lv;
|
||||
if (isSolid) lv = levelB;
|
||||
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
|
||||
else lv = quantizeBayerDither(data.avgB, x, y);
|
||||
renderer.drawPixelGray(x, y, lv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SleepActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
renderPopup("Entering Sleep...");
|
||||
GUI.drawPopup(renderer, "Entering Sleep...");
|
||||
|
||||
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
|
||||
return renderBlankSleepScreen();
|
||||
switch (SETTINGS.sleepScreen) {
|
||||
case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK):
|
||||
return renderBlankSleepScreen();
|
||||
case (CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM):
|
||||
return renderCustomSleepScreen();
|
||||
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER):
|
||||
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM):
|
||||
return renderCoverSleepScreen();
|
||||
default:
|
||||
return renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) {
|
||||
return renderCustomSleepScreen();
|
||||
}
|
||||
|
||||
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) {
|
||||
return renderCoverSleepScreen();
|
||||
}
|
||||
|
||||
renderDefaultSleepScreen();
|
||||
}
|
||||
|
||||
void SleepActivity::renderPopup(const char* message) const {
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
|
||||
constexpr int margin = 20;
|
||||
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
|
||||
constexpr int y = 117;
|
||||
const int w = textWidth + margin * 2;
|
||||
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
|
||||
// renderer.clearScreen();
|
||||
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
|
||||
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
|
||||
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void SleepActivity::renderCustomSleepScreen() const {
|
||||
// Check if we have a /sleep directory
|
||||
auto dir = SdMan.open("/sleep");
|
||||
auto dir = Storage.open("/sleep");
|
||||
if (dir && dir.isDirectory()) {
|
||||
std::vector<std::string> files;
|
||||
char name[500];
|
||||
@@ -65,13 +381,13 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
}
|
||||
|
||||
if (filename.substr(filename.length() - 4) != ".bmp") {
|
||||
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name);
|
||||
LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name);
|
||||
file.close();
|
||||
continue;
|
||||
}
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
|
||||
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name);
|
||||
LOG_DBG("SLP", "Skipping invalid BMP file: %s", name);
|
||||
file.close();
|
||||
continue;
|
||||
}
|
||||
@@ -90,8 +406,8 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
APP_STATE.saveToFile();
|
||||
const auto filename = "/sleep/" + files[randomFileIndex];
|
||||
FsFile file;
|
||||
if (SdMan.openFileForRead("SLP", filename, file)) {
|
||||
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
|
||||
if (Storage.openFileForRead("SLP", filename, file)) {
|
||||
LOG_DBG("SLP", "Randomly loading: /sleep/%s", files[randomFileIndex].c_str());
|
||||
delay(100);
|
||||
Bitmap bitmap(file, true);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
@@ -107,10 +423,10 @@ void SleepActivity::renderCustomSleepScreen() const {
|
||||
// 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.
|
||||
FsFile file;
|
||||
if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) {
|
||||
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
|
||||
Bitmap bitmap(file, true);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
|
||||
LOG_DBG("SLP", "Loading: /sleep.bmp");
|
||||
renderBitmapSleepScreen(bitmap);
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +440,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
|
||||
renderer.drawImage(Logo120, (pageWidth - 120) / 2, (pageHeight - 120) / 2, 120, 120);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
|
||||
|
||||
@@ -133,74 +449,119 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
|
||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath,
|
||||
uint8_t fillModeOverride) const {
|
||||
int x, y;
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
float cropX = 0, cropY = 0;
|
||||
|
||||
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
|
||||
pageWidth, pageHeight);
|
||||
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
|
||||
// image will scale, make sure placement is right
|
||||
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
||||
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
|
||||
|
||||
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio);
|
||||
if (ratio > screenRatio) {
|
||||
// image wider than viewport ratio, scaled down image needs to be centered vertically
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropX = 1.0f - (screenRatio / ratio);
|
||||
Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX);
|
||||
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
}
|
||||
x = 0;
|
||||
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
||||
Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y);
|
||||
} else {
|
||||
// image taller than viewport ratio, scaled down image needs to be centered horizontally
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropY = 1.0f - (ratio / screenRatio);
|
||||
Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY);
|
||||
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
||||
}
|
||||
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
||||
y = 0;
|
||||
Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x);
|
||||
// Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images)
|
||||
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
||||
|
||||
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
|
||||
if (ratio > screenRatio) {
|
||||
// image wider than viewport ratio, needs to be centered vertically
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropX = 1.0f - (screenRatio / ratio);
|
||||
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
|
||||
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
}
|
||||
x = 0;
|
||||
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
||||
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
|
||||
} else {
|
||||
// center the image
|
||||
x = (pageWidth - bitmap.getWidth()) / 2;
|
||||
y = (pageHeight - bitmap.getHeight()) / 2;
|
||||
// image taller than or equal to viewport ratio, needs to be centered horizontally
|
||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||
cropY = 1.0f - (ratio / screenRatio);
|
||||
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
|
||||
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
||||
}
|
||||
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
||||
y = 0;
|
||||
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
|
||||
}
|
||||
|
||||
LOG_DBG("SLP", "drawing to %d x %d", x, y);
|
||||
|
||||
// Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords
|
||||
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
|
||||
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
|
||||
const float scale =
|
||||
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
|
||||
|
||||
// Determine letterbox fill settings (per-book override takes precedence)
|
||||
const uint8_t fillMode = (fillModeOverride != BookSettings::USE_GLOBAL &&
|
||||
fillModeOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT)
|
||||
? fillModeOverride
|
||||
: SETTINGS.sleepScreenLetterboxFill;
|
||||
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
|
||||
|
||||
static const char* fillModeNames[] = {"dithered", "solid", "none"};
|
||||
const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown";
|
||||
|
||||
// Compute edge averages if letterbox fill is requested (try cache first)
|
||||
LetterboxFillData fillData;
|
||||
const bool hasLetterbox = (x > 0 || y > 0);
|
||||
if (hasLetterbox && wantFill) {
|
||||
bool cacheLoaded = false;
|
||||
if (!edgeCachePath.empty()) {
|
||||
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||||
}
|
||||
if (!cacheLoaded) {
|
||||
LOG_DBG("SLP", "Letterbox detected (x=%d, y=%d), computing edge averages for %s fill", x, y, fillModeName);
|
||||
fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
|
||||
if (fillData.valid && !edgeCachePath.empty()) {
|
||||
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||||
}
|
||||
}
|
||||
if (fillData.valid) {
|
||||
LOG_DBG("SLP", "Letterbox fill: %s, horizontal=%d, avgA=%d, avgB=%d, letterboxA=%d, letterboxB=%d",
|
||||
fillModeName, fillData.horizontal, fillData.avgA, fillData.avgB, fillData.letterboxA,
|
||||
fillData.letterboxB);
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y);
|
||||
renderer.clearScreen();
|
||||
|
||||
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
||||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
||||
|
||||
// Draw letterbox fill (BW pass)
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
|
||||
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
|
||||
if (hasGreyscale) {
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleLsbBuffers();
|
||||
|
||||
bitmap.rewindToData();
|
||||
renderer.clearScreen(0x00);
|
||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||
if (fillData.valid) {
|
||||
drawLetterboxFill(renderer, fillData, fillMode);
|
||||
}
|
||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||
renderer.copyGrayscaleMsbBuffers();
|
||||
|
||||
@@ -210,11 +571,22 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
}
|
||||
|
||||
void SleepActivity::renderCoverSleepScreen() const {
|
||||
void (SleepActivity::*renderNoCoverSleepScreen)() const;
|
||||
switch (SETTINGS.sleepScreen) {
|
||||
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM):
|
||||
renderNoCoverSleepScreen = &SleepActivity::renderCustomSleepScreen;
|
||||
break;
|
||||
default:
|
||||
renderNoCoverSleepScreen = &SleepActivity::renderDefaultSleepScreen;
|
||||
break;
|
||||
}
|
||||
|
||||
if (APP_STATE.openEpubPath.empty()) {
|
||||
return renderDefaultSleepScreen();
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
std::string coverBmpPath;
|
||||
std::string bookCachePath;
|
||||
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
||||
|
||||
// Check if the current book is XTC, TXT, or EPUB
|
||||
@@ -223,62 +595,95 @@ void SleepActivity::renderCoverSleepScreen() const {
|
||||
// Handle XTC file
|
||||
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
if (!lastXtc.load()) {
|
||||
Serial.println("[SLP] Failed to load last XTC");
|
||||
return renderDefaultSleepScreen();
|
||||
LOG_ERR("SLP", "Failed to load last XTC");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
if (!lastXtc.generateCoverBmp()) {
|
||||
Serial.println("[SLP] Failed to generate XTC cover bmp");
|
||||
return renderDefaultSleepScreen();
|
||||
LOG_DBG("SLP", "XTC cover generation failed, trying placeholder");
|
||||
PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800);
|
||||
}
|
||||
|
||||
if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) {
|
||||
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
||||
bookCachePath = lastXtc.getCachePath();
|
||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
||||
// Handle TXT file - looks for cover image in the same folder
|
||||
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
if (!lastTxt.load()) {
|
||||
Serial.println("[SLP] Failed to load last TXT");
|
||||
return renderDefaultSleepScreen();
|
||||
LOG_ERR("SLP", "Failed to load last TXT");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
if (!lastTxt.generateCoverBmp()) {
|
||||
Serial.println("[SLP] No cover image found for TXT file");
|
||||
return renderDefaultSleepScreen();
|
||||
LOG_DBG("SLP", "TXT cover generation failed, trying placeholder");
|
||||
PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800);
|
||||
}
|
||||
|
||||
if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) {
|
||||
LOG_ERR("SLP", "No cover image found for TXT file");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
coverBmpPath = lastTxt.getCoverBmpPath();
|
||||
bookCachePath = lastTxt.getCachePath();
|
||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
||||
// Handle EPUB file
|
||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||
if (!lastEpub.load()) {
|
||||
Serial.println("[SLP] Failed to load last epub");
|
||||
return renderDefaultSleepScreen();
|
||||
// Skip loading css since we only need metadata here
|
||||
if (!lastEpub.load(true, true)) {
|
||||
LOG_ERR("SLP", "Failed to load last epub");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
if (!lastEpub.generateCoverBmp(cropped)) {
|
||||
Serial.println("[SLP] Failed to generate cover bmp");
|
||||
return renderDefaultSleepScreen();
|
||||
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
|
||||
PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
|
||||
lastEpub.getAuthor(), 480, 800);
|
||||
}
|
||||
|
||||
if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) {
|
||||
LOG_ERR("SLP", "Failed to generate cover bmp");
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
|
||||
bookCachePath = lastEpub.getCachePath();
|
||||
} else {
|
||||
return renderDefaultSleepScreen();
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
// Load per-book letterbox fill override (falls back to global if not set)
|
||||
uint8_t fillModeOverride = BookSettings::USE_GLOBAL;
|
||||
if (!bookCachePath.empty()) {
|
||||
auto bookSettings = BookSettings::load(bookCachePath);
|
||||
fillModeOverride = bookSettings.letterboxFillOverride;
|
||||
}
|
||||
|
||||
FsFile file;
|
||||
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
|
||||
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath);
|
||||
renderBitmapSleepScreen(bitmap);
|
||||
LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
|
||||
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
|
||||
std::string edgeCachePath;
|
||||
const auto dotPos = coverBmpPath.rfind(".bmp");
|
||||
if (dotPos != std::string::npos) {
|
||||
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
|
||||
}
|
||||
renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderDefaultSleepScreen();
|
||||
return (this->*renderNoCoverSleepScreen)();
|
||||
}
|
||||
|
||||
void SleepActivity::renderBlankSleepScreen() const {
|
||||
renderer.clearScreen();
|
||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
class Bitmap;
|
||||
@@ -10,10 +13,11 @@ class SleepActivity final : public Activity {
|
||||
void onEnter() override;
|
||||
|
||||
private:
|
||||
void renderPopup(const char* message) const;
|
||||
void renderDefaultSleepScreen() const;
|
||||
void renderCustomSleepScreen() const;
|
||||
void renderCoverSleepScreen() const;
|
||||
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
|
||||
// fillModeOverride: 0xFF = use global setting, otherwise a SLEEP_SCREEN_LETTERBOX_FILL value.
|
||||
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "",
|
||||
uint8_t fillModeOverride = 0xFF) const;
|
||||
void renderBlankSleepScreen() const;
|
||||
};
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
#include <Epub.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <Logging.h>
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
#include "util/StringUtils.h"
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
namespace {
|
||||
constexpr int PAGE_ITEMS = 23;
|
||||
constexpr int SKIP_PAGE_MS = 700;
|
||||
} // namespace
|
||||
|
||||
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
||||
@@ -79,14 +78,14 @@ void OpdsBookBrowserActivity::loop() {
|
||||
// Check if WiFi is still connected
|
||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
||||
// WiFi connected - just retry fetching the feed
|
||||
Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis());
|
||||
LOG_DBG("OPDS", "Retry: WiFi connected, retrying fetch");
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = "Loading...";
|
||||
updateRequired = true;
|
||||
fetchFeed(currentPath);
|
||||
} else {
|
||||
// WiFi not connected - launch WiFi selection
|
||||
Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis());
|
||||
LOG_DBG("OPDS", "Retry: WiFi not connected, launching selection");
|
||||
launchWifiSelection();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -118,12 +117,6 @@ void OpdsBookBrowserActivity::loop() {
|
||||
|
||||
// Handle browsing state
|
||||
if (state == BrowserState::BROWSING) {
|
||||
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
@@ -135,20 +128,29 @@ void OpdsBookBrowserActivity::loop() {
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
} else if (prevReleased && !entries.empty()) {
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
|
||||
}
|
||||
updateRequired = true;
|
||||
} else if (nextReleased && !entries.empty()) {
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size();
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + 1) % entries.size();
|
||||
}
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
if (!entries.empty()) {
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size());
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
updateRequired = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +178,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
@@ -184,7 +186,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
if (state == BrowserState::LOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
@@ -193,7 +195,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:");
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Retry", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +208,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
constexpr int barHeight = 20;
|
||||
constexpr int barX = 50;
|
||||
const int barY = pageHeight / 2 + 20;
|
||||
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal);
|
||||
GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal);
|
||||
}
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
@@ -219,7 +221,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
confirmLabel = "Download";
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
if (entries.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");
|
||||
@@ -263,7 +265,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
}
|
||||
|
||||
std::string url = UrlUtils::buildUrl(serverUrl, path);
|
||||
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
|
||||
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
|
||||
|
||||
OpdsParser parser;
|
||||
|
||||
@@ -285,7 +287,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
}
|
||||
|
||||
entries = std::move(parser).getEntries();
|
||||
Serial.printf("[%lu] [OPDS] Found %d entries\n", millis(), entries.size());
|
||||
LOG_DBG("OPDS", "Found %d entries", entries.size());
|
||||
selectorIndex = 0;
|
||||
|
||||
if (entries.empty()) {
|
||||
@@ -349,7 +351,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
}
|
||||
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
|
||||
|
||||
Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str());
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
const auto result =
|
||||
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
|
||||
@@ -359,12 +361,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
});
|
||||
|
||||
if (result == HttpDownloader::OK) {
|
||||
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str());
|
||||
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
|
||||
|
||||
// Invalidate any existing cache for this file to prevent stale metadata issues
|
||||
Epub epub(filename, "/.crosspoint");
|
||||
epub.clearCache();
|
||||
Serial.printf("[%lu] [OPDS] Cleared cache for: %s\n", millis(), filename.c_str());
|
||||
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
||||
|
||||
state = BrowserState::BROWSING;
|
||||
updateRequired = true;
|
||||
@@ -401,13 +403,13 @@ void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) {
|
||||
exitActivity();
|
||||
|
||||
if (connected) {
|
||||
Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis());
|
||||
LOG_DBG("OPDS", "WiFi connected via selection, fetching feed");
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = "Loading...";
|
||||
updateRequired = true;
|
||||
fetchFeed(currentPath);
|
||||
} else {
|
||||
Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis());
|
||||
LOG_DBG("OPDS", "WiFi selection cancelled/failed");
|
||||
// Force disconnect to ensure clean state for next retry
|
||||
// This prevents stale connection status from interfering
|
||||
WiFi.disconnect();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user