Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3093e3f71 | ||
|
|
498e087a68 | ||
|
|
e44c004be6 | ||
|
|
45d19f6e1b | ||
|
|
8f33bee6ef | ||
|
|
bc1ba7277f | ||
|
|
57267f5372 | ||
|
|
7c4f69680c | ||
|
|
2cc497cdca | ||
|
|
8db3542e90 | ||
|
|
87d9d1dc2a | ||
|
|
588984ec30 | ||
|
|
b8e743ef80 | ||
|
|
3e2c518b8e | ||
|
|
402e887f73 | ||
|
|
103fac2ee1 | ||
|
|
6527f43cb1 | ||
|
|
eb241ab3fc | ||
|
|
840e8c38f1 | ||
|
|
c4e3c244ea | ||
|
|
3c1bd77813 | ||
|
|
10b7769865 | ||
|
|
448a77f02b | ||
|
|
e1074a84c0 | ||
|
|
e70066e7c2 | ||
|
|
00e25b1a90 | ||
|
|
fdcd71e94d | ||
|
|
e7ee6ff05e | ||
|
|
c6ddc5d6a0 | ||
|
|
feff739963 | ||
|
|
f1740dbe1e | ||
|
|
6be4413c97 | ||
|
|
47aa0dda76 | ||
|
|
f16c0e52fd | ||
|
|
d02e21a48f | ||
|
|
6ec5fc5603 | ||
|
|
9125a7ce68 | ||
|
|
dc6562a51c | ||
|
|
530d43997b | ||
|
|
97c33141bd | ||
|
|
2a32d8a182 | ||
|
|
d6f38d4441 | ||
|
|
513d111634 | ||
|
|
ad9137cfdf | ||
|
|
5c80cface7 | ||
|
|
86d3774a8f | ||
|
|
7ba5978848 | ||
|
|
3d47c081f2 | ||
|
|
6702060960 | ||
|
|
0bc6747483 | ||
|
|
00666377de | ||
|
|
22b77edddf | ||
|
|
2e673c753d | ||
|
|
1a30826981 | ||
|
|
50e6ef9bd8 | ||
|
|
a616f42cb4 | ||
|
|
0508bfc1f7 | ||
|
|
6c3a615fac | ||
|
|
46c2109f1f | ||
|
|
5816ab2a47 | ||
|
|
2c0a105550 | ||
|
|
6e51afb977 | ||
|
|
cb24947477 | ||
|
|
7a385d78a4 | ||
|
|
0991782fb4 | ||
|
|
3ae1007cbe | ||
|
|
efb9b72e64 | ||
|
|
4a210823a8 | ||
|
|
f5b85f5ca1 | ||
|
|
7e93411f46 | ||
|
|
44452a42e9 | ||
|
|
0c2df24f5c | ||
|
|
3a12ca2725 | ||
|
|
98e6789626 | ||
|
|
b5d28a3a9c | ||
|
|
14ef625679 | ||
|
|
64d161e88b | ||
|
|
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 | ||
|
|
ebcd813ff6 | ||
|
|
712c566664 | ||
|
|
5894ae5afe | ||
|
|
8c1c80787a | ||
|
|
140fcb9db5 | ||
|
|
e0b6b9b28a | ||
|
|
83315b6179 | ||
|
|
8e0d2bece2 | ||
|
|
4848a77e1b | ||
|
|
49190cca6d | ||
|
|
e9c2fe1c87 | ||
|
|
dd1741bf0b | ||
|
|
51c5c3c0aa | ||
|
|
5e24895f6d | ||
|
|
e2ca0e94ca | ||
|
|
a4b9a43ca1 | ||
|
|
c73fca26f5 | ||
|
|
dfd7b615dc | ||
|
|
aca6dceaa8 | ||
|
|
6ca75c4653 | ||
|
|
1b9c8ab545 | ||
|
|
bf6cf83577 | ||
|
|
3a761b18af | ||
|
|
13f0ebed96 | ||
|
|
0bc0baa966 | ||
|
|
5d369df6be | ||
|
|
b8ebcf5867 | ||
|
|
e858ebbe88 | ||
|
|
9224bc3f8c | ||
|
|
67a679ab41 | ||
|
|
7a53342f9d |
+89
-17
@@ -1,10 +1,55 @@
|
|||||||
name: CI
|
name: CI (build)
|
||||||
'on':
|
|
||||||
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -19,19 +64,46 @@ jobs:
|
|||||||
- name: Install PlatformIO Core
|
- name: Install PlatformIO Core
|
||||||
run: pip install --upgrade platformio
|
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
|
- 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,6 +1,7 @@
|
|||||||
name: "PR Formatting"
|
name: "PR Formatting"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
|
|||||||
@@ -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
-1
@@ -3,6 +3,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
lib/EpdFont/fontsrc
|
lib/EpdFont/fontsrc
|
||||||
|
lib/I18n/I18nStrings.cpp
|
||||||
*.generated.h
|
*.generated.h
|
||||||
|
.vs
|
||||||
build
|
build
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
|
/compile_commands.json
|
||||||
|
/.cache
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -41,13 +41,17 @@ This project is **not affiliated with Xteink**; it's built as a community projec
|
|||||||
- [ ] Full UTF support
|
- [ ] Full UTF support
|
||||||
- [x] Screen rotation
|
- [x] Screen rotation
|
||||||
|
|
||||||
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
|
Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages).
|
||||||
|
|
||||||
|
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
|
## Installing
|
||||||
|
|
||||||
### Web (latest firmware)
|
### 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"
|
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
|
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||||
@@ -56,7 +60,7 @@ back to the other partition using the "Swap boot partition" button here https://
|
|||||||
### Web (specific firmware version)
|
### Web (specific firmware version)
|
||||||
|
|
||||||
1. Connect your Xteink X4 to your computer via USB-C
|
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
|
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
|
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||||
@@ -80,7 +84,7 @@ See [Development](#development) below.
|
|||||||
CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone the repository:
|
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:
|
# Or, if you've already cloned without --recursive:
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
@@ -93,6 +97,25 @@ Connect your Xteink X4 to your computer via USB-C and run the following command.
|
|||||||
```sh
|
```sh
|
||||||
pio run --target upload
|
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
|
## Internals
|
||||||
|
|
||||||
@@ -131,9 +154,12 @@ For more details on the internal file structures, see the [file formats document
|
|||||||
|
|
||||||
Contributions are very welcome!
|
Contributions are very welcome!
|
||||||
|
|
||||||
If you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/daveallie/crosspoint-reader/discussions/categories/ideas).
|
If 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.
|
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:
|
### To submit a contribution:
|
||||||
|
|
||||||
1. Fork the repo
|
1. Fork the repo
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.
|
||||||
|
* **Reference Tools:** E.g. Local dictionary lookup. Providing quick, offline definitions to enhance comprehension
|
||||||
|
without breaking focus.
|
||||||
|
|
||||||
|
### 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 Annotation:** No typed out notes. 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!
|
||||||
+53
-2
@@ -13,14 +13,17 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [3.2 Book Selection](#32-book-selection)
|
- [3.2 Book Selection](#32-book-selection)
|
||||||
- [3.3 Reading Mode](#33-reading-mode)
|
- [3.3 Reading Mode](#33-reading-mode)
|
||||||
- [3.4 File Upload Screen](#34-file-upload-screen)
|
- [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.5 Settings](#35-settings)
|
||||||
- [3.6 Sleep Screen](#36-sleep-screen)
|
- [3.6 Sleep Screen](#36-sleep-screen)
|
||||||
- [4. Reading Mode](#4-reading-mode)
|
- [4. Reading Mode](#4-reading-mode)
|
||||||
- [Page Turning](#page-turning)
|
- [Page Turning](#page-turning)
|
||||||
- [Chapter Navigation](#chapter-navigation)
|
- [Chapter Navigation](#chapter-navigation)
|
||||||
- [System Navigation](#system-navigation)
|
- [System Navigation](#system-navigation)
|
||||||
|
- [Supported Languages](#supported-languages)
|
||||||
- [5. Chapter Selection Screen](#5-chapter-selection-screen)
|
- [5. Chapter Selection Screen](#5-chapter-selection-screen)
|
||||||
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
|
||||||
|
- [7. Troubleshooting Issues \& Escaping Bootloop](#7-troubleshooting-issues--escaping-bootloop)
|
||||||
|
|
||||||
|
|
||||||
## 1. Hardware Overview
|
## 1. Hardware Overview
|
||||||
@@ -81,6 +84,18 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details.
|
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details.
|
||||||
|
|
||||||
|
### 3.4.1 Calibre Wireless Transfers
|
||||||
|
|
||||||
|
CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin.
|
||||||
|
|
||||||
|
1. Install the plugin in Calibre:
|
||||||
|
- Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin.
|
||||||
|
- Download the zip file.
|
||||||
|
- Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file.
|
||||||
|
2. On the device: File Transfer → Connect to Calibre → Join a network.
|
||||||
|
3. Make sure your computer is on the same WiFi network.
|
||||||
|
4. In Calibre, click "Send to device" to transfer books.
|
||||||
|
|
||||||
### 3.5 Settings
|
### 3.5 Settings
|
||||||
|
|
||||||
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
|
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
|
||||||
@@ -90,13 +105,21 @@ 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
|
- "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)
|
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
|
||||||
- "None" - A blank screen
|
- "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:
|
- **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
|
- "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)
|
- "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)
|
||||||
|
- **Sleep Screen Cover Filter**: What filter will be applied to the book cover when "Cover" sleep screen is selected
|
||||||
|
- "None" (default) - The cover image will be converted to a grayscale image and displayed as it is
|
||||||
|
- "Contrast" - The image will be displayed as a black & white image without grayscale conversion
|
||||||
|
- "Inverted" - The image will be inverted as in white&black and will be displayed without grayscale conversion
|
||||||
- **Status Bar**: Configure the status bar displayed while reading:
|
- **Status Bar**: Configure the status bar displayed while reading:
|
||||||
- "None" - No status bar
|
- "None" - No status bar
|
||||||
- "No Progress" - Show status bar without reading progress
|
- "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:
|
- **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)
|
- "Never" - Always show battery percentage (default)
|
||||||
- "In Reader" - Show battery percentage everywhere except in reading mode
|
- "In Reader" - Show battery percentage everywhere except in reading mode
|
||||||
@@ -116,6 +139,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
|||||||
- Back, Confirm, Left, Right (default)
|
- Back, Confirm, Left, Right (default)
|
||||||
- Left, Right, Back, Confirm
|
- Left, Right, Back, Confirm
|
||||||
- Left, Back, Confirm, Right
|
- Left, Back, Confirm, Right
|
||||||
|
- Back, Confirm, Right, Left
|
||||||
- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading.
|
- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading.
|
||||||
- **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter.
|
- **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter.
|
||||||
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
|
- "Chapter Skip" (default) - Long-pressing skips to next/previous chapter
|
||||||
@@ -131,7 +155,10 @@ 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".
|
- **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.
|
- **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.
|
- **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting.
|
||||||
- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device.
|
- **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.
|
- **Check for updates**: Check for firmware updates over WiFi.
|
||||||
|
|
||||||
### 3.6 Sleep Screen
|
### 3.6 Sleep Screen
|
||||||
@@ -177,6 +204,15 @@ This feature can be disabled in **[Settings](#35-settings)** to help avoid chang
|
|||||||
* **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
|
* **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
|
||||||
* **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**.
|
* **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**.
|
||||||
|
|
||||||
|
### Supported Languages
|
||||||
|
|
||||||
|
CrossPoint renders text using the following Unicode character blocks, enabling support for a wide range of languages:
|
||||||
|
|
||||||
|
* **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, Greek and Farsi.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Chapter Selection Screen
|
## 5. Chapter Selection Screen
|
||||||
@@ -194,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:
|
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.
|
* **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).
|
||||||
|
|||||||
+12
-1
@@ -1,10 +1,18 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Check if clang-format is availible
|
||||||
|
command -v clang-format >/dev/null 2>&1 || {
|
||||||
|
printf "'clang-format' not found in current environment\n"
|
||||||
|
printf "install 'clang', 'clang-tools', or 'clang-format' depending on your distro/os and tooling requirements\n"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
GIT_LS_FILES_FLAGS=""
|
GIT_LS_FILES_FLAGS=""
|
||||||
if [[ "$1" == "-g" ]]; then
|
if [[ "$1" == "-g" ]]; then
|
||||||
GIT_LS_FILES_FLAGS="--modified"
|
GIT_LS_FILES_FLAGS="--modified"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# --- Main Logic ---
|
# --- Main Logic ---
|
||||||
|
|
||||||
# Format all files (or only modified files if -g is passed)
|
# Format all files (or only modified files if -g is passed)
|
||||||
@@ -13,7 +21,10 @@ fi
|
|||||||
# --modified: files tracked by git that have been modified (staged or unstaged)
|
# --modified: files tracked by git that have been modified (staged or unstaged)
|
||||||
# --exclude-standard: ignores files in .gitignore
|
# --exclude-standard: ignores files in .gitignore
|
||||||
# Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated.
|
# 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} \
|
git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
|
||||||
| grep -E '\.(c|cpp|h|hpp)$' \
|
| grep -E '\.(c|cpp|h|hpp)$' \
|
||||||
| grep -v -E '^lib/EpdFont/builtinFonts/' \
|
| grep -v -E '^lib/EpdFont/builtinFonts/' \
|
||||||
|
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
|
||||||
|
| grep -v -E '^lib/uzlib/' \
|
||||||
| xargs -r clang-format -style=file -i
|
| xargs -r clang-format -style=file -i
|
||||||
|
|||||||
@@ -45,22 +45,9 @@ byte arrays, and emits headers under
|
|||||||
`SerializedHyphenationPatterns` descriptor so the reader can keep the automaton
|
`SerializedHyphenationPatterns` descriptor so the reader can keep the automaton
|
||||||
in flash.
|
in flash.
|
||||||
|
|
||||||
To refresh the firmware assets after updating the `.bin` files, run:
|
A convenient script `update_hyphenation.sh` is used to update all languages.
|
||||||
|
To use it, run:
|
||||||
|
|
||||||
```
|
```sh
|
||||||
./scripts/generate_hyphenation_trie.py \
|
./scripts/update_hypenation.sh
|
||||||
--input lib/Epub/Epub/hyphenation/tries/en.bin \
|
|
||||||
--output lib/Epub/Epub/hyphenation/generated/hyph-en.trie.h
|
|
||||||
|
|
||||||
./scripts/generate_hyphenation_trie.py \
|
|
||||||
--input lib/Epub/Epub/hyphenation/tries/fr.bin \
|
|
||||||
--output lib/Epub/Epub/hyphenation/generated/hyph-fr.trie.h
|
|
||||||
|
|
||||||
./scripts/generate_hyphenation_trie.py \
|
|
||||||
--input lib/Epub/Epub/hyphenation/tries/de.bin \
|
|
||||||
--output lib/Epub/Epub/hyphenation/generated/hyph-de.trie.h
|
|
||||||
|
|
||||||
./scripts/generate_hyphenation_trie.py \
|
|
||||||
--input lib/Epub/Epub/hyphenation/tries/ru.bin \
|
|
||||||
--output lib/Epub/Epub/hyphenation/generated/hyph-ru.trie.h
|
|
||||||
```
|
```
|
||||||
|
|||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
# Internationalization (I18N)
|
||||||
|
|
||||||
|
This guide explains the multi-language support system in CrossPoint Reader.
|
||||||
|
|
||||||
|
## Supported Languages
|
||||||
|
|
||||||
|
- English
|
||||||
|
- French
|
||||||
|
- German
|
||||||
|
- Portuguese
|
||||||
|
- Spanish
|
||||||
|
- Swedish
|
||||||
|
- Czech
|
||||||
|
- Russian
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## For Developers
|
||||||
|
|
||||||
|
### Translation System Architecture
|
||||||
|
|
||||||
|
The I18N system uses **per-language YAML files** to maintain translations and a Python script to generate C++ code:
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/I18n/
|
||||||
|
├── translations/ # One YAML file per language
|
||||||
|
│ ├── english.yaml
|
||||||
|
│ ├── spanish.yaml
|
||||||
|
│ ├── french.yaml
|
||||||
|
│ └── ...
|
||||||
|
├── I18n.h
|
||||||
|
├── I18n.cpp
|
||||||
|
├── I18nKeys.h # Enums (auto-generated)
|
||||||
|
├── I18nStrings.h # String array declarations (auto-generated)
|
||||||
|
└── I18nStrings.cpp # String array definitions (auto-generated)
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
└── gen_i18n.py # Code generator script
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key principle:** All translations are managed in the YAML files under `lib/I18n/translations/`. The Python script generates the necessary C++ code automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### YAML File Format
|
||||||
|
|
||||||
|
Each language has its own file in `lib/I18n/translations/` (e.g. `spanish.yaml`).
|
||||||
|
|
||||||
|
A file looks like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
_language_name: "Español"
|
||||||
|
_language_code: "SPANISH"
|
||||||
|
_order: "1"
|
||||||
|
|
||||||
|
STR_CROSSPOINT: "CrossPoint"
|
||||||
|
STR_BOOTING: "BOOTING"
|
||||||
|
STR_BROWSE_FILES: "Buscar archivos"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Metadata keys** (prefixed with `_`):
|
||||||
|
- `_language_name` — Native display name shown to the user (e.g. "Français")
|
||||||
|
- `_language_code` — C++ enum name (e.g. "FRENCH"). Must be a valid C++ identifier.
|
||||||
|
- `_order` — Controls the position in the Language enum (English is always 0)
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Use UTF-8 encoding
|
||||||
|
- Every line must follow the format: `KEY: "value"`
|
||||||
|
- Keys must be valid C++ identifiers (uppercase, strats with STR_)
|
||||||
|
- Keys must be unique within a file
|
||||||
|
- String values must be quoted
|
||||||
|
- Use `\n` for newlines, `\\` for literal backslashes, `\"` for literal quotes inside values
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Adding New Strings
|
||||||
|
|
||||||
|
To add a new translatable string:
|
||||||
|
|
||||||
|
#### 1. Edit the English YAML file
|
||||||
|
|
||||||
|
Add the key to `lib/I18n/translations/english.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
STR_MY_NEW_STRING: "My New String"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add translations in each language file. If a key is missing from a
|
||||||
|
language file, the generator will automatically use the English text as a
|
||||||
|
fallback (and print a warning).
|
||||||
|
|
||||||
|
#### 2. Run the generator script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically:
|
||||||
|
- Fills missing translations from English
|
||||||
|
- Updates the `StrId` enum in `I18nKeys.h`
|
||||||
|
- Regenerates all language arrays in `I18nStrings.cpp`
|
||||||
|
|
||||||
|
#### 3. Use in code
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
// Using the tr() macro (recommended)
|
||||||
|
renderer.drawText(font, x, y, tr(STR_MY_NEW_STRING));
|
||||||
|
|
||||||
|
// Using I18N.get() directly
|
||||||
|
const char* text = I18N.get(StrId::STR_MY_NEW_STRING);
|
||||||
|
```
|
||||||
|
|
||||||
|
**That's it!** No manual array synchronization needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Adding a New Language
|
||||||
|
|
||||||
|
To add support for a new language (e.g., Italian):
|
||||||
|
|
||||||
|
#### 1. Create a new YAML file
|
||||||
|
|
||||||
|
Create `lib/I18n/translations/italian.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
_language_name: "Italiano"
|
||||||
|
_language_code: "ITALIAN"
|
||||||
|
_order: "7"
|
||||||
|
|
||||||
|
STR_CROSSPOINT: "CrossPoint"
|
||||||
|
STR_BOOTING: "AVVIO"
|
||||||
|
```
|
||||||
|
|
||||||
|
You only need to include the strings you have translations for. Missing
|
||||||
|
keys will fall back to English automatically.
|
||||||
|
|
||||||
|
#### 2. Run the generator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically updates all necessary code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modifying Existing Translations
|
||||||
|
|
||||||
|
Simply edit the relevant YAML file and regenerate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### UTF-8 Encoding
|
||||||
|
|
||||||
|
The YAML files use UTF-8 encoding. Special characters are automatically converted to C++ UTF-8 hex sequences by the generator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I18N API Reference
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// === Convenience Macros (Recommended) ===
|
||||||
|
|
||||||
|
// tr(id) - Get translated string without StrId:: prefix
|
||||||
|
const char* text = tr(STR_SETTINGS_TITLE);
|
||||||
|
renderer.drawText(font, x, y, tr(STR_BROWSE_FILES));
|
||||||
|
Serial.printf("Status: %s\n", tr(STR_CONNECTED));
|
||||||
|
|
||||||
|
// I18N - Shorthand for I18n::getInstance()
|
||||||
|
I18N.setLanguage(Language::SPANISH);
|
||||||
|
Language lang = I18N.getLanguage();
|
||||||
|
|
||||||
|
// === Full API ===
|
||||||
|
|
||||||
|
// Get the singleton instance
|
||||||
|
I18n& instance = I18n::getInstance();
|
||||||
|
|
||||||
|
// Get translated string (three equivalent ways)
|
||||||
|
const char* text = tr(STR_SETTINGS_TITLE); // Macro (recommended)
|
||||||
|
const char* text = I18N.get(StrId::STR_SETTINGS_TITLE); // Direct call
|
||||||
|
const char* text = I18N[StrId::STR_SETTINGS_TITLE]; // Operator overload
|
||||||
|
|
||||||
|
// Set language
|
||||||
|
I18N.setLanguage(Language::SPANISH);
|
||||||
|
|
||||||
|
// Get current language
|
||||||
|
Language lang = I18N.getLanguage();
|
||||||
|
|
||||||
|
// Save language setting to file
|
||||||
|
I18N.saveSettings();
|
||||||
|
|
||||||
|
// Load language setting from file
|
||||||
|
I18N.loadSettings();
|
||||||
|
|
||||||
|
// Get character set for font subsetting (static method)
|
||||||
|
const char* chars = I18n::getCharacterSet(Language::FRENCH);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
Language settings are stored in:
|
||||||
|
```
|
||||||
|
/.crosspoint/language.bin
|
||||||
|
```
|
||||||
|
|
||||||
|
This file contains:
|
||||||
|
- Version byte
|
||||||
|
- Current language selection (1 byte)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Translation Workflow
|
||||||
|
|
||||||
|
### For Developers (Adding Features)
|
||||||
|
|
||||||
|
1. Add new strings to `lib/I18n/translations/english.yaml`
|
||||||
|
2. Run `python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/`
|
||||||
|
3. Use the new `StrId` in your code
|
||||||
|
4. Request translations from translators
|
||||||
|
|
||||||
|
### For Translators
|
||||||
|
|
||||||
|
1. Open the YAML file for your language in `lib/I18n/translations/`
|
||||||
|
2. Add or update translations using the format `STR_KEY: "translated text"`
|
||||||
|
3. Keep translations concise (E-ink space constraints)
|
||||||
|
4. Make sure the file is in UTF-8 encoding
|
||||||
|
5. Run `python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/` to verify
|
||||||
|
6. Test on device or submit for review
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
# Translators
|
||||||
|
|
||||||
|
Below is a list of users and languages CrossPoint may support in the future.
|
||||||
|
Note because a language is below does not mean there is official support for the language at this time.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
If you'd like to add your name to this list, please open a PR adding yourself and your Github link. Thank you!
|
||||||
|
|
||||||
|
## French
|
||||||
|
- [Spigaw](https://github.com/Spigaw)
|
||||||
|
- [CaptainFrito](https://github.com/CaptainFrito)
|
||||||
|
|
||||||
|
## German
|
||||||
|
- [DavidOrtmann](https://github.com/DavidOrtmann)
|
||||||
|
|
||||||
|
## Czech
|
||||||
|
- [brbla](https://github.com/brbla)
|
||||||
|
|
||||||
|
## Portuguese (Brazil)
|
||||||
|
- [yagofarias](https://github.com/yagofarias)
|
||||||
|
|
||||||
|
## Italian
|
||||||
|
- [fragolinux](https://github.com/fragolinux)
|
||||||
|
|
||||||
|
## Russian
|
||||||
|
- [madebyKir](https://github.com/madebyKir)
|
||||||
|
- [mrtnvgr](https://github.com/mrtnvgr)
|
||||||
|
|
||||||
|
## Spanish
|
||||||
|
- [yeyeto2788](https://github.com/yeyeto2788)
|
||||||
|
- [Skrzakk](https://github.com/Skrzakk)
|
||||||
|
- [pablohc](https://github.com/pablohc)
|
||||||
|
|
||||||
|
## Swedish
|
||||||
|
- [dawiik](https://github.com/dawiik)
|
||||||
+32
-22
@@ -1,14 +1,14 @@
|
|||||||
# Web Server Guide
|
# Web Server Guide
|
||||||
|
|
||||||
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload EPUB files from your computer or phone.
|
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload files from your computer or phone.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
CrossPoint Reader includes a built-in web server that allows you to:
|
CrossPoint Reader includes a built-in web server that allows you to:
|
||||||
|
|
||||||
- Upload EPUB files wirelessly from any device on the same WiFi network
|
- Upload files wirelessly from any device on the same WiFi network
|
||||||
- Browse and manage files on your device's SD card
|
- Browse and manage files on your device's SD card
|
||||||
- Create folders to organize your ebooks
|
- Create folders to organize your library
|
||||||
- Delete files and folders
|
- Delete files and folders
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -129,34 +129,31 @@ Click **File Manager** to access file management features.
|
|||||||
#### Browsing Files
|
#### Browsing Files
|
||||||
|
|
||||||
- The file manager displays all files and folders on your SD card
|
- The file manager displays all files and folders on your SD card
|
||||||
- **Folders** are highlighted in yellow with a 📁 icon
|
- **Folders** are highlighted in yellow and indicated with a 📁 icon
|
||||||
- **EPUB files** are highlighted in green with a 📗 icon
|
- **EPUB Files** are highlighted in green and indicated with a 📗 icon
|
||||||
|
- **All Other Files** are not highlighted and indicated with a 📄 icon
|
||||||
- Click on a folder name to navigate into it
|
- Click on a folder name to navigate into it
|
||||||
- Use the breadcrumb navigation at the top to go back to parent folders
|
- Use the breadcrumb navigation at the top to go back to parent folders
|
||||||
|
|
||||||
<img src="./images/wifi/webserver_files.png" width="600">
|
<img src="./images/wifi/webserver_files.png" width="600">
|
||||||
|
|
||||||
#### Uploading EPUB Files
|
#### Uploading Files
|
||||||
|
|
||||||
1. Click the **+ Add** button in the top-right corner
|
1. Click the **📤 Upload** button in the top-right corner
|
||||||
2. Select **Upload eBook** from the dropdown menu
|
2. Click **Choose File** and select a file from your device
|
||||||
3. Click **Choose File** and select an `.epub` file from your device
|
3. Click **Upload**
|
||||||
4. Click **Upload**
|
4. A progress bar will show the upload status
|
||||||
5. A progress bar will show the upload status
|
5. The page will automatically refresh when the upload is complete
|
||||||
6. The page will automatically refresh when the upload is complete
|
|
||||||
|
|
||||||
**Note:** Only `.epub` files are accepted. Other file types will be rejected.
|
|
||||||
|
|
||||||
<img src="./images/wifi/webserver_upload.png" width="600">
|
<img src="./images/wifi/webserver_upload.png" width="600">
|
||||||
|
|
||||||
#### Creating Folders
|
#### Creating Folders
|
||||||
|
|
||||||
1. Click the **+ Add** button in the top-right corner
|
1. Click the **📁 New Folder** button in the top-right corner
|
||||||
2. Select **New Folder** from the dropdown menu
|
2. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
||||||
3. Enter a folder name (letters, numbers, underscores, and hyphens only)
|
3. Click **Create Folder**
|
||||||
4. Click **Create Folder**
|
|
||||||
|
|
||||||
This is useful for organizing your ebooks by genre, author, or series.
|
This is useful for organizing your library by genre, author, series or file type.
|
||||||
|
|
||||||
#### Deleting Files and Folders
|
#### Deleting Files and Folders
|
||||||
|
|
||||||
@@ -168,11 +165,25 @@ This is useful for organizing your ebooks by genre, author, or series.
|
|||||||
|
|
||||||
**Note:** Folders must be empty before they can be deleted.
|
**Note:** Folders must be empty before they can be deleted.
|
||||||
|
|
||||||
|
#### Moving Files
|
||||||
|
|
||||||
|
1. Click the **📂** (folder) icon next to any file
|
||||||
|
2. Enter a folder name or select one from the dropdown
|
||||||
|
3. Click **Move** to relocate the file
|
||||||
|
|
||||||
|
**Note:** Typing in a nonexistent folder name will result in the following error: "Failed to move: Destination not found"
|
||||||
|
|
||||||
|
#### Renaming Files
|
||||||
|
|
||||||
|
1. Click the **✏️** (pencil) icon next to any file
|
||||||
|
2. Enter a file name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
||||||
|
3. Click **Rename** to permanently rename the file
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Command Line File Management
|
## Command Line File Management
|
||||||
|
|
||||||
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode a detailed documentation can be found [here](./webserver-endpoints.md).
|
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode. Detailed documentation can be found [here](./webserver-endpoints.md).
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
@@ -189,7 +200,6 @@ For power users, you can manage files directly from your terminal using `curl` w
|
|||||||
- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
|
- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
|
||||||
- **Web Server Port:** 80 (HTTP)
|
- **Web Server Port:** 80 (HTTP)
|
||||||
- **Maximum Upload Size:** Limited by available SD card space
|
- **Maximum Upload Size:** Limited by available SD card space
|
||||||
- **Supported File Format:** `.epub` only
|
|
||||||
- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
|
- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -198,7 +208,7 @@ For power users, you can manage files directly from your terminal using `curl` w
|
|||||||
|
|
||||||
1. **Organize with folders** - Create folders before uploading to keep your library organized
|
1. **Organize with folders** - Create folders before uploading to keep your library organized
|
||||||
2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
|
2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
|
||||||
3. **Upload multiple files** - You can upload files one at a time; the page refreshes after each upload
|
3. **Upload multiple files** - You can select and upload multiple files at once; the manager will queue them and refresh when the batch is finished
|
||||||
4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
|
4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
|
||||||
5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
|
5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
|
||||||
6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
|
6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
|
||||||
|
|||||||
+29
-13
@@ -17,6 +17,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
|||||||
|
|
||||||
int cursorX = startX;
|
int cursorX = startX;
|
||||||
const int cursorY = startY;
|
const int cursorY = startY;
|
||||||
|
int lastBaseX = startX;
|
||||||
|
int lastBaseAdvance = 0;
|
||||||
|
int lastBaseTop = 0;
|
||||||
|
bool hasBaseGlyph = false;
|
||||||
|
constexpr int MIN_COMBINING_GAP_PX = 1;
|
||||||
uint32_t cp;
|
uint32_t cp;
|
||||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
|
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
|
||||||
const EpdGlyph* glyph = getGlyph(cp);
|
const EpdGlyph* glyph = getGlyph(cp);
|
||||||
@@ -30,11 +35,30 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
*minX = std::min(*minX, cursorX + glyph->left);
|
const bool isCombining = utf8IsCombiningMark(cp);
|
||||||
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
|
int raiseBy = 0;
|
||||||
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
|
if (isCombining && hasBaseGlyph) {
|
||||||
*maxY = std::max(*maxY, cursorY + glyph->top);
|
const int currentGap = glyph->top - glyph->height - lastBaseTop;
|
||||||
cursorX += glyph->advanceX;
|
if (currentGap < MIN_COMBINING_GAP_PX) {
|
||||||
|
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
|
||||||
|
const int glyphBaseY = cursorY - raiseBy;
|
||||||
|
|
||||||
|
*minX = std::min(*minX, glyphBaseX + glyph->left);
|
||||||
|
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
|
||||||
|
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
|
||||||
|
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
|
||||||
|
|
||||||
|
if (!isCombining) {
|
||||||
|
lastBaseX = cursorX;
|
||||||
|
lastBaseAdvance = glyph->advanceX;
|
||||||
|
lastBaseTop = glyph->top;
|
||||||
|
hasBaseGlyph = true;
|
||||||
|
cursorX += glyph->advanceX;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +71,6 @@ void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {
|
|||||||
*h = maxY - minY;
|
*h = maxY - minY;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool EpdFont::hasPrintableChars(const char* string) const {
|
|
||||||
int w = 0, h = 0;
|
|
||||||
|
|
||||||
getTextDimensions(string, &w, &h);
|
|
||||||
|
|
||||||
return w > 0 || h > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
|
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
|
||||||
const EpdUnicodeInterval* intervals = data->intervals;
|
const EpdUnicodeInterval* intervals = data->intervals;
|
||||||
const int count = data->intervalCount;
|
const int count = data->intervalCount;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ class EpdFont {
|
|||||||
explicit EpdFont(const EpdFontData* data) : data(data) {}
|
explicit EpdFont(const EpdFontData* data) : data(data) {}
|
||||||
~EpdFont() = default;
|
~EpdFont() = default;
|
||||||
void getTextDimensions(const char* string, int* w, int* h) const;
|
void getTextDimensions(const char* string, int* w, int* h) const;
|
||||||
bool hasPrintableChars(const char* string) const;
|
|
||||||
|
|
||||||
const EpdGlyph* getGlyph(uint32_t cp) const;
|
const EpdGlyph* getGlyph(uint32_t cp) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ typedef struct {
|
|||||||
int16_t left; ///< X dist from cursor pos to UL corner
|
int16_t left; ///< X dist from cursor pos to UL corner
|
||||||
int16_t top; ///< Y dist from cursor pos to UL corner
|
int16_t top; ///< Y dist from cursor pos to UL corner
|
||||||
uint16_t dataLength; ///< Size of the font data.
|
uint16_t dataLength; ///< Size of the font data.
|
||||||
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap
|
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap (or within-group offset for compressed fonts)
|
||||||
} EpdGlyph;
|
} EpdGlyph;
|
||||||
|
|
||||||
|
/// Compressed font group: a DEFLATE-compressed block of glyph bitmaps
|
||||||
|
typedef struct {
|
||||||
|
uint32_t compressedOffset; ///< Byte offset into compressed data array
|
||||||
|
uint32_t compressedSize; ///< Compressed DEFLATE stream size
|
||||||
|
uint32_t uncompressedSize; ///< Decompressed size
|
||||||
|
uint16_t glyphCount; ///< Number of glyphs in this group
|
||||||
|
uint16_t firstGlyphIndex; ///< First glyph index in the global glyph array
|
||||||
|
} EpdFontGroup;
|
||||||
|
|
||||||
/// Glyph interval structure
|
/// Glyph interval structure
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t first; ///< The first unicode code point of the interval
|
uint32_t first; ///< The first unicode code point of the interval
|
||||||
@@ -32,4 +41,6 @@ typedef struct {
|
|||||||
int ascender; ///< Maximal height of a glyph above the base line
|
int ascender; ///< Maximal height of a glyph above the base line
|
||||||
int descender; ///< Maximal height of a glyph below the base line
|
int descender; ///< Maximal height of a glyph below the base line
|
||||||
bool is2Bit;
|
bool is2Bit;
|
||||||
|
const EpdFontGroup* groups; ///< NULL for uncompressed fonts
|
||||||
|
uint16_t groupCount; ///< 0 for uncompressed fonts
|
||||||
} EpdFontData;
|
} EpdFontData;
|
||||||
|
|||||||
@@ -1,23 +1,19 @@
|
|||||||
#include "EpdFontFamily.h"
|
#include "EpdFontFamily.h"
|
||||||
|
|
||||||
const EpdFont* EpdFontFamily::getFont(const Style style) const {
|
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;
|
return bold;
|
||||||
}
|
} else if (hasItalic && italic) {
|
||||||
if (style == ITALIC && italic) {
|
|
||||||
return italic;
|
return italic;
|
||||||
}
|
}
|
||||||
if (style == BOLD_ITALIC) {
|
|
||||||
if (boldItalic) {
|
|
||||||
return boldItalic;
|
|
||||||
}
|
|
||||||
if (bold) {
|
|
||||||
return bold;
|
|
||||||
}
|
|
||||||
if (italic) {
|
|
||||||
return italic;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return regular;
|
return regular;
|
||||||
}
|
}
|
||||||
@@ -26,10 +22,6 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const
|
|||||||
getFont(style)->getTextDimensions(string, w, h);
|
getFont(style)->getTextDimensions(string, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool EpdFontFamily::hasPrintableChars(const char* string, const Style style) const {
|
|
||||||
return getFont(style)->hasPrintableChars(string);
|
|
||||||
}
|
|
||||||
|
|
||||||
const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; }
|
const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; }
|
||||||
|
|
||||||
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
|
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
|
|
||||||
class EpdFontFamily {
|
class EpdFontFamily {
|
||||||
public:
|
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,
|
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
|
||||||
const EpdFont* boldItalic = nullptr)
|
const EpdFont* boldItalic = nullptr)
|
||||||
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
|
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
|
||||||
~EpdFontFamily() = default;
|
~EpdFontFamily() = default;
|
||||||
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
|
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
|
||||||
bool hasPrintableChars(const char* string, Style style = REGULAR) const;
|
|
||||||
const EpdFontData* getData(Style style = REGULAR) const;
|
const EpdFontData* getData(Style style = REGULAR) const;
|
||||||
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#include "FontDecompressor.h"
|
||||||
|
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <uzlib.h>
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
bool FontDecompressor::init() {
|
||||||
|
clearCache();
|
||||||
|
memset(&decomp, 0, sizeof(decomp));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontDecompressor::freeAllEntries() {
|
||||||
|
for (auto& entry : cache) {
|
||||||
|
if (entry.data) {
|
||||||
|
free(entry.data);
|
||||||
|
entry.data = nullptr;
|
||||||
|
}
|
||||||
|
entry.valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FontDecompressor::deinit() { freeAllEntries(); }
|
||||||
|
|
||||||
|
void FontDecompressor::clearCache() {
|
||||||
|
freeAllEntries();
|
||||||
|
accessCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex) {
|
||||||
|
for (uint16_t i = 0; i < fontData->groupCount; i++) {
|
||||||
|
uint16_t first = fontData->groups[i].firstGlyphIndex;
|
||||||
|
if (glyphIndex >= first && glyphIndex < first + fontData->groups[i].glyphCount) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fontData->groupCount; // sentinel = not found
|
||||||
|
}
|
||||||
|
|
||||||
|
FontDecompressor::CacheEntry* FontDecompressor::findInCache(const EpdFontData* fontData, uint16_t groupIndex) {
|
||||||
|
for (auto& entry : cache) {
|
||||||
|
if (entry.valid && entry.font == fontData && entry.groupIndex == groupIndex) {
|
||||||
|
return &entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
FontDecompressor::CacheEntry* FontDecompressor::findEvictionCandidate() {
|
||||||
|
// Find an invalid slot first
|
||||||
|
for (auto& entry : cache) {
|
||||||
|
if (!entry.valid) {
|
||||||
|
return &entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise evict LRU
|
||||||
|
CacheEntry* lru = &cache[0];
|
||||||
|
for (auto& entry : cache) {
|
||||||
|
if (entry.lastUsed < lru->lastUsed) {
|
||||||
|
lru = &entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lru;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FontDecompressor::decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry) {
|
||||||
|
const EpdFontGroup& group = fontData->groups[groupIndex];
|
||||||
|
|
||||||
|
// Free old buffer if reusing a slot
|
||||||
|
if (entry->data) {
|
||||||
|
free(entry->data);
|
||||||
|
entry->data = nullptr;
|
||||||
|
}
|
||||||
|
entry->valid = false;
|
||||||
|
|
||||||
|
// Allocate output buffer
|
||||||
|
auto* outBuf = static_cast<uint8_t*>(malloc(group.uncompressedSize));
|
||||||
|
if (!outBuf) {
|
||||||
|
LOG_ERR("FDC", "Failed to allocate %u bytes for group %u", group.uncompressedSize, groupIndex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress using uzlib
|
||||||
|
const uint8_t* inputBuf = &fontData->bitmap[group.compressedOffset];
|
||||||
|
|
||||||
|
uzlib_uncompress_init(&decomp, NULL, 0);
|
||||||
|
decomp.source = inputBuf;
|
||||||
|
decomp.source_limit = inputBuf + group.compressedSize;
|
||||||
|
decomp.dest_start = outBuf;
|
||||||
|
decomp.dest = outBuf;
|
||||||
|
decomp.dest_limit = outBuf + group.uncompressedSize;
|
||||||
|
|
||||||
|
int res = uzlib_uncompress(&decomp);
|
||||||
|
|
||||||
|
if (res < 0 || decomp.dest != decomp.dest_limit) {
|
||||||
|
LOG_ERR("FDC", "Decompression failed for group %u (status %d)", groupIndex, res);
|
||||||
|
free(outBuf);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry->font = fontData;
|
||||||
|
entry->groupIndex = groupIndex;
|
||||||
|
entry->data = outBuf;
|
||||||
|
entry->dataSize = group.uncompressedSize;
|
||||||
|
entry->valid = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex) {
|
||||||
|
if (!fontData->groups || fontData->groupCount == 0) {
|
||||||
|
return &fontData->bitmap[glyph->dataOffset];
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t groupIndex = getGroupIndex(fontData, glyphIndex);
|
||||||
|
if (groupIndex >= fontData->groupCount) {
|
||||||
|
LOG_ERR("FDC", "Glyph %u not found in any group", glyphIndex);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
CacheEntry* entry = findInCache(fontData, groupIndex);
|
||||||
|
if (entry) {
|
||||||
|
entry->lastUsed = ++accessCounter;
|
||||||
|
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
|
||||||
|
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
|
||||||
|
glyph->dataLength, groupIndex, entry->dataSize);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return &entry->data[glyph->dataOffset];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - decompress
|
||||||
|
entry = findEvictionCandidate();
|
||||||
|
if (!decompressGroup(fontData, groupIndex, entry)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry->lastUsed = ++accessCounter;
|
||||||
|
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
|
||||||
|
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
|
||||||
|
glyph->dataLength, groupIndex, entry->dataSize);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return &entry->data[glyph->dataOffset];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <uzlib.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "EpdFontData.h"
|
||||||
|
|
||||||
|
class FontDecompressor {
|
||||||
|
public:
|
||||||
|
bool init();
|
||||||
|
void deinit();
|
||||||
|
|
||||||
|
// Returns pointer to decompressed bitmap data for the given glyph.
|
||||||
|
// Valid until LRU eviction (safe for the duration of one glyph render).
|
||||||
|
const uint8_t* getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex);
|
||||||
|
|
||||||
|
// Evict all cached decompressed groups (call between pages for within-page-only caching).
|
||||||
|
void clearCache();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr uint8_t CACHE_SLOTS = 4;
|
||||||
|
|
||||||
|
struct CacheEntry {
|
||||||
|
const EpdFontData* font = nullptr;
|
||||||
|
uint16_t groupIndex = 0;
|
||||||
|
uint8_t* data = nullptr;
|
||||||
|
uint32_t dataSize = 0;
|
||||||
|
uint32_t lastUsed = 0;
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct uzlib_uncomp decomp = {};
|
||||||
|
CacheEntry cache[CACHE_SLOTS] = {};
|
||||||
|
uint32_t accessCounter = 0;
|
||||||
|
|
||||||
|
void freeAllEntries();
|
||||||
|
uint16_t getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex);
|
||||||
|
CacheEntry* findInCache(const EpdFontData* fontData, uint16_t groupIndex);
|
||||||
|
CacheEntry* findEvictionCandidate();
|
||||||
|
bool decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry);
|
||||||
|
};
|
||||||
+2156
-3895
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2508
-4896
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2679
-6045
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3195
-7773
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2303
-3951
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2688
-5073
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2937
-6331
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3281
-7684
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
* name: notosans_8_regular
|
* name: notosans_8_regular
|
||||||
* size: 8
|
* size: 8
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
@@ -1618,4 +1619,6 @@ static const EpdFontData notosans_8_regular = {
|
|||||||
18,
|
18,
|
||||||
-5,
|
-5,
|
||||||
false,
|
false,
|
||||||
|
nullptr,
|
||||||
|
0,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_10_bold
|
* name: ubuntu_10_bold
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_10_bold 10 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
@@ -1521,4 +1522,6 @@ static const EpdFontData ubuntu_10_bold = {
|
|||||||
20,
|
20,
|
||||||
-4,
|
-4,
|
||||||
false,
|
false,
|
||||||
|
nullptr,
|
||||||
|
0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_10_regular
|
* name: ubuntu_10_regular
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_10_regular 10 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
@@ -1435,4 +1436,6 @@ static const EpdFontData ubuntu_10_regular = {
|
|||||||
20,
|
20,
|
||||||
-4,
|
-4,
|
||||||
false,
|
false,
|
||||||
|
nullptr,
|
||||||
|
0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_12_bold
|
* name: ubuntu_12_bold
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_12_bold 12 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
@@ -1831,4 +1832,6 @@ static const EpdFontData ubuntu_12_bold = {
|
|||||||
24,
|
24,
|
||||||
-5,
|
-5,
|
||||||
false,
|
false,
|
||||||
|
nullptr,
|
||||||
|
0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_12_regular
|
* name: ubuntu_12_regular
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_12_regular 12 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
@@ -1723,4 +1724,6 @@ static const EpdFontData ubuntu_12_regular = {
|
|||||||
24,
|
24,
|
||||||
-5,
|
-5,
|
||||||
false,
|
false,
|
||||||
|
nullptr,
|
||||||
|
0,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ for size in ${BOOKERLY_FONT_SIZES[@]}; do
|
|||||||
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||||
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
|
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
|
||||||
output_path="../builtinFonts/${font_name}.h"
|
output_path="../builtinFonts/${font_name}.h"
|
||||||
python fontconvert.py $font_name $size $font_path --2bit > $output_path
|
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
|
||||||
echo "Generated $output_path"
|
echo "Generated $output_path"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -24,7 +24,7 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
|
|||||||
font_name="notosans_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
font_name="notosans_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||||
font_path="../builtinFonts/source/NotoSans/NotoSans-${style}.ttf"
|
font_path="../builtinFonts/source/NotoSans/NotoSans-${style}.ttf"
|
||||||
output_path="../builtinFonts/${font_name}.h"
|
output_path="../builtinFonts/${font_name}.h"
|
||||||
python fontconvert.py $font_name $size $font_path --2bit > $output_path
|
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
|
||||||
echo "Generated $output_path"
|
echo "Generated $output_path"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -34,7 +34,7 @@ for size in ${OPENDYSLEXIC_FONT_SIZES[@]}; do
|
|||||||
font_name="opendyslexic_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
font_name="opendyslexic_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||||
font_path="../builtinFonts/source/OpenDyslexic/OpenDyslexic-${style}.otf"
|
font_path="../builtinFonts/source/OpenDyslexic/OpenDyslexic-${style}.otf"
|
||||||
output_path="../builtinFonts/${font_name}.h"
|
output_path="../builtinFonts/${font_name}.h"
|
||||||
python fontconvert.py $font_name $size $font_path --2bit > $output_path
|
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
|
||||||
echo "Generated $output_path"
|
echo "Generated $output_path"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -53,3 +53,7 @@ for size in ${UI_FONT_SIZES[@]}; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
python fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf > ../builtinFonts/notosans_8_regular.h
|
python fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf > ../builtinFonts/notosans_8_regular.h
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running compression verification..."
|
||||||
|
python verify_compression.py ../builtinFonts/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ parser.add_argument("size", type=int, help="font size to use.")
|
|||||||
parser.add_argument("fontstack", action="store", nargs='+', help="list of font files, ordered by descending priority.")
|
parser.add_argument("fontstack", action="store", nargs='+', help="list of font files, ordered by descending priority.")
|
||||||
parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate 2-bit greyscale bitmap instead of 1-bit black and white.")
|
parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate 2-bit greyscale bitmap instead of 1-bit black and white.")
|
||||||
parser.add_argument("--additional-intervals", dest="additional_intervals", action="append", help="Additional code point intervals to export as min,max. This argument can be repeated.")
|
parser.add_argument("--additional-intervals", dest="additional_intervals", action="append", help="Additional code point intervals to export as min,max. This argument can be repeated.")
|
||||||
|
parser.add_argument("--compress", dest="compress", action="store_true", help="Compress glyph bitmaps using DEFLATE with group-based compression.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
|
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
|
||||||
@@ -270,13 +271,115 @@ for index, glyph in enumerate(all_glyphs):
|
|||||||
glyph_data.extend([b for b in packed])
|
glyph_data.extend([b for b in packed])
|
||||||
glyph_props.append(props)
|
glyph_props.append(props)
|
||||||
|
|
||||||
print(f"/**\n * generated by fontconvert.py\n * name: {font_name}\n * size: {size}\n * mode: {'2-bit' if is2Bit else '1-bit'}\n */")
|
compress = args.compress
|
||||||
print("#pragma once")
|
|
||||||
print("#include \"EpdFontData.h\"\n")
|
# Build groups for compression
|
||||||
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
|
if compress:
|
||||||
for c in chunks(glyph_data, 16):
|
# Script-based grouping: glyphs that co-occur in typical text rendering
|
||||||
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
# are grouped together for efficient LRU caching on the embedded target.
|
||||||
print ("};\n");
|
# Since glyphs are in codepoint order, glyphs in the same Unicode block
|
||||||
|
# are contiguous in the array and form natural groups.
|
||||||
|
SCRIPT_GROUP_RANGES = [
|
||||||
|
(0x0000, 0x007F), # ASCII
|
||||||
|
(0x0080, 0x00FF), # Latin-1 Supplement
|
||||||
|
(0x0100, 0x017F), # Latin Extended-A
|
||||||
|
(0x0300, 0x036F), # Combining Diacritical Marks
|
||||||
|
(0x0400, 0x04FF), # Cyrillic
|
||||||
|
(0x2000, 0x206F), # General Punctuation
|
||||||
|
(0x2070, 0x209F), # Superscripts & Subscripts
|
||||||
|
(0x20A0, 0x20CF), # Currency Symbols
|
||||||
|
(0x2190, 0x21FF), # Arrows
|
||||||
|
(0x2200, 0x22FF), # Math Operators
|
||||||
|
(0xFFFD, 0xFFFD), # Replacement Character
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_script_group(code_point):
|
||||||
|
for i, (start, end) in enumerate(SCRIPT_GROUP_RANGES):
|
||||||
|
if start <= code_point <= end:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
groups = [] # list of (first_glyph_index, glyph_count)
|
||||||
|
current_group_id = None
|
||||||
|
group_start = 0
|
||||||
|
group_count = 0
|
||||||
|
|
||||||
|
for i, (props, packed) in enumerate(all_glyphs):
|
||||||
|
sg = get_script_group(props.code_point)
|
||||||
|
if sg != current_group_id:
|
||||||
|
if group_count > 0:
|
||||||
|
groups.append((group_start, group_count))
|
||||||
|
current_group_id = sg
|
||||||
|
group_start = i
|
||||||
|
group_count = 1
|
||||||
|
else:
|
||||||
|
group_count += 1
|
||||||
|
|
||||||
|
if group_count > 0:
|
||||||
|
groups.append((group_start, group_count))
|
||||||
|
|
||||||
|
# Compress each group
|
||||||
|
compressed_groups = [] # list of (compressed_bytes, uncompressed_size, glyph_count, first_glyph_index)
|
||||||
|
compressed_bitmap_data = []
|
||||||
|
compressed_offset = 0
|
||||||
|
|
||||||
|
# Also build modified glyph props with within-group offsets
|
||||||
|
modified_glyph_props = list(glyph_props)
|
||||||
|
|
||||||
|
for first_idx, count in groups:
|
||||||
|
# Concatenate bitmap data for this group
|
||||||
|
group_data = b''
|
||||||
|
for gi in range(first_idx, first_idx + count):
|
||||||
|
props, packed = all_glyphs[gi]
|
||||||
|
# Update glyph's dataOffset to be within-group offset
|
||||||
|
within_group_offset = len(group_data)
|
||||||
|
old_props = modified_glyph_props[gi]
|
||||||
|
modified_glyph_props[gi] = GlyphProps(
|
||||||
|
width=old_props.width,
|
||||||
|
height=old_props.height,
|
||||||
|
advance_x=old_props.advance_x,
|
||||||
|
left=old_props.left,
|
||||||
|
top=old_props.top,
|
||||||
|
data_length=old_props.data_length,
|
||||||
|
data_offset=within_group_offset,
|
||||||
|
code_point=old_props.code_point,
|
||||||
|
)
|
||||||
|
group_data += packed
|
||||||
|
|
||||||
|
# Compress with raw DEFLATE (no zlib/gzip header)
|
||||||
|
compressor = zlib.compressobj(level=9, wbits=-15)
|
||||||
|
compressed = compressor.compress(group_data) + compressor.flush()
|
||||||
|
|
||||||
|
compressed_groups.append((compressed, len(group_data), count, first_idx))
|
||||||
|
compressed_bitmap_data.extend(compressed)
|
||||||
|
compressed_offset += len(compressed)
|
||||||
|
|
||||||
|
glyph_props = modified_glyph_props
|
||||||
|
total_compressed = len(compressed_bitmap_data)
|
||||||
|
total_uncompressed = len(glyph_data)
|
||||||
|
print(f"// Compression: {total_uncompressed} -> {total_compressed} bytes ({100*total_compressed/total_uncompressed:.1f}%), {len(groups)} groups", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"""/**
|
||||||
|
* generated by fontconvert.py
|
||||||
|
* name: {font_name}
|
||||||
|
* size: {size}
|
||||||
|
* mode: {'2-bit' if is2Bit else '1-bit'}{' compressed: true' if compress else ''}
|
||||||
|
* Command used: {' '.join(sys.argv)}
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
#include "EpdFontData.h"
|
||||||
|
""")
|
||||||
|
|
||||||
|
if compress:
|
||||||
|
print(f"static const uint8_t {font_name}Bitmaps[{len(compressed_bitmap_data)}] = {{")
|
||||||
|
for c in chunks(compressed_bitmap_data, 16):
|
||||||
|
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
||||||
|
print ("};\n");
|
||||||
|
else:
|
||||||
|
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
|
||||||
|
for c in chunks(glyph_data, 16):
|
||||||
|
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
||||||
|
print ("};\n");
|
||||||
|
|
||||||
print(f"static const EpdGlyph {font_name}Glyphs[] = {{")
|
print(f"static const EpdGlyph {font_name}Glyphs[] = {{")
|
||||||
for i, g in enumerate(glyph_props):
|
for i, g in enumerate(glyph_props):
|
||||||
@@ -290,6 +393,14 @@ for i_start, i_end in intervals:
|
|||||||
offset += i_end - i_start + 1
|
offset += i_end - i_start + 1
|
||||||
print ("};\n");
|
print ("};\n");
|
||||||
|
|
||||||
|
if compress:
|
||||||
|
print(f"static const EpdFontGroup {font_name}Groups[] = {{")
|
||||||
|
compressed_offset = 0
|
||||||
|
for compressed, uncompressed_size, count, first_idx in compressed_groups:
|
||||||
|
print(f" {{ {compressed_offset}, {len(compressed)}, {uncompressed_size}, {count}, {first_idx} }},")
|
||||||
|
compressed_offset += len(compressed)
|
||||||
|
print("};\n")
|
||||||
|
|
||||||
print(f"static const EpdFontData {font_name} = {{")
|
print(f"static const EpdFontData {font_name} = {{")
|
||||||
print(f" {font_name}Bitmaps,")
|
print(f" {font_name}Bitmaps,")
|
||||||
print(f" {font_name}Glyphs,")
|
print(f" {font_name}Glyphs,")
|
||||||
@@ -299,4 +410,10 @@ print(f" {norm_ceil(face.size.height)},")
|
|||||||
print(f" {norm_ceil(face.size.ascender)},")
|
print(f" {norm_ceil(face.size.ascender)},")
|
||||||
print(f" {norm_floor(face.size.descender)},")
|
print(f" {norm_floor(face.size.descender)},")
|
||||||
print(f" {'true' if is2Bit else 'false'},")
|
print(f" {'true' if is2Bit else 'false'},")
|
||||||
|
if compress:
|
||||||
|
print(f" {font_name}Groups,")
|
||||||
|
print(f" {len(compressed_groups)},")
|
||||||
|
else:
|
||||||
|
print(f" nullptr,")
|
||||||
|
print(f" 0,")
|
||||||
print("};")
|
print("};")
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Round-trip verification for compressed font headers.
|
||||||
|
|
||||||
|
Parses each generated .h file in the given directory, identifies compressed fonts
|
||||||
|
(those with a Groups array), decompresses each group, and verifies that
|
||||||
|
decompression succeeds and all glyph offsets/lengths fall within bounds.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hex_array(text):
|
||||||
|
"""Extract bytes from a C hex array string like '{ 0xAB, 0xCD, ... }'"""
|
||||||
|
hex_vals = re.findall(r'0x([0-9A-Fa-f]{2})', text)
|
||||||
|
return bytes(int(h, 16) for h in hex_vals)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_groups(text):
|
||||||
|
"""Parse EpdFontGroup array entries: { compressedOffset, compressedSize, uncompressedSize, glyphCount, firstGlyphIndex }"""
|
||||||
|
groups = []
|
||||||
|
for match in re.finditer(r'\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}', text):
|
||||||
|
groups.append({
|
||||||
|
'compressedOffset': int(match.group(1)),
|
||||||
|
'compressedSize': int(match.group(2)),
|
||||||
|
'uncompressedSize': int(match.group(3)),
|
||||||
|
'glyphCount': int(match.group(4)),
|
||||||
|
'firstGlyphIndex': int(match.group(5)),
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def parse_glyphs(text):
|
||||||
|
"""Parse EpdGlyph array entries: { width, height, advanceX, left, top, dataLength, dataOffset }"""
|
||||||
|
glyphs = []
|
||||||
|
for match in re.finditer(r'\{\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\}', text):
|
||||||
|
glyphs.append({
|
||||||
|
'width': int(match.group(1)),
|
||||||
|
'height': int(match.group(2)),
|
||||||
|
'advanceX': int(match.group(3)),
|
||||||
|
'left': int(match.group(4)),
|
||||||
|
'top': int(match.group(5)),
|
||||||
|
'dataLength': int(match.group(6)),
|
||||||
|
'dataOffset': int(match.group(7)),
|
||||||
|
})
|
||||||
|
return glyphs
|
||||||
|
|
||||||
|
|
||||||
|
def verify_font_file(filepath):
|
||||||
|
"""Verify a single font header file. Returns (font_name, success, message)."""
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check if this is a compressed font (has Groups array)
|
||||||
|
groups_match = re.search(r'static const EpdFontGroup (\w+)Groups\[\]', content)
|
||||||
|
if not groups_match:
|
||||||
|
return (os.path.basename(filepath), None, "uncompressed, skipping")
|
||||||
|
|
||||||
|
font_name = groups_match.group(1)
|
||||||
|
|
||||||
|
# Extract bitmap data
|
||||||
|
bitmap_match = re.search(
|
||||||
|
r'static const uint8_t ' + re.escape(font_name) + r'Bitmaps\[\d+\]\s*=\s*\{([^}]+)\}',
|
||||||
|
content, re.DOTALL
|
||||||
|
)
|
||||||
|
if not bitmap_match:
|
||||||
|
return (font_name, False, "could not find Bitmaps array")
|
||||||
|
|
||||||
|
compressed_data = parse_hex_array(bitmap_match.group(1))
|
||||||
|
|
||||||
|
# Extract groups
|
||||||
|
groups_array_match = re.search(
|
||||||
|
r'static const EpdFontGroup ' + re.escape(font_name) + r'Groups\[\]\s*=\s*\{(.+?)\};',
|
||||||
|
content, re.DOTALL
|
||||||
|
)
|
||||||
|
if not groups_array_match:
|
||||||
|
return (font_name, False, "could not find Groups array")
|
||||||
|
|
||||||
|
groups = parse_groups(groups_array_match.group(1))
|
||||||
|
if not groups:
|
||||||
|
return (font_name, False, "Groups array parsed to 0 entries; check format")
|
||||||
|
|
||||||
|
# Extract glyphs
|
||||||
|
glyphs_match = re.search(
|
||||||
|
r'static const EpdGlyph ' + re.escape(font_name) + r'Glyphs\[\]\s*=\s*\{(.+?)\};',
|
||||||
|
content, re.DOTALL
|
||||||
|
)
|
||||||
|
if not glyphs_match:
|
||||||
|
return (font_name, False, "could not find Glyphs array")
|
||||||
|
|
||||||
|
glyphs = parse_glyphs(glyphs_match.group(1))
|
||||||
|
|
||||||
|
# Verify each group
|
||||||
|
for gi, group in enumerate(groups):
|
||||||
|
# Extract compressed chunk
|
||||||
|
chunk = compressed_data[group['compressedOffset']:group['compressedOffset'] + group['compressedSize']]
|
||||||
|
if len(chunk) != group['compressedSize']:
|
||||||
|
return (font_name, False, f"group {gi}: compressed data truncated (expected {group['compressedSize']}, got {len(chunk)})")
|
||||||
|
|
||||||
|
# Decompress with raw DEFLATE
|
||||||
|
try:
|
||||||
|
decompressed = zlib.decompress(chunk, -15)
|
||||||
|
except zlib.error as e:
|
||||||
|
return (font_name, False, f"group {gi}: decompression failed: {e}")
|
||||||
|
|
||||||
|
if len(decompressed) != group['uncompressedSize']:
|
||||||
|
return (font_name, False, f"group {gi}: size mismatch (expected {group['uncompressedSize']}, got {len(decompressed)})")
|
||||||
|
|
||||||
|
# Verify each glyph's data within the group
|
||||||
|
first = group['firstGlyphIndex']
|
||||||
|
for j in range(group['glyphCount']):
|
||||||
|
glyph_idx = first + j
|
||||||
|
if glyph_idx >= len(glyphs):
|
||||||
|
return (font_name, False, f"group {gi}: glyph index {glyph_idx} out of range")
|
||||||
|
|
||||||
|
glyph = glyphs[glyph_idx]
|
||||||
|
offset = glyph['dataOffset']
|
||||||
|
length = glyph['dataLength']
|
||||||
|
|
||||||
|
if offset + length > len(decompressed):
|
||||||
|
return (font_name, False, f"group {gi}, glyph {glyph_idx}: data extends beyond decompressed buffer "
|
||||||
|
f"(offset={offset}, length={length}, decompressed_size={len(decompressed)})")
|
||||||
|
|
||||||
|
return (font_name, True, f"{len(groups)} groups, {len(glyphs)} glyphs OK")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <font_headers_directory>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
font_dir = sys.argv[1]
|
||||||
|
if not os.path.isdir(font_dir):
|
||||||
|
print(f"Error: {font_dir} is not a directory", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
files = sorted(f for f in os.listdir(font_dir) if f.endswith('.h') and f != 'all.h')
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
filepath = os.path.join(font_dir, filename)
|
||||||
|
_font_name, success, message = verify_font_file(filepath)
|
||||||
|
|
||||||
|
if success is None:
|
||||||
|
skipped += 1
|
||||||
|
elif success:
|
||||||
|
passed += 1
|
||||||
|
print(f" PASS: {filename} ({message})")
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
print(f" FAIL: {filename} - {message}")
|
||||||
|
|
||||||
|
print(f"\nResults: {passed} passed, {failed} failed, {skipped} skipped (uncompressed)")
|
||||||
|
|
||||||
|
if failed > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+337
-110
@@ -1,9 +1,10 @@
|
|||||||
#include "Epub.h"
|
#include "Epub.h"
|
||||||
|
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <HardwareSerial.h>
|
#include <HalStorage.h>
|
||||||
#include <JpegToBmpConverter.h>
|
#include <JpegToBmpConverter.h>
|
||||||
#include <SDCardManager.h>
|
#include <Logging.h>
|
||||||
|
#include <PngToBmpConverter.h>
|
||||||
#include <ZipFile.h>
|
#include <ZipFile.h>
|
||||||
|
|
||||||
#include "Epub/parsers/ContainerParser.h"
|
#include "Epub/parsers/ContainerParser.h"
|
||||||
@@ -17,7 +18,7 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
|||||||
|
|
||||||
// Get file size without loading it all into heap
|
// Get file size without loading it all into heap
|
||||||
if (!getItemSize(containerPath, &containerSize)) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,13 +30,13 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
|||||||
|
|
||||||
// Stream read (reusing your existing stream logic)
|
// Stream read (reusing your existing stream logic)
|
||||||
if (!readItemContentsToStream(containerPath, containerParser, 512)) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the result
|
// Extract the result
|
||||||
if (containerParser.fullPath.empty()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,28 +47,28 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
|||||||
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
||||||
std::string contentOpfFilePath;
|
std::string contentOpfFilePath;
|
||||||
if (!findContentOpfFile(&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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
|
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;
|
size_t contentOpfSize;
|
||||||
if (!getItemSize(contentOpfFilePath, &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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
|
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
|
||||||
if (!opfParser.setup()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +77,54 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|||||||
bookMetadata.author = opfParser.author;
|
bookMetadata.author = opfParser.author;
|
||||||
bookMetadata.language = opfParser.language;
|
bookMetadata.language = opfParser.language;
|
||||||
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
||||||
|
|
||||||
|
// Guide-based cover fallback: if no cover found via metadata/properties,
|
||||||
|
// try extracting the image reference from the guide's cover page XHTML
|
||||||
|
if (bookMetadata.coverItemHref.empty() && !opfParser.guideCoverPageHref.empty()) {
|
||||||
|
LOG_DBG("EBP", "No cover from metadata, trying guide cover page: %s", opfParser.guideCoverPageHref.c_str());
|
||||||
|
size_t coverPageSize;
|
||||||
|
uint8_t* coverPageData = readItemContentsToBytes(opfParser.guideCoverPageHref, &coverPageSize, true);
|
||||||
|
if (coverPageData) {
|
||||||
|
const std::string coverPageHtml(reinterpret_cast<char*>(coverPageData), coverPageSize);
|
||||||
|
free(coverPageData);
|
||||||
|
|
||||||
|
// Determine base path of the cover page for resolving relative image references
|
||||||
|
std::string coverPageBase;
|
||||||
|
const auto lastSlash = opfParser.guideCoverPageHref.rfind('/');
|
||||||
|
if (lastSlash != std::string::npos) {
|
||||||
|
coverPageBase = opfParser.guideCoverPageHref.substr(0, lastSlash + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for image references: xlink:href="..." (SVG) and src="..." (img)
|
||||||
|
std::string imageRef;
|
||||||
|
for (const char* pattern : {"xlink:href=\"", "src=\""}) {
|
||||||
|
auto pos = coverPageHtml.find(pattern);
|
||||||
|
while (pos != std::string::npos) {
|
||||||
|
pos += strlen(pattern);
|
||||||
|
const auto endPos = coverPageHtml.find('"', pos);
|
||||||
|
if (endPos != std::string::npos) {
|
||||||
|
const auto ref = coverPageHtml.substr(pos, endPos - pos);
|
||||||
|
// Check if it's an image file
|
||||||
|
if (ref.length() >= 4) {
|
||||||
|
const auto ext = ref.substr(ref.length() - 4);
|
||||||
|
if (ext == ".png" || ext == ".jpg" || ext == "jpeg" || ext == ".gif") {
|
||||||
|
imageRef = ref;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = coverPageHtml.find(pattern, pos);
|
||||||
|
}
|
||||||
|
if (!imageRef.empty()) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageRef.empty()) {
|
||||||
|
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
|
||||||
|
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
||||||
|
|
||||||
if (!opfParser.tocNcxPath.empty()) {
|
if (!opfParser.tocNcxPath.empty()) {
|
||||||
@@ -86,27 +135,31 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|||||||
tocNavItem = opfParser.tocNavPath;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Epub::parseTocNcxFile() const {
|
bool Epub::parseTocNcxFile() const {
|
||||||
// the ncx file should have been specified in the content.opf file
|
// the ncx file should have been specified in the content.opf file
|
||||||
if (tocNcxItem.empty()) {
|
if (tocNcxItem.empty()) {
|
||||||
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis());
|
LOG_DBG("EBP", "No ncx file specified");
|
||||||
return false;
|
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";
|
const auto tmpNcxPath = getCachePath() + "/toc.ncx";
|
||||||
FsFile tempNcxFile;
|
FsFile tempNcxFile;
|
||||||
if (!SdMan.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
|
if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
|
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
|
||||||
tempNcxFile.close();
|
tempNcxFile.close();
|
||||||
if (!SdMan.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
|
if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const auto ncxSize = tempNcxFile.size();
|
const auto ncxSize = tempNcxFile.size();
|
||||||
@@ -114,14 +167,14 @@ bool Epub::parseTocNcxFile() const {
|
|||||||
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
|
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
|
||||||
|
|
||||||
if (!ncxParser.setup()) {
|
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();
|
tempNcxFile.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
|
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||||
if (!ncxBuffer) {
|
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();
|
tempNcxFile.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -132,7 +185,7 @@ bool Epub::parseTocNcxFile() const {
|
|||||||
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
|
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
|
||||||
|
|
||||||
if (processedSize != 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);
|
free(ncxBuffer);
|
||||||
tempNcxFile.close();
|
tempNcxFile.close();
|
||||||
return false;
|
return false;
|
||||||
@@ -141,29 +194,29 @@ bool Epub::parseTocNcxFile() const {
|
|||||||
|
|
||||||
free(ncxBuffer);
|
free(ncxBuffer);
|
||||||
tempNcxFile.close();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Epub::parseTocNavFile() const {
|
bool Epub::parseTocNavFile() const {
|
||||||
// the nav file should have been specified in the content.opf file (EPUB 3)
|
// the nav file should have been specified in the content.opf file (EPUB 3)
|
||||||
if (tocNavItem.empty()) {
|
if (tocNavItem.empty()) {
|
||||||
Serial.printf("[%lu] [EBP] No nav file specified\n", millis());
|
LOG_DBG("EBP", "No nav file specified");
|
||||||
return false;
|
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";
|
const auto tmpNavPath = getCachePath() + "/toc.nav";
|
||||||
FsFile tempNavFile;
|
FsFile tempNavFile;
|
||||||
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
||||||
tempNavFile.close();
|
tempNavFile.close();
|
||||||
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const auto navSize = tempNavFile.size();
|
const auto navSize = tempNavFile.size();
|
||||||
@@ -174,13 +227,13 @@ bool Epub::parseTocNavFile() const {
|
|||||||
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
|
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
|
||||||
|
|
||||||
if (!navParser.setup()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||||
if (!navBuffer) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +242,7 @@ bool Epub::parseTocNavFile() const {
|
|||||||
const auto processedSize = navParser.write(navBuffer, readSize);
|
const auto processedSize = navParser.write(navBuffer, readSize);
|
||||||
|
|
||||||
if (processedSize != 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);
|
free(navBuffer);
|
||||||
tempNavFile.close();
|
tempNavFile.close();
|
||||||
return false;
|
return false;
|
||||||
@@ -198,22 +251,115 @@ bool Epub::parseTocNavFile() const {
|
|||||||
|
|
||||||
free(navBuffer);
|
free(navBuffer);
|
||||||
tempNavFile.close();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Epub::parseCssFiles() const {
|
||||||
|
// Maximum CSS file size we'll attempt to parse (uncompressed)
|
||||||
|
// Larger files risk memory exhaustion on ESP32
|
||||||
|
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
|
||||||
|
// Minimum heap required before attempting CSS parsing
|
||||||
|
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
|
||||||
|
|
||||||
|
if (cssFiles.empty()) {
|
||||||
|
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
|
||||||
|
|
||||||
|
// See if we have a cached version of the CSS rules
|
||||||
|
if (cssParser->hasCache()) {
|
||||||
|
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache yet - parse CSS files
|
||||||
|
for (const auto& cssPath : cssFiles) {
|
||||||
|
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||||
|
|
||||||
|
// Check heap before parsing - CSS parsing allocates heavily
|
||||||
|
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||||
|
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
|
||||||
|
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
|
||||||
|
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check CSS file size before decompressing - skip files that are too large
|
||||||
|
size_t cssFileSize = 0;
|
||||||
|
if (getItemSize(cssPath, &cssFileSize)) {
|
||||||
|
if (cssFileSize > MAX_CSS_FILE_SIZE) {
|
||||||
|
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
|
||||||
|
cssPath.c_str());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if (!cssParser->saveToCache()) {
|
||||||
|
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||||
|
}
|
||||||
|
cssParser->clear();
|
||||||
|
|
||||||
|
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||||
|
}
|
||||||
|
|
||||||
// load in the meta data for the epub file
|
// load in the meta data for the epub file
|
||||||
bool Epub::load(const bool buildIfMissing) {
|
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
||||||
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
|
||||||
|
|
||||||
// Initialize spine/TOC cache
|
// Initialize spine/TOC cache
|
||||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||||
|
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||||
|
cssParser.reset(new CssParser(cachePath));
|
||||||
|
|
||||||
// Try to load existing cache first
|
// Try to load existing cache first
|
||||||
if (bookMetadataCache->load()) {
|
if (bookMetadataCache->load()) {
|
||||||
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
if (!skipLoadingCss) {
|
||||||
|
// Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file)
|
||||||
|
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
|
||||||
|
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
|
||||||
|
cssParser->deleteCache();
|
||||||
|
|
||||||
|
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||||
|
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
||||||
|
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
||||||
|
}
|
||||||
|
parseCssFiles();
|
||||||
|
// Invalidate section caches so they are rebuilt with the new CSS
|
||||||
|
Storage.removeDir((cachePath + "/sections").c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,33 +369,38 @@ bool Epub::load(const bool buildIfMissing) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache doesn't exist or is invalid, build it
|
// 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();
|
setupCacheDir();
|
||||||
|
|
||||||
|
const uint32_t indexingStart = millis();
|
||||||
|
|
||||||
// Begin building cache - stream entries to disk immediately
|
// Begin building cache - stream entries to disk immediately
|
||||||
if (!bookMetadataCache->beginWrite()) {
|
if (!bookMetadataCache->beginWrite()) {
|
||||||
Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis());
|
LOG_ERR("EBP", "Could not begin writing cache");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPF Pass
|
// OPF Pass
|
||||||
|
const uint32_t opfStart = millis();
|
||||||
BookMetadataCache::BookMetadata bookMetadata;
|
BookMetadataCache::BookMetadata bookMetadata;
|
||||||
if (!bookMetadataCache->beginContentOpfPass()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (!parseContentOpf(bookMetadata)) {
|
if (!parseContentOpf(bookMetadata)) {
|
||||||
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis());
|
LOG_ERR("EBP", "Could not parse content.opf");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!bookMetadataCache->endContentOpfPass()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
LOG_DBG("EBP", "OPF pass completed in %lu ms", millis() - opfStart);
|
||||||
|
|
||||||
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
||||||
|
const uint32_t tocStart = millis();
|
||||||
if (!bookMetadataCache->beginTocPass()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,74 +408,84 @@ bool Epub::load(const bool buildIfMissing) {
|
|||||||
|
|
||||||
// Try EPUB 3 nav document first (preferred)
|
// Try EPUB 3 nav document first (preferred)
|
||||||
if (!tocNavItem.empty()) {
|
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();
|
tocParsed = parseTocNavFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to NCX if nav parsing failed or wasn't available
|
// Fall back to NCX if nav parsing failed or wasn't available
|
||||||
if (!tocParsed && !tocNcxItem.empty()) {
|
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();
|
tocParsed = parseTocNcxFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tocParsed) {
|
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
|
// Continue anyway - book will work without TOC
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bookMetadataCache->endTocPass()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
LOG_DBG("EBP", "TOC pass completed in %lu ms", millis() - tocStart);
|
||||||
|
|
||||||
// Close the cache files
|
// Close the cache files
|
||||||
if (!bookMetadataCache->endWrite()) {
|
if (!bookMetadataCache->endWrite()) {
|
||||||
Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis());
|
LOG_ERR("EBP", "Could not end writing cache");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build final book.bin
|
// Build final book.bin
|
||||||
|
const uint32_t buildStart = millis();
|
||||||
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
LOG_DBG("EBP", "buildBookBin completed in %lu ms", millis() - buildStart);
|
||||||
|
LOG_DBG("EBP", "Total indexing completed in %lu ms", millis() - indexingStart);
|
||||||
|
|
||||||
if (!bookMetadataCache->cleanupTmpFiles()) {
|
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
|
// Reload the cache from disk so it's in the correct state
|
||||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||||
if (!bookMetadataCache->load()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
|
if (!skipLoadingCss) {
|
||||||
|
// Parse CSS files after cache reload
|
||||||
|
parseCssFiles();
|
||||||
|
Storage.removeDir((cachePath + "/sections").c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Epub::clearCache() const {
|
bool Epub::clearCache() const {
|
||||||
if (!SdMan.exists(cachePath.c_str())) {
|
if (!Storage.exists(cachePath.c_str())) {
|
||||||
Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis());
|
LOG_DBG("EPB", "Cache does not exist, no action needed");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SdMan.removeDir(cachePath.c_str())) {
|
if (!Storage.removeDir(cachePath.c_str())) {
|
||||||
Serial.printf("[%lu] [EPB] Failed to clear cache\n", millis());
|
LOG_ERR("EPB", "Failed to clear cache");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [EPB] Cache cleared successfully\n", millis());
|
LOG_DBG("EPB", "Cache cleared successfully");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Epub::setupCacheDir() const {
|
void Epub::setupCacheDir() const {
|
||||||
if (SdMan.exists(cachePath.c_str())) {
|
if (Storage.exists(cachePath.c_str())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SdMan.mkdir(cachePath.c_str());
|
Storage.mkdir(cachePath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string& Epub::getCachePath() const { return cachePath; }
|
const std::string& Epub::getCachePath() const { return cachePath; }
|
||||||
@@ -359,133 +520,200 @@ const std::string& Epub::getLanguage() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string Epub::getCoverBmpPath(bool cropped) const {
|
std::string Epub::getCoverBmpPath(bool cropped) const {
|
||||||
const auto coverFileName = "cover" + cropped ? "_crop" : "";
|
const auto coverFileName = std::string("cover") + (cropped ? "_crop" : "");
|
||||||
return cachePath + "/" + coverFileName + ".bmp";
|
return cachePath + "/" + coverFileName + ".bmp";
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Epub::generateCoverBmp(bool cropped) const {
|
bool Epub::generateCoverBmp(bool cropped) const {
|
||||||
// Already generated, return true
|
// Already generated, return true
|
||||||
if (SdMan.exists(getCoverBmpPath(cropped).c_str())) {
|
if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||||
if (coverImageHref.empty()) {
|
if (coverImageHref.empty()) {
|
||||||
Serial.printf("[%lu] [EBP] No known cover image\n", millis());
|
LOG_ERR("EBP", "No known cover image");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||||
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis());
|
LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||||
|
|
||||||
FsFile coverJpg;
|
FsFile coverJpg;
|
||||||
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
|
|
||||||
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
FsFile coverBmp;
|
FsFile coverBmp;
|
||||||
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
|
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
coverBmp.close();
|
coverBmp.close();
|
||||||
SdMan.remove(coverJpgTempPath.c_str());
|
Storage.remove(coverJpgTempPath.c_str());
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
|
LOG_ERR("EBP", "Failed to generate BMP from cover image");
|
||||||
SdMan.remove(getCoverBmpPath(cropped).c_str());
|
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 JPG cover image, success: %s", success ? "yes" : "no");
|
||||||
return success;
|
return success;
|
||||||
} else {
|
|
||||||
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
|
||||||
|
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
||||||
|
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||||
|
|
||||||
|
FsFile coverPng;
|
||||||
|
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||||
|
coverPng.close();
|
||||||
|
|
||||||
|
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile coverBmp;
|
||||||
|
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
||||||
|
coverPng.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
|
||||||
|
coverPng.close();
|
||||||
|
coverBmp.close();
|
||||||
|
Storage.remove(coverPngTempPath.c_str());
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
LOG_ERR("EBP", "Failed to generate BMP from PNG cover image");
|
||||||
|
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||||
|
}
|
||||||
|
LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
||||||
return false;
|
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
|
// Already generated, return true
|
||||||
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
if (Storage.exists(getThumbBmpPath(height).c_str())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||||
if (coverImageHref.empty()) {
|
if (coverImageHref.empty()) {
|
||||||
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
|
LOG_DBG("EBP", "No known cover image for thumbnail");
|
||||||
return false;
|
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||||
}
|
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||||
|
LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
|
||||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
|
||||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
|
||||||
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis());
|
|
||||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||||
|
|
||||||
FsFile coverJpg;
|
FsFile coverJpg;
|
||||||
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
readItemContentsToStream(coverImageHref, coverJpg, 1024);
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
|
|
||||||
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
FsFile thumbBmp;
|
FsFile thumbBmp;
|
||||||
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
|
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Use smaller target size for Continue Reading card (half of screen: 240x400)
|
// 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)
|
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
|
||||||
constexpr int THUMB_TARGET_WIDTH = 240;
|
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||||
constexpr int THUMB_TARGET_HEIGHT = 400;
|
int THUMB_TARGET_HEIGHT = height;
|
||||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
||||||
THUMB_TARGET_HEIGHT);
|
THUMB_TARGET_HEIGHT);
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
thumbBmp.close();
|
thumbBmp.close();
|
||||||
SdMan.remove(coverJpgTempPath.c_str());
|
Storage.remove(coverJpgTempPath.c_str());
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
|
LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
|
||||||
SdMan.remove(getThumbBmpPath().c_str());
|
Storage.remove(getThumbBmpPath(height).c_str());
|
||||||
}
|
}
|
||||||
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
|
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||||
success ? "yes" : "no");
|
return success;
|
||||||
|
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
|
||||||
|
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
||||||
|
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
||||||
|
|
||||||
|
FsFile coverPng;
|
||||||
|
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
||||||
|
coverPng.close();
|
||||||
|
|
||||||
|
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile thumbBmp;
|
||||||
|
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||||
|
coverPng.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||||
|
int THUMB_TARGET_HEIGHT = height;
|
||||||
|
const bool success =
|
||||||
|
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
|
||||||
|
coverPng.close();
|
||||||
|
thumbBmp.close();
|
||||||
|
Storage.remove(coverPngTempPath.c_str());
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
|
||||||
|
Storage.remove(getThumbBmpPath(height).c_str());
|
||||||
|
}
|
||||||
|
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
||||||
return success;
|
return success;
|
||||||
} else {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
|
||||||
if (itemHref.empty()) {
|
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;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +721,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
|||||||
|
|
||||||
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
|
||||||
if (!content) {
|
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;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +730,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 {
|
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
|
||||||
if (itemHref.empty()) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,12 +754,12 @@ size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return get
|
|||||||
|
|
||||||
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
|
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);
|
return bookMetadataCache->getSpineEntry(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,12 +768,12 @@ BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
|
|||||||
|
|
||||||
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
|
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,18 +791,18 @@ int Epub::getTocItemsCount() const {
|
|||||||
// work out the section index for a toc index
|
// work out the section index for a toc index
|
||||||
int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
|
int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
|
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
|
||||||
if (spineIndex < 0) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,14 +820,13 @@ size_t Epub::getBookSize() const {
|
|||||||
|
|
||||||
int Epub::getSpineIndexForTextReference() const {
|
int Epub::getSpineIndexForTextReference() const {
|
||||||
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
Serial.printf("[%lu] [ERS] Core Metadata: cover(%d)=%s, textReference(%d)=%s\n", millis(),
|
LOG_DBG("EBP", "Core Metadata: cover(%d)=%s, textReference(%d)=%s",
|
||||||
bookMetadataCache->coreMetadata.coverItemHref.size(),
|
bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.c_str(),
|
||||||
bookMetadataCache->coreMetadata.coverItemHref.c_str(),
|
bookMetadataCache->coreMetadata.textReferenceHref.size(),
|
||||||
bookMetadataCache->coreMetadata.textReferenceHref.size(),
|
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
|
||||||
bookMetadataCache->coreMetadata.textReferenceHref.c_str());
|
|
||||||
|
|
||||||
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
|
if (bookMetadataCache->coreMetadata.textReferenceHref.empty()) {
|
||||||
// there was no textReference in epub, so we return 0 (the first chapter)
|
// there was no textReference in epub, so we return 0 (the first chapter)
|
||||||
@@ -609,13 +836,13 @@ int Epub::getSpineIndexForTextReference() const {
|
|||||||
// loop through spine items to get the correct index matching the text href
|
// loop through spine items to get the correct index matching the text href
|
||||||
for (size_t i = 0; i < getSpineItemsCount(); i++) {
|
for (size_t i = 0; i < getSpineItemsCount(); i++) {
|
||||||
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
|
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
|
||||||
Serial.printf("[%lu] [ERS] Text reference %s found at index %d\n", millis(),
|
LOG_DBG("EBP", "Text reference %s found at index %d", bookMetadataCache->coreMetadata.textReferenceHref.c_str(),
|
||||||
bookMetadataCache->coreMetadata.textReferenceHref.c_str(), i);
|
i);
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// This should not happen, as we checked for empty textReferenceHref earlier
|
// 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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -8,6 +8,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "Epub/BookMetadataCache.h"
|
#include "Epub/BookMetadataCache.h"
|
||||||
|
#include "Epub/css/CssParser.h"
|
||||||
|
|
||||||
class ZipFile;
|
class ZipFile;
|
||||||
|
|
||||||
@@ -24,11 +25,16 @@ class Epub {
|
|||||||
std::string cachePath;
|
std::string cachePath;
|
||||||
// Spine and TOC cache
|
// Spine and TOC cache
|
||||||
std::unique_ptr<BookMetadataCache> bookMetadataCache;
|
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 findContentOpfFile(std::string* contentOpfFile) const;
|
||||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
||||||
bool parseTocNcxFile() const;
|
bool parseTocNcxFile() const;
|
||||||
bool parseTocNavFile() const;
|
bool parseTocNavFile() const;
|
||||||
|
void parseCssFiles() const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||||
@@ -37,7 +43,7 @@ class Epub {
|
|||||||
}
|
}
|
||||||
~Epub() = default;
|
~Epub() = default;
|
||||||
std::string& getBasePath() { return contentBasePath; }
|
std::string& getBasePath() { return contentBasePath; }
|
||||||
bool load(bool buildIfMissing = true);
|
bool load(bool buildIfMissing = true, bool skipLoadingCss = false);
|
||||||
bool clearCache() const;
|
bool clearCache() const;
|
||||||
void setupCacheDir() const;
|
void setupCacheDir() const;
|
||||||
const std::string& getCachePath() const;
|
const std::string& getCachePath() const;
|
||||||
@@ -48,7 +54,8 @@ class Epub {
|
|||||||
std::string getCoverBmpPath(bool cropped = false) const;
|
std::string getCoverBmpPath(bool cropped = false) const;
|
||||||
bool generateCoverBmp(bool cropped = false) const;
|
bool generateCoverBmp(bool cropped = false) const;
|
||||||
std::string getThumbBmpPath() 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,
|
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
|
||||||
bool trailingNullByte = false) const;
|
bool trailingNullByte = false) const;
|
||||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||||
@@ -64,4 +71,5 @@ class Epub {
|
|||||||
|
|
||||||
size_t getBookSize() const;
|
size_t getBookSize() const;
|
||||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||||
|
CssParser* getCssParser() const { return cssParser.get(); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#include "BookMetadataCache.h"
|
#include "BookMetadataCache.h"
|
||||||
|
|
||||||
#include <HardwareSerial.h>
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
#include <ZipFile.h>
|
#include <ZipFile.h>
|
||||||
|
|
||||||
@@ -21,15 +21,15 @@ bool BookMetadataCache::beginWrite() {
|
|||||||
buildMode = true;
|
buildMode = true;
|
||||||
spineCount = 0;
|
spineCount = 0;
|
||||||
tocCount = 0;
|
tocCount = 0;
|
||||||
Serial.printf("[%lu] [BMC] Entering write mode\n", millis());
|
LOG_DBG("BMC", "Entering write mode");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool BookMetadataCache::beginContentOpfPass() {
|
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
|
// Open spine file for writing
|
||||||
return SdMan.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
|
return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool BookMetadataCache::endContentOpfPass() {
|
bool BookMetadataCache::endContentOpfPass() {
|
||||||
@@ -38,48 +38,76 @@ bool BookMetadataCache::endContentOpfPass() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool BookMetadataCache::beginTocPass() {
|
bool BookMetadataCache::beginTocPass() {
|
||||||
Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis());
|
LOG_DBG("BMC", "Beginning toc pass");
|
||||||
|
|
||||||
// Open spine file for reading
|
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||||
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!SdMan.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||||
spineFile.close();
|
spineFile.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (spineCount >= LARGE_SPINE_THRESHOLD) {
|
||||||
|
spineHrefIndex.clear();
|
||||||
|
spineHrefIndex.reserve(spineCount);
|
||||||
|
spineFile.seek(0);
|
||||||
|
for (int i = 0; i < spineCount; i++) {
|
||||||
|
auto entry = readSpineEntry(spineFile);
|
||||||
|
SpineHrefIndexEntry idx;
|
||||||
|
idx.hrefHash = fnvHash64(entry.href);
|
||||||
|
idx.hrefLen = static_cast<uint16_t>(entry.href.size());
|
||||||
|
idx.spineIndex = static_cast<int16_t>(i);
|
||||||
|
spineHrefIndex.push_back(idx);
|
||||||
|
}
|
||||||
|
std::sort(spineHrefIndex.begin(), spineHrefIndex.end(),
|
||||||
|
[](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) {
|
||||||
|
return a.hrefHash < b.hrefHash || (a.hrefHash == b.hrefHash && a.hrefLen < b.hrefLen);
|
||||||
|
});
|
||||||
|
spineFile.seek(0);
|
||||||
|
useSpineHrefIndex = true;
|
||||||
|
LOG_DBG("BMC", "Using fast index for %d spine items", spineCount);
|
||||||
|
} else {
|
||||||
|
useSpineHrefIndex = false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool BookMetadataCache::endTocPass() {
|
bool BookMetadataCache::endTocPass() {
|
||||||
tocFile.close();
|
tocFile.close();
|
||||||
spineFile.close();
|
spineFile.close();
|
||||||
|
|
||||||
|
spineHrefIndex.clear();
|
||||||
|
spineHrefIndex.shrink_to_fit();
|
||||||
|
useSpineHrefIndex = false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool BookMetadataCache::endWrite() {
|
bool BookMetadataCache::endWrite() {
|
||||||
if (!buildMode) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMode = 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) {
|
bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) {
|
||||||
// Open all three files, writing to meta, reading from spine and toc
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
|
||||||
bookFile.close();
|
bookFile.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SdMan.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
|
||||||
bookFile.close();
|
bookFile.close();
|
||||||
spineFile.close();
|
spineFile.close();
|
||||||
return false;
|
return false;
|
||||||
@@ -124,61 +152,106 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
|||||||
// LUTs complete
|
// LUTs complete
|
||||||
// Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin
|
// Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin
|
||||||
|
|
||||||
|
// Build spineIndex->tocIndex mapping in one pass (O(n) instead of O(n*m))
|
||||||
|
std::vector<int16_t> spineToTocIndex(spineCount, -1);
|
||||||
|
tocFile.seek(0);
|
||||||
|
for (int j = 0; j < tocCount; j++) {
|
||||||
|
auto tocEntry = readTocEntry(tocFile);
|
||||||
|
if (tocEntry.spineIndex >= 0 && tocEntry.spineIndex < spineCount) {
|
||||||
|
if (spineToTocIndex[tocEntry.spineIndex] == -1) {
|
||||||
|
spineToTocIndex[tocEntry.spineIndex] = static_cast<int16_t>(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ZipFile zip(epubPath);
|
ZipFile zip(epubPath);
|
||||||
// Pre-open zip file to speed up size calculations
|
// Pre-open zip file to speed up size calculations
|
||||||
if (!zip.open()) {
|
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();
|
bookFile.close();
|
||||||
spineFile.close();
|
spineFile.close();
|
||||||
tocFile.close();
|
tocFile.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// TODO: For large ZIPs loading the all localHeaderOffsets will crash.
|
// NOTE: We intentionally skip calling loadAllFileStatSlims() here.
|
||||||
// However not having them loaded is extremely slow. Need a better solution here.
|
// For large EPUBs (2000+ chapters), pre-loading all ZIP central directory entries
|
||||||
// Perhaps only a cache of spine items or a better way to speedup lookups?
|
// into memory causes OOM crashes on ESP32-C3's limited ~380KB RAM.
|
||||||
if (!zip.loadAllFileStatSlims()) {
|
// Instead, for large books we use a one-pass batch lookup that scans the ZIP
|
||||||
Serial.printf("[%lu] [BMC] Could not load zip local header offsets for size calculations\n", millis());
|
// central directory once and matches against spine targets using hash comparison.
|
||||||
bookFile.close();
|
// This is O(n*log(m)) instead of O(n*m) while avoiding memory exhaustion.
|
||||||
spineFile.close();
|
// See: https://github.com/crosspoint-reader/crosspoint-reader/issues/134
|
||||||
tocFile.close();
|
|
||||||
zip.close();
|
std::vector<uint32_t> spineSizes;
|
||||||
return false;
|
bool useBatchSizes = false;
|
||||||
|
|
||||||
|
if (spineCount >= LARGE_SPINE_THRESHOLD) {
|
||||||
|
LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount);
|
||||||
|
|
||||||
|
std::vector<ZipFile::SizeTarget> targets;
|
||||||
|
targets.reserve(spineCount);
|
||||||
|
|
||||||
|
spineFile.seek(0);
|
||||||
|
for (int i = 0; i < spineCount; i++) {
|
||||||
|
auto entry = readSpineEntry(spineFile);
|
||||||
|
std::string path = FsHelpers::normalisePath(entry.href);
|
||||||
|
|
||||||
|
ZipFile::SizeTarget t;
|
||||||
|
t.hash = ZipFile::fnvHash64(path.c_str(), path.size());
|
||||||
|
t.len = static_cast<uint16_t>(path.size());
|
||||||
|
t.index = static_cast<uint16_t>(i);
|
||||||
|
targets.push_back(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::sort(targets.begin(), targets.end(), [](const ZipFile::SizeTarget& a, const ZipFile::SizeTarget& b) {
|
||||||
|
return a.hash < b.hash || (a.hash == b.hash && a.len < b.len);
|
||||||
|
});
|
||||||
|
|
||||||
|
spineSizes.resize(spineCount, 0);
|
||||||
|
int matched = zip.fillUncompressedSizes(targets, spineSizes);
|
||||||
|
LOG_DBG("BMC", "Batch lookup matched %d/%d spine items", matched, spineCount);
|
||||||
|
|
||||||
|
targets.clear();
|
||||||
|
targets.shrink_to_fit();
|
||||||
|
|
||||||
|
useBatchSizes = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint32_t cumSize = 0;
|
uint32_t cumSize = 0;
|
||||||
spineFile.seek(0);
|
spineFile.seek(0);
|
||||||
int lastSpineTocIndex = -1;
|
int lastSpineTocIndex = -1;
|
||||||
for (int i = 0; i < spineCount; i++) {
|
for (int i = 0; i < spineCount; i++) {
|
||||||
auto spineEntry = readSpineEntry(spineFile);
|
auto spineEntry = readSpineEntry(spineFile);
|
||||||
|
|
||||||
tocFile.seek(0);
|
spineEntry.tocIndex = spineToTocIndex[i];
|
||||||
for (int j = 0; j < tocCount; j++) {
|
|
||||||
auto tocEntry = readTocEntry(tocFile);
|
|
||||||
if (tocEntry.spineIndex == i) {
|
|
||||||
spineEntry.tocIndex = j;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs
|
// 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
|
// Logging here is for debugging
|
||||||
if (spineEntry.tocIndex == -1) {
|
if (spineEntry.tocIndex == -1) {
|
||||||
Serial.printf(
|
LOG_DBG("BMC", "Warning: Could not find TOC entry for spine item %d: %s, using title from last section", i,
|
||||||
"[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s, using title from last section\n",
|
spineEntry.href.c_str());
|
||||||
millis(), i, spineEntry.href.c_str());
|
|
||||||
spineEntry.tocIndex = lastSpineTocIndex;
|
spineEntry.tocIndex = lastSpineTocIndex;
|
||||||
}
|
}
|
||||||
lastSpineTocIndex = spineEntry.tocIndex;
|
lastSpineTocIndex = spineEntry.tocIndex;
|
||||||
|
|
||||||
// Calculate size for cumulative size
|
|
||||||
size_t itemSize = 0;
|
size_t itemSize = 0;
|
||||||
const std::string path = FsHelpers::normalisePath(spineEntry.href);
|
if (useBatchSizes) {
|
||||||
if (zip.getInflatedFileSize(path.c_str(), &itemSize)) {
|
itemSize = spineSizes[i];
|
||||||
cumSize += itemSize;
|
if (itemSize == 0) {
|
||||||
spineEntry.cumulativeSize = cumSize;
|
const std::string path = FsHelpers::normalisePath(spineEntry.href);
|
||||||
|
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
|
||||||
|
LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str());
|
const std::string path = FsHelpers::normalisePath(spineEntry.href);
|
||||||
|
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
|
||||||
|
LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cumSize += itemSize;
|
||||||
|
spineEntry.cumulativeSize = cumSize;
|
||||||
|
|
||||||
// Write out spine data to book.bin
|
// Write out spine data to book.bin
|
||||||
writeSpineEntry(bookFile, spineEntry);
|
writeSpineEntry(bookFile, spineEntry);
|
||||||
}
|
}
|
||||||
@@ -196,16 +269,16 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
|
|||||||
spineFile.close();
|
spineFile.close();
|
||||||
tocFile.close();
|
tocFile.close();
|
||||||
|
|
||||||
Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis());
|
LOG_DBG("BMC", "Successfully built book.bin");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool BookMetadataCache::cleanupTmpFiles() const {
|
bool BookMetadataCache::cleanupTmpFiles() const {
|
||||||
if (SdMan.exists((cachePath + tmpSpineBinFile).c_str())) {
|
if (Storage.exists((cachePath + tmpSpineBinFile).c_str())) {
|
||||||
SdMan.remove((cachePath + tmpSpineBinFile).c_str());
|
Storage.remove((cachePath + tmpSpineBinFile).c_str());
|
||||||
}
|
}
|
||||||
if (SdMan.exists((cachePath + tmpTocBinFile).c_str())) {
|
if (Storage.exists((cachePath + tmpTocBinFile).c_str())) {
|
||||||
SdMan.remove((cachePath + tmpTocBinFile).c_str());
|
Storage.remove((cachePath + tmpTocBinFile).c_str());
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -232,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
|
// this is because in this function we're marking positions of the items
|
||||||
void BookMetadataCache::createSpineEntry(const std::string& href) {
|
void BookMetadataCache::createSpineEntry(const std::string& href) {
|
||||||
if (!buildMode || !spineFile) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,25 +317,42 @@ void BookMetadataCache::createSpineEntry(const std::string& href) {
|
|||||||
void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
|
void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
|
||||||
const uint8_t level) {
|
const uint8_t level) {
|
||||||
if (!buildMode || !tocFile || !spineFile) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int spineIndex = -1;
|
int16_t spineIndex = -1;
|
||||||
// find spine index
|
|
||||||
// TODO: This lookup is slow as need to scan through all items each time. We can't hold it all in memory due to size.
|
if (useSpineHrefIndex) {
|
||||||
// But perhaps we can load just the hrefs in a vector/list to do an index lookup?
|
uint64_t targetHash = fnvHash64(href);
|
||||||
spineFile.seek(0);
|
uint16_t targetLen = static_cast<uint16_t>(href.size());
|
||||||
for (int i = 0; i < spineCount; i++) {
|
|
||||||
auto spineEntry = readSpineEntry(spineFile);
|
auto it =
|
||||||
if (spineEntry.href == href) {
|
std::lower_bound(spineHrefIndex.begin(), spineHrefIndex.end(), SpineHrefIndexEntry{targetHash, targetLen, 0},
|
||||||
spineIndex = i;
|
[](const SpineHrefIndexEntry& a, const SpineHrefIndexEntry& b) {
|
||||||
|
return a.hrefHash < b.hrefHash || (a.hrefHash == b.hrefHash && a.hrefLen < b.hrefLen);
|
||||||
|
});
|
||||||
|
|
||||||
|
while (it != spineHrefIndex.end() && it->hrefHash == targetHash && it->hrefLen == targetLen) {
|
||||||
|
spineIndex = it->spineIndex;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (spineIndex == -1) {
|
if (spineIndex == -1) {
|
||||||
Serial.printf("[%lu] [BMC] addTocEntry: 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);
|
||||||
|
for (int i = 0; i < spineCount; i++) {
|
||||||
|
auto spineEntry = readSpineEntry(spineFile);
|
||||||
|
if (spineEntry.href == href) {
|
||||||
|
spineIndex = static_cast<int16_t>(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (spineIndex == -1) {
|
||||||
|
LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TocEntry entry(title, href, anchor, level, spineIndex);
|
const TocEntry entry(title, href, anchor, level, spineIndex);
|
||||||
@@ -273,14 +363,14 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
|
|||||||
/* ============= READING / LOADING FUNCTIONS ================ */
|
/* ============= READING / LOADING FUNCTIONS ================ */
|
||||||
|
|
||||||
bool BookMetadataCache::load() {
|
bool BookMetadataCache::load() {
|
||||||
if (!SdMan.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
|
if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t version;
|
uint8_t version;
|
||||||
serialization::readPod(bookFile, version);
|
serialization::readPod(bookFile, version);
|
||||||
if (version != BOOK_CACHE_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();
|
bookFile.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -296,18 +386,18 @@ bool BookMetadataCache::load() {
|
|||||||
serialization::readString(bookFile, coreMetadata.textReferenceHref);
|
serialization::readString(bookFile, coreMetadata.textReferenceHref);
|
||||||
|
|
||||||
loaded = true;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
|
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis());
|
LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index < 0 || index >= static_cast<int>(spineCount)) {
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,12 +411,12 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
|
|||||||
|
|
||||||
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
|
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis());
|
LOG_ERR("BMC", "getTocEntry called but cache not loaded");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index < 0 || index >= static_cast<int>(tocCount)) {
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <SDCardManager.h>
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
class BookMetadataCache {
|
class BookMetadataCache {
|
||||||
public:
|
public:
|
||||||
@@ -53,6 +55,27 @@ class BookMetadataCache {
|
|||||||
FsFile spineFile;
|
FsFile spineFile;
|
||||||
FsFile tocFile;
|
FsFile tocFile;
|
||||||
|
|
||||||
|
// Index for fast href→spineIndex lookup (used only for large EPUBs)
|
||||||
|
struct SpineHrefIndexEntry {
|
||||||
|
uint64_t hrefHash; // FNV-1a 64-bit hash
|
||||||
|
uint16_t hrefLen; // length for collision reduction
|
||||||
|
int16_t spineIndex;
|
||||||
|
};
|
||||||
|
std::vector<SpineHrefIndexEntry> spineHrefIndex;
|
||||||
|
bool useSpineHrefIndex = false;
|
||||||
|
|
||||||
|
static constexpr uint16_t LARGE_SPINE_THRESHOLD = 400;
|
||||||
|
|
||||||
|
// FNV-1a 64-bit hash function
|
||||||
|
static uint64_t fnvHash64(const std::string& s) {
|
||||||
|
uint64_t hash = 14695981039346656037ull;
|
||||||
|
for (char c : s) {
|
||||||
|
hash ^= static_cast<uint8_t>(c);
|
||||||
|
hash *= 1099511628211ull;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
uint32_t writeSpineEntry(FsFile& file, const SpineEntry& entry) const;
|
uint32_t writeSpineEntry(FsFile& file, const SpineEntry& entry) const;
|
||||||
uint32_t writeTocEntry(FsFile& file, const TocEntry& entry) const;
|
uint32_t writeTocEntry(FsFile& file, const TocEntry& entry) const;
|
||||||
SpineEntry readSpineEntry(FsFile& file) const;
|
SpineEntry readSpineEntry(FsFile& file) const;
|
||||||
|
|||||||
+31
-4
@@ -1,6 +1,6 @@
|
|||||||
#include "Page.h"
|
#include "Page.h"
|
||||||
|
|
||||||
#include <HardwareSerial.h>
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||||
@@ -25,6 +25,29 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
|
|||||||
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
||||||
|
// Images don't use fontId or text rendering
|
||||||
|
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PageImage::serialize(FsFile& file) {
|
||||||
|
serialization::writePod(file, xPos);
|
||||||
|
serialization::writePod(file, yPos);
|
||||||
|
|
||||||
|
// serialize ImageBlock
|
||||||
|
return imageBlock->serialize(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
||||||
|
int16_t xPos;
|
||||||
|
int16_t yPos;
|
||||||
|
serialization::readPod(file, xPos);
|
||||||
|
serialization::readPod(file, yPos);
|
||||||
|
|
||||||
|
auto ib = ImageBlock::deserialize(file);
|
||||||
|
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
||||||
|
}
|
||||||
|
|
||||||
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||||
for (auto& element : elements) {
|
for (auto& element : elements) {
|
||||||
element->render(renderer, fontId, xOffset, yOffset);
|
element->render(renderer, fontId, xOffset, yOffset);
|
||||||
@@ -36,8 +59,9 @@ bool Page::serialize(FsFile& file) const {
|
|||||||
serialization::writePod(file, count);
|
serialization::writePod(file, count);
|
||||||
|
|
||||||
for (const auto& el : elements) {
|
for (const auto& el : elements) {
|
||||||
// Only PageLine exists currently
|
// Use getTag() method to determine type
|
||||||
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
|
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
|
||||||
|
|
||||||
if (!el->serialize(file)) {
|
if (!el->serialize(file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -59,8 +83,11 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
|||||||
if (tag == TAG_PageLine) {
|
if (tag == TAG_PageLine) {
|
||||||
auto pl = PageLine::deserialize(file);
|
auto pl = PageLine::deserialize(file);
|
||||||
page->elements.push_back(std::move(pl));
|
page->elements.push_back(std::move(pl));
|
||||||
|
} else if (tag == TAG_PageImage) {
|
||||||
|
auto pi = PageImage::deserialize(file);
|
||||||
|
page->elements.push_back(std::move(pi));
|
||||||
} else {
|
} else {
|
||||||
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);
|
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-1
@@ -1,13 +1,16 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <SdFat.h>
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "blocks/ImageBlock.h"
|
||||||
#include "blocks/TextBlock.h"
|
#include "blocks/TextBlock.h"
|
||||||
|
|
||||||
enum PageElementTag : uint8_t {
|
enum PageElementTag : uint8_t {
|
||||||
TAG_PageLine = 1,
|
TAG_PageLine = 1,
|
||||||
|
TAG_PageImage = 2, // New tag
|
||||||
};
|
};
|
||||||
|
|
||||||
// represents something that has been added to a page
|
// represents something that has been added to a page
|
||||||
@@ -19,6 +22,7 @@ class PageElement {
|
|||||||
virtual ~PageElement() = default;
|
virtual ~PageElement() = default;
|
||||||
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
||||||
virtual bool serialize(FsFile& file) = 0;
|
virtual bool serialize(FsFile& file) = 0;
|
||||||
|
virtual PageElementTag getTag() const = 0; // Add type identification
|
||||||
};
|
};
|
||||||
|
|
||||||
// a line from a block element
|
// a line from a block element
|
||||||
@@ -30,9 +34,24 @@ class PageLine final : public PageElement {
|
|||||||
: PageElement(xPos, yPos), block(std::move(block)) {}
|
: PageElement(xPos, yPos), block(std::move(block)) {}
|
||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||||
bool serialize(FsFile& file) override;
|
bool serialize(FsFile& file) override;
|
||||||
|
PageElementTag getTag() const override { return TAG_PageLine; }
|
||||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// New PageImage class
|
||||||
|
class PageImage final : public PageElement {
|
||||||
|
std::shared_ptr<ImageBlock> imageBlock;
|
||||||
|
|
||||||
|
public:
|
||||||
|
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
|
||||||
|
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
|
||||||
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||||
|
bool serialize(FsFile& file) override;
|
||||||
|
PageElementTag getTag() const override { return TAG_PageImage; }
|
||||||
|
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
||||||
|
const ImageBlock& getImageBlock() const { return *imageBlock; }
|
||||||
|
};
|
||||||
|
|
||||||
class Page {
|
class Page {
|
||||||
public:
|
public:
|
||||||
// the list of block index and line numbers on this page
|
// the list of block index and line numbers on this page
|
||||||
@@ -40,4 +59,38 @@ class Page {
|
|||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||||
bool serialize(FsFile& file) const;
|
bool serialize(FsFile& file) const;
|
||||||
static std::unique_ptr<Page> deserialize(FsFile& file);
|
static std::unique_ptr<Page> deserialize(FsFile& file);
|
||||||
|
|
||||||
|
// Check if page contains any images (used to force full refresh)
|
||||||
|
bool hasImages() const {
|
||||||
|
return std::any_of(elements.begin(), elements.end(),
|
||||||
|
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bounding box of all images on the page (union of image rects)
|
||||||
|
// Returns false if no images. Coordinates are relative to page origin.
|
||||||
|
bool getImageBoundingBox(int16_t& outX, int16_t& outY, int16_t& outW, int16_t& outH) const {
|
||||||
|
bool found = false;
|
||||||
|
int16_t minX = INT16_MAX, minY = INT16_MAX, maxX = INT16_MIN, maxY = INT16_MIN;
|
||||||
|
for (const auto& el : elements) {
|
||||||
|
if (el->getTag() == TAG_PageImage) {
|
||||||
|
const auto& img = static_cast<const PageImage&>(*el);
|
||||||
|
int16_t x = img.xPos;
|
||||||
|
int16_t y = img.yPos;
|
||||||
|
int16_t right = x + img.getImageBlock().getWidth();
|
||||||
|
int16_t bottom = y + img.getImageBlock().getHeight();
|
||||||
|
minX = std::min(minX, x);
|
||||||
|
minY = std::min(minY, y);
|
||||||
|
maxX = std::max(maxX, right);
|
||||||
|
maxY = std::max(maxY, bottom);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
outX = minX;
|
||||||
|
outY = minY;
|
||||||
|
outW = maxX - minX;
|
||||||
|
outH = maxY - minY;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+166
-45
@@ -29,12 +29,17 @@ void stripSoftHyphensInPlace(std::string& word) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
|
// Returns the advance width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
|
||||||
|
// Uses advance width (sum of glyph advances) rather than bounding box width so that italic glyph overhangs
|
||||||
|
// don't inflate inter-word spacing.
|
||||||
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
|
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
|
||||||
const EpdFontFamily::Style style, const bool appendHyphen = false) {
|
const EpdFontFamily::Style style, const bool appendHyphen = false) {
|
||||||
|
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
|
||||||
|
return renderer.getSpaceWidth(fontId, style);
|
||||||
|
}
|
||||||
const bool hasSoftHyphen = containsSoftHyphen(word);
|
const bool hasSoftHyphen = containsSoftHyphen(word);
|
||||||
if (!hasSoftHyphen && !appendHyphen) {
|
if (!hasSoftHyphen && !appendHyphen) {
|
||||||
return renderer.getTextWidth(fontId, word.c_str(), style);
|
return renderer.getTextAdvanceX(fontId, word.c_str(), style);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string sanitized = word;
|
std::string sanitized = word;
|
||||||
@@ -44,16 +49,22 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
|
|||||||
if (appendHyphen) {
|
if (appendHyphen) {
|
||||||
sanitized.push_back('-');
|
sanitized.push_back('-');
|
||||||
}
|
}
|
||||||
return renderer.getTextWidth(fontId, sanitized.c_str(), style);
|
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // 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;
|
if (word.empty()) return;
|
||||||
|
|
||||||
words.push_back(std::move(word));
|
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
|
// Consumes data to minimize memory usage
|
||||||
@@ -70,17 +81,21 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
|||||||
const int pageWidth = viewportWidth;
|
const int pageWidth = viewportWidth;
|
||||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||||
|
|
||||||
|
// Build indexed continues vector from the parallel list for O(1) access during layout
|
||||||
|
std::vector<bool> continuesVec(wordContinues.begin(), wordContinues.end());
|
||||||
|
|
||||||
std::vector<size_t> lineBreakIndices;
|
std::vector<size_t> lineBreakIndices;
|
||||||
if (hyphenationEnabled) {
|
if (hyphenationEnabled) {
|
||||||
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
// 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, continuesVec);
|
||||||
} else {
|
} else {
|
||||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths);
|
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
|
||||||
}
|
}
|
||||||
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||||
|
|
||||||
for (size_t i = 0; i < lineCount; ++i) {
|
for (size_t i = 0; i < lineCount; ++i) {
|
||||||
extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine);
|
extractLine(i, pageWidth, spaceWidth, wordWidths, continuesVec, lineBreakIndices, processLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,15 +119,26 @@ std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& rendere
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, const int fontId, const int pageWidth,
|
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()) {
|
if (words.empty()) {
|
||||||
return {};
|
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.
|
// 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) {
|
for (size_t i = 0; i < wordWidths.size(); ++i) {
|
||||||
while (wordWidths[i] > pageWidth) {
|
// First word needs to fit in reduced width if there's an indent
|
||||||
if (!hyphenateWordAtIndex(i, pageWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
|
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||||
|
while (wordWidths[i] > effectiveWidth) {
|
||||||
|
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true,
|
||||||
|
&continuesVec)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,22 +156,31 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
|||||||
ans[totalWordCount - 1] = totalWordCount - 1;
|
ans[totalWordCount - 1] = totalWordCount - 1;
|
||||||
|
|
||||||
for (int i = totalWordCount - 2; i >= 0; --i) {
|
for (int i = totalWordCount - 2; i >= 0; --i) {
|
||||||
int currlen = -spaceWidth;
|
int currlen = 0;
|
||||||
dp[i] = MAX_COST;
|
dp[i] = MAX_COST;
|
||||||
|
|
||||||
for (size_t j = i; j < totalWordCount; ++j) {
|
// First line has reduced width due to text-indent
|
||||||
// Current line length: previous width + space + current word width
|
const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||||
currlen += wordWidths[j] + spaceWidth;
|
|
||||||
|
|
||||||
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;
|
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;
|
int cost;
|
||||||
if (j == totalWordCount - 1) {
|
if (j == totalWordCount - 1) {
|
||||||
cost = 0; // Last line
|
cost = 0; // Last line
|
||||||
} else {
|
} else {
|
||||||
const int remainingSpace = pageWidth - currlen;
|
const int remainingSpace = effectivePageWidth - currlen;
|
||||||
// Use long long for the square to prevent overflow
|
// Use long long for the square to prevent overflow
|
||||||
const long long cost_ll = static_cast<long long>(remainingSpace) * remainingSpace + dp[j + 1];
|
const long long cost_ll = static_cast<long long>(remainingSpace) * remainingSpace + dp[j + 1];
|
||||||
|
|
||||||
@@ -200,7 +235,11 @@ void ParsedText::applyParagraphIndent() {
|
|||||||
return;
|
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");
|
words.front().insert(0, "\xe2\x80\x83");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,33 +247,45 @@ void ParsedText::applyParagraphIndent() {
|
|||||||
// Builds break indices while opportunistically splitting the word that would overflow the current line.
|
// 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,
|
std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& renderer, const int fontId,
|
||||||
const int pageWidth, const int spaceWidth,
|
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;
|
std::vector<size_t> lineBreakIndices;
|
||||||
size_t currentIndex = 0;
|
size_t currentIndex = 0;
|
||||||
|
bool isFirstLine = true;
|
||||||
|
|
||||||
while (currentIndex < wordWidths.size()) {
|
while (currentIndex < wordWidths.size()) {
|
||||||
const size_t lineStart = currentIndex;
|
const size_t lineStart = currentIndex;
|
||||||
int lineWidth = 0;
|
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
|
// Consume as many words as possible for current line, splitting when prefixes fit
|
||||||
while (currentIndex < wordWidths.size()) {
|
while (currentIndex < wordWidths.size()) {
|
||||||
const bool isFirstWord = currentIndex == lineStart;
|
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];
|
const int candidateWidth = spacing + wordWidths[currentIndex];
|
||||||
|
|
||||||
// Word fits on current line
|
// Word fits on current line
|
||||||
if (lineWidth + candidateWidth <= pageWidth) {
|
if (lineWidth + candidateWidth <= effectivePageWidth) {
|
||||||
lineWidth += candidateWidth;
|
lineWidth += candidateWidth;
|
||||||
++currentIndex;
|
++currentIndex;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Word would overflow — try to split based on hyphenation points
|
// 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
|
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
|
||||||
|
|
||||||
if (availableWidth > 0 &&
|
if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths,
|
||||||
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) {
|
allowFallbackBreaks, &continuesVec)) {
|
||||||
// Prefix now fits; append it to this line and move to next line
|
// Prefix now fits; append it to this line and move to next line
|
||||||
lineWidth += spacing + wordWidths[currentIndex];
|
lineWidth += spacing + wordWidths[currentIndex];
|
||||||
++currentIndex;
|
++currentIndex;
|
||||||
@@ -249,7 +300,14 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
|||||||
break;
|
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);
|
lineBreakIndices.push_back(currentIndex);
|
||||||
|
isFirstLine = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return lineBreakIndices;
|
return lineBreakIndices;
|
||||||
@@ -259,7 +317,7 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
|||||||
// available width.
|
// available width.
|
||||||
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
|
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
|
||||||
const int fontId, std::vector<uint16_t>& wordWidths,
|
const int fontId, std::vector<uint16_t>& wordWidths,
|
||||||
const bool allowFallbackBreaks) {
|
const bool allowFallbackBreaks, std::vector<bool>* continuesVec) {
|
||||||
// Guard against invalid indices or zero available width before attempting to split.
|
// Guard against invalid indices or zero available width before attempting to split.
|
||||||
if (availableWidth <= 0 || wordIndex >= words.size()) {
|
if (availableWidth <= 0 || wordIndex >= words.size()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -314,12 +372,43 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
|||||||
wordIt->push_back('-');
|
wordIt->push_back('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the remainder word (with matching style) directly after the prefix.
|
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
|
||||||
auto insertWordIt = std::next(wordIt);
|
auto insertWordIt = std::next(wordIt);
|
||||||
auto insertStyleIt = std::next(styleIt);
|
auto insertStyleIt = std::next(styleIt);
|
||||||
words.insert(insertWordIt, remainder);
|
words.insert(insertWordIt, remainder);
|
||||||
wordStyles.insert(insertStyleIt, style);
|
wordStyles.insert(insertStyleIt, style);
|
||||||
|
|
||||||
|
// Continuation flag handling after splitting a word into prefix + remainder.
|
||||||
|
//
|
||||||
|
// The prefix keeps the original word's continuation flag so that no-break-space groups
|
||||||
|
// stay linked. The remainder always gets continues=false because it starts on the next
|
||||||
|
// line and is not attached to the prefix.
|
||||||
|
//
|
||||||
|
// Example: "200 Quadratkilometer" produces tokens:
|
||||||
|
// [0] "200" continues=false
|
||||||
|
// [1] " " continues=true
|
||||||
|
// [2] "Quadratkilometer" continues=true <-- the word being split
|
||||||
|
//
|
||||||
|
// After splitting "Quadratkilometer" at "Quadrat-" / "kilometer":
|
||||||
|
// [0] "200" continues=false
|
||||||
|
// [1] " " continues=true
|
||||||
|
// [2] "Quadrat-" continues=true (KEPT — still attached to the no-break group)
|
||||||
|
// [3] "kilometer" continues=false (NEW — starts fresh on the next line)
|
||||||
|
//
|
||||||
|
// This lets the backtracking loop keep the entire prefix group ("200 Quadrat-") on one
|
||||||
|
// line, while "kilometer" moves to the next line.
|
||||||
|
auto continuesIt = wordContinues.begin();
|
||||||
|
std::advance(continuesIt, wordIndex);
|
||||||
|
// *continuesIt is intentionally left unchanged — the prefix keeps its original attachment.
|
||||||
|
const auto insertContinuesIt = std::next(continuesIt);
|
||||||
|
wordContinues.insert(insertContinuesIt, false);
|
||||||
|
|
||||||
|
// Keep the indexed vector in sync if provided.
|
||||||
|
if (continuesVec) {
|
||||||
|
// (*continuesVec)[wordIndex] stays unchanged — prefix keeps its attachment.
|
||||||
|
continuesVec->insert(continuesVec->begin() + wordIndex + 1, false);
|
||||||
|
}
|
||||||
|
|
||||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||||
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
||||||
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
|
const uint16_t remainderWidth = measureWordWidth(renderer, fontId, remainder, style);
|
||||||
@@ -328,49 +417,76 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
|
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 std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
|
||||||
const size_t lineBreak = lineBreakIndices[breakIndex];
|
const size_t lineBreak = lineBreakIndices[breakIndex];
|
||||||
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
|
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
|
||||||
const size_t lineWordCount = lineBreak - lastBreakAt;
|
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;
|
int lineWordWidthSum = 0;
|
||||||
for (size_t i = lastBreakAt; i < lineBreak; i++) {
|
size_t actualGapCount = 0;
|
||||||
lineWordWidthSum += wordWidths[i];
|
|
||||||
|
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
|
// Calculate spacing (account for indent reducing effective page width on first line)
|
||||||
const int spareSpace = pageWidth - lineWordWidthSum;
|
const int effectivePageWidth = pageWidth - firstLineIndent;
|
||||||
|
const int spareSpace = effectivePageWidth - lineWordWidthSum;
|
||||||
|
|
||||||
int spacing = spaceWidth;
|
int spacing = spaceWidth;
|
||||||
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
|
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
|
||||||
|
|
||||||
if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) {
|
// For justified text, calculate spacing based on actual gap count
|
||||||
spacing = spareSpace / (lineWordCount - 1);
|
if (blockStyle.alignment == CssTextAlign::Justify && !isLastLine && actualGapCount >= 1) {
|
||||||
|
spacing = spareSpace / static_cast<int>(actualGapCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate initial x position
|
// Calculate initial x position (first line starts at indent for left/justified text)
|
||||||
uint16_t xpos = 0;
|
auto xpos = static_cast<uint16_t>(firstLineIndent);
|
||||||
if (style == TextBlock::RIGHT_ALIGN) {
|
if (blockStyle.alignment == CssTextAlign::Right) {
|
||||||
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
|
xpos = spareSpace - static_cast<int>(actualGapCount) * spaceWidth;
|
||||||
} else if (style == TextBlock::CENTER_ALIGN) {
|
} else if (blockStyle.alignment == CssTextAlign::Center) {
|
||||||
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
|
xpos = (spareSpace - static_cast<int>(actualGapCount) * spaceWidth) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-calculate X positions for words
|
// Pre-calculate X positions for words
|
||||||
|
// Continuation words attach to the previous word with no space before them
|
||||||
std::list<uint16_t> lineXPos;
|
std::list<uint16_t> lineXPos;
|
||||||
for (size_t i = lastBreakAt; i < lineBreak; i++) {
|
|
||||||
const uint16_t currentWordWidth = wordWidths[i];
|
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||||
|
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
|
||||||
|
|
||||||
lineXPos.push_back(xpos);
|
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
|
// Iterators always start at the beginning as we are moving content with splice below
|
||||||
auto wordEndIt = words.begin();
|
auto wordEndIt = words.begin();
|
||||||
auto wordStyleEndIt = wordStyles.begin();
|
auto wordStyleEndIt = wordStyles.begin();
|
||||||
|
auto wordContinuesEndIt = wordContinues.begin();
|
||||||
std::advance(wordEndIt, lineWordCount);
|
std::advance(wordEndIt, lineWordCount);
|
||||||
std::advance(wordStyleEndIt, lineWordCount);
|
std::advance(wordStyleEndIt, lineWordCount);
|
||||||
|
std::advance(wordContinuesEndIt, lineWordCount);
|
||||||
|
|
||||||
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
||||||
std::list<std::string> lineWords;
|
std::list<std::string> lineWords;
|
||||||
@@ -378,11 +494,16 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
std::list<EpdFontFamily::Style> lineWordStyles;
|
std::list<EpdFontFamily::Style> lineWordStyles;
|
||||||
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
||||||
|
|
||||||
|
// Consume continues flags (not passed to TextBlock, but must be consumed to stay in sync)
|
||||||
|
std::list<bool> lineContinues;
|
||||||
|
lineContinues.splice(lineContinues.begin(), wordContinues, wordContinues.begin(), wordContinuesEndIt);
|
||||||
|
|
||||||
for (auto& word : lineWords) {
|
for (auto& word : lineWords) {
|
||||||
if (containsSoftHyphen(word)) {
|
if (containsSoftHyphen(word)) {
|
||||||
stripSoftHyphensInPlace(word);
|
stripSoftHyphensInPlace(word);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
|
}
|
||||||
|
|||||||
+15
-11
@@ -8,6 +8,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "blocks/BlockStyle.h"
|
||||||
#include "blocks/TextBlock.h"
|
#include "blocks/TextBlock.h"
|
||||||
|
|
||||||
class GfxRenderer;
|
class GfxRenderer;
|
||||||
@@ -15,31 +16,34 @@ class GfxRenderer;
|
|||||||
class ParsedText {
|
class ParsedText {
|
||||||
std::list<std::string> words;
|
std::list<std::string> words;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::list<EpdFontFamily::Style> wordStyles;
|
||||||
TextBlock::Style style;
|
std::list<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||||
|
BlockStyle blockStyle;
|
||||||
bool extraParagraphSpacing;
|
bool extraParagraphSpacing;
|
||||||
bool hyphenationEnabled;
|
bool hyphenationEnabled;
|
||||||
|
|
||||||
void applyParagraphIndent();
|
void applyParagraphIndent();
|
||||||
std::vector<size_t> computeLineBreaks(const GfxRenderer& renderer, int fontId, int pageWidth, int spaceWidth,
|
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,
|
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,
|
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
||||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks,
|
||||||
|
std::vector<bool>* continuesVec = nullptr);
|
||||||
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
|
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);
|
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
||||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ParsedText(const TextBlock::Style style, const bool extraParagraphSpacing,
|
explicit ParsedText(const bool extraParagraphSpacing, const bool hyphenationEnabled = false,
|
||||||
const bool hyphenationEnabled = false)
|
const BlockStyle& blockStyle = BlockStyle())
|
||||||
: style(style), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
|
: blockStyle(blockStyle), extraParagraphSpacing(extraParagraphSpacing), hyphenationEnabled(hyphenationEnabled) {}
|
||||||
~ParsedText() = default;
|
~ParsedText() = default;
|
||||||
|
|
||||||
void addWord(std::string word, EpdFontFamily::Style fontStyle);
|
void addWord(std::string word, EpdFontFamily::Style fontStyle, bool underline = false, bool attachToPrevious = false);
|
||||||
void setStyle(const TextBlock::Style style) { this->style = style; }
|
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||||
TextBlock::Style getStyle() const { return style; }
|
BlockStyle& getBlockStyle() { return blockStyle; }
|
||||||
size_t size() const { return words.size(); }
|
size_t size() const { return words.size(); }
|
||||||
bool isEmpty() const { return words.empty(); }
|
bool isEmpty() const { return words.empty(); }
|
||||||
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
|
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
|
||||||
|
|||||||
+69
-49
@@ -1,31 +1,33 @@
|
|||||||
#include "Section.h"
|
#include "Section.h"
|
||||||
|
|
||||||
#include <SDCardManager.h>
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
#include "Epub/css/CssParser.h"
|
||||||
#include "Page.h"
|
#include "Page.h"
|
||||||
#include "hyphenation/Hyphenator.h"
|
#include "hyphenation/Hyphenator.h"
|
||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 10;
|
constexpr uint8_t SECTION_FILE_VERSION = 13;
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
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);
|
sizeof(uint32_t);
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
||||||
if (!file) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t position = file.position();
|
const uint32_t position = file.position();
|
||||||
if (!page->serialize(file)) {
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
Serial.printf("[%lu] [SCT] Page %d processed\n", millis(), pageCount);
|
LOG_DBG("SCT", "Page %d processed", pageCount);
|
||||||
|
|
||||||
pageCount++;
|
pageCount++;
|
||||||
return position;
|
return position;
|
||||||
@@ -33,15 +35,16 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
|
|||||||
|
|
||||||
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
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) {
|
if (!file) {
|
||||||
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
|
LOG_DBG("SCT", "File not open for writing header");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
|
||||||
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
|
||||||
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
|
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
|
||||||
sizeof(uint32_t),
|
sizeof(embeddedStyle) + sizeof(uint32_t),
|
||||||
"Header size mismatch");
|
"Header size mismatch");
|
||||||
serialization::writePod(file, SECTION_FILE_VERSION);
|
serialization::writePod(file, SECTION_FILE_VERSION);
|
||||||
serialization::writePod(file, fontId);
|
serialization::writePod(file, fontId);
|
||||||
@@ -51,14 +54,15 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
|
|||||||
serialization::writePod(file, viewportWidth);
|
serialization::writePod(file, viewportWidth);
|
||||||
serialization::writePod(file, viewportHeight);
|
serialization::writePod(file, viewportHeight);
|
||||||
serialization::writePod(file, hyphenationEnabled);
|
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, pageCount); // Placeholder for page count (will be initially 0 when written)
|
||||||
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset
|
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
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 (!SdMan.openFileForRead("SCT", filePath, file)) {
|
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
serialization::readPod(file, version);
|
serialization::readPod(file, version);
|
||||||
if (version != SECTION_FILE_VERSION) {
|
if (version != SECTION_FILE_VERSION) {
|
||||||
file.close();
|
file.close();
|
||||||
Serial.printf("[%lu] [SCT] Deserialization failed: Unknown version %u\n", millis(), version);
|
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
|
||||||
clearCache();
|
clearCache();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -79,6 +83,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
bool fileExtraParagraphSpacing;
|
bool fileExtraParagraphSpacing;
|
||||||
uint8_t fileParagraphAlignment;
|
uint8_t fileParagraphAlignment;
|
||||||
bool fileHyphenationEnabled;
|
bool fileHyphenationEnabled;
|
||||||
|
bool fileEmbeddedStyle;
|
||||||
serialization::readPod(file, fileFontId);
|
serialization::readPod(file, fileFontId);
|
||||||
serialization::readPod(file, fileLineCompression);
|
serialization::readPod(file, fileLineCompression);
|
||||||
serialization::readPod(file, fileExtraParagraphSpacing);
|
serialization::readPod(file, fileExtraParagraphSpacing);
|
||||||
@@ -86,13 +91,14 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
serialization::readPod(file, fileViewportWidth);
|
serialization::readPod(file, fileViewportWidth);
|
||||||
serialization::readPod(file, fileViewportHeight);
|
serialization::readPod(file, fileViewportHeight);
|
||||||
serialization::readPod(file, fileHyphenationEnabled);
|
serialization::readPod(file, fileHyphenationEnabled);
|
||||||
|
serialization::readPod(file, fileEmbeddedStyle);
|
||||||
|
|
||||||
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
if (fontId != fileFontId || lineCompression != fileLineCompression ||
|
||||||
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
|
||||||
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
|
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
|
||||||
hyphenationEnabled != fileHyphenationEnabled) {
|
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) {
|
||||||
file.close();
|
file.close();
|
||||||
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
|
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
|
||||||
clearCache();
|
clearCache();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -100,39 +106,37 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
|
|||||||
|
|
||||||
serialization::readPod(file, pageCount);
|
serialization::readPod(file, pageCount);
|
||||||
file.close();
|
file.close();
|
||||||
Serial.printf("[%lu] [SCT] Deserialization succeeded: %d pages\n", millis(), pageCount);
|
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
|
||||||
bool Section::clearCache() const {
|
bool Section::clearCache() const {
|
||||||
if (!SdMan.exists(filePath.c_str())) {
|
if (!Storage.exists(filePath.c_str())) {
|
||||||
Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis());
|
LOG_DBG("SCT", "Cache does not exist, no action needed");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SdMan.remove(filePath.c_str())) {
|
if (!Storage.remove(filePath.c_str())) {
|
||||||
Serial.printf("[%lu] [SCT] Failed to clear cache\n", millis());
|
LOG_ERR("SCT", "Failed to clear cache");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [SCT] Cache cleared successfully\n", millis());
|
LOG_DBG("SCT", "Cache cleared successfully");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
|
||||||
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
|
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,
|
||||||
const std::function<void()>& progressSetupFn,
|
const std::function<void()>& popupFn) {
|
||||||
const std::function<void(int)>& progressFn) {
|
|
||||||
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
|
||||||
const auto localPath = epub->getSpineItem(spineIndex).href;
|
const auto localPath = epub->getSpineItem(spineIndex).href;
|
||||||
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
|
||||||
|
|
||||||
// Create cache directory if it doesn't exist
|
// Create cache directory if it doesn't exist
|
||||||
{
|
{
|
||||||
const auto sectionsDir = epub->getCachePath() + "/sections";
|
const auto sectionsDir = epub->getCachePath() + "/sections";
|
||||||
SdMan.mkdir(sectionsDir.c_str());
|
Storage.mkdir(sectionsDir.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry logic for SD card timing issues
|
// Retry logic for SD card timing issues
|
||||||
@@ -140,17 +144,17 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
uint32_t fileSize = 0;
|
uint32_t fileSize = 0;
|
||||||
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
for (int attempt = 0; attempt < 3 && !success; attempt++) {
|
||||||
if (attempt > 0) {
|
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
|
delay(50); // Brief delay before retry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove any incomplete file from previous attempt before retrying
|
// Remove any incomplete file from previous attempt before retrying
|
||||||
if (SdMan.exists(tmpHtmlPath.c_str())) {
|
if (Storage.exists(tmpHtmlPath.c_str())) {
|
||||||
SdMan.remove(tmpHtmlPath.c_str());
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
FsFile tmpHtml;
|
FsFile tmpHtml;
|
||||||
if (!SdMan.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
|
||||||
@@ -158,44 +162,57 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
tmpHtml.close();
|
tmpHtml.close();
|
||||||
|
|
||||||
// If streaming failed, remove the incomplete file immediately
|
// If streaming failed, remove the incomplete file immediately
|
||||||
if (!success && SdMan.exists(tmpHtmlPath.c_str())) {
|
if (!success && Storage.exists(tmpHtmlPath.c_str())) {
|
||||||
SdMan.remove(tmpHtmlPath.c_str());
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis());
|
LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!success) {
|
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;
|
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 (!Storage.openFileForWrite("SCT", filePath, file)) {
|
||||||
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
|
|
||||||
progressSetupFn();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!SdMan.openFileForWrite("SCT", filePath, file)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled);
|
viewportHeight, hyphenationEnabled, embeddedStyle);
|
||||||
std::vector<uint32_t> lut = {};
|
std::vector<uint32_t> lut = {};
|
||||||
|
|
||||||
|
// Derive the content base directory and image cache path prefix for the parser
|
||||||
|
size_t lastSlash = localPath.find_last_of('/');
|
||||||
|
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
||||||
|
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
||||||
|
|
||||||
|
CssParser* cssParser = nullptr;
|
||||||
|
if (embeddedStyle) {
|
||||||
|
cssParser = epub->getCssParser();
|
||||||
|
if (cssParser) {
|
||||||
|
if (!cssParser->loadFromCache()) {
|
||||||
|
LOG_ERR("SCT", "Failed to load CSS from cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ChapterHtmlSlimParser visitor(
|
ChapterHtmlSlimParser visitor(
|
||||||
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled,
|
viewportHeight, hyphenationEnabled,
|
||||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||||
progressFn);
|
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
success = visitor.parseAndBuildPages();
|
success = visitor.parseAndBuildPages();
|
||||||
|
|
||||||
SdMan.remove(tmpHtmlPath.c_str());
|
Storage.remove(tmpHtmlPath.c_str());
|
||||||
if (!success) {
|
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();
|
file.close();
|
||||||
SdMan.remove(filePath.c_str());
|
Storage.remove(filePath.c_str());
|
||||||
|
if (cssParser) {
|
||||||
|
cssParser->clear();
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,9 +228,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasFailedLutRecords) {
|
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();
|
file.close();
|
||||||
SdMan.remove(filePath.c_str());
|
Storage.remove(filePath.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,11 +239,14 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
serialization::writePod(file, pageCount);
|
serialization::writePod(file, pageCount);
|
||||||
serialization::writePod(file, lutOffset);
|
serialization::writePod(file, lutOffset);
|
||||||
file.close();
|
file.close();
|
||||||
|
if (cssParser) {
|
||||||
|
cssParser->clear();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
|
||||||
if (!SdMan.openFileForRead("SCT", filePath, file)) {
|
if (!Storage.openFileForRead("SCT", filePath, file)) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ class Section {
|
|||||||
FsFile file;
|
FsFile file;
|
||||||
|
|
||||||
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
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);
|
uint32_t onPageComplete(std::unique_ptr<Page> page);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -29,11 +30,10 @@ class Section {
|
|||||||
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
|
||||||
~Section() = default;
|
~Section() = default;
|
||||||
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
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 clearCache() const;
|
||||||
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
|
bool createSectionFile(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,
|
||||||
const std::function<void()>& progressSetupFn = nullptr,
|
const std::function<void()>& popupFn = nullptr);
|
||||||
const std::function<void(int)>& progressFn = nullptr);
|
|
||||||
std::unique_ptr<Page> loadPageFromSectionFile();
|
std::unique_ptr<Page> loadPageFromSectionFile();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
|
|||||||
class Block {
|
class Block {
|
||||||
public:
|
public:
|
||||||
virtual ~Block() = default;
|
virtual ~Block() = default;
|
||||||
virtual void layout(GfxRenderer& renderer) = 0;
|
|
||||||
virtual BlockType getType() = 0;
|
virtual BlockType getType() = 0;
|
||||||
virtual bool isEmpty() = 0;
|
virtual bool isEmpty() = 0;
|
||||||
virtual void finish() {}
|
virtual void finish() {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#include "ImageBlock.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
#include "../converters/DitherUtils.h"
|
||||||
|
#include "../converters/ImageDecoderFactory.h"
|
||||||
|
|
||||||
|
// Cache file format:
|
||||||
|
// - uint16_t width
|
||||||
|
// - uint16_t height
|
||||||
|
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
|
||||||
|
|
||||||
|
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
|
||||||
|
: imagePath(imagePath), width(width), height(height) {}
|
||||||
|
|
||||||
|
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string getCachePath(const std::string& imagePath) {
|
||||||
|
// Replace extension with .pxc (pixel cache)
|
||||||
|
size_t dotPos = imagePath.rfind('.');
|
||||||
|
if (dotPos != std::string::npos) {
|
||||||
|
return imagePath.substr(0, dotPos) + ".pxc";
|
||||||
|
}
|
||||||
|
return imagePath + ".pxc";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||||
|
int expectedHeight) {
|
||||||
|
FsFile cacheFile;
|
||||||
|
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t cachedWidth, cachedHeight;
|
||||||
|
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
|
||||||
|
int widthDiff = abs(cachedWidth - expectedWidth);
|
||||||
|
int heightDiff = abs(cachedHeight - expectedHeight);
|
||||||
|
if (widthDiff > 1 || heightDiff > 1) {
|
||||||
|
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
|
||||||
|
expectedHeight);
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached dimensions for rendering (they're the actual decoded size)
|
||||||
|
expectedWidth = cachedWidth;
|
||||||
|
expectedHeight = cachedHeight;
|
||||||
|
|
||||||
|
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
|
||||||
|
|
||||||
|
// Read and render row by row to minimize memory usage
|
||||||
|
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||||
|
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
|
||||||
|
if (!rowBuffer) {
|
||||||
|
LOG_ERR("IMG", "Failed to allocate row buffer");
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int row = 0; row < cachedHeight; row++) {
|
||||||
|
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
|
||||||
|
LOG_ERR("IMG", "Cache read error at row %d", row);
|
||||||
|
free(rowBuffer);
|
||||||
|
cacheFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int destY = y + row;
|
||||||
|
for (int col = 0; col < cachedWidth; col++) {
|
||||||
|
int byteIdx = col / 4;
|
||||||
|
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
|
||||||
|
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||||
|
|
||||||
|
drawPixelWithRenderMode(renderer, x + col, destY, pixelValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(rowBuffer);
|
||||||
|
cacheFile.close();
|
||||||
|
LOG_DBG("IMG", "Cache render complete");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||||
|
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
|
||||||
|
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
// Bounds check render position using logical screen dimensions
|
||||||
|
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
|
||||||
|
LOG_ERR("IMG", "Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)", x, y, width, height, screenWidth,
|
||||||
|
screenHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to render from cache first
|
||||||
|
std::string cachePath = getCachePath(imagePath);
|
||||||
|
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
||||||
|
return; // Successfully rendered from cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache - need to decode the image
|
||||||
|
// Check if image file exists
|
||||||
|
FsFile file;
|
||||||
|
if (!Storage.openFileForRead("IMG", imagePath, file)) {
|
||||||
|
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
size_t fileSize = file.size();
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (fileSize == 0) {
|
||||||
|
LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("IMG", "Decoding and caching: %s", imagePath.c_str());
|
||||||
|
|
||||||
|
RenderConfig config;
|
||||||
|
config.x = x;
|
||||||
|
config.y = y;
|
||||||
|
config.maxWidth = width;
|
||||||
|
config.maxHeight = height;
|
||||||
|
config.useGrayscale = true;
|
||||||
|
config.useDithering = true;
|
||||||
|
config.performanceMode = false;
|
||||||
|
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
|
||||||
|
config.cachePath = cachePath; // Enable caching during decode
|
||||||
|
|
||||||
|
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
|
||||||
|
if (!decoder) {
|
||||||
|
LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("IMG", "Using %s decoder", decoder->getFormatName());
|
||||||
|
|
||||||
|
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
|
||||||
|
if (!success) {
|
||||||
|
LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("IMG", "Decode successful");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageBlock::serialize(FsFile& file) {
|
||||||
|
serialization::writeString(file, imagePath);
|
||||||
|
serialization::writePod(file, width);
|
||||||
|
serialization::writePod(file, height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
|
||||||
|
std::string path;
|
||||||
|
serialization::readString(file, path);
|
||||||
|
int16_t w, h;
|
||||||
|
serialization::readPod(file, w);
|
||||||
|
serialization::readPod(file, h);
|
||||||
|
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <SdFat.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "Block.h"
|
||||||
|
|
||||||
|
class ImageBlock final : public Block {
|
||||||
|
public:
|
||||||
|
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
||||||
|
~ImageBlock() override = default;
|
||||||
|
|
||||||
|
const std::string& getImagePath() const { return imagePath; }
|
||||||
|
int16_t getWidth() const { return width; }
|
||||||
|
int16_t getHeight() const { return height; }
|
||||||
|
|
||||||
|
bool imageExists() const;
|
||||||
|
|
||||||
|
BlockType getType() override { return IMAGE_BLOCK; }
|
||||||
|
bool isEmpty() override { return false; }
|
||||||
|
|
||||||
|
void render(GfxRenderer& renderer, const int x, const int y);
|
||||||
|
bool serialize(FsFile& file);
|
||||||
|
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string imagePath;
|
||||||
|
int16_t width;
|
||||||
|
int16_t height;
|
||||||
|
};
|
||||||
@@ -1,22 +1,46 @@
|
|||||||
#include "TextBlock.h"
|
#include "TextBlock.h"
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
|
||||||
// Validate iterator bounds before rendering
|
// Validate iterator bounds before rendering
|
||||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
|
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(),
|
LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", (uint32_t)words.size(),
|
||||||
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
|
(uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto wordIt = words.begin();
|
auto wordIt = words.begin();
|
||||||
auto wordStylesIt = wordStyles.begin();
|
auto wordStylesIt = wordStyles.begin();
|
||||||
auto wordXposIt = wordXpos.begin();
|
auto wordXposIt = wordXpos.begin();
|
||||||
|
|
||||||
for (size_t i = 0; i < words.size(); i++) {
|
for (size_t i = 0; i < words.size(); i++) {
|
||||||
renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt);
|
const int wordX = *wordXposIt + x;
|
||||||
|
const EpdFontFamily::Style currentStyle = *wordStylesIt;
|
||||||
|
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle);
|
||||||
|
|
||||||
|
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||||
|
const std::string& w = *wordIt;
|
||||||
|
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, "\xe2\x80\x83", currentStyle);
|
||||||
|
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
||||||
|
startX = wordX + prefixWidth;
|
||||||
|
underlineWidth = visibleWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
|
||||||
|
}
|
||||||
|
|
||||||
std::advance(wordIt, 1);
|
std::advance(wordIt, 1);
|
||||||
std::advance(wordStylesIt, 1);
|
std::advance(wordStylesIt, 1);
|
||||||
@@ -26,8 +50,8 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
|
|
||||||
bool TextBlock::serialize(FsFile& file) const {
|
bool TextBlock::serialize(FsFile& file) const {
|
||||||
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
|
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(),
|
LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", words.size(),
|
||||||
words.size(), wordXpos.size(), wordStyles.size());
|
wordXpos.size(), wordStyles.size());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +61,19 @@ bool TextBlock::serialize(FsFile& file) const {
|
|||||||
for (auto x : wordXpos) serialization::writePod(file, x);
|
for (auto x : wordXpos) serialization::writePod(file, x);
|
||||||
for (auto s : wordStyles) serialization::writePod(file, s);
|
for (auto s : wordStyles) serialization::writePod(file, s);
|
||||||
|
|
||||||
// Block style
|
// Style (alignment + margins/padding/indent)
|
||||||
serialization::writePod(file, style);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -48,14 +83,14 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
|||||||
std::list<std::string> words;
|
std::list<std::string> words;
|
||||||
std::list<uint16_t> wordXpos;
|
std::list<uint16_t> wordXpos;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::list<EpdFontFamily::Style> wordStyles;
|
||||||
Style style;
|
BlockStyle blockStyle;
|
||||||
|
|
||||||
// Word count
|
// Word count
|
||||||
serialization::readPod(file, wc);
|
serialization::readPod(file, wc);
|
||||||
|
|
||||||
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
|
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
|
||||||
if (wc > 10000) {
|
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;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +102,20 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
|||||||
for (auto& x : wordXpos) serialization::readPod(file, x);
|
for (auto& x : wordXpos) serialization::readPod(file, x);
|
||||||
for (auto& s : wordStyles) serialization::readPod(file, s);
|
for (auto& s : wordStyles) serialization::readPod(file, s);
|
||||||
|
|
||||||
// Block style
|
// Style (alignment + margins/padding/indent)
|
||||||
serialization::readPod(file, style);
|
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,38 +1,33 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
#include <SdFat.h>
|
#include <HalStorage.h>
|
||||||
|
|
||||||
#include <list>
|
#include <list>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "Block.h"
|
#include "Block.h"
|
||||||
|
#include "BlockStyle.h"
|
||||||
|
|
||||||
// Represents a line of text on a page
|
// Represents a line of text on a page
|
||||||
class TextBlock final : public Block {
|
class TextBlock final : public Block {
|
||||||
public:
|
|
||||||
enum Style : uint8_t {
|
|
||||||
JUSTIFIED = 0,
|
|
||||||
LEFT_ALIGN = 1,
|
|
||||||
CENTER_ALIGN = 2,
|
|
||||||
RIGHT_ALIGN = 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::list<std::string> words;
|
std::list<std::string> words;
|
||||||
std::list<uint16_t> wordXpos;
|
std::list<uint16_t> wordXpos;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::list<EpdFontFamily::Style> wordStyles;
|
||||||
Style style;
|
BlockStyle blockStyle;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
||||||
std::list<EpdFontFamily::Style> word_styles, const Style style)
|
std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
|
||||||
: words(std::move(words)), wordXpos(std::move(word_xpos)), wordStyles(std::move(word_styles)), style(style) {}
|
: words(std::move(words)),
|
||||||
|
wordXpos(std::move(word_xpos)),
|
||||||
|
wordStyles(std::move(word_styles)),
|
||||||
|
blockStyle(blockStyle) {}
|
||||||
~TextBlock() override = default;
|
~TextBlock() override = default;
|
||||||
void setStyle(const Style style) { this->style = style; }
|
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||||
Style getStyle() const { return style; }
|
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||||
bool isEmpty() override { return words.empty(); }
|
bool isEmpty() override { return words.empty(); }
|
||||||
void layout(GfxRenderer& renderer) override {};
|
|
||||||
// given a renderer works out where to break the words into lines
|
// given a renderer works out where to break the words into lines
|
||||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||||
BlockType getType() override { return TEXT_BLOCK; }
|
BlockType getType() override { return TEXT_BLOCK; }
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// 4x4 Bayer matrix for ordered dithering
|
||||||
|
inline const uint8_t bayer4x4[4][4] = {
|
||||||
|
{0, 8, 2, 10},
|
||||||
|
{12, 4, 14, 6},
|
||||||
|
{3, 11, 1, 9},
|
||||||
|
{15, 7, 13, 5},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
||||||
|
// Stateless - works correctly with any pixel processing order
|
||||||
|
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
||||||
|
int bayer = bayer4x4[y & 3][x & 3];
|
||||||
|
int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85)
|
||||||
|
|
||||||
|
int adjusted = gray + dither;
|
||||||
|
if (adjusted < 0) adjusted = 0;
|
||||||
|
if (adjusted > 255) adjusted = 255;
|
||||||
|
|
||||||
|
if (adjusted < 64) return 0;
|
||||||
|
if (adjusted < 128) return 1;
|
||||||
|
if (adjusted < 192) return 2;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a pixel respecting the current render mode for grayscale support
|
||||||
|
inline void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) {
|
||||||
|
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
|
||||||
|
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
|
||||||
|
renderer.drawPixel(x, y, true);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
|
||||||
|
renderer.drawPixel(x, y, false);
|
||||||
|
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
|
||||||
|
renderer.drawPixel(x, y, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#include "ImageDecoderFactory.h"
|
||||||
|
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "JpegToFramebufferConverter.h"
|
||||||
|
#include "PngToFramebufferConverter.h"
|
||||||
|
|
||||||
|
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
|
||||||
|
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
|
||||||
|
|
||||||
|
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
|
||||||
|
std::string ext = imagePath;
|
||||||
|
size_t dotPos = ext.rfind('.');
|
||||||
|
if (dotPos != std::string::npos) {
|
||||||
|
ext = ext.substr(dotPos);
|
||||||
|
for (auto& c : ext) {
|
||||||
|
c = tolower(c);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ext = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JpegToFramebufferConverter::supportsFormat(ext)) {
|
||||||
|
if (!jpegDecoder) {
|
||||||
|
jpegDecoder.reset(new JpegToFramebufferConverter());
|
||||||
|
}
|
||||||
|
return jpegDecoder.get();
|
||||||
|
} else if (PngToFramebufferConverter::supportsFormat(ext)) {
|
||||||
|
if (!pngDecoder) {
|
||||||
|
pngDecoder.reset(new PngToFramebufferConverter());
|
||||||
|
}
|
||||||
|
return pngDecoder.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_ERR("DEC", "No decoder found for image: %s", imagePath.c_str());
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
|
class JpegToFramebufferConverter;
|
||||||
|
class PngToFramebufferConverter;
|
||||||
|
|
||||||
|
class ImageDecoderFactory {
|
||||||
|
public:
|
||||||
|
// Returns non-owning pointer - factory owns the decoder lifetime
|
||||||
|
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
|
||||||
|
static bool isFormatSupported(const std::string& imagePath);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
|
||||||
|
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#include "ImageToFramebufferDecoder.h"
|
||||||
|
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
|
||||||
|
if (width * height > MAX_SOURCE_PIXELS) {
|
||||||
|
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
|
||||||
|
format.c_str(), MAX_SOURCE_PIXELS);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
|
||||||
|
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
|
||||||
|
imagePath.c_str());
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <SdFat.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
|
||||||
|
struct ImageDimensions {
|
||||||
|
int16_t width;
|
||||||
|
int16_t height;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RenderConfig {
|
||||||
|
int x, y;
|
||||||
|
int maxWidth, maxHeight;
|
||||||
|
bool useGrayscale = true;
|
||||||
|
bool useDithering = true;
|
||||||
|
bool performanceMode = false;
|
||||||
|
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
|
||||||
|
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImageToFramebufferDecoder {
|
||||||
|
public:
|
||||||
|
virtual ~ImageToFramebufferDecoder() = default;
|
||||||
|
|
||||||
|
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
|
||||||
|
|
||||||
|
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
|
||||||
|
|
||||||
|
virtual const char* getFormatName() const = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Size validation helpers
|
||||||
|
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
|
||||||
|
|
||||||
|
bool validateImageDimensions(int width, int height, const std::string& format);
|
||||||
|
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
||||||
|
};
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
#include "JpegToFramebufferConverter.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <SdFat.h>
|
||||||
|
#include <picojpeg.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "DitherUtils.h"
|
||||||
|
#include "PixelCache.h"
|
||||||
|
|
||||||
|
struct JpegContext {
|
||||||
|
FsFile& file;
|
||||||
|
uint8_t buffer[512];
|
||||||
|
size_t bufferPos;
|
||||||
|
size_t bufferFilled;
|
||||||
|
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
||||||
|
FsFile file;
|
||||||
|
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
||||||
|
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JpegContext context(file);
|
||||||
|
pjpeg_image_info_t imageInfo;
|
||||||
|
|
||||||
|
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (status != 0) {
|
||||||
|
LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.width = imageInfo.m_width;
|
||||||
|
out.height = imageInfo.m_height;
|
||||||
|
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
||||||
|
const RenderConfig& config) {
|
||||||
|
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
||||||
|
LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
JpegContext context(file);
|
||||||
|
pjpeg_image_info_t imageInfo;
|
||||||
|
|
||||||
|
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
||||||
|
if (status != 0) {
|
||||||
|
LOG_ERR("JPG", "picojpeg init failed: %d", status);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate output dimensions
|
||||||
|
int destWidth, destHeight;
|
||||||
|
float scale;
|
||||||
|
|
||||||
|
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
||||||
|
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
||||||
|
destWidth = config.maxWidth;
|
||||||
|
destHeight = config.maxHeight;
|
||||||
|
scale = (float)destWidth / imageInfo.m_width;
|
||||||
|
} else {
|
||||||
|
// Calculate scale factor to fit within maxWidth/maxHeight
|
||||||
|
float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth)
|
||||||
|
? (float)config.maxWidth / imageInfo.m_width
|
||||||
|
: 1.0f;
|
||||||
|
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
|
||||||
|
? (float)config.maxHeight / imageInfo.m_height
|
||||||
|
: 1.0f;
|
||||||
|
scale = (scaleX < scaleY) ? scaleX : scaleY;
|
||||||
|
if (scale > 1.0f) scale = 1.0f;
|
||||||
|
|
||||||
|
destWidth = (int)(imageInfo.m_width * scale);
|
||||||
|
destHeight = (int)(imageInfo.m_height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height,
|
||||||
|
destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
|
||||||
|
|
||||||
|
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
|
||||||
|
LOG_ERR("JPG", "Null buffer pointers in imageInfo");
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
// Allocate pixel cache if cachePath is provided
|
||||||
|
PixelCache cache;
|
||||||
|
bool caching = !config.cachePath.empty();
|
||||||
|
if (caching) {
|
||||||
|
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
|
||||||
|
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
|
||||||
|
caching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int mcuX = 0;
|
||||||
|
int mcuY = 0;
|
||||||
|
|
||||||
|
while (mcuY < imageInfo.m_MCUSPerCol) {
|
||||||
|
status = pjpeg_decode_mcu();
|
||||||
|
if (status == PJPG_NO_MORE_BLOCKS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (status != 0) {
|
||||||
|
LOG_ERR("JPG", "MCU decode failed: %d", status);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source position in image coordinates
|
||||||
|
int srcStartX = mcuX * imageInfo.m_MCUWidth;
|
||||||
|
int srcStartY = mcuY * imageInfo.m_MCUHeight;
|
||||||
|
|
||||||
|
switch (imageInfo.m_scanType) {
|
||||||
|
case PJPG_GRAYSCALE:
|
||||||
|
for (int row = 0; row < 8; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 8; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH1V1:
|
||||||
|
for (int row = 0; row < 8; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 8; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH2V1:
|
||||||
|
for (int row = 0; row < 8; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 16; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
int blockIndex = (col < 8) ? 0 : 1;
|
||||||
|
int pixelIndex = row * 8 + (col % 8);
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH1V2:
|
||||||
|
for (int row = 0; row < 16; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 8; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
int blockIndex = (row < 8) ? 0 : 1;
|
||||||
|
int pixelIndex = (row % 8) * 8 + col;
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PJPG_YH2V2:
|
||||||
|
for (int row = 0; row < 16; row++) {
|
||||||
|
int srcY = srcStartY + row;
|
||||||
|
int destY = config.y + (int)(srcY * scale);
|
||||||
|
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
||||||
|
for (int col = 0; col < 16; col++) {
|
||||||
|
int srcX = srcStartX + col;
|
||||||
|
int destX = config.x + (int)(srcX * scale);
|
||||||
|
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
||||||
|
int blockX = (col < 8) ? 0 : 1;
|
||||||
|
int blockY = (row < 8) ? 0 : 1;
|
||||||
|
int blockIndex = blockY * 2 + blockX;
|
||||||
|
int pixelIndex = (row % 8) * 8 + (col % 8);
|
||||||
|
int blockOffset = blockIndex * 64;
|
||||||
|
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
|
||||||
|
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
|
||||||
|
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
|
||||||
|
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
||||||
|
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
||||||
|
if (dithered > 3) dithered = 3;
|
||||||
|
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
||||||
|
if (caching) cache.setPixel(destX, destY, dithered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
mcuX++;
|
||||||
|
if (mcuX >= imageInfo.m_MCUSPerRow) {
|
||||||
|
mcuX = 0;
|
||||||
|
mcuY++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_DBG("JPG", "Decoding complete");
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
// Write cache file if caching was enabled
|
||||||
|
if (caching) {
|
||||||
|
cache.writeToFile(config.cachePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
||||||
|
unsigned char* pBytes_actually_read, void* pCallback_data) {
|
||||||
|
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
|
||||||
|
|
||||||
|
if (context->bufferPos >= context->bufferFilled) {
|
||||||
|
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
|
||||||
|
if (readCount <= 0) {
|
||||||
|
*pBytes_actually_read = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
context->bufferFilled = readCount;
|
||||||
|
context->bufferPos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
|
||||||
|
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
|
||||||
|
|
||||||
|
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
|
||||||
|
context->bufferPos += bytesToCopy;
|
||||||
|
*pBytes_actually_read = bytesToCopy;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
|
||||||
|
std::string ext = extension;
|
||||||
|
for (auto& c : ext) {
|
||||||
|
c = tolower(c);
|
||||||
|
}
|
||||||
|
return (ext == ".jpg" || ext == ".jpeg");
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user