Merge branch 'master' into integrate-upstream

This commit is contained in:
jpirnay
2026-04-04 17:55:49 +02:00
182 changed files with 22161 additions and 7905 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "open-x4-sdk"]
path = open-x4-sdk
url = https://github.com/open-x4-epaper/community-sdk.git
url = https://github.com/jpirnay/community-sdk.git
+2 -2
View File
@@ -848,7 +848,7 @@ rm -rf /path/to/sd/.crosspoint/epub_<hash>/sections/
**Current Versions** (as of docs/file-formats.md):
- `book.bin`: **Version 5** (metadata structure)
- `section.bin`: **Version 12** (layout structure)
- `section.bin`: **Version 20** (layout structure, includes paragraph LUT)
**Version Increment Rules**:
1. **ALWAYS increment version** BEFORE changing binary structure
@@ -858,7 +858,7 @@ rm -rf /path/to/sd/.crosspoint/epub_<hash>/sections/
**Example** (incrementing section format version):
```cpp
// lib/Epub/Epub/Section.cpp
static constexpr uint8_t SECTION_FILE_VERSION = 13; // Was 12, now 13
static constexpr uint8_t SECTION_FILE_VERSION = 20; // Was 19, now 20
// Add new field to structure
struct PageLine {
+16 -174
View File
@@ -1,179 +1,21 @@
# CrossPoint Reader
# CrossPoint Reader ++
Firmware for the **Xteink X4** e-paper display reader (unaffiliated with Xteink).
Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller.
This firmware is based on the [crosspoint-reader](https://github.com/crosspoint-reader/crosspoint-reader) for the XTEINK X4, a great piece of software by Dave Allie and others
CrossPoint Reader is a purpose-built firmware designed to be a drop-in, fully open-source replacement for the official
Xteink firmware. It aims to match or improve upon the standard EPUB reading experience.
Unfortunately the official repository suffers from too many good ideas floating around and a lack of clear governance how to deal
with these contributions, so it's lacking fundamental fixes for a proper reading experience (rendering issues, sub-par sync
capabilities with KOReader, a popular multi-platform open-source epub reader)
![](./docs/images/cover.jpg)
Therefore this branch focuses on real fixes and real improvements while trying to keep up to pace with developments in the main branch.
## Motivation
# What's different
E-paper devices are fantastic for reading, but most commercially available readers are closed systems with limited
customisation. The **Xteink X4** is an affordable, e-paper device, however the official firmware remains closed.
CrossPoint exists partly as a fun side-project and partly to open up the ecosystem and truely unlock the device's
potential.
CrossPoint Reader aims to:
* Provide a **fully open-source alternative** to the official firmware.
* Offer a **document reader** capable of handling EPUB content on constrained hardware.
* Support **customisable font, layout, and display** options.
* Run purely on the **Xteink X4 hardware**.
This project is **not affiliated with Xteink**; it's built as a community project.
## Features & Usage
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
- [x] Image support within EPUB
- [x] Saved reading position
- [x] File explorer with file picker
- [x] Basic EPUB picker from root directory
- [x] Support nested folders
- [ ] EPUB picker with cover art
- [x] Custom sleep screen
- [x] Cover sleep screen
- [x] Wifi book upload
- [x] Wifi OTA updates
- [x] KOReader Sync integration for cross-device reading progress
- [x] Configurable font, layout, and display options
- [ ] User provided fonts
- [ ] Full UTF support
- [x] Screen rotation
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, including the
[KOReader Sync quick setup](./USER_GUIDE.md#365-koreader-sync-quick-setup).
For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) document.
## Installing
### Web (latest firmware)
1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
### Web (specific firmware version)
1. Connect your Xteink X4 to your computer via USB-C
2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases)
3. Go to https://xteink.dve.al/ and flash the firmware file using the "OTA fast flash controls" section
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug.
### Manual
See [Development](#development) below.
## Development
### Prerequisites
* **PlatformIO Core** (`pio`) or **VS Code + PlatformIO IDE**
* Python 3.8+
* USB-C cable for flashing the ESP32-C3
* Xteink X4
### Checking out the code
CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone the repository:
```
git clone --recursive https://github.com/crosspoint-reader/crosspoint-reader
# Or, if you've already cloned without --recursive:
git submodule update --init --recursive
```
### Flashing your device
Connect your Xteink X4 to your computer via USB-C and run the following command.
```sh
pio run --target upload
```
### Debugging
After flashing the new features, its 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
CrossPoint Reader is pretty aggressive about caching data down to the SD card to minimise RAM usage. The ESP32-C3 only
has ~380KB of usable RAM, so we have to be careful. A lot of the decisions made in the design of the firmware were based
on this constraint.
### Data caching
The first time chapters of a book are loaded, they are cached to the SD card. Subsequent loads are served from the
cache. This cache directory exists at `.crosspoint` on the SD card. The structure is as follows:
```
.crosspoint/
├── epub_12471232/ # Each EPUB is cached to a subdirectory named `epub_<hash>`
│ ├── progress.bin # Stores reading progress (chapter, page, etc.)
│ ├── cover.bmp # Book cover image (once generated)
│ ├── book.bin # Book metadata (title, author, spine, table of contents, etc.)
│ └── sections/ # All chapter data is stored in the sections subdirectory
│ ├── 0.bin # Chapter data (screen count, all text layout info, etc.)
│ ├── 1.bin # files are named by their index in the spine
│ └── ...
└── epub_189013891/
```
Deleting the `.crosspoint` directory will clear the entire cache.
Due the way it's currently implemented, the cache is not automatically cleared when a book is deleted and moving a book
file will use a new cache directory, resetting the reading progress.
For more details on the internal file structures, see the [file formats document](./docs/file-formats.md).
## Contributing
Contributions are very welcome!
If you are new to the codebase, start with the [contributing docs](./docs/contributing/README.md).
If you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas).
If there's something there you'd like to work on, leave a comment so that we can avoid duplicated effort.
Everyone here is a volunteer, so please be respectful and patient. For more details on our goverance and community
principles, please see [GOVERNANCE.md](GOVERNANCE.md).
### To submit a contribution:
1. Fork the repo
2. Create a branch (`feature/dithering-improvement`)
3. Make changes
4. Submit a PR
---
CrossPoint Reader is **not affiliated with Xteink or any manufacturer of the X4 hardware**.
Huge shoutout to [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader), which was a project I took a lot of inspiration from as I
was making CrossPoint.
- Proper KOReader Snychronisation (including https TLS OOM fix)
- Fixes for a lot of css rendering issues
- Additional sleep screens support (information overlay, transparent pictures over current reader screen)
- Clock-Support
- Weather information panel
- Multiple under-the-hood performance improvements
- Book information screen
- Reading ruler
- ...
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M3.89,17.6c0-0.99,0.31-1.88,0.93-2.65s1.41-1.27,2.38-1.49c0.26-1.17,0.85-2.14,1.78-2.88c0.93-0.75,2-1.12,3.22-1.12
c1.18,0,2.24,0.36,3.16,1.09c0.93,0.73,1.53,1.66,1.8,2.8h0.27c1.18,0,2.18,0.41,3.01,1.24s1.25,1.83,1.25,3
c0,1.18-0.42,2.18-1.25,3.01s-1.83,1.25-3.01,1.25H8.16c-0.58,0-1.13-0.11-1.65-0.34S5.52,21,5.14,20.62
c-0.38-0.38-0.68-0.84-0.91-1.36S3.89,18.17,3.89,17.6z M5.34,17.6c0,0.76,0.28,1.42,0.82,1.96s1.21,0.82,1.99,0.82h9.28
c0.77,0,1.44-0.27,1.99-0.82c0.55-0.55,0.83-1.2,0.83-1.96c0-0.76-0.27-1.42-0.83-1.96c-0.55-0.54-1.21-0.82-1.99-0.82h-1.39
c-0.1,0-0.15-0.05-0.15-0.15l-0.07-0.49c-0.1-0.94-0.5-1.73-1.19-2.35s-1.51-0.93-2.45-0.93c-0.94,0-1.76,0.31-2.46,0.94
c-0.7,0.62-1.09,1.41-1.18,2.34l-0.07,0.42c0,0.1-0.05,0.15-0.16,0.15l-0.45,0.07c-0.72,0.06-1.32,0.36-1.81,0.89
C5.59,16.24,5.34,16.87,5.34,17.6z M14.19,8.88c-0.1,0.09-0.08,0.16,0.07,0.21c0.43,0.19,0.79,0.37,1.08,0.55
c0.11,0.03,0.19,0.02,0.22-0.03c0.61-0.57,1.31-0.86,2.12-0.86c0.81,0,1.5,0.27,2.1,0.81c0.59,0.54,0.92,1.21,0.99,2l0.09,0.64h1.42
c0.65,0,1.21,0.23,1.68,0.7c0.47,0.47,0.7,1.02,0.7,1.66c0,0.6-0.21,1.12-0.62,1.57s-0.92,0.7-1.53,0.77c-0.1,0-0.15,0.05-0.15,0.16
v1.13c0,0.11,0.05,0.16,0.15,0.16c1.01-0.06,1.86-0.46,2.55-1.19s1.04-1.6,1.04-2.6c0-1.06-0.37-1.96-1.12-2.7
c-0.75-0.75-1.65-1.12-2.7-1.12h-0.15c-0.26-1-0.81-1.82-1.65-2.47c-0.83-0.65-1.77-0.97-2.8-0.97C16.28,7.29,15.11,7.82,14.19,8.88
z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M1.56,16.9c0,0.9,0.22,1.73,0.66,2.49s1.04,1.36,1.8,1.8c0.76,0.44,1.58,0.66,2.47,0.66h10.83c0.89,0,1.72-0.22,2.48-0.66
c0.76-0.44,1.37-1.04,1.81-1.8c0.44-0.76,0.67-1.59,0.67-2.49c0-0.66-0.14-1.33-0.42-2C22.62,13.98,23,12.87,23,11.6
c0-0.71-0.14-1.39-0.41-2.04c-0.27-0.65-0.65-1.2-1.12-1.67C21,7.42,20.45,7.04,19.8,6.77c-0.65-0.28-1.33-0.41-2.04-0.41
c-1.48,0-2.77,0.58-3.88,1.74c-0.77-0.44-1.67-0.66-2.7-0.66c-1.41,0-2.65,0.44-3.73,1.31c-1.08,0.87-1.78,1.99-2.08,3.35
c-1.12,0.26-2.03,0.83-2.74,1.73S1.56,15.75,1.56,16.9z M3.27,16.9c0-0.84,0.28-1.56,0.84-2.17c0.56-0.61,1.26-0.96,2.1-1.06
l0.5-0.03c0.12,0,0.19-0.06,0.19-0.18l0.07-0.54c0.14-1.08,0.61-1.99,1.41-2.71c0.8-0.73,1.74-1.09,2.81-1.09
c1.1,0,2.06,0.37,2.87,1.1c0.82,0.73,1.27,1.63,1.37,2.71l0.07,0.58c0.02,0.11,0.09,0.17,0.21,0.17h1.61c0.88,0,1.64,0.32,2.28,0.96
c0.64,0.64,0.96,1.39,0.96,2.27c0,0.91-0.32,1.68-0.95,2.32c-0.63,0.64-1.4,0.96-2.28,0.96H6.49c-0.88,0-1.63-0.32-2.27-0.97
C3.59,18.57,3.27,17.8,3.27,16.9z M9.97,4.63c0,0.24,0.08,0.45,0.24,0.63l0.66,0.64c0.25,0.19,0.46,0.27,0.64,0.25
c0.21,0,0.39-0.09,0.55-0.26s0.24-0.38,0.24-0.62c0-0.24-0.09-0.44-0.26-0.59l-0.59-0.66c-0.18-0.16-0.38-0.24-0.61-0.24
c-0.24,0-0.45,0.08-0.62,0.25C10.05,4.19,9.97,4.39,9.97,4.63z M15.31,9.06c0.69-0.67,1.51-1,2.45-1c0.99,0,1.83,0.34,2.52,1.03
c0.69,0.69,1.04,1.52,1.04,2.51c0,0.62-0.17,1.24-0.51,1.84C19.84,12.48,18.68,12,17.32,12H17C16.75,10.91,16.19,9.93,15.31,9.06z
M16.94,3.78c0,0.26,0.08,0.46,0.23,0.62s0.35,0.23,0.59,0.23c0.26,0,0.46-0.08,0.62-0.23c0.16-0.16,0.23-0.36,0.23-0.62V1.73
c0-0.24-0.08-0.43-0.24-0.59s-0.36-0.23-0.61-0.23c-0.24,0-0.43,0.08-0.59,0.23s-0.23,0.35-0.23,0.59V3.78z M22.46,6.07
c0,0.26,0.07,0.46,0.22,0.62c0.21,0.16,0.42,0.24,0.62,0.24c0.18,0,0.38-0.08,0.59-0.24l1.43-1.43c0.16-0.18,0.24-0.39,0.24-0.64
c0-0.24-0.08-0.44-0.24-0.6c-0.16-0.16-0.36-0.24-0.59-0.24c-0.24,0-0.43,0.08-0.58,0.24l-1.47,1.43
C22.53,5.64,22.46,5.84,22.46,6.07z M23.25,17.91c0,0.24,0.08,0.45,0.25,0.63l0.65,0.63c0.15,0.16,0.34,0.24,0.58,0.24
s0.44-0.08,0.6-0.25c0.16-0.17,0.24-0.37,0.24-0.62c0-0.22-0.08-0.42-0.24-0.58l-0.65-0.65c-0.16-0.16-0.35-0.24-0.57-0.24
c-0.24,0-0.44,0.08-0.6,0.24C23.34,17.47,23.25,17.67,23.25,17.91z M24.72,11.6c0,0.23,0.09,0.42,0.26,0.58
c0.16,0.16,0.37,0.24,0.61,0.24h2.04c0.23,0,0.42-0.08,0.58-0.23s0.23-0.35,0.23-0.59c0-0.24-0.08-0.44-0.23-0.6
s-0.35-0.25-0.58-0.25h-2.04c-0.24,0-0.44,0.08-0.61,0.25C24.8,11.17,24.72,11.37,24.72,11.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.37,14.62c0-0.24,0.08-0.45,0.25-0.62c0.17-0.16,0.38-0.24,0.6-0.24h2.04c0.23,0,0.42,0.08,0.58,0.25
c0.15,0.17,0.23,0.37,0.23,0.61S8,15.06,7.85,15.23c-0.15,0.17-0.35,0.25-0.58,0.25H5.23c-0.23,0-0.43-0.08-0.6-0.25
C4.46,15.06,4.37,14.86,4.37,14.62z M7.23,21.55c0-0.23,0.08-0.43,0.23-0.61l1.47-1.43c0.15-0.16,0.35-0.23,0.59-0.23
c0.24,0,0.44,0.08,0.6,0.23s0.24,0.34,0.24,0.57c0,0.24-0.08,0.46-0.24,0.64L8.7,22.14c-0.41,0.32-0.82,0.32-1.23,0
C7.31,21.98,7.23,21.78,7.23,21.55z M7.23,7.71c0-0.23,0.08-0.43,0.23-0.61C7.66,6.93,7.87,6.85,8.1,6.85
c0.22,0,0.42,0.08,0.59,0.24l1.43,1.47c0.16,0.15,0.24,0.35,0.24,0.59c0,0.24-0.08,0.44-0.24,0.6s-0.36,0.24-0.6,0.24
c-0.24,0-0.44-0.08-0.59-0.24L7.47,8.32C7.31,8.16,7.23,7.95,7.23,7.71z M9.78,14.62c0-0.93,0.23-1.8,0.7-2.6s1.1-1.44,1.91-1.91
s1.67-0.7,2.6-0.7c0.7,0,1.37,0.14,2.02,0.42c0.64,0.28,1.2,0.65,1.66,1.12c0.47,0.47,0.84,1.02,1.11,1.66
c0.27,0.64,0.41,1.32,0.41,2.02c0,0.94-0.23,1.81-0.7,2.61c-0.47,0.8-1.1,1.43-1.9,1.9c-0.8,0.47-1.67,0.7-2.61,0.7
s-1.81-0.23-2.61-0.7c-0.8-0.47-1.43-1.1-1.9-1.9C10.02,16.43,9.78,15.56,9.78,14.62z M11.48,14.62c0,0.98,0.34,1.81,1.03,2.5
c0.68,0.69,1.51,1.04,2.49,1.04s1.81-0.35,2.5-1.04s1.04-1.52,1.04-2.5c0-0.96-0.35-1.78-1.04-2.47c-0.69-0.68-1.52-1.02-2.5-1.02
c-0.97,0-1.8,0.34-2.48,1.02C11.82,12.84,11.48,13.66,11.48,14.62z M14.14,22.4c0-0.24,0.08-0.44,0.25-0.6s0.37-0.24,0.6-0.24
c0.24,0,0.45,0.08,0.61,0.24s0.24,0.36,0.24,0.6v1.99c0,0.24-0.08,0.45-0.25,0.62c-0.17,0.17-0.37,0.25-0.6,0.25
s-0.44-0.08-0.6-0.25c-0.17-0.17-0.25-0.38-0.25-0.62V22.4z M14.14,6.9V4.86c0-0.23,0.08-0.43,0.25-0.6C14.56,4.09,14.76,4,15,4
s0.43,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6V6.9c0,0.23-0.08,0.42-0.25,0.58S15.23,7.71,15,7.71s-0.44-0.08-0.6-0.23
S14.14,7.13,14.14,6.9z M19.66,20.08c0-0.23,0.08-0.42,0.23-0.56c0.15-0.16,0.34-0.23,0.56-0.23c0.24,0,0.44,0.08,0.6,0.23
l1.46,1.43c0.16,0.17,0.24,0.38,0.24,0.61c0,0.23-0.08,0.43-0.24,0.59c-0.4,0.31-0.8,0.31-1.2,0l-1.42-1.42
C19.74,20.55,19.66,20.34,19.66,20.08z M19.66,9.16c0-0.25,0.08-0.45,0.23-0.59l1.42-1.47c0.17-0.16,0.37-0.24,0.59-0.24
c0.24,0,0.44,0.08,0.6,0.25c0.17,0.17,0.25,0.37,0.25,0.6c0,0.25-0.08,0.46-0.24,0.62l-1.46,1.43c-0.18,0.16-0.38,0.24-0.6,0.24
c-0.23,0-0.41-0.08-0.56-0.24S19.66,9.4,19.66,9.16z M21.92,14.62c0-0.24,0.08-0.44,0.24-0.62c0.16-0.16,0.35-0.24,0.57-0.24h2.02
c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6s-0.09,0.43-0.26,0.6c-0.17,0.17-0.37,0.25-0.6,0.25h-2.02
c-0.23,0-0.43-0.08-0.58-0.25S21.92,14.86,21.92,14.62z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M2.62,21.05c0-0.24,0.08-0.45,0.25-0.61c0.17-0.16,0.38-0.24,0.63-0.24h18.67c0.25,0,0.45,0.08,0.61,0.24
c0.16,0.16,0.24,0.36,0.24,0.61c0,0.23-0.08,0.43-0.25,0.58c-0.17,0.16-0.37,0.23-0.6,0.23H3.5c-0.25,0-0.46-0.08-0.63-0.23
C2.7,21.47,2.62,21.28,2.62,21.05z M5.24,17.91c0-0.24,0.09-0.44,0.26-0.6c0.15-0.15,0.35-0.23,0.59-0.23h18.67
c0.23,0,0.42,0.08,0.58,0.24c0.16,0.16,0.23,0.35,0.23,0.59c0,0.24-0.08,0.44-0.23,0.6c-0.16,0.17-0.35,0.25-0.58,0.25H6.09
c-0.24,0-0.44-0.08-0.6-0.25C5.32,18.34,5.24,18.14,5.24,17.91z M5.37,15.52c0,0.09,0.05,0.13,0.15,0.13h1.43
c0.06,0,0.13-0.05,0.2-0.16c0.24-0.52,0.59-0.94,1.06-1.27c0.47-0.33,0.99-0.52,1.55-0.56l0.55-0.07c0.11,0,0.17-0.06,0.17-0.18
l0.07-0.5c0.11-1.08,0.56-1.98,1.37-2.7c0.81-0.72,1.76-1.08,2.85-1.08c1.08,0,2.02,0.36,2.83,1.07c0.8,0.71,1.26,1.61,1.37,2.68
l0.08,0.57c0,0.11,0.07,0.17,0.2,0.17h1.59c0.64,0,1.23,0.17,1.76,0.52s0.92,0.8,1.18,1.37c0.07,0.11,0.14,0.16,0.21,0.16h1.43
c0.12,0,0.17-0.07,0.14-0.23c-0.29-1.02-0.88-1.86-1.74-2.51c-0.87-0.65-1.86-0.97-2.97-0.97h-0.32c-0.33-1.33-1.03-2.42-2.1-3.27
s-2.28-1.27-3.65-1.27c-1.4,0-2.64,0.44-3.73,1.32s-1.78,2-2.09,3.36c-0.85,0.2-1.6,0.6-2.24,1.21c-0.64,0.61-1.09,1.33-1.34,2.18
v-0.04C5.37,15.45,5.37,15.48,5.37,15.52z M6.98,24.11c0-0.24,0.09-0.43,0.26-0.59c0.15-0.15,0.35-0.23,0.6-0.23h18.68
c0.24,0,0.44,0.08,0.6,0.23c0.17,0.16,0.25,0.35,0.25,0.58c0,0.24-0.08,0.44-0.25,0.61c-0.17,0.17-0.37,0.25-0.6,0.25H7.84
c-0.23,0-0.43-0.09-0.6-0.26C7.07,24.55,6.98,24.34,6.98,24.11z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.14,16.9c0-1.16,0.35-2.18,1.06-3.08s1.62-1.47,2.74-1.72c0.23-1.03,0.7-1.93,1.4-2.7c0.7-0.77,1.55-1.32,2.53-1.65
c0.62-0.21,1.26-0.32,1.93-0.32c0.81,0,1.6,0.16,2.35,0.48c0.28-0.47,0.61-0.88,0.99-1.22c0.38-0.34,0.77-0.61,1.17-0.79
c0.4-0.18,0.8-0.32,1.18-0.41s0.76-0.13,1.12-0.13c0.38,0,0.79,0.05,1.23,0.16l0.82,0.25c0.14,0.06,0.18,0.13,0.14,0.22l-0.14,0.6
c-0.07,0.31-0.1,0.6-0.1,0.86c0,0.31,0.05,0.63,0.15,0.95c0.1,0.32,0.24,0.63,0.44,0.94c0.19,0.31,0.46,0.58,0.8,0.83
c0.34,0.25,0.72,0.44,1.15,0.57l0.62,0.22c0.1,0.03,0.15,0.08,0.15,0.16c0,0.02-0.01,0.04-0.02,0.07l-0.18,0.67
c-0.27,1.08-0.78,1.93-1.5,2.57c0.4,0.7,0.62,1.45,0.65,2.24c0.01,0.05,0.01,0.12,0.01,0.23c0,0.89-0.22,1.72-0.67,2.48
c-0.44,0.76-1.05,1.36-1.8,1.8c-0.76,0.44-1.59,0.67-2.48,0.67H9.07c-0.89,0-1.72-0.22-2.48-0.67s-1.35-1.05-1.79-1.8
S4.14,17.8,4.14,16.9z M5.85,16.9c0,0.89,0.32,1.66,0.96,2.31c0.64,0.65,1.39,0.98,2.26,0.98h10.81c0.89,0,1.65-0.32,2.28-0.97
s0.95-1.42,0.95-2.32c0-0.88-0.32-1.63-0.96-2.26c-0.64-0.63-1.4-0.95-2.28-0.95h-1.78l-0.1-0.75c-0.1-1.01-0.52-1.88-1.26-2.59
s-1.62-1.11-2.63-1.2c-0.03,0-0.08,0-0.15-0.01c-0.07-0.01-0.11-0.01-0.15-0.01c-0.51,0-1.02,0.1-1.54,0.29V9.4
c-0.73,0.28-1.35,0.74-1.84,1.37c-0.5,0.63-0.8,1.35-0.9,2.17l-0.07,0.72l-0.68,0.03c-0.84,0.1-1.54,0.45-2.1,1.06
S5.85,16.07,5.85,16.9z M17.6,8.79c1.06,0.91,1.72,1.97,1.97,3.18h0.32c1.24,0,2.3,0.39,3.17,1.18c0.33-0.31,0.58-0.67,0.76-1.07
c-0.91-0.43-1.63-1.09-2.16-1.97c-0.52-0.88-0.79-1.81-0.79-2.78V7.09c-0.05-0.01-0.13-0.01-0.24-0.01
c-0.58-0.01-1.15,0.13-1.7,0.44C18.38,7.82,17.93,8.24,17.6,8.79z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M7.91,14.48c0-0.96,0.19-1.87,0.56-2.75s0.88-1.63,1.51-2.26c0.63-0.63,1.39-1.14,2.27-1.52c0.88-0.38,1.8-0.57,2.75-0.57
h1.14c0.16,0.04,0.23,0.14,0.23,0.28l0.05,0.88c0.04,1.27,0.49,2.35,1.37,3.24c0.88,0.89,1.94,1.37,3.19,1.42l0.82,0.07
c0.16,0,0.24,0.08,0.24,0.23v0.98c0.01,1.28-0.3,2.47-0.93,3.56c-0.63,1.09-1.48,1.95-2.57,2.59c-1.08,0.63-2.27,0.95-3.55,0.95
c-0.97,0-1.9-0.19-2.78-0.56s-1.63-0.88-2.26-1.51c-0.63-0.63-1.13-1.39-1.5-2.26C8.1,16.37,7.91,15.45,7.91,14.48z M9.74,14.48
c0,0.76,0.15,1.48,0.45,2.16c0.3,0.67,0.7,1.24,1.19,1.7c0.49,0.46,1.05,0.82,1.69,1.08c0.63,0.27,1.28,0.4,1.94,0.4
c0.58,0,1.17-0.11,1.76-0.34c0.59-0.23,1.14-0.55,1.65-0.96c0.51-0.41,0.94-0.93,1.31-1.57c0.37-0.64,0.6-1.33,0.71-2.09
c-1.63-0.34-2.94-1.04-3.92-2.1s-1.55-2.3-1.7-3.74C13.86,9.08,13,9.37,12.21,9.9c-0.78,0.53-1.39,1.2-1.82,2.02
C9.96,12.74,9.74,13.59,9.74,14.48z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.64,16.91c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.47,2.73-1.73c0.31-1.36,1.02-2.48,2.11-3.36s2.34-1.31,3.75-1.31
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.65s1.37,1.03,1.81,1.78
c0.44,0.75,0.67,1.58,0.67,2.47c0,0.88-0.21,1.69-0.63,2.44c-0.42,0.75-1,1.35-1.73,1.8c-0.73,0.45-1.53,0.69-2.4,0.71
c-0.13,0-0.2-0.06-0.2-0.17v-1.33c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.39,0.9-2.26s-0.33-1.62-0.98-2.26
s-1.42-0.96-2.31-0.96h-1.61c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.71
c-0.82-0.73-1.76-1.09-2.85-1.09c-1.09,0-2.05,0.36-2.85,1.09c-0.81,0.73-1.26,1.63-1.36,2.71l-0.07,0.53c0,0.12-0.07,0.19-0.2,0.19
l-0.53,0.03c-0.83,0.1-1.53,0.46-2.1,1.07s-0.85,1.33-0.85,2.16c0,0.87,0.3,1.62,0.9,2.26s1.33,0.98,2.18,1.02
c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17c-1.34-0.06-2.47-0.57-3.4-1.53S4.64,18.24,4.64,16.91z M9.99,23.6
c0-0.04,0.01-0.11,0.04-0.2l1.63-5.77c0.06-0.19,0.17-0.34,0.32-0.44c0.15-0.1,0.31-0.15,0.46-0.15c0.07,0,0.15,0.01,0.24,0.03
c0.24,0.04,0.42,0.17,0.54,0.37c0.12,0.2,0.15,0.42,0.08,0.67l-1.63,5.73c-0.12,0.43-0.4,0.64-0.82,0.64
c-0.04,0-0.07-0.01-0.11-0.02c-0.06-0.02-0.09-0.03-0.1-0.03c-0.22-0.06-0.38-0.17-0.49-0.33C10.04,23.93,9.99,23.77,9.99,23.6z
M12.61,26.41l2.44-8.77c0.04-0.19,0.14-0.34,0.3-0.44c0.16-0.1,0.32-0.15,0.49-0.15c0.09,0,0.18,0.01,0.27,0.03
c0.22,0.06,0.38,0.19,0.49,0.39c0.11,0.2,0.13,0.41,0.07,0.64l-2.43,8.78c-0.04,0.17-0.13,0.31-0.29,0.43
c-0.16,0.12-0.32,0.18-0.51,0.18c-0.09,0-0.18-0.02-0.25-0.05c-0.2-0.05-0.37-0.18-0.52-0.39C12.56,26.88,12.54,26.67,12.61,26.41z
M16.74,23.62c0-0.04,0.01-0.11,0.04-0.23l1.63-5.77c0.06-0.19,0.16-0.34,0.3-0.44c0.15-0.1,0.3-0.15,0.46-0.15
c0.08,0,0.17,0.01,0.26,0.03c0.21,0.06,0.36,0.16,0.46,0.31c0.1,0.15,0.15,0.31,0.15,0.47c0,0.03-0.01,0.08-0.02,0.14
s-0.02,0.1-0.02,0.12l-1.63,5.73c-0.04,0.19-0.13,0.35-0.28,0.46s-0.32,0.17-0.51,0.17l-0.24-0.05c-0.2-0.06-0.35-0.16-0.46-0.32
C16.79,23.94,16.74,23.78,16.74,23.62z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.64,16.95c0-1.16,0.35-2.18,1.06-3.08s1.62-1.48,2.74-1.76c0.31-1.36,1.01-2.48,2.1-3.36s2.34-1.31,3.75-1.31
c1.38,0,2.6,0.43,3.68,1.28c1.08,0.85,1.78,1.95,2.1,3.29h0.32c0.89,0,1.72,0.22,2.48,0.66c0.76,0.44,1.37,1.04,1.81,1.8
c0.44,0.76,0.67,1.59,0.67,2.48c0,1.32-0.46,2.47-1.39,3.42c-0.92,0.96-2.05,1.46-3.38,1.5c-0.13,0-0.2-0.06-0.2-0.17v-1.33
c0-0.12,0.07-0.18,0.2-0.18c0.85-0.04,1.58-0.38,2.18-1.02s0.9-1.38,0.9-2.23c0-0.89-0.32-1.65-0.97-2.3s-1.42-0.97-2.32-0.97h-1.61
c-0.12,0-0.18-0.06-0.18-0.17l-0.08-0.58c-0.11-1.08-0.58-1.99-1.39-2.72c-0.82-0.73-1.76-1.1-2.85-1.1c-1.1,0-2.05,0.37-2.86,1.11
c-0.81,0.74-1.27,1.65-1.37,2.75l-0.06,0.5c0,0.12-0.07,0.19-0.2,0.19l-0.53,0.07c-0.83,0.07-1.53,0.41-2.1,1.04
s-0.85,1.35-0.85,2.19c0,0.85,0.3,1.59,0.9,2.23s1.33,0.97,2.18,1.02c0.11,0,0.17,0.06,0.17,0.18v1.33c0,0.11-0.06,0.17-0.17,0.17
c-1.34-0.04-2.47-0.54-3.4-1.5C5.1,19.42,4.64,18.27,4.64,16.95z M11,21.02c0-0.22,0.08-0.42,0.24-0.58
c0.16-0.16,0.35-0.24,0.59-0.24c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58c0,0.24-0.08,0.44-0.24,0.6
c-0.16,0.17-0.35,0.25-0.59,0.25c-0.23,0-0.43-0.08-0.59-0.25C11.08,21.46,11,21.26,11,21.02z M11,24.65c0-0.24,0.08-0.44,0.24-0.6
c0.16-0.15,0.35-0.23,0.58-0.23c0.23,0,0.43,0.08,0.59,0.23c0.16,0.16,0.24,0.35,0.24,0.59c0,0.24-0.08,0.43-0.24,0.59
c-0.16,0.16-0.35,0.23-0.59,0.23c-0.23,0-0.43-0.08-0.59-0.23C11.08,25.08,11,24.88,11,24.65z M14.19,22.95
c0-0.23,0.08-0.44,0.25-0.62c0.16-0.16,0.35-0.24,0.57-0.24c0.23,0,0.43,0.09,0.6,0.26c0.17,0.17,0.26,0.37,0.26,0.6
c0,0.23-0.08,0.43-0.25,0.6c-0.17,0.17-0.37,0.25-0.61,0.25c-0.23,0-0.42-0.08-0.58-0.25S14.19,23.18,14.19,22.95z M14.19,19.33
c0-0.23,0.08-0.43,0.25-0.6c0.18-0.16,0.37-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.36,0.25,0.6
c0,0.23-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,19.76,14.19,19.56,14.19,19.33z
M14.19,26.61c0-0.23,0.08-0.43,0.25-0.61c0.16-0.16,0.35-0.24,0.57-0.24c0.24,0,0.44,0.08,0.61,0.25c0.17,0.17,0.25,0.37,0.25,0.6
s-0.08,0.43-0.25,0.59c-0.17,0.16-0.37,0.24-0.61,0.24c-0.23,0-0.42-0.08-0.58-0.24C14.27,27.03,14.19,26.84,14.19,26.61z
M17.41,21.02c0-0.22,0.08-0.41,0.25-0.58c0.17-0.17,0.37-0.25,0.6-0.25c0.23,0,0.43,0.08,0.59,0.24c0.16,0.16,0.24,0.36,0.24,0.58
c0,0.24-0.08,0.44-0.24,0.6c-0.16,0.17-0.35,0.25-0.59,0.25c-0.24,0-0.44-0.08-0.6-0.25C17.5,21.45,17.41,21.25,17.41,21.02z
M17.41,24.65c0-0.22,0.08-0.42,0.25-0.6c0.16-0.15,0.36-0.23,0.6-0.23c0.24,0,0.43,0.08,0.59,0.23s0.23,0.35,0.23,0.59
c0,0.24-0.08,0.43-0.23,0.59c-0.16,0.16-0.35,0.23-0.59,0.23c-0.24,0-0.44-0.08-0.6-0.24C17.5,25.07,17.41,24.88,17.41,24.65z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.64,16.91c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.47,2.73-1.73c0.31-1.36,1.01-2.48,2.1-3.35s2.35-1.31,3.76-1.31
c1.38,0,2.6,0.43,3.68,1.27c1.07,0.85,1.78,1.94,2.11,3.28h0.31c0.89,0,1.72,0.22,2.48,0.65s1.37,1.03,1.81,1.78
c0.44,0.75,0.67,1.58,0.67,2.47c0,1.34-0.46,2.49-1.38,3.45s-2.05,1.47-3.38,1.51c-0.13,0-0.2-0.06-0.2-0.17v-1.33
c0-0.12,0.07-0.18,0.2-0.18c0.86-0.04,1.58-0.38,2.18-1.02s0.9-1.39,0.9-2.26s-0.32-1.62-0.98-2.26c-0.65-0.64-1.42-0.96-2.31-0.96
h-1.6c-0.12,0-0.19-0.06-0.19-0.17l-0.07-0.58c-0.11-1.07-0.57-1.98-1.38-2.71c-0.82-0.73-1.77-1.1-2.85-1.1
c-1.09,0-2.05,0.36-2.86,1.09c-0.81,0.73-1.27,1.63-1.38,2.71l-0.06,0.54c0,0.12-0.07,0.18-0.2,0.18l-0.53,0.03
c-0.82,0.04-1.51,0.37-2.09,1s-0.86,1.37-0.86,2.22c0,0.87,0.3,1.62,0.9,2.26s1.33,0.98,2.18,1.02c0.11,0,0.17,0.06,0.17,0.18v1.33
c0,0.11-0.06,0.17-0.17,0.17c-1.34-0.06-2.47-0.57-3.4-1.53S4.64,18.24,4.64,16.91z M10.57,17.79c0-0.24,0.12-0.57,0.37-0.99
c0.24-0.42,0.47-0.75,0.68-1.01c0.21-0.24,0.34-0.38,0.38-0.42l0.36,0.4c0.26,0.28,0.5,0.61,0.72,1.02c0.22,0.4,0.33,0.74,0.33,1
c0,0.39-0.13,0.72-0.4,0.98c-0.27,0.26-0.6,0.39-1,0.39c-0.39,0-0.73-0.13-1.01-0.4C10.71,18.5,10.57,18.17,10.57,17.79z
M13.55,21.78c0-0.28,0.08-0.59,0.24-0.96s0.35-0.7,0.59-1.02c0.18-0.26,0.4-0.54,0.67-0.84c0.26-0.3,0.46-0.52,0.6-0.65
c0.07-0.06,0.15-0.14,0.24-0.23l0.24,0.23c0.38,0.33,0.8,0.82,1.27,1.46c0.24,0.33,0.43,0.68,0.59,1.04s0.23,0.68,0.23,0.97
c0,0.64-0.23,1.19-0.68,1.65s-1.01,0.68-1.66,0.68c-0.64,0-1.19-0.23-1.65-0.67C13.77,22.98,13.55,22.43,13.55,21.78z M15.02,15.12
c0-0.42,0.32-0.95,0.97-1.6l0.24,0.25c0.18,0.21,0.33,0.45,0.48,0.71c0.14,0.26,0.22,0.47,0.22,0.64c0,0.26-0.09,0.48-0.28,0.66
c-0.18,0.18-0.4,0.28-0.66,0.28c-0.27,0-0.5-0.09-0.69-0.28C15.11,15.6,15.02,15.38,15.02,15.12z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.63,16.91c0,1.11,0.33,2.1,0.99,2.97s1.52,1.47,2.58,1.79l-0.66,1.68c-0.03,0.14,0.02,0.22,0.14,0.22h2.13l-0.98,4.3h0.28
l3.92-5.75c0.04-0.04,0.04-0.09,0.01-0.14c-0.03-0.05-0.08-0.07-0.15-0.07h-2.18l2.48-4.64c0.07-0.14,0.02-0.22-0.14-0.22h-2.94
c-0.09,0-0.17,0.05-0.23,0.15l-1.07,2.87c-0.71-0.18-1.3-0.57-1.77-1.16c-0.47-0.59-0.7-1.26-0.7-2.01c0-0.83,0.28-1.55,0.85-2.17
c0.57-0.61,1.27-0.97,2.1-1.07l0.53-0.07c0.13,0,0.2-0.06,0.2-0.18l0.07-0.51c0.11-1.08,0.56-1.99,1.37-2.72
c0.81-0.73,1.76-1.1,2.85-1.1c1.09,0,2.04,0.37,2.85,1.1c0.82,0.73,1.28,1.64,1.4,2.72l0.07,0.58c0,0.11,0.06,0.17,0.18,0.17h1.6
c0.91,0,1.68,0.32,2.32,0.95c0.64,0.63,0.97,1.4,0.97,2.28c0,0.85-0.3,1.59-0.89,2.21c-0.59,0.62-1.33,0.97-2.2,1.04
c-0.13,0-0.2,0.06-0.2,0.18v1.37c0,0.11,0.07,0.17,0.2,0.17c1.33-0.04,2.46-0.55,3.39-1.51s1.39-2.11,1.39-3.45
c0-0.9-0.22-1.73-0.67-2.49c-0.44-0.76-1.05-1.36-1.81-1.8c-0.77-0.44-1.6-0.66-2.5-0.66H20.1c-0.33-1.33-1.04-2.42-2.11-3.26
s-2.3-1.27-3.68-1.27c-1.41,0-2.67,0.44-3.76,1.31s-1.79,1.99-2.1,3.36c-1.11,0.26-2.02,0.83-2.74,1.73S4.63,15.76,4.63,16.91z
M12.77,26.62c0,0.39,0.19,0.65,0.58,0.77c0.01,0,0.05,0,0.11,0.01c0.06,0.01,0.11,0.01,0.14,0.01c0.17,0,0.33-0.05,0.49-0.15
c0.16-0.1,0.27-0.26,0.32-0.48l2.25-8.69c0.06-0.24,0.04-0.45-0.07-0.65c-0.11-0.19-0.27-0.32-0.5-0.39
c-0.17-0.02-0.26-0.03-0.26-0.03c-0.16,0-0.32,0.05-0.47,0.15c-0.15,0.1-0.26,0.25-0.31,0.45l-2.26,8.72
C12.78,26.44,12.77,26.53,12.77,26.62z M16.93,23.56c0,0.13,0.03,0.26,0.1,0.38c0.14,0.22,0.31,0.37,0.51,0.44
c0.11,0.03,0.21,0.05,0.3,0.05s0.2-0.02,0.32-0.08c0.21-0.09,0.35-0.28,0.42-0.57l1.44-5.67c0.03-0.14,0.05-0.23,0.05-0.27
c0-0.15-0.05-0.3-0.16-0.45s-0.26-0.26-0.46-0.32c-0.17-0.02-0.26-0.03-0.26-0.03c-0.17,0-0.33,0.05-0.47,0.15
c-0.14,0.1-0.24,0.25-0.3,0.45l-1.46,5.7c0,0.02,0,0.05-0.01,0.11C16.93,23.5,16.93,23.53,16.93,23.56z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+4 -3
View File
@@ -97,6 +97,7 @@ $exclude = @(
'lib\Epub\Epub\hyphenation\generated'
'lib\uzlib'
'.pio'
'.venv'
)
function Test-Excluded($fullPath) {
@@ -108,16 +109,16 @@ function Test-Excluded($fullPath) {
}
if ($g) {
# Only git-modified *.cpp / *.h files
# Only git-modified *.cpp / *.c / *.h files
# Covers both staged and unstaged changes
$files = @(git -C $repoRoot diff --name-only HEAD) +
@(git -C $repoRoot diff --name-only --cached) |
Sort-Object -Unique |
Where-Object { $_ -match '\.(cpp|h)$' } |
Where-Object { $_ -match '\.(cpp|c|h)$' } |
ForEach-Object { Get-Item (Join-Path $repoRoot $_) -ErrorAction SilentlyContinue } |
Where-Object { $_ -and -not (Test-Excluded $_.FullName) }
} else {
$files = Get-ChildItem -Path $repoRoot -Recurse -Include *.cpp, *.h -File |
$files = Get-ChildItem -Path $repoRoot -Recurse -Include *.cpp, *.c, *.h -File |
Where-Object { -not (Test-Excluded $_.FullName) }
}
+17
View File
@@ -125,6 +125,23 @@ Notes:
- rendering favors reusing precomputed layout data to keep page turns responsive on constrained hardware
- progress/session state is persisted so the reader can reopen at the last position after reboot/sleep
## KOReader sync position mapping
KOReader sync integration is implemented under `lib/KOReaderSync/` and is used by
`src/activities/reader/KOReaderSyncActivity.*`.
Position translation currently follows a dual-path strategy:
- CrossPoint -> KOReader: prefer element-level XPath extracted from the current
spine XHTML; fallback to chapter-level `DocFragment` path when needed.
- KOReader -> CrossPoint: prefer incoming XPath resolution; fallback to
percentage-based estimation if XPath is invalid or cannot be resolved.
Detailed algorithm and constraints (including low-memory rationale for ESP32-C3)
are documented in:
- [KOReader Sync XPath Mapping](koreader-sync-xpath-mapping.md)
## State and persistence
Two singletons are central:
@@ -0,0 +1,128 @@
# KOReader Sync XPath Mapping
This note documents how CrossPoint maps reading positions to and from KOReader sync payloads.
Related architecture overview: [koreader-synchronization.md](koreader-synchronization.md)
## Problem
CrossPoint internally stores position as:
- `spineIndex` (chapter index, 0-based)
- `pageNumber` + `totalPages`
KOReader sync payload stores:
- `progress` (XPath-like location)
- `percentage` (overall progress)
A direct 1:1 mapping is not guaranteed because page layout differs between engines/devices.
## DocFragment Index Convention
KOReader uses **1-based** XPath predicates throughout, following standard XPath conventions.
The first EPUB spine item is `DocFragment[1]`, the second is `DocFragment[2]`, and so on.
CrossPoint stores spine items as 0-based indices internally. The conversion is:
- **Generating XPath (to KOReader):** `DocFragment[spineIndex + 1]`
- **Parsing XPath (from KOReader):** `spineIndex = DocFragment[N] - 1`
Reference: [koreader/koreader#11585](https://github.com/koreader/koreader/issues/11585) confirms this
via a KOReader contributor mapping spine items to DocFragment numbers.
## Current Strategy
### CrossPoint -> KOReader
Implemented in `ProgressMapper::toKOReader`.
1. Compute overall `percentage` from chapter/page.
2. If a paragraph index is available from the section cache LUT (`CrossPointPosition::hasParagraphIndex`),
generate an XPath directly: `/body/DocFragment[spineIndex + 1]/body/p[paragraphIndex]`.
3. Otherwise, attempt byte-offset estimation via `ChapterXPathIndexer::findXPathForProgress`.
4. If XPath extraction fails, fallback to synthetic chapter path:
- `/body/DocFragment[spineIndex + 1]/body`
### KOReader -> CrossPoint
Implemented in `ProgressMapper::toCrossPoint`.
1. Attempt to parse `DocFragment[N]` from incoming XPath; convert N to 0-based `spineIndex = N - 1`.
2. If valid, attempt XPath-to-offset mapping via `ChapterXPathIndexer::findProgressForXPath`.
3. Extract paragraph index from XPath via `ChapterXPathIndexer::tryExtractParagraphIndexFromXPath`
(e.g. `/body/DocFragment[7]/body/p[685]/text().96``paragraphIndex = 685`).
4. Convert resolved intra-spine progress to page estimate.
5. If XPath path is invalid/unresolvable, fallback to percentage-based chapter/page estimation.
When a paragraph index is available, `EpubReaderActivity` refines the page estimate using
the section cache's per-page paragraph LUT (`Section::getPageForParagraphIndex`). This finds
the first page whose recorded paragraph index is >= the target, giving a more accurate
landing position than byte-offset-based estimation alone.
## ChapterXPathIndexer Design
The module reparses **one spine XHTML** on demand using Expat and builds temporary anchors:
Source-of-truth note: XPath anchors are built from the original EPUB spine XHTML bytes (zip item contents), not from CrossPoint's distilled section render cache. This is intentional to preserve KOReader XPath compatibility.
- anchor: `<xpath, textOffset>`
- `textOffset` counts non-whitespace bytes
- When multiple anchors exist for the same path, the one with the **smallest** textOffset is used
(start of element), not the latest periodic anchor.
Forward lookup (CrossPoint → XPath): uses `upper_bound` to find the last anchor at or before the
target text offset, ensuring the returned XPath corresponds to the element the user is currently
inside rather than the next element.
Matching for reverse lookup:
1. exact path match — reported as `exact=yes`
2. index-insensitive path match (`div[2]` vs `div[3]` tolerated) — reported as `exact=no`
3. ancestor fallback — reported as `exact=no`
If no match is found, caller must fallback to percentage.
## Memory / Safety Constraints (ESP32-C3)
The implementation intentionally avoids full DOM storage.
- Parse one chapter only.
- Keep anchors in transient vectors only for duration of call.
- Free XML parser and chapter byte buffer on all success/failure paths.
- No persistent cache structures are introduced by this module.
## Paragraph Index LUT
The section cache stores a per-page paragraph index LUT built during page layout
(`ChapterHtmlSlimParser`). Each entry records the 1-based `<p>` sibling index
(direct children of `<body>`, matching XPath convention) at the time each page was completed.
This enables two lookups without reparsing:
- **XPath → page** (`Section::getPageForParagraphIndex`): finds the first page where the
recorded paragraph index >= target. Used when applying remote KOReader progress.
- **Page → XPath** (`Section::getParagraphIndexForPage`): returns the paragraph index for
a given page. Used when uploading local progress to KOReader.
The paragraph counter in `ChapterHtmlSlimParser` counts **all** `<p>` elements at body-child
level, including `display:none` elements. This matches `ChapterXPathIndexer` and crengine's
standard XPath same-name sibling counting.
## Known Limitations
- Page number on reverse mapping is still an estimate (renderer differences).
The paragraph LUT refines this but cannot guarantee exact page matching.
- XPath mapping intentionally uses original spine XHTML while pagination comes from distilled renderer output, so minor roundtrip page drift is expected.
- Image-only/low-text chapters may yield coarse anchors.
- Extremely malformed XHTML can force fallback behavior.
## Operational Logging
`ProgressMapper` logs mapping source in reverse direction:
- `xpath` when XPath mapping path was used
- `percentage` when fallback path was used
It also logs exactness (`exact=yes/no`) for XPath matches. Note that `exact=yes` is only set for
a full path match with correct indices; index-insensitive and ancestor matches always log `exact=no`.
@@ -0,0 +1,115 @@
# KOReader Synchronization Architecture
This document explains the intent and internal structure of the KOReader synchronization code in CrossPoint.
Scope:
- Synchronization logic that maps between CrossPoint reading position and KOReader sync payloads.
- Module boundaries and responsibilities.
- Matching rules, fallback strategy, and expected behavior.
For XPath-specific details and examples, see [koreader-sync-xpath-mapping.md](koreader-sync-xpath-mapping.md).
## Goals
The synchronization layer is designed to:
- Be robust on constrained devices (ESP32-C3 memory constraints).
- Be deterministic and debuggable when mapping positions.
- Keep transport/client logic separated from parsing/mapping logic.
- Prefer precise anchors when available, but degrade gracefully.
## Data Model Mismatch
CrossPoint stores position as chapter/page-centric state.
KOReader sync payload stores position as XPath-like anchor plus percentage.
Because layout engines differ, page equality cannot be guaranteed across devices.
The synchronization strategy therefore combines:
- Structural anchor mapping (XPath).
- Percent-based fallback.
- Paragraph LUT refinement when available.
## Module Responsibilities
### Client / orchestration
- [lib/KOReaderSync/KOReaderSyncClient.cpp](../../lib/KOReaderSync/KOReaderSyncClient.cpp)
- HTTP calls and payload exchange.
- [lib/KOReaderSync/ProgressMapper.cpp](../../lib/KOReaderSync/ProgressMapper.cpp)
- High-level mapping from app state to KOReader payload and back.
- Chooses XPath path or percentage fallback.
### XPath indexing facade
- [lib/KOReaderSync/ChapterXPathIndexer.h](../../lib/KOReaderSync/ChapterXPathIndexer.h)
- [lib/KOReaderSync/ChapterXPathIndexer.cpp](../../lib/KOReaderSync/ChapterXPathIndexer.cpp)
- Public API consumed by ProgressMapper.
- Thin facade over forward/reverse mapper internals.
- Utility extraction helpers (DocFragment index, paragraph index).
### Forward mapping engine
- [lib/KOReaderSync/ChapterXPathForwardMapper.cpp](../../lib/KOReaderSync/ChapterXPathForwardMapper.cpp)
- Maps intra-spine progress to XPath.
- Emits /text()[N].M for body-level text-node locations.
### Reverse mapping engine
- [lib/KOReaderSync/ChapterXPathReverseMapper.cpp](../../lib/KOReaderSync/ChapterXPathReverseMapper.cpp)
- Maps XPath to intra-spine progress.
- Supports exact and tolerant matching tiers.
- Handles /text()[N].M codepoint offsets.
### Shared parser/state/utilities
- [lib/KOReaderSync/ChapterXPathIndexerInternal.cpp](../../lib/KOReaderSync/ChapterXPathIndexerInternal.cpp)
- [lib/KOReaderSync/ChapterXPathIndexerInternal.h](../../lib/KOReaderSync/ChapterXPathIndexerInternal.h)
- UTF-8 helpers, XPath normalization, parse runner, and chapter text-byte counting.
- [lib/KOReaderSync/ChapterXPathIndexerState.h](../../lib/KOReaderSync/ChapterXPathIndexerState.h)
- Shared stack model and generic Expat callback adapters.
- Common parser code pattern used by both forward/reverse engines.
## Core Logic
### Forward (CrossPoint -> KOReader)
1. Decompress one spine XHTML to a temporary file.
2. Count total visible text bytes.
3. Cache that total per spine (cache-path + spine index + href) so repeated
mappings for the same chapter can skip the expensive counting pass.
4. Convert intra-spine progress to target visible-byte offset.
5. Stream parse and stop at target.
6. Emit anchor:
- element XPath, or
- /text()[N].M when in body-level text-node context.
### Reverse (KOReader -> CrossPoint)
1. Decompress one spine XHTML to a temporary file.
2. Stream parse chapter while evaluating candidate matches.
3. Resolve best tier in this order:
- exact
- exact-no-index
- ancestor
- ancestor-no-index
4. Convert resolved byte offset to intra-spine progress.
For text-node anchors /text()[N].M:
- N is treated as 1-based text node index.
- M is treated as 0-based codepoint offset.
## Fallback Strategy
When XPath mapping fails or is ambiguous:
- Fall back to percentage-driven chapter/page estimation.
- Use paragraph LUT refinement where available.
This guarantees user progress continuity even for malformed or sparse content.
## Constraints and Non-Goals
- No full DOM materialization for entire books.
- Parse only one spine item on demand.
- Keep memory usage bounded and transient.
- Do not attempt pixel-perfect page parity with KOReader.
+159
View File
@@ -0,0 +1,159 @@
# EPUB TOC Anchor Navigation
This document describes how the reader handles EPUB Table of Contents (TOC) entries that use fragment anchors to point into spine files, enabling navigation to sub-chapters within a single XHTML file.
## Background: EPUB spine and TOC structure
An EPUB's **spine** is an ordered list of XHTML files that define reading order. The **TOC** (table of contents) maps chapter names to positions in the spine, optionally with fragment anchors (e.g. `chapter1.xhtml#section-5`).
Two layouts are relevant here:
- **1:1** -- one TOC entry per spine item (most common, no anchors needed)
- **Multi-TOC-per-spine** -- multiple TOC entries point into a single spine file using fragment anchors (e.g. Moby Dick from Project Gutenberg packs 3-9 chapters per file)
Spine items before the first TOC entry (cover pages) and after the last (appendices, copyright) have no TOC entry of their own.
## BookMetadataCache and TOC-to-spine mapping
`BookMetadataCache` builds the mapping between spine items and TOC entries at epub open time. Key details:
- Each `SpineEntry` has a `tocIndex` field set during cache building. For spines with no matching TOC entry, `tocIndex` inherits the previous spine's value (`lastSpineTocIndex`). This means orphan spines (cover pages, appendices) are treated as continuations of the nearest preceding chapter.
- `getTocIndexForSpineIndex(i)` returns the stored `tocIndex` for spine `i` -- a file seek into BookMetadataCache, not computed on the fly.
- `getTocItem(i)` returns the TOC entry (title, spineIndex, anchor) for TOC index `i` -- also a file seek per call, not cached in memory. Code that queries TOC metadata in a loop should cache the results locally first.
- `getSpineIndexForTocIndex(i)` does the reverse lookup (TOC index to spine index).
## Section cache file format
The section cache (`.bin`) stores pre-rendered page data for a spine item. The file layout:
```
[header: version, render parameters, pageCount, lutOffset, anchorMapOffset]
[serialized pages...]
[page LUT: array of uint32_t file offsets, one per page]
[anchor map: uint16_t count, then (string, uint16_t) pairs]
```
The header size is defined by `HEADER_SIZE` (a constexpr computed via `sizeof` sum) and validated with a `static_assert`. Three functions read this header independently and must stay in sync:
- `loadSectionFile` -- full section load, reads header + builds TOC boundaries from anchor map
- `getPageForAnchor` -- seeks directly to anchor map offset from header
- `writeSectionFileHeader` -- writes the header during cache creation
When modifying the header layout, bump `SECTION_FILE_VERSION` to invalidate stale caches and update all read paths.
## Anchor-to-page mapping
### Recording anchors during parsing
`ChapterHtmlSlimParser` records every HTML `id` attribute and its corresponding page number into `anchorData` (a flat `std::vector<std::pair<std::string, uint16_t>>`). Recording is deferred via `pendingAnchorId` until `startNewTextBlock()`, after the previous text block is flushed to pages via `makePages()`. This ensures `completedPageCount` reflects the correct page.
For TOC anchors specifically, `startNewTextBlock` also forces a page break before recording, so chapters start on fresh pages rather than mid-page. The parser receives the set of TOC anchor strings via `tocAnchors` (a `std::vector<std::string>`) from `Section::createSectionFile`.
### On-disk format
The anchor data is serialized at the end of the section cache file (`.bin`), after the page LUT. The header stores the anchor map offset. Format:
```
[uint16_t count]
[string anchor_1][uint16_t page_1]
[string anchor_2][uint16_t page_2]
...
```
This data serves two purposes:
- **Footnote navigation** (`getPageForAnchor`): on-demand linear scan for a single anchor
- **TOC boundary resolution** (`buildTocBoundariesFromFile`): scan matching only TOC anchors
### Data structure choices
All anchor storage uses flat vectors, not `std::map` or `std::set`. On the ESP32-C3, each `std::map`/`std::set` node requires its own heap allocation, causing fragmentation. Vectors use a single contiguous allocation. The entry counts are small enough (typically 1-10 TOC anchors per spine, dozens to hundreds of total anchors) that linear scans are faster than tree lookups at these sizes.
## TOC boundaries in Section
When a section is loaded or created, `Section` builds an in-memory `tocBoundaries` vector mapping each TOC entry in that spine to its start page. This is a small vector (1-3 entries typically) that enables O(1) lookups without file I/O.
### Two build paths
**From in-memory anchors** (`buildTocBoundaries`): Called after `createSectionFile` when the parser's anchor vector is still in memory. Iterates TOC entries and does linear scans against the anchor vector.
**From disk** (`buildTocBoundariesFromFile`): Called from `loadSectionFile` when loading a cached section. Caches the small set of TOC anchor strings first (since `getTocItem()` does file I/O to `BookMetadataCache`), then streams through on-disk anchors matching only those, stopping early once all are resolved. Uses a reusable `std::string` buffer to avoid per-entry heap allocation.
The two functions are kept separate because their iteration patterns differ fundamentally: in-memory iterates TOC entries with inner scans of anchors, while the disk path iterates disk entries with inner scans of the small TOC anchor set.
### Early exit optimization
If no TOC entries in the spine have anchors (`unresolvedCount == 0`), both functions return immediately without storing any boundaries. `getTocIndexForPage` falls back to `epub->getTocIndexForSpineIndex`, which gives the correct answer for the common 1:1 case.
### Query methods
- `getTocIndexForPage(page)` -- binary search on sorted `tocBoundaries` to find which chapter a page belongs to
- `getPageForTocIndex(tocIndex)` -- linear scan to find a chapter's start page
- `getPageRangeForTocIndex(tocIndex)` -- returns `[startPage, endPage)` range for a chapter within this spine
All are in-memory, no file I/O.
## Chapter navigation in EpubReaderActivity
### Chapter skip (long-press)
Navigates by TOC index, not spine index. Uses `getTocIndexForPage` to determine the current chapter, then increments or decrements.
- **Same-spine skip**: Resolves the target page via `getPageForTocIndex` entirely in memory
- **Cross-spine skip**: Sets `pendingTocIndex` (a `std::optional<int>`) which is resolved after the target section loads in `render()`
- **Forward past last TOC entry**: Jumps to end-of-book (spine index clamped in `render()`)
- **Backward before first TOC entry**: Jumps to the spine before the current chapter's first spine (clamped to 0 in `render()`)
- **No TOC entry for spine** (`curTocIndex < 0`): Falls back to spine-level skip
### Chapter selector
The chapter selection activity receives `currentTocIndex` (per-page, not per-spine) so it highlights the correct sub-chapter. Returns `ChapterResult` with both `spineIndex` and `std::optional<int> tocIndex`. The reader resolves the page via `getPageForTocIndex` for same-spine navigation or defers via `pendingTocIndex` for cross-spine.
### Footnote navigation
Uses the existing `pendingAnchor` mechanism from the footnote anchor navigation commit (4d222567). `getPageForAnchor` does an on-demand linear scan of the on-disk anchor data. This is separate from TOC boundaries -- it reads all anchors (not just TOC ones) and is only called for footnote jumps.
### Status bar
Uses `getTocIndexForPage()` for the chapter title, so the status bar shows the correct sub-chapter name when reading a multi-TOC-per-spine file.
## Orphan spine handling
Spine items without a TOC entry inherit the previous spine's `tocIndex` in `BookMetadataCache`. This means:
- Pre-TOC spines (cover pages) may have `tocIndex == -1` if they're before any chapter
- Post-TOC spines (appendices, copyright) inherit the last chapter's `tocIndex`
The chapter skip logic guards against `curTocIndex < 0` and falls back to spine-level navigation.
## Implementation pitfalls and edge cases
### Anchor recording timing
The `pendingAnchorId` deferred recording pattern is critical for correctness. Anchors must be recorded *after* `makePages()` flushes the previous text block (so `completedPageCount` reflects the right page) but the TOC page break must happen *before* recording (so the anchor lands on the new page). Both of these happen inside `startNewTextBlock()`. An earlier design used a `recordAnchor` lambda called at various points in `startElement()`, but this had wrong timing for headings and block elements -- `startNewTextBlock` would consume `pendingAnchorId` before `recordAnchor` could force the page break. Moving all page-break logic into `startNewTextBlock` fixed this.
### pendingAnchorId overwrite on consecutive elements
If two elements with `id` attributes appear before any `startNewTextBlock` call (e.g. nested divs), the second `id` overwrites `pendingAnchorId` and the first anchor is never recorded. This is a known limitation inherited from the footnote anchor navigation commit (4d222567) on master. In practice, TOC anchors are on chapter headings which trigger `startNewTextBlock`, so this doesn't affect TOC navigation.
### wordsExtractedInBlock reset on empty block reuse
When `startNewTextBlock` reuses an empty text block (the early-return path), `wordsExtractedInBlock` must be reset to 0. Without this, footnotes in the reused block could be assigned to wrong pages based on stale word counts from a prior block.
### getTocItem() does file I/O
`epub->getTocItem()` reads from `BookMetadataCache` via file seek on every call. This is why `buildTocBoundariesFromFile` caches the TOC anchor strings into a small vector before entering the disk scan loop -- otherwise the inner loop would do file I/O (BookMetadataCache) for every on-disk anchor entry.
### Defensive sort on tocBoundaries
`tocBoundaries` is sorted by `startPage` after building. In well-formed EPUBs, entries are already in order (TOC follows document order). The sort is a safety net for malformed EPUBs where TOC entries might be out of document order. With 1-3 entries it has no measurable cost.
## Test epub
`scripts/generate_spine_toc_edges_epub.py` generates `test/epubs/test_spine_toc_edges.epub`, a purpose-built epub that exercises spine/TOC relationship patterns. See the script header for the full list of edge cases covered.
## Performance characteristics
- **Per page turn**: All in-memory. `getTocIndexForPage` (binary search on 1-3 entries), `getTocItem` for title (one file seek to BookMetadataCache -- noted as a future optimization opportunity).
- **Section load**: One file open for the section cache. `buildTocBoundariesFromFile` scans the anchor map for a few TOC entries with early exit.
- **Footnote navigation**: One additional file open to scan the anchor map for a single anchor.
- **1:1 TOC-to-spine (common case)**: No overhead. `unresolvedCount == 0`, `tocBoundaries` stays empty, all queries fall back to spine-level methods.
+37 -13
View File
@@ -104,7 +104,7 @@ if (parsedSize != fileSize) {
## `section.bin`
### Version 8
### Version 20
ImHex Pattern:
@@ -114,7 +114,7 @@ import std.string;
import std.core;
// === Configuration ===
#define EXPECTED_VERSION 8
#define EXPECTED_VERSION 20
#define MAX_STRING_LENGTH 65535
// === String Structure ===
@@ -175,36 +175,60 @@ struct Page {
PageElement elements[elementCount] [[inline]];
};
// === Anchor Map Entry ===
struct AnchorEntry {
String anchorId [[comment("HTML id attribute value")]];
u16 pageNumber [[comment("Page where the anchor appears")]];
};
// === Section Bin Structure ===
struct SectionBin {
// Header
u8 version [[comment("Format version"), color("FFD93D")]];
// Version validation
if (version != EXPECTED_VERSION) {
std::error(std::format("Unsupported version: {} (expected {})", version, EXPECTED_VERSION));
}
// Cache busting parameters
s32 fontId;
float lineCompression;
bool extraParagraphSpacing;
u8 paragraphAlignment;
u16 viewportWidth;
u16 vieportHeight;
u16 viewportHeight;
u16 pageCount;
u32 lutOffset;
bool hyphenationEnabled;
bool embeddedStyle;
u8 imageRendering;
u32 pageLutOffset [[comment("Offset to page offset LUT")]];
u32 anchorMapOffset [[comment("Offset to anchor map")]];
u32 paragraphLutOffset [[comment("Offset to per-page paragraph index LUT")]];
Page page[pageCount];
// === Page Offset LUT ===
// Validate LUT offset alignment
u32 currentOffset = $;
if (currentOffset != lutOffset) {
std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset));
if (currentOffset != pageLutOffset) {
std::warning(std::format("Page LUT offset mismatch: expected 0x{:X}, got 0x{:X}", pageLutOffset, currentOffset));
}
// Lookup Tables
u32 lut[pageCount];
u32 pageOffsets[pageCount] [[comment("File offsets to serialized pages")]];
// === Anchor Map ===
u16 anchorCount;
AnchorEntry anchors[anchorCount];
// === Paragraph Index LUT ===
// One entry per page: the 1-based <p> sibling index (XPath convention)
// at the time each page was completed during parsing.
// Used to resolve KOReader XPath p[N] positions to page numbers.
u16 paragraphEntryCount;
u16 paragraphIndexPerPage[paragraphEntryCount] [[comment("1-based <p> index at page completion")]];
};
// === File Parsing ===
+113 -1
View File
@@ -2,6 +2,7 @@
#include <FsHelpers.h>
#include <HalStorage.h>
#include <I18n.h>
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
@@ -77,6 +78,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
bookMetadata.author = opfParser.author;
bookMetadata.language = opfParser.language;
bookMetadata.coverItemHref = opfParser.coverItemHref;
bookMetadata.series = opfParser.series;
bookMetadata.seriesIndex = opfParser.seriesIndex;
bookMetadata.description = opfParser.description;
// Guide-based cover fallback: if no cover found via metadata/properties,
// try extracting the image reference from the guide's cover page XHTML
@@ -333,6 +337,7 @@ void Epub::parseCssFiles() const {
// load in the meta data for the epub file
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
tocReliabilityState = -1;
// Initialize spine/TOC cache
bookMetadataCache.reset(new BookMetadataCache(cachePath));
@@ -516,6 +521,30 @@ const std::string& Epub::getLanguage() const {
return bookMetadataCache->coreMetadata.language;
}
const std::string& Epub::getSeries() const {
static std::string blank;
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return blank;
}
return bookMetadataCache->coreMetadata.series;
}
const std::string& Epub::getSeriesIndex() const {
static std::string blank;
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return blank;
}
return bookMetadataCache->coreMetadata.seriesIndex;
}
const std::string& Epub::getDescription() const {
static std::string blank;
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return blank;
}
return bookMetadataCache->coreMetadata.description;
}
std::string Epub::getCoverBmpPath(bool cropped) const {
const auto coverFileName = std::string("cover") + (cropped ? "_crop" : "");
return cachePath + "/" + coverFileName + ".bmp";
@@ -767,6 +796,18 @@ BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
return {};
}
if (syntheticTocFallbackEnabled && !hasReliableToc()) {
const int spineCount = bookMetadataCache->getSpineCount();
if (tocIndex < 0 || tocIndex >= spineCount) {
LOG_DBG("EBP", "getTocItem synthetic index:%d is out of range", tocIndex);
return {};
}
const auto spine = bookMetadataCache->getSpineEntry(tocIndex);
return BookMetadataCache::TocEntry(tr(STR_SECTION_PREFIX) + std::to_string(tocIndex + 1), spine.href, "", 1,
static_cast<int16_t>(tocIndex));
}
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
LOG_DBG("EBP", "getTocItem index:%d is out of range", tocIndex);
return {};
@@ -780,6 +821,10 @@ int Epub::getTocItemsCount() const {
return 0;
}
if (syntheticTocFallbackEnabled && !hasReliableToc()) {
return bookMetadataCache->getSpineCount();
}
return bookMetadataCache->getTocCount();
}
@@ -790,6 +835,14 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
return 0;
}
if (syntheticTocFallbackEnabled && !hasReliableToc()) {
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getSpineCount()) {
LOG_ERR("EBP", "getSpineIndexForTocIndex synthetic tocIndex %d out of range", tocIndex);
return 0;
}
return tocIndex;
}
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
LOG_ERR("EBP", "getSpineIndexForTocIndex: tocIndex %d out of range", tocIndex);
return 0;
@@ -804,7 +857,66 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
return spineIndex;
}
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; }
bool Epub::hasReliableToc() const {
if (tocReliabilityState != -1) {
return tocReliabilityState == 1;
}
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
tocReliabilityState = 0;
return false;
}
const int spineCount = bookMetadataCache->getSpineCount();
const int tocCount = bookMetadataCache->getTocCount();
if (spineCount <= 0 || tocCount <= 0) {
tocReliabilityState = 0;
return false;
}
// If a larger book only exposes one TOC entry, treat TOC as unusable for chapter UX.
if (spineCount >= 8 && tocCount <= 1) {
tocReliabilityState = 0;
return false;
}
std::vector<bool> spineReferenced(static_cast<size_t>(spineCount), false);
int distinctSpinesReferenced = 0;
for (int i = 0; i < tocCount; i++) {
const auto toc = bookMetadataCache->getTocEntry(i);
if (toc.spineIndex >= 0 && toc.spineIndex < spineCount) {
const size_t idx = static_cast<size_t>(toc.spineIndex);
if (!spineReferenced[idx]) {
spineReferenced[idx] = true;
distinctSpinesReferenced++;
}
}
}
// Require at least 25% spine coverage from TOC references.
const bool reliable = (distinctSpinesReferenced * 4 >= spineCount);
tocReliabilityState = reliable ? 1 : 0;
return reliable;
}
int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
LOG_ERR("EBP", "getTocIndexForSpineIndex called but cache not loaded");
return -1;
}
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
LOG_ERR("EBP", "getTocIndexForSpineIndex: spineIndex %d out of range", spineIndex);
return -1;
}
if (syntheticTocFallbackEnabled && !hasReliableToc()) {
return spineIndex;
}
return bookMetadataCache->getSpineEntry(spineIndex).tocIndex;
}
size_t Epub::getBookSize() const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) {
+9
View File
@@ -29,6 +29,10 @@ class Epub {
std::unique_ptr<CssParser> cssParser;
// CSS files
std::vector<std::string> cssFiles;
// -1 unknown, 0 unreliable, 1 reliable
mutable int tocReliabilityState = -1;
// Library-level option: app code can override this per-book instance.
bool syntheticTocFallbackEnabled = false;
bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
@@ -51,6 +55,9 @@ class Epub {
const std::string& getTitle() const;
const std::string& getAuthor() const;
const std::string& getLanguage() const;
const std::string& getSeries() const;
const std::string& getSeriesIndex() const;
const std::string& getDescription() const;
std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
@@ -66,6 +73,8 @@ class Epub {
int getTocItemsCount() const;
int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const;
bool hasReliableToc() const;
void setSyntheticTocFallbackEnabled(bool enabled) { syntheticTocFallbackEnabled = enabled; }
size_t getCumulativeSpineItemSize(int spineIndex) const;
int getSpineIndexForTextReference() const;
+9 -2
View File
@@ -9,7 +9,7 @@
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 5;
constexpr uint8_t BOOK_CACHE_VERSION = 6;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
@@ -117,7 +117,8 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount);
const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.language.size() +
metadata.coverItemHref.size() + metadata.textReferenceHref.size() +
sizeof(uint32_t) * 5;
metadata.series.size() + metadata.seriesIndex.size() + metadata.description.size() +
sizeof(uint32_t) * 8;
const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount;
const uint32_t lutOffset = headerASize + metadataSize;
@@ -132,6 +133,9 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
serialization::writeString(bookFile, metadata.language);
serialization::writeString(bookFile, metadata.coverItemHref);
serialization::writeString(bookFile, metadata.textReferenceHref);
serialization::writeString(bookFile, metadata.series);
serialization::writeString(bookFile, metadata.seriesIndex);
serialization::writeString(bookFile, metadata.description);
// Loop through spine entries, writing LUT positions
spineFile.seek(0);
@@ -386,6 +390,9 @@ bool BookMetadataCache::load() {
serialization::readString(bookFile, coreMetadata.language);
serialization::readString(bookFile, coreMetadata.coverItemHref);
serialization::readString(bookFile, coreMetadata.textReferenceHref);
serialization::readString(bookFile, coreMetadata.series);
serialization::readString(bookFile, coreMetadata.seriesIndex);
serialization::readString(bookFile, coreMetadata.description);
loaded = true;
LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
+3
View File
@@ -14,6 +14,9 @@ class BookMetadataCache {
std::string language;
std::string coverItemHref;
std::string textReferenceHref;
std::string series;
std::string seriesIndex;
std::string description;
};
struct SpineEntry {
+34 -18
View File
@@ -165,6 +165,20 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
const size_t totalWordCount = words.size();
// Pre-compute inter-word gaps once so the O(n²) DP inner loop avoids repeated
// codepoint scanning and renderer calls for every (i,j) pair.
// interWordGaps[j] = the spacing between words[j-1] and words[j] (0 for j==0).
std::vector<int> interWordGaps(totalWordCount, 0);
for (size_t j = 1; j < totalWordCount; ++j) {
if (!continuesVec[j]) {
interWordGaps[j] =
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
} else {
interWordGaps[j] =
renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
}
}
// DP table to store the minimum badness (cost) of lines starting at index i
std::vector<int> dp(totalWordCount);
// 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i'
@@ -182,15 +196,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : 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
int gap = 0;
if (j > static_cast<size_t>(i) && !continuesVec[j]) {
gap =
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
} else if (j > static_cast<size_t>(i) && continuesVec[j]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
}
const int gap = (j > static_cast<size_t>(i)) ? interWordGaps[j] : 0;
currlen += wordWidths[j] + gap;
if (currlen > effectivePageWidth) {
@@ -284,6 +290,21 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
? blockStyle.textIndent
: 0;
// Pre-compute inter-word gaps to avoid repeated codepoint scanning and renderer
// calls in the inner loop. When hyphenateWordAtIndex inserts a new word, we insert
// a placeholder gap (0) at that position to keep the vector in sync; the remainder
// is always the first word on the next line so its spacing is never used.
std::vector<int> interWordGaps(wordWidths.size(), 0);
for (size_t j = 1; j < wordWidths.size(); ++j) {
if (!continuesVec[j]) {
interWordGaps[j] =
renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
} else {
interWordGaps[j] =
renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]);
}
}
std::vector<size_t> lineBreakIndices;
size_t currentIndex = 0;
bool isFirstLine = true;
@@ -298,15 +319,7 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
// Consume as many words as possible for current line, splitting when prefixes fit
while (currentIndex < wordWidths.size()) {
const bool isFirstWord = currentIndex == lineStart;
int spacing = 0;
if (!isFirstWord && !continuesVec[currentIndex]) {
spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]),
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
} else if (!isFirstWord && continuesVec[currentIndex]) {
// Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation)
spacing = renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]),
firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]);
}
const int spacing = isFirstWord ? 0 : interWordGaps[currentIndex];
const int candidateWidth = spacing + wordWidths[currentIndex];
// Word fits on current line
@@ -322,6 +335,9 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
if (availableWidth > 0 &&
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) {
// Keep interWordGaps in sync: insert placeholder for the new remainder word.
// The remainder is always the first word on the next line so this slot is never read.
interWordGaps.insert(interWordGaps.begin() + currentIndex + 1, 0);
// Prefix now fits; append it to this line and move to next line
lineWidth += spacing + wordWidths[currentIndex];
++currentIndex;
+354 -30
View File
@@ -4,16 +4,29 @@
#include <Logging.h>
#include <Serialization.h>
#include <algorithm>
#include "Epub/css/CssParser.h"
#include "Page.h"
#include "hyphenation/Hyphenator.h"
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 18;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t);
constexpr uint8_t SECTION_FILE_VERSION = 20;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
sizeof(int) + // fontId
sizeof(float) + // lineCompression
sizeof(bool) + // extraParagraphSpacing
sizeof(uint8_t) + // paragraphAlignment
sizeof(uint16_t) + // viewportWidth
sizeof(uint16_t) + // viewportHeight
sizeof(uint16_t) + // pageCount (stored as 16-bit in header)
sizeof(bool) + // hyphenationEnabled
sizeof(bool) + // embeddedStyle
sizeof(uint8_t) + // imageRendering
sizeof(uint32_t) + // page LUT offset
sizeof(uint32_t) + // anchor map offset
sizeof(uint32_t); // paragraph LUT offset
} // namespace
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
@@ -44,7 +57,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) +
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + sizeof(uint32_t),
sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) +
sizeof(uint32_t) + sizeof(uint32_t),
"Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId);
@@ -59,6 +73,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for LUT offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for anchor map offset (patched later)
serialization::writePod(file, static_cast<uint32_t>(0)); // Placeholder for paragraph LUT offset (patched later)
}
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
@@ -74,9 +89,8 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
uint8_t version;
serialization::readPod(file, version);
if (version != SECTION_FILE_VERSION) {
file.close();
LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
clearCache();
clearCache(); // closes file before removal
return false;
}
@@ -103,21 +117,54 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle ||
imageRendering != fileImageRendering) {
file.close();
LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache();
clearCache(); // closes file before removal
return false;
}
}
serialization::readPod(file, pageCount);
file.close();
LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
// Sanity check: same upper bound used by TextBlock::deserialize for word count
if (pageCount > 10000) {
LOG_ERR("SCT", "Deserialization failed: page count %u exceeds maximum", pageCount);
clearCache();
return false;
}
// Load LUT into memory (file is now positioned at the lutOffset field)
uint32_t lutOffset;
serialization::readPod(file, lutOffset);
lut.resize(pageCount);
if (!file.seek(lutOffset)) {
LOG_ERR("SCT", "Deserialization failed: seek to LUT offset %u failed", lutOffset);
clearCache();
return false;
}
for (uint32_t& pos : lut) {
serialization::readPod(file, pos);
if (pos < HEADER_SIZE || pos >= lutOffset) {
LOG_ERR("SCT", "Deserialization failed: LUT entry %u out of range [%u, %u)", pos, HEADER_SIZE, lutOffset);
clearCache();
return false;
}
}
// Build TOC boundaries by scanning anchor data from the still-open file,
// matching only the TOC anchors we need (avoids loading all anchors into memory).
buildTocBoundariesFromFile(file);
// File is intentionally left open; subsequent loadPageFromSectionFile() calls
// seek within this handle instead of re-opening the file each time.
LOG_DBG("SCT", "Deserialization succeeded: %d pages, LUT cached", pageCount);
return true;
}
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
bool Section::clearCache() const {
bool Section::clearCache() {
file.close(); // Must be closed before removal on FAT32
lut.clear();
pageCount = 0;
currentPage = 0;
if (!Storage.exists(filePath.c_str())) {
LOG_DBG("SCT", "Cache does not exist, no action needed");
return true;
@@ -135,7 +182,7 @@ bool Section::clearCache() const {
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle,
const uint8_t imageRendering, const std::function<void()>& popupFn) {
const uint8_t imageRendering, const std::function<void(int)>& progressFn) {
const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@@ -203,11 +250,24 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
}
}
// Collect TOC anchors for this spine so the parser can insert page breaks at chapter boundaries
std::vector<std::string> tocAnchors;
const int startTocIndex = epub->getTocIndexForSpineIndex(spineIndex);
if (startTocIndex >= 0) {
for (int i = startTocIndex; i < epub->getTocItemsCount(); i++) {
auto entry = epub->getTocItem(i);
if (entry.spineIndex != spineIndex) break;
if (!entry.anchor.empty()) {
tocAnchors.push_back(std::move(entry.anchor));
}
}
}
ChapterHtmlSlimParser visitor(
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser);
embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), progressFn, cssParser);
Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages();
@@ -240,7 +300,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
return false;
}
// Write anchor-to-page map for fragment navigation (e.g. footnote targets)
// Write anchor-to-page map for fragment navigation (TOC + footnote targets)
const uint32_t anchorMapOffset = file.position();
const auto& anchors = visitor.getAnchors();
serialization::writePod(file, static_cast<uint16_t>(anchors.size()));
@@ -249,34 +309,210 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, page);
}
// Patch header with final pageCount, lutOffset, and anchorMapOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2 - sizeof(pageCount));
// Write per-page paragraph index LUT for XPath-to-page resolution
const uint32_t paragraphLutOffset = file.position();
const auto& paragraphPerPage = visitor.getParagraphIndexPerPage();
serialization::writePod(file, static_cast<uint16_t>(paragraphPerPage.size()));
for (const uint16_t& pIdx : paragraphPerPage) {
serialization::writePod(file, pIdx);
}
// Patch header with final pageCount, lutOffset, anchorMapOffset, and paragraphLutOffset
file.seek(HEADER_SIZE - sizeof(uint32_t) * 3 - sizeof(pageCount));
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
serialization::writePod(file, anchorMapOffset);
serialization::writePod(file, paragraphLutOffset);
file.close();
if (cssParser) {
cssParser->clear();
}
buildTocBoundaries(anchors);
// Cache the LUT in memory and open the file for reading so that
// subsequent loadPageFromSectionFile() calls can seek directly without re-opening.
if (!Storage.openFileForRead("SCT", filePath, file)) {
LOG_ERR("SCT", "Failed to open section file for reading after creation");
return false;
}
this->lut = std::move(lut);
return true;
}
std::unique_ptr<Page> Section::loadPageFromSectionFile() {
if (!Storage.openFileForRead("SCT", filePath, file)) {
if (currentPage < 0 || currentPage >= static_cast<int>(lut.size())) {
LOG_ERR("SCT", "loadPageFromSectionFile: page %d out of LUT range (%u entries)", currentPage,
static_cast<uint32_t>(lut.size()));
return nullptr;
}
file.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
uint32_t lutOffset;
serialization::readPod(file, lutOffset);
file.seek(lutOffset + sizeof(uint32_t) * currentPage);
uint32_t pagePos;
serialization::readPod(file, pagePos);
file.seek(pagePos);
if (!file) {
// Safety fallback: file was closed unexpectedly; reopen
LOG_ERR("SCT", "loadPageFromSectionFile: file not open, reopening");
if (!Storage.openFileForRead("SCT", filePath, file)) {
return nullptr;
}
}
auto page = Page::deserialize(file);
file.close();
return page;
if (!file.seek(lut[currentPage])) {
LOG_ERR("SCT", "loadPageFromSectionFile: seek to page %d offset %u failed", currentPage, lut[currentPage]);
return nullptr;
}
return Page::deserialize(file);
// File is intentionally NOT closed; stays open for the next page load
}
// Resolve TOC anchor-to-page mappings from the parser's in-memory anchor vector.
// Called after createSectionFile when anchors are already in memory.
// See buildTocBoundariesFromFile for the on-disk variant; the two are kept separate
// because the anchor resolution has fundamentally different iteration patterns
// (scan in-memory vector vs. stream from file with early exit).
void Section::buildTocBoundaries(const std::vector<std::pair<std::string, uint16_t>>& anchors) {
const int startTocIndex = epub->getTocIndexForSpineIndex(spineIndex);
if (startTocIndex < 0) return;
// Count TOC entries for this spine and how many have anchors to resolve
const int tocCount = epub->getTocItemsCount();
uint16_t totalEntries = 0;
uint16_t unresolvedCount = 0;
for (int i = startTocIndex; i < tocCount; i++) {
const auto entry = epub->getTocItem(i);
if (entry.spineIndex != spineIndex) break;
totalEntries++;
if (!entry.anchor.empty()) unresolvedCount++;
}
// If no TOC entries have anchors, all chapters start at page 0 and
// getTocIndexForPage falls back to epub->getTocIndexForSpineIndex,
// so there's nothing to resolve and no value in storing boundaries.
if (totalEntries == 0 || unresolvedCount == 0) return;
tocBoundaries.reserve(totalEntries);
for (int i = startTocIndex; i < startTocIndex + totalEntries; i++) {
const auto entry = epub->getTocItem(i);
uint16_t page = 0;
if (!entry.anchor.empty()) {
for (const auto& [key, val] : anchors) {
if (key == entry.anchor) {
page = val;
break;
}
}
}
tocBoundaries.push_back({i, page});
}
// Defensive sort in case TOC entries are out of document order in a malformed epub
std::sort(tocBoundaries.begin(), tocBoundaries.end(),
[](const TocBoundary& a, const TocBoundary& b) { return a.startPage < b.startPage; });
}
// Resolve TOC anchor-to-page mappings by scanning the section cache's on-disk anchor data.
// Called from loadSectionFile when anchors are not in memory. Caches the small set of
// TOC anchor strings first (since getTocItem does file I/O to BookMetadataCache), then
// streams through on-disk anchors matching only those, stopping as soon as all are found.
// See buildTocBoundaries for the in-memory variant.
void Section::buildTocBoundariesFromFile(FsFile& f) {
const int startTocIndex = epub->getTocIndexForSpineIndex(spineIndex);
if (startTocIndex < 0) return;
// Count TOC entries for this spine, then reserve and populate
const int tocCount = epub->getTocItemsCount();
uint16_t totalEntries = 0;
uint16_t unresolvedCount = 0;
for (int i = startTocIndex; i < tocCount; i++) {
const auto entry = epub->getTocItem(i);
if (entry.spineIndex != spineIndex) break;
totalEntries++;
if (!entry.anchor.empty()) unresolvedCount++;
}
// If no TOC entries have anchors, all chapters start at page 0 and
// getTocIndexForPage falls back to epub->getTocIndexForSpineIndex,
// so there's nothing to resolve and no value in storing boundaries.
if (totalEntries == 0 || unresolvedCount == 0) return;
// Cache TOC anchor strings before scanning disk, since getTocItem() does file I/O
struct TocAnchorEntry {
int tocIndex;
std::string anchor;
};
std::vector<TocAnchorEntry> tocAnchorsToResolve;
tocAnchorsToResolve.reserve(unresolvedCount);
tocBoundaries.reserve(totalEntries);
for (int i = startTocIndex; i < startTocIndex + totalEntries; i++) {
const auto entry = epub->getTocItem(i);
tocBoundaries.push_back({i, 0});
if (!entry.anchor.empty()) {
tocAnchorsToResolve.push_back({i, std::move(entry.anchor)});
}
}
// Single pass through on-disk anchors, matching against cached TOC anchors.
// Stop early once all TOC anchors are resolved.
// Header layout: ... | lutOffset (u32) | anchorMapOffset (u32) | paragraphLutOffset (u32) |
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
uint32_t anchorMapOffset;
serialization::readPod(f, anchorMapOffset);
if (anchorMapOffset != 0) {
f.seek(anchorMapOffset);
uint16_t count;
serialization::readPod(f, count);
std::string key;
for (uint16_t i = 0; i < count && unresolvedCount > 0; i++) {
uint16_t page;
serialization::readString(f, key);
serialization::readPod(f, page);
for (auto& tocAnchor : tocAnchorsToResolve) {
if (!tocAnchor.anchor.empty() && key == tocAnchor.anchor) {
tocBoundaries[tocAnchor.tocIndex - startTocIndex].startPage = page;
tocAnchor.anchor.clear(); // mark resolved
unresolvedCount--;
break;
}
}
}
}
// Defensive sort in case TOC entries are out of document order in a malformed epub
std::sort(tocBoundaries.begin(), tocBoundaries.end(),
[](const TocBoundary& a, const TocBoundary& b) { return a.startPage < b.startPage; });
}
int Section::getTocIndexForPage(const int page) const {
if (tocBoundaries.empty()) {
return epub->getTocIndexForSpineIndex(spineIndex);
}
// Find the first boundary AFTER page, then step back one
auto it = std::upper_bound(tocBoundaries.begin(), tocBoundaries.end(), static_cast<uint16_t>(page),
[](uint16_t page, const TocBoundary& boundary) { return page < boundary.startPage; });
if (it == tocBoundaries.begin()) {
return tocBoundaries[0].tocIndex;
}
return std::prev(it)->tocIndex;
}
std::optional<int> Section::getPageForTocIndex(const int tocIndex) const {
for (const auto& boundary : tocBoundaries) {
if (boundary.tocIndex == tocIndex) {
return boundary.startPage;
}
}
return std::nullopt;
}
std::optional<Section::TocPageRange> Section::getPageRangeForTocIndex(const int tocIndex) const {
for (size_t i = 0; i < tocBoundaries.size(); i++) {
if (tocBoundaries[i].tocIndex == tocIndex) {
const int startPage = tocBoundaries[i].startPage;
const int endPage = (i + 1 < tocBoundaries.size()) ? static_cast<int>(tocBoundaries[i + 1].startPage) : pageCount;
return TocPageRange{startPage, endPage};
}
}
return std::nullopt;
}
std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) const {
@@ -286,7 +522,7 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
}
const uint32_t fileSize = f.size();
f.seek(HEADER_SIZE - sizeof(uint32_t));
f.seek(HEADER_SIZE - sizeof(uint32_t) * 2);
uint32_t anchorMapOffset;
serialization::readPod(f, anchorMapOffset);
if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) {
@@ -311,3 +547,91 @@ std::optional<uint16_t> Section::getPageForAnchor(const std::string& anchor) con
f.close();
return std::nullopt;
}
std::optional<uint16_t> Section::getPageForParagraphIndex(const uint16_t pIndex) const {
FsFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
const uint32_t fileSize = f.size();
// Read paragraph LUT offset from end of header
f.seek(HEADER_SIZE - sizeof(uint32_t));
uint32_t paragraphLutOffset;
serialization::readPod(f, paragraphLutOffset);
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
f.close();
return std::nullopt;
}
f.seek(paragraphLutOffset);
uint16_t count;
serialization::readPod(f, count);
if (count == 0) {
f.close();
return std::nullopt;
}
// Validate that all entries fit within the file
const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * sizeof(uint16_t);
if (lutEnd > fileSize) {
f.close();
return std::nullopt;
}
// Find the first page whose paragraph index >= pIndex.
// Each entry stores the <p> index at the time that page was completed.
uint16_t resultPage = count - 1; // default to last page
for (uint16_t i = 0; i < count; i++) {
uint16_t pagePIdx;
serialization::readPod(f, pagePIdx);
if (pagePIdx >= pIndex) {
resultPage = i;
break;
}
}
f.close();
return resultPage;
}
std::optional<uint16_t> Section::getParagraphIndexForPage(const uint16_t page) const {
FsFile f;
if (!Storage.openFileForRead("SCT", filePath, f)) {
return std::nullopt;
}
const uint32_t fileSize = f.size();
f.seek(HEADER_SIZE - sizeof(uint32_t));
uint32_t paragraphLutOffset;
serialization::readPod(f, paragraphLutOffset);
if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) {
f.close();
return std::nullopt;
}
f.seek(paragraphLutOffset);
uint16_t count;
serialization::readPod(f, count);
if (count == 0 || page >= count) {
f.close();
return std::nullopt;
}
// Validate that the target entry fits within the file
const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * sizeof(uint16_t);
if (entryEnd > fileSize) {
f.close();
return std::nullopt;
}
// Seek to the entry for the requested page
f.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t));
uint16_t pIdx;
serialization::readPod(f, pIdx);
f.close();
return pIdx;
}
+36 -2
View File
@@ -3,6 +3,7 @@
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "Epub.h"
@@ -15,12 +16,22 @@ class Section {
GfxRenderer& renderer;
std::string filePath;
FsFile file;
std::vector<uint32_t> lut; // Cached page byte-offsets; loaded once, avoids per-page LUT seek
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
bool embeddedStyle, uint8_t imageRendering);
uint32_t onPageComplete(std::unique_ptr<Page> page);
struct TocBoundary {
int tocIndex = 0;
uint16_t startPage = 0;
};
std::vector<TocBoundary> tocBoundaries;
void buildTocBoundaries(const std::vector<std::pair<std::string, uint16_t>>& anchors);
void buildTocBoundariesFromFile(FsFile& f);
public:
uint16_t pageCount = 0;
int currentPage = 0;
@@ -34,12 +45,35 @@ class Section {
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering);
bool clearCache() const;
bool clearCache();
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle,
uint8_t imageRendering, const std::function<void()>& popupFn = nullptr);
uint8_t imageRendering, const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile();
// Given a page in this section, return the TOC index for that page.
int getTocIndexForPage(int page) const;
// Given a TOC index, return the start page in this section.
// Returns nullopt if the TOC index doesn't map to a boundary in this spine (e.g. belongs to a different spine).
std::optional<int> getPageForTocIndex(int tocIndex) const;
struct TocPageRange {
int startPage; // inclusive
int endPage; // exclusive
};
// Returns the page range [start, end) within this spine that belongs to the given TOC index.
std::optional<TocPageRange> getPageRangeForTocIndex(int tocIndex) const;
// Look up the page number for an anchor id from the section cache file.
std::optional<uint16_t> getPageForAnchor(const std::string& anchor) const;
// Look up the page number for a paragraph index (1-based, from XPath p[N]).
// Uses the per-page paragraph index LUT stored in the section cache.
// Returns nullopt if the paragraph LUT is not available (old cache format).
std::optional<uint16_t> getPageForParagraphIndex(uint16_t pIndex) const;
// Look up the paragraph index for a given page number.
// Returns the 1-based paragraph index of the last <p> element on or before the page.
// Returns nullopt if the paragraph LUT is not available (old cache format).
std::optional<uint16_t> getParagraphIndexForPage(uint16_t page) const;
};
+7
View File
@@ -22,6 +22,10 @@ struct BlockStyle {
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
// Set when this block was created by a <br> element. Used by startNewTextBlock to inject
// a full line-height gap when the <br> block stays empty (section-break use case).
// NOT propagated through getCombinedBlockStyle so it can't leak into sibling blocks.
bool fromBrElement = false;
// Combined horizontal insets (margin + padding)
[[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; }
@@ -58,6 +62,9 @@ struct BlockStyle {
combinedBlockStyle.alignment = alignment;
combinedBlockStyle.textAlignDefined = textAlignDefined;
}
// fromBrElement is never propagated — it is consumed by startNewTextBlock
// when the empty <br> block is merged with the following paragraph.
combinedBlockStyle.fromBrElement = false;
return combinedBlockStyle;
}
+330 -26
View File
@@ -7,6 +7,9 @@
#include <Utf8.h>
#include <expat.h>
#include <algorithm>
#include <cctype>
#include "../../Epub.h"
#include "../Page.h"
#include "../converters/ImageDecoderFactory.h"
@@ -20,7 +23,7 @@ constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
constexpr size_t PARSE_BUFFER_SIZE = 1024;
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote", "pre"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
const char* BOLD_TAGS[] = {"b", "strong"};
@@ -76,6 +79,28 @@ bool isTableStructuralTag(const char* name) {
return strcmp(name, "table") == 0 || strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || strcmp(name, "th") == 0;
}
// Calibre sometimes injects empty <p style="margin:0; border:0; height:0">...</p>
// spacers inside running prose. Keep them as paragraph boundaries, but ignore
// their inner text payload (usually NBSP) to avoid no-break-space glue artifacts.
bool isZeroHeightSpacerParagraph(const char* name, const std::string& styleAttr) {
if (strcmp(name, "p") != 0 || styleAttr.empty()) {
return false;
}
std::string normalized;
normalized.reserve(styleAttr.size());
for (const char ch : styleAttr) {
if (!isWhitespace(ch)) {
normalized.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(ch))));
}
}
const bool hasZeroHeight = normalized.find("height:0") != std::string::npos;
const bool hasZeroMargin = normalized.find("margin:0") != std::string::npos;
const bool hasZeroBorder = normalized.find("border:0") != std::string::npos;
return hasZeroHeight && hasZeroMargin && hasZeroBorder;
}
// Update effective bold/italic/underline based on block style and inline style stack
void ChapterHtmlSlimParser::updateEffectiveInlineStyle() {
// Start with block-level styles
@@ -133,18 +158,52 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) {
// Merge with existing block style to accumulate CSS styling from parent block elements.
// This handles cases like <div style="margin-bottom:2em"><h1>text</h1></div> where the
// div's margin should be preserved, even though it has no direct text content.
currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle));
BlockStyle incoming = blockStyle;
const bool brGapPending = currentTextBlock->getBlockStyle().fromBrElement;
if (brGapPending) {
// The empty block was created by a <br> section separator. Inject a full line of
// blank space before the following paragraph so the scene/section break is visible.
// This only fires when the <br> block stayed empty (i.e. no inline text was added).
const int16_t lineHeight = static_cast<int16_t>(renderer.getLineHeight(fontId) * lineCompression + 0.5f);
incoming.marginTop = static_cast<int16_t>(incoming.marginTop + lineHeight);
}
BlockStyle merged = currentTextBlock->getBlockStyle().getCombinedBlockStyle(incoming);
// Preserve only whether the current empty block still represents <br> separators.
// This lets consecutive <br> accumulate one line each without leaking the flag to real content blocks.
merged.fromBrElement = blockStyle.fromBrElement;
currentTextBlock->setBlockStyle(merged);
if (!pendingAnchorId.empty()) {
if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) {
if (currentPage && !currentPage->elements.empty()) {
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}
}
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
wordsExtractedInBlock = 0;
return;
}
makePages();
}
// Record deferred anchor after previous block is flushed
// If the pending anchor is a TOC chapter boundary, force a page break after the previous
// block is flushed so the chapter starts on a fresh page.
if (!pendingAnchorId.empty() &&
std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) {
if (currentPage && !currentPage->elements.empty()) {
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}
}
// Record deferred anchor after previous block is flushed (and any TOC page break)
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
@@ -172,7 +231,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else if (strcmp(atts[i], "style") == 0) {
styleAttr = atts[i + 1];
} else if (strcmp(atts[i], "id") == 0) {
// Defer recording until startNewTextBlock, after previous block is flushed to pages
// Defer both anchor recording and TOC page breaks until startNewTextBlock,
// after the previous block is flushed to pages via makePages().
self->pendingAnchorId = atts[i + 1];
}
}
@@ -186,10 +246,26 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// before tag-specific branches emit any content or metadata.
CssStyle cssStyle;
if (self->cssParser) {
cssStyle = self->cssParser->resolveStyle(name, classAttr);
{
std::string cacheKey(name);
cacheKey += '|';
cacheKey += classAttr;
auto it = self->cssStyleCache_.find(cacheKey);
if (it != self->cssStyleCache_.end()) {
cssStyle = it->second;
} else {
CssStyle resolved = self->cssParser->resolveStyle(name, classAttr);
if (resolved.defined.anySet())
cssStyle = self->cssStyleCache_.emplace(cacheKey, resolved).first->second;
else
cssStyle = resolved; // transient fallback: skip cache so future calls can re-resolve
}
}
if (!styleAttr.empty()) {
CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr);
cssStyle.applyOver(inlineStyle);
auto it = self->inlineStyleCache_.find(styleAttr);
if (it == self->inlineStyleCache_.end())
it = self->inlineStyleCache_.emplace(styleAttr, CssParser::parseInlineStyle(styleAttr)).first;
cssStyle.applyOver(it->second);
}
}
@@ -277,6 +353,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// imageRendering: 0=display, 1=placeholder (alt text only), 2=suppress entirely
if (self->imageRendering == 2) {
// Suppressing an image should not leak accumulated wrapper block spacing
// (e.g. figure/h1 margins) into the next text paragraph.
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
BlockStyle resetStyle;
resetStyle.textAlignDefined = true;
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
resetStyle.alignment = align;
self->currentTextBlock->setBlockStyle(resetStyle);
LOG_DBG("EHP", "Image suppressed: pending empty block style reset");
}
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
@@ -284,11 +372,30 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
// Skip image if CSS display:none
if (self->cssParser) {
CssStyle imgDisplayStyle = self->cssParser->resolveStyle("img", classAttr);
std::string imgCacheKey("img|");
imgCacheKey += classAttr;
auto imgIt = self->cssStyleCache_.find(imgCacheKey);
if (imgIt == self->cssStyleCache_.end())
imgIt = self->cssStyleCache_.emplace(imgCacheKey, self->cssParser->resolveStyle("img", classAttr)).first;
CssStyle imgDisplayStyle = imgIt->second;
if (!styleAttr.empty()) {
imgDisplayStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
auto it = self->inlineStyleCache_.find(styleAttr);
if (it == self->inlineStyleCache_.end())
it = self->inlineStyleCache_.emplace(styleAttr, CssParser::parseInlineStyle(styleAttr)).first;
imgDisplayStyle.applyOver(it->second);
}
if (imgDisplayStyle.hasDisplay() && imgDisplayStyle.display == CssDisplay::None) {
// CSS-hidden images should behave like suppressed images for spacing.
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
BlockStyle resetStyle;
resetStyle.textAlignDefined = true;
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
resetStyle.alignment = align;
self->currentTextBlock->setBlockStyle(resetStyle);
LOG_DBG("EHP", "Image hidden via CSS display:none: pending empty block style reset");
}
self->skipUntilDepth = self->depth;
self->depth += 1;
return;
@@ -331,10 +438,19 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
int displayWidth = 0;
int displayHeight = 0;
const float emSize = static_cast<float>(self->renderer.getFontAscenderSize(self->fontId));
CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{};
std::string imgCacheKey("img|");
imgCacheKey += classAttr;
auto imgStyleIt = self->cssParser ? self->cssStyleCache_.find(imgCacheKey) : self->cssStyleCache_.end();
if (self->cssParser && imgStyleIt == self->cssStyleCache_.end())
imgStyleIt =
self->cssStyleCache_.emplace(imgCacheKey, self->cssParser->resolveStyle("img", classAttr)).first;
CssStyle imgStyle = self->cssParser ? imgStyleIt->second : CssStyle{};
// Merge inline style (e.g. style="height: 2em") so it overrides stylesheet rules
if (!styleAttr.empty()) {
imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr));
auto it = self->inlineStyleCache_.find(styleAttr);
if (it == self->inlineStyleCache_.end())
it = self->inlineStyleCache_.emplace(styleAttr, CssParser::parseInlineStyle(styleAttr)).first;
imgStyle.applyOver(it->second);
}
const bool hasCssHeight = imgStyle.hasImageHeight();
const bool hasCssWidth = imgStyle.hasImageWidth();
@@ -424,9 +540,31 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->startNewTextBlock(parentBlockStyle);
}
// If the current text block is still empty, it may carry accumulated parent
// block spacing (e.g. div/figure/h1 wrappers). Apply that spacing around the
// image itself so it doesn't leak into the next text paragraph.
BlockStyle pendingImageBlockStyle;
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
pendingImageBlockStyle = self->currentTextBlock->getBlockStyle();
}
const int imageSpacingTop = std::max(0, static_cast<int>(pendingImageBlockStyle.marginTop)) +
std::max(0, static_cast<int>(pendingImageBlockStyle.paddingTop));
const int imageSpacingBottom = std::max(0, static_cast<int>(pendingImageBlockStyle.marginBottom)) +
std::max(0, static_cast<int>(pendingImageBlockStyle.paddingBottom));
const int totalImageHeightWithSpacing = imageSpacingTop + displayHeight + imageSpacingBottom;
LOG_DBG("EHP",
"Image layout prep: src=%s dims=%dx%d display=%dx%d y=%d spacing(top=%d,bottom=%d,total=%d)",
src.c_str(), dims.width, dims.height, displayWidth, displayHeight, self->currentPageNextY,
imageSpacingTop, imageSpacingBottom, totalImageHeightWithSpacing);
// Create page for image - only break if image won't fit remaining space
if (self->currentPage && !self->currentPage->elements.empty() &&
(self->currentPageNextY + displayHeight > self->viewportHeight)) {
(self->currentPageNextY + totalImageHeightWithSpacing > self->viewportHeight)) {
LOG_DBG("EHP", "Image page break: currentY=%d needed=%d viewportH=%d", self->currentPageNextY,
totalImageHeightWithSpacing, self->viewportHeight);
self->paragraphIndexPerPage.push_back(self->xpathParagraphIndex);
self->completePageFn(std::move(self->currentPage));
self->completedPageCount++;
self->currentPage.reset(new Page());
@@ -444,6 +582,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->currentPageNextY = 0;
}
self->currentPageNextY += imageSpacingTop;
// Create ImageBlock and add to page
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
if (!imageBlock) {
@@ -458,6 +598,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
self->currentPage->elements.push_back(pageImage);
self->currentPageNextY += displayHeight;
self->currentPageNextY += imageSpacingBottom;
LOG_DBG("EHP", "Image placed: x=%d y=%d w=%d h=%d nextY=%d", xPos, pageImage->yPos, displayWidth,
displayHeight, self->currentPageNextY);
// Reset empty pending block style after consuming spacing around the image.
// This prevents figure/header wrapper margins from being applied again to the
// next paragraph block.
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
BlockStyle resetStyle;
resetStyle.textAlignDefined = true;
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
resetStyle.alignment = align;
self->currentTextBlock->setBlockStyle(resetStyle);
LOG_DBG("EHP", "Image spacing consumed; pending empty block style reset for following text");
}
self->depth += 1;
return;
@@ -491,6 +649,19 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
// Track body element depth for paragraph index counting
if (strcmp(name, "body") == 0 && self->xpathBodyDepth < 0) {
self->xpathBodyDepth = self->depth;
}
// Count <p> sibling indices at body-child level. Must happen BEFORE the display:none
// check so that hidden <p> elements are still counted, matching ChapterXPathIndexer's
// counting (pure XML, no CSS). This ensures paragraph indices in the section cache LUT
// align with KOReader's crengine XPath indices.
if (self->xpathBodyDepth >= 0 && self->depth == self->xpathBodyDepth + 1 && strcmp(name, "p") == 0) {
self->xpathParagraphIndex++;
}
if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) {
// start skip
self->skipUntilDepth = self->depth;
@@ -554,10 +725,21 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
}
if (strcmp(name, "ul") == 0 || strcmp(name, "ol") == 0) {
self->listStack.push_back({self->depth, name[0] == 'o', 0});
}
const float emSize = static_cast<float>(self->renderer.getFontAscenderSize(self->fontId));
const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle(
cssStyle, emSize, static_cast<CssTextAlign>(self->paragraphAlignment), self->viewportWidth);
// Block/header boundaries must flush any buffered trailing word first.
// Otherwise tags like ..."item?"<p ...> can carry the final word into the next paragraph.
if (self->partWordBufferIndex > 0 && ((matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) ||
(matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) && strcmp(name, "br") != 0))) {
self->flushPartWordBuffer();
}
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
self->currentCssStyle = cssStyle;
auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center, self->viewportWidth);
@@ -569,19 +751,64 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
self->updateEffectiveInlineStyle();
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
if (isZeroHeightSpacerParagraph(name, styleAttr)) {
// Preserve paragraph break semantics for this <p>, but skip its inner text payload.
self->currentCssStyle = cssStyle;
auto blockStyle = userAlignmentBlockStyle;
if (self->embeddedStyle && cssStyle.hasTextAlign()) {
blockStyle.alignment = cssStyle.textAlign;
blockStyle.textAlignDefined = true;
}
self->startNewTextBlock(blockStyle);
self->updateEffectiveInlineStyle();
self->skipTextUntilDepth = self->depth;
self->depth += 1;
return;
}
if (strcmp(name, "br") == 0) {
if (self->partWordBufferIndex > 0) {
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
self->flushPartWordBuffer();
}
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
// Tag the new block so startNewTextBlock can inject a full line-height gap if
// the block remains empty (i.e. <br> is a section separator between paragraphs).
// If the block gets text added before the next block opens it becomes non-empty,
// goes through makePages() normally, and the flag has no effect (inline <br> case).
// Build a neutral <br> style that keeps inline alignment/indent context but avoids
// carrying cumulative margins from previous empty blocks (which can force spurious page breaks).
const BlockStyle& currentStyle = self->currentTextBlock->getBlockStyle();
BlockStyle brStyle;
brStyle.alignment = currentStyle.alignment;
brStyle.textAlignDefined = currentStyle.textAlignDefined;
brStyle.textIndent = currentStyle.textIndent;
brStyle.textIndentDefined = currentStyle.textIndentDefined;
brStyle.fromBrElement = true;
self->startNewTextBlock(brStyle);
} else {
self->currentCssStyle = cssStyle;
self->startNewTextBlock(userAlignmentBlockStyle);
auto blockStyle = userAlignmentBlockStyle;
if (self->embeddedStyle && cssStyle.hasTextAlign()) {
blockStyle.alignment = cssStyle.textAlign;
blockStyle.textAlignDefined = true;
}
self->startNewTextBlock(blockStyle);
self->updateEffectiveInlineStyle();
if (strcmp(name, "li") == 0) {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
char marker[12];
if (!self->listStack.empty() && self->listStack.back().isOrdered) {
self->listStack.back().counter += 1;
snprintf(marker, sizeof(marker), "%d.", self->listStack.back().counter);
} else {
strcpy(marker, "\xe2\x80\xa2");
}
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
} else if (strcmp(name, "pre") == 0) {
// Record depth so characterData can treat \n as a hard line break inside <pre>.
// depth has not been incremented yet here; it will be after startElement returns.
self->preUntilDepth = std::min(self->preUntilDepth, self->depth);
}
}
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
@@ -694,6 +921,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
return;
}
// Ignore character data inside synthetic zero-height spacer <p> tags.
if (self->skipTextUntilDepth < self->depth) {
return;
}
// Collect footnote link display text (for the number label)
// Skip whitespace and brackets to normalize noterefs like "[1]" → "1"
if (self->insideFootnoteLink) {
@@ -708,7 +940,38 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
}
for (int i = 0; i < len; i++) {
const unsigned char c = static_cast<unsigned char>(s[i]);
// Fast path for plain ASCII word characters (> 0x20 and < 0x80).
// This covers the vast majority of characters in Latin-script text.
// All multi-byte UTF-8 sequences start with a byte >= 0x80, so this
// path is safe to take without any further multi-byte checks.
if (c > 0x20 && c < 0x80) {
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
// Buffer is full — flush before appending. Pure ASCII means no
// partial multi-byte sequence can be at the boundary.
self->flushPartWordBuffer();
}
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
continue;
}
if (isWhitespace(s[i])) {
// Inside <pre>: treat \n as a hard line break.
if (s[i] == '\n' && self->preUntilDepth < self->depth) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
// Blank line: the current block is empty, but we still need to emit a visible
// empty line. Add a single space so the block is non-empty and makePages()
// will produce a line of the correct height instead of reusing the empty block.
if (self->currentTextBlock->isEmpty()) {
self->currentTextBlock->addWord(" ", EpdFontFamily::REGULAR);
}
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
self->nextWordContinues = false;
continue;
}
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
@@ -893,6 +1156,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->depth -= 1;
// Pop list entries whose ul/ol is now out of scope
while (!self->listStack.empty() && self->listStack.back().depth >= self->depth) {
self->listStack.pop_back();
}
// Closing a footnote link — create entry from collected text and href
if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) {
if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') {
@@ -913,6 +1181,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->skipUntilDepth = INT_MAX;
}
// Leaving zero-height spacer paragraph text-skip scope
if (self->skipTextUntilDepth == self->depth) {
self->skipTextUntilDepth = INT_MAX;
}
if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
self->nextWordContinues = false;
}
@@ -943,6 +1216,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->underlineUntilDepth = INT_MAX;
}
// Leaving pre tag
if (self->preUntilDepth == self->depth) {
self->preUntilDepth = INT_MAX;
}
// Pop from inline style stack if we pushed an entry at this depth
// This handles all inline elements: b, i, u, span, etc.
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
@@ -962,11 +1240,17 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
// Margins/padding are preserved so parent element spacing still accumulates correctly.
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
auto style = self->currentTextBlock->getBlockStyle();
style.textAlignDefined = false;
style.alignment = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
self->currentTextBlock->setBlockStyle(style);
// Keep alignment only when closing the <br> separator itself so subsequent text
// within the same block container stays aligned. Reset alignment when closing
// other block tags (e.g. div/p) to avoid leaking centered/right alignment globally.
const bool preserveForBrClose = style.fromBrElement && strcmp(name, "br") == 0;
if (!preserveForBrClose) {
style.textAlignDefined = false;
style.alignment = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
self->currentTextBlock->setBlockStyle(style);
}
}
}
}
@@ -999,9 +1283,13 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
// Get file size to decide whether to show indexing popup.
if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
popupFn();
const size_t totalFileSize = file.size();
size_t bytesRead = 0;
int lastReportedProgress = -1;
// Show initial progress popup for files above threshold.
if (progressFn && totalFileSize >= MIN_SIZE_FOR_POPUP) {
progressFn(0);
}
XML_SetUserData(parser, this);
@@ -1023,6 +1311,16 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
}
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
bytesRead += len;
// Report progress in 5% increments to limit e-ink refreshes.
if (progressFn && totalFileSize >= MIN_SIZE_FOR_POPUP) {
const int progress = static_cast<int>(bytesRead * 100 / totalFileSize);
if (progress / 5 > lastReportedProgress / 5) {
lastReportedProgress = progress;
progressFn(progress);
}
}
if (len == 0 && file.available() > 0) {
LOG_ERR("EHP", "File read error");
@@ -1047,7 +1345,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
} while (!done);
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime);
const uint32_t totalTimeMs = millis() - chapterStartTime;
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", totalTimeMs);
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
@@ -1062,6 +1361,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
anchorData.push_back({std::move(pendingAnchorId), static_cast<uint16_t>(completedPageCount)});
pendingAnchorId.clear();
}
paragraphIndexPerPage.push_back(xpathParagraphIndex);
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset();
@@ -1080,6 +1380,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
}
if (currentPageNextY + lineHeight > viewportHeight) {
paragraphIndexPerPage.push_back(xpathParagraphIndex);
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
@@ -1150,8 +1451,11 @@ void ChapterHtmlSlimParser::makePages() {
currentPageNextY += blockStyle.paddingBottom;
}
// Extra paragraph spacing if enabled (default behavior)
if (extraParagraphSpacing) {
// Extra paragraph spacing if enabled (default behavior).
// Suppressed between lines within a <pre> block so code/preformatted text is not
// double-spaced; the last line of the block is flushed after </pre> is closed and
// preUntilDepth has already been reset, so it still receives normal paragraph spacing.
if (extraParagraphSpacing && preUntilDepth == INT_MAX) {
currentPageNextY += lineHeight / 2;
}
}
+32 -4
View File
@@ -6,6 +6,7 @@
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include "../FootnoteEntry.h"
@@ -26,12 +27,14 @@ class ChapterHtmlSlimParser {
const std::string& filepath;
GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void()> popupFn; // Popup callback
std::function<void(int)> progressFn; // Progress callback (0-100)
int depth = 0;
int skipUntilDepth = INT_MAX;
int skipTextUntilDepth = INT_MAX; // skip character data inside synthetic zero-height spacer <p>
int boldUntilDepth = INT_MAX;
int italicUntilDepth = INT_MAX;
int underlineUntilDepth = INT_MAX;
int preUntilDepth = INT_MAX; // set when inside a <pre> element; enables \n → line-break handling
// buffer for building up words from characters, will auto break if longer than this
// leave one char at end for null pointer
char partWordBuffer[MAX_WORD_SIZE + 1] = {};
@@ -70,10 +73,26 @@ class ChapterHtmlSlimParser {
int tableRowIndex = 0;
int tableColIndex = 0;
struct ListEntry {
int depth;
bool isOrdered;
int counter;
};
std::vector<ListEntry> listStack;
// Anchor-to-page mapping: tracks which page each HTML id attribute lands on
int completedPageCount = 0;
std::vector<std::pair<std::string, uint16_t>> anchorData;
std::string pendingAnchorId; // deferred until after previous text block is flushed
std::vector<std::string> tocAnchors;
// Paragraph index tracking for XPath-to-page lookup table.
// Counts <p> sibling indices (1-based, matching XPath convention) during page building.
// Stored per page in the section cache so that XPath p[N] can be resolved to a page
// without reparsing, and current page can generate an XPath without reparsing.
uint16_t xpathParagraphIndex = 0; // current <p> sibling index (1-based)
int xpathBodyDepth = -1; // depth of the <body> element (-1 = not yet seen)
std::vector<uint16_t> paragraphIndexPerPage; // <p> index at each page completion
// Footnote link tracking
bool insideFootnoteLink = false;
@@ -84,6 +103,11 @@ class ChapterHtmlSlimParser {
std::vector<std::pair<int, FootnoteEntry>> pendingFootnotes; // <wordIndex, entry>
int wordsExtractedInBlock = 0;
// Per-chapter caches: resolveStyle and parseInlineStyle are called for every HTML element;
// caching by (tag|classAttr) and styleAttr avoids repeated string operations and hash lookups.
std::unordered_map<std::string, CssStyle> cssStyleCache_;
std::unordered_map<std::string, CssStyle> inlineStyleCache_;
void updateEffectiveInlineStyle();
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer();
@@ -102,7 +126,9 @@ class ChapterHtmlSlimParser {
const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const bool embeddedStyle, const std::string& contentBase,
const std::string& imageBasePath, const uint8_t imageRendering = 0,
const std::function<void()>& popupFn = nullptr, const CssParser* cssParser = nullptr)
std::vector<std::string> tocAnchors = {},
const std::function<void(int)>& progressFn = nullptr,
const CssParser* cssParser = nullptr)
: epub(epub),
filepath(filepath),
@@ -115,15 +141,17 @@ class ChapterHtmlSlimParser {
viewportHeight(viewportHeight),
hyphenationEnabled(hyphenationEnabled),
completePageFn(completePageFn),
popupFn(popupFn),
progressFn(progressFn),
cssParser(cssParser),
embeddedStyle(embeddedStyle),
imageRendering(imageRendering),
contentBase(contentBase),
imageBasePath(imageBasePath) {}
imageBasePath(imageBasePath),
tocAnchors(std::move(tocAnchors)) {}
~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line);
const std::vector<std::pair<std::string, uint16_t>>& getAnchors() const { return anchorData; }
const std::vector<uint16_t>& getParagraphIndexPerPage() const { return paragraphIndexPerPage; }
};
+173 -7
View File
@@ -10,6 +10,87 @@ namespace {
constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
constexpr char MEDIA_TYPE_CSS[] = "text/css";
constexpr char itemCacheFile[] = "/.items.bin";
constexpr size_t MAX_DESCRIPTION_LENGTH = 1024;
// Strip HTML tags and collapse whitespace from a description string.
// Expat already decodes XML entities (&lt; → <), so we see raw angle brackets.
std::string stripHtml(const std::string& html) {
std::string result;
result.reserve(html.size());
bool inTag = false;
for (size_t i = 0; i < html.size(); ++i) {
const char c = html[i];
if (c == '<') {
// Only treat as a tag if immediately followed (no space skip) by a tag-like character
const size_t j = i + 1;
if (j < html.size() &&
(isalpha(static_cast<unsigned char>(html[j])) || html[j] == '/' || html[j] == '!' || html[j] == '?')) {
inTag = true;
// Ensure words don't merge when a tag is removed
if (!result.empty() && result.back() != ' ') result += ' ';
} else {
result += c;
}
} else if (c == '>') {
if (inTag) {
inTag = false;
} else {
result += c;
}
} else if (!inTag) {
if (c == '&') {
// Decode common HTML entities not covered by Expat
if (html.compare(i, 6, "&nbsp;") == 0) {
result += ' ';
i += 5;
} else if (html.compare(i, 7, "&ndash;") == 0) {
result += '-';
i += 6;
} else if (html.compare(i, 7, "&mdash;") == 0) {
result += '-';
i += 6;
} else if (html.compare(i, 8, "&hellip;") == 0) {
result += "...";
i += 7;
} else
result += c;
} else if (c == '\n' || c == '\r' || c == '\t') {
if (!result.empty() && result.back() != ' ') result += ' ';
} else {
result += c;
}
}
}
// Collapse consecutive spaces and trim trailing whitespace
std::string out;
out.reserve(result.size());
bool lastSpace = false;
for (char c : result) {
if (c == ' ') {
if (!lastSpace && !out.empty()) {
out += ' ';
lastSpace = true;
}
} else {
out += c;
lastSpace = false;
}
}
while (!out.empty() && out.back() == ' ') out.pop_back();
return out;
}
std::string trim(const std::string& in) {
size_t start = 0;
while (start < in.size() && (in[start] == ' ' || in[start] == '\n' || in[start] == '\r' || in[start] == '\t')) {
++start;
}
size_t end = in.size();
while (end > start && (in[end - 1] == ' ' || in[end - 1] == '\n' || in[end - 1] == '\r' || in[end - 1] == '\t')) {
--end;
}
return in.substr(start, end - start);
}
} // namespace
bool ContentOpfParser::setup() {
@@ -117,6 +198,14 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return;
}
if (self->state == IN_METADATA && strcmp(name, "dc:description") == 0) {
// Only capture the first dc:description element; subsequent ones are alternate/localized variants
if (self->description.empty()) {
self->state = IN_BOOK_DESCRIPTION;
}
return;
}
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_MANIFEST;
if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
@@ -153,20 +242,55 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
}
if (self->state == IN_METADATA && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) {
bool isCover = false;
std::string coverItemId;
const char* metaName = nullptr;
const char* metaContent = nullptr;
const char* metaProperty = nullptr;
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "name") == 0 && strcmp(atts[i + 1], "cover") == 0) {
isCover = true;
if (strcmp(atts[i], "name") == 0) {
metaName = atts[i + 1];
} else if (strcmp(atts[i], "content") == 0) {
coverItemId = atts[i + 1];
metaContent = atts[i + 1];
} else if (strcmp(atts[i], "property") == 0) {
metaProperty = atts[i + 1];
}
}
if (isCover) {
self->coverItemId = coverItemId;
if (metaName && metaContent) {
if (strcmp(metaName, "cover") == 0) {
self->coverItemId = metaContent;
} else if (strcmp(metaName, "calibre:series") == 0 && self->series.empty()) {
self->series = trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
} else if (strcmp(metaName, "calibre:series_index") == 0 && self->seriesIndex.empty()) {
self->seriesIndex =
trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
}
}
// EPUB 3 collection metadata:
// <meta property="belongs-to-collection">Series Name</meta> (character data)
// <meta property="belongs-to-collection" content="Series Name"/> (attribute, some generators)
// <meta property="group-position">1</meta>
if (metaProperty) {
if (strcmp(metaProperty, "belongs-to-collection") == 0 && self->series.empty()) {
if (metaContent) {
self->series = trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
} else {
self->state = IN_BOOK_SERIES;
return;
}
}
if (strcmp(metaProperty, "group-position") == 0 && self->seriesIndex.empty()) {
if (metaContent) {
self->seriesIndex =
trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH})));
} else {
self->state = IN_BOOK_SERIES_INDEX;
return;
}
}
}
return;
}
@@ -338,6 +462,30 @@ void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s,
self->language.append(s, len);
return;
}
if (self->state == IN_BOOK_DESCRIPTION) {
if (self->description.size() < MAX_DESCRIPTION_LENGTH) {
const size_t remaining = MAX_DESCRIPTION_LENGTH - self->description.size();
self->description.append(s, std::min(static_cast<size_t>(len), remaining));
}
return;
}
if (self->state == IN_BOOK_SERIES) {
if (self->series.size() < MAX_DESCRIPTION_LENGTH) {
const size_t remaining = MAX_DESCRIPTION_LENGTH - self->series.size();
self->series.append(s, std::min(static_cast<size_t>(len), remaining));
}
return;
}
if (self->state == IN_BOOK_SERIES_INDEX) {
if (self->seriesIndex.size() < MAX_DESCRIPTION_LENGTH) {
const size_t remaining = MAX_DESCRIPTION_LENGTH - self->seriesIndex.size();
self->seriesIndex.append(s, std::min(static_cast<size_t>(len), remaining));
}
return;
}
}
void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) {
@@ -377,6 +525,24 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name)
return;
}
if (self->state == IN_BOOK_DESCRIPTION && strcmp(name, "dc:description") == 0) {
self->description = stripHtml(self->description);
self->state = IN_METADATA;
return;
}
if (self->state == IN_BOOK_SERIES && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) {
self->series = trim(self->series);
self->state = IN_METADATA;
return;
}
if (self->state == IN_BOOK_SERIES_INDEX && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) {
self->seriesIndex = trim(self->seriesIndex);
self->state = IN_METADATA;
return;
}
if (self->state == IN_METADATA && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) {
self->state = IN_PACKAGE;
return;
+6
View File
@@ -17,6 +17,9 @@ class ContentOpfParser final : public Print {
IN_BOOK_TITLE,
IN_BOOK_AUTHOR,
IN_BOOK_LANGUAGE,
IN_BOOK_DESCRIPTION,
IN_BOOK_SERIES,
IN_BOOK_SERIES_INDEX,
IN_MANIFEST,
IN_SPINE,
IN_GUIDE,
@@ -60,6 +63,9 @@ class ContentOpfParser final : public Print {
std::string title;
std::string author;
std::string language;
std::string description;
std::string series;
std::string seriesIndex;
std::string tocNcxPath;
std::string tocNavPath; // EPUB 3 nav document path
std::string coverItemHref;
+864 -33
View File
@@ -74,6 +74,512 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation,
enum class TextRotation { None, Rotated90CW };
// =============================================================================
// Fast-path glyph rendering helpers (1-bit BW fonts, TextRotation::None)
// =============================================================================
//
// OVERVIEW
// --------
// The legacy path called drawPixel() once per set glyph pixel. drawPixel()
// invokes rotateCoordinates() (a switch), does a bounds check, logs on OOB,
// then writes one bit. For a typical 10×14 UI glyph that is ~100 calls.
//
// This fast path eliminates drawPixel() entirely by writing directly to the
// framebuffer in up to 8-pixel chunks via writeRowBits().
//
// FRAMEBUFFER LAYOUT
// ------------------
// 1 bpp, MSB-first, DISPLAY_WIDTH (800) pixels per row stored in
// DISPLAY_WIDTH_BYTES (100) bytes. Bit 7 of byte 0 = leftmost pixel of
// row 0. "Physical row" phyY occupies bytes [phyY*100 .. phyY*100+99].
// A set bit (1) is WHITE; a cleared bit (0) is BLACK.
//
// LANDSCAPE ORIENTATIONS (2.53.1× speedup vs legacy)
// -------------------------------------------------------
// phyX and phyY are both linear functions of glyphX/glyphY in these modes,
// so each glyph row maps directly to a physical framebuffer row.
//
// LandscapeCounterClockwise: phyX = screenXBase+glyphX, phyY = screenYBase+glyphY
// LandscapeClockwise: phyX = W-1-screenXBase-glyphX, phyY = H-1-screenYBase-glyphY
//
// Strategy: outer loop over glyphY (one physical row per iteration), inner
// loop reads 8-pixel chunks of that glyph row with bitmapExtract() and writes
// them with writeRowBits(). Bitmap access is purely sequential — fastest.
// LandscapeClockwise iterates glyph chunks right-to-left and applies
// reverseBits8() to flip horizontal direction.
//
// PORTRAIT ORIENTATIONS (~2× speedup vs legacy)
// -----------------------------------------------
// Portrait (90° CW panel rotation):
// phyX = screenYBase+glyphY, phyY = H-1-screenXBase-glyphX
// PortraitInverted (90° CCW panel rotation):
// phyX = W-1-screenYBase-glyphY, phyY = screenXBase+glyphX
//
// Here glyph COLUMNS map to physical rows. Naively iterating column-by-column
// reads the bitmap with stride glyphWidth — cache-unfriendly and one bit at a
// time. Instead we use an 8×8 bit-matrix transpose:
//
// For each 8-row × 8-column glyph block:
// 1. Read 8 consecutive glyph rows (sequential bitmap access) into the
// top 8 bytes of a uint64_t (one bitmapExtract per row).
// 2. Call transpose8x8() — an O(log 8) butterfly transform — to swap
// the role of rows and columns in 3 passes of XOR-masking.
// 3. The resulting uint64_t holds 8 column bytes: byte k contains the
// bits for glyph column glyphX+k, one per physical row, MSB-aligned.
// 4. Write each column byte with writeRowBits() to its physical row.
//
// For PortraitInverted the glyph rows are packed in reverse order (last row
// at MSB of the uint64_t) before transposing. This ensures the post-transpose
// column bytes are already correctly ordered (MSB = leftmost phyX) without any
// per-column bit-reversal step.
//
// PARAMETERS
// ----------
// screenXBase = cursorX + glyph->left (logical X of glyph pixel [0,0])
// screenYBase = cursorY - glyph->top (logical Y of glyph pixel [0,0])
// Reverse all 8 bits of a byte (bit 7 ↔ bit 0).
static inline uint8_t reverseBits8(uint8_t b) {
b = (b & 0xF0) >> 4 | (b & 0x0F) << 4;
b = (b & 0xCC) >> 2 | (b & 0x33) << 2;
b = (b & 0xAA) >> 1 | (b & 0x55) << 1;
return b;
}
// Transpose an 8×8 bit matrix packed into a uint64_t.
//
// Input layout (row-major, row 0 at MSB):
// bit (63 - 8*r - c) = matrix[r][c] (r=row 0..7, c=col 0..7)
//
// After transposition:
// bit (63 - 8*c - r) = matrix[r][c]
// i.e. byte k = bits [63-8k .. 56-8k] holds column k, MSB = row 0.
//
// Uses the classic 3-pass butterfly (Warren, "Hacker's Delight" §7-3):
// pass 1 swaps adjacent bit-pairs across a stride of 7 (nibble level),
// pass 2 swaps across stride 14 (byte level),
// pass 3 swaps across stride 28 (half-word level).
static inline uint64_t transpose8x8(uint64_t x) {
uint64_t t;
t = (x ^ (x >> 7)) & 0x00AA00AA00AA00AAULL;
x ^= t ^ (t << 7);
t = (x ^ (x >> 14)) & 0x0000CCCC0000CCCCULL;
x ^= t ^ (t << 14);
t = (x ^ (x >> 28)) & 0x00000000F0F0F0F0ULL;
x ^= t ^ (t << 28);
return x;
}
// Extract up to 8 bits from a 1-bit MSB-first packed bitmap starting at bit
// position 'bitPos'. Returns them MSB-aligned (bit 7 = first extracted bit);
// the lower (8-count) bits are zeroed.
// All 'count' bits must lie within the valid bitmap byte range.
static inline uint8_t bitmapExtract(const uint8_t* bitmap, const int bitPos, const int count) {
const int byteIdx = bitPos >> 3;
const int bitOff = bitPos & 7;
uint8_t result;
if (bitOff == 0) {
result = bitmap[byteIdx];
} else if (count <= 8 - bitOff) {
result = bitmap[byteIdx] << bitOff; // all bits inside first byte
} else {
result = (uint8_t)(((uint16_t)bitmap[byteIdx] << 8 | bitmap[byteIdx + 1]) >> (8 - bitOff));
}
if (count < 8) result &= static_cast<uint8_t>(0xFF << (8 - count));
return result;
}
// ---------------------------------------------------------------------------
// Fast glyph render pipeline
// ---------------------------------------------------------------------------
// Both 1-bit (BW) and 2-bit (antialiased) paths share the same structure:
//
// gather → [reindex] → scatter
//
// The glyph bitmap is a row-major 2D tensor [glyphHeight][glyphWidth].
// The framebuffer is a row-major 2D tensor [DISPLAY_HEIGHT][DISPLAY_WIDTH_BYTES]
// (1 bpp) with a fixed row stride of DISPLAY_WIDTH_BYTES bytes.
//
// Non-rotated (Landscape): glyph rows map 1-to-1 to framebuffer rows.
// Reindex is a no-op; the pipeline is a tight per-row gather+scatter loop.
//
// Rotated 90° (Portrait): glyph rows become framebuffer columns.
// A row↔column axis swap (reindex) is required before scattering.
//
// 1-bit pipeline
// gather : extractGlyphBlock reads an 8×8 glyph tile into a
// contiguous uint64_t block
// (≈ glyphTensor[tile].contiguous())
// reindex : transpose8x8 swaps row↔column axes in the uint64_t;
// pure index transform, no data movement
// scatter : scatterBlockToFrameBuffer → writeRowBits
// writes each column-byte to its row
//
// 2-bit pipeline (why it differs)
// The glyph stores 4 gray levels (03). Rendering reduces these to a 1-bit
// draw/skip decision via a render-mode threshold. That reduction is
// information-lossy, so gather and threshold cannot be separated — there is
// no contiguous 2-bit block to transpose. The two steps are fused:
//
// gather+threshold : build2BitRowMask Landscape — samples along glyph X
// build2BitColMask Portrait — samples along glyph Y
// both return a 1-bit mask ready for writeRowBits
// scatter : writeRowBits same atom as the 1-bit path
// ---------------------------------------------------------------------------
// Scatter atom: merges 8 MSB-aligned bits into the framebuffer row at physical bit offset phyBitPos.
// Shared by both pipelines (1-bit: via scatterBlockToFrameBuffer; 2-bit: called directly).
// bits — MSB-aligned; bit 7 = pixel at phyBitPos, lower (8-count) bits are zero.
// phyBitPos — physical X of the MSB pixel; may be negative for left-edge partial chunks.
// pixelState true → black (clear bits to 0), false → white (set bits to 1).
static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const uint8_t bits, const bool pixelState) {
uint8_t effectiveBits = bits;
int byteIdx;
int shift;
if (phyBitPos < 0) {
// Chunk starts off-screen left: clip by shifting out the off-screen MSBs.
// bits is MSB-aligned, so (bits << neg) discards the neg off-screen pixels
// and leaves the on-screen pixels MSB-aligned starting at physical X=0.
const int neg = -phyBitPos;
if (neg >= 8) return; // entire chunk is off-screen left
effectiveBits = bits << neg;
byteIdx = 0;
shift = 0;
} else {
byteIdx = phyBitPos >> 3;
shift = phyBitPos & 7;
}
if (pixelState) {
row[byteIdx] &= ~(effectiveBits >> shift);
if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES)
row[byteIdx + 1] &= ~(uint8_t)(effectiveBits << (8 - shift));
} else {
row[byteIdx] |= (effectiveBits >> shift);
if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES)
row[byteIdx + 1] |= (uint8_t)(effectiveBits << (8 - shift));
}
}
// 1-bit pipeline step 1 — gather: reads an up-to-8×8 tile from the glyph tensor
// ([glyphHeight][glyphWidth], 1 bpp, row stride = glyphWidth bits) into a contiguous uint64_t.
// Equivalent to glyphTensor[glyphY:+rowCount, glyphX:+colCount].contiguous().
// Byte 7 = first source row (MSB-aligned). reverseRows implements a negative-stride gather along Y
// (reads rows bottom-to-top), needed for PortraitInverted.
// Full pipeline: extractGlyphBlock (gather) → transpose8x8 (reindex) → scatterBlockToFrameBuffer (scatter).
static inline uint64_t extractGlyphBlock(const uint8_t* const bitmap, const int stride, const int glyphX,
const int glyphY, const int rowCount, const int colCount,
const bool reverseRows) {
uint64_t pack = 0;
int bitStart = glyphY * stride + glyphX;
for (int n = 0; n < rowCount; n++, bitStart += stride) {
const int slot = reverseRows ? (rowCount - 1 - n) : n;
pack |= static_cast<uint64_t>(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * slot);
}
return pack;
}
// 1-bit pipeline step 3 — scatter: writes column-bytes of the transposed block into framebuffer rows.
// The framebuffer is a 2D tensor [DISPLAY_HEIGHT][DISPLAY_WIDTH_BYTES] with non-unit row stride;
// phyYStride=±1 selects the traversal direction along Y (positive = top-to-bottom, negative = inverted).
// Each column k maps to row (phyYBase + k*phyYStride) via writeRowBits.
static inline void scatterBlockToFrameBuffer(uint8_t* const frameBuffer, const uint64_t pack, const int colCount,
const int phyYBase, const int phyYStride, const int phyBitPos,
const bool pixelState) {
for (int k = 0; k < colCount; k++) {
const uint8_t cols_k = static_cast<uint8_t>(pack >> (56 - 8 * k));
if (cols_k == 0) continue;
const int phyY = phyYBase + k * phyYStride;
if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState);
}
}
static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth,
const int glyphHeight, const int screenXBase, const int screenYBase,
const bool pixelState, const GfxRenderer::Orientation orientation) {
switch (orientation) {
case GfxRenderer::LandscapeCounterClockwise: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = screenYBase + glyphY;
if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
const int rowBitStart = glyphY * glyphWidth;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int count = std::min(8, glyphWidth - glyphX);
const uint8_t gbyte = bitmapExtract(bitmap, rowBitStart + glyphX, count);
if (gbyte == 0) continue;
const int phyBitPos = screenXBase + glyphX;
if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue;
writeRowBits(row, phyBitPos, gbyte, pixelState);
}
}
break;
}
case GfxRenderer::LandscapeClockwise: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY);
if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
const int rowBitStart = glyphY * glyphWidth;
for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) {
const int chunkStart = std::max(0, chunkEnd - 7);
const int count = chunkEnd - chunkStart + 1;
const uint8_t gbyte_fwd = bitmapExtract(bitmap, rowBitStart + chunkStart, count);
const uint8_t gbyte = reverseBits8(gbyte_fwd >> (8 - count));
if (gbyte == 0) continue;
const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd;
if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue;
writeRowBits(row, phyBitPos, gbyte, pixelState);
}
}
break;
}
case GfxRenderer::Portrait: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) {
const int rowCount = std::min(8, glyphHeight - glyphY);
const int phyBitPos = screenYBase + glyphY;
if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int colCount = std::min(8, glyphWidth - glyphX);
const uint64_t pack =
transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, false));
scatterBlockToFrameBuffer(frameBuffer, pack, colCount, HalDisplay::DISPLAY_HEIGHT - 1 - screenXBase - glyphX,
-1, phyBitPos, pixelState);
}
}
break;
}
case GfxRenderer::PortraitInverted: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) {
const int rowCount = std::min(8, glyphHeight - glyphY);
const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + rowCount - 1);
if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int colCount = std::min(8, glyphWidth - glyphX);
const uint64_t pack =
transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, true));
scatterBlockToFrameBuffer(frameBuffer, pack, colCount, screenXBase + glyphX, 1, phyBitPos, pixelState);
}
}
break;
}
}
}
// Read one pixel from a tightly-packed 2-bit-per-pixel glyph bitmap.
// The bitmap is a row-major tensor [glyphHeight][glyphWidth] with no row padding;
// its pixel-row stride equals glyphWidth. pixelPosition = row * glyphWidth + col.
// Returns the raw font value: 0=white, 1=light-gray, 2=dark-gray, 3=black.
static inline uint8_t get2BitPixel(const uint8_t* const bitmap, const int pixelPosition) {
return (bitmap[pixelPosition >> 2] >> ((3 - (pixelPosition & 3)) * 2)) & 0x3;
}
// Convenience overload using explicit row/col/stride (tensor element access).
static inline uint8_t get2BitPixel(const uint8_t* const bitmap, const int stride, const int row, const int col) {
return get2BitPixel(bitmap, row * stride + col);
}
template <GfxRenderer::RenderMode mode>
static constexpr uint8_t drawMaskFor2BitMode() {
if constexpr (mode == GfxRenderer::BW)
return 0x0E; // draw raw {1,2,3}
else if constexpr (mode == GfxRenderer::GRAYSCALE_MSB)
return 0x06; // draw raw {1,2}
else
return 0x04; // GRAYSCALE_LSB: draw raw {2}
}
// 2-bit pipeline — fused gather+threshold (X axis): the 2-bit analog of extractGlyphBlock, but
// gather and threshold are collapsed into one pass. The threshold (2-bit raw value → 1-bit on/off)
// is information-lossy, so no contiguous 2-bit intermediate block can be formed mid-pipeline.
// The resulting 1-bit mask feeds writeRowBits directly (scatter). build2BitColMask is the Y-axis counterpart.
template <GfxRenderer::RenderMode mode>
static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, const int glyphXStartOrEnd,
const int count, const bool reverseXInChunk) {
// drawMask uses raw 2-bit glyph values directly from font bitmaps:
// raw 0=white, 1=light gray, 2=dark gray, 3=black.
// Bit N set means: draw/update when raw==N.
// Compile-time constant lets the compiler reduce (drawMask >> raw) & 1 to a single comparison.
constexpr uint8_t drawMask = drawMaskFor2BitMode<mode>();
uint8_t mask = 0;
for (int i = 0; i < count; i++) {
const int logicalX = reverseXInChunk ? (glyphXStartOrEnd - i) : (glyphXStartOrEnd + i);
const uint8_t raw = get2BitPixel(bitmap, rowStartPixel + logicalX);
if ((drawMask >> raw) & 0x01) mask |= static_cast<uint8_t>(1u << (7 - i));
}
return mask;
}
// Fast-path 2-bit mask builder for 8 byte-aligned pixels.
//
// The 2-bit glyph bitmap stores 4 pixels per byte, MSB-first:
// byte b = [p0.msb p0.lsb p1.msb p1.lsb p2.msb p2.lsb p3.msb p3.lsb]
//
// For each render mode the draw decision collapses to a two-bit boolean:
// BW (draw if raw ≠ 0): msb | lsb
// GRAYSCALE_MSB (draw if raw ∈ {1,2}): msb ^ lsb
// GRAYSCALE_LSB (draw if raw == 2): msb & ~lsb
//
// Derivation for one byte:
// msb_bits = b & 0xAA → bits 7,5,3,1 hold p0.msb … p3.msb; bits 6,4,2,0 = 0
// lsb_bits = (b & 0x55) << 1 → same positions hold p0.lsb … p3.lsb
// draw_bits = msb_bits OP lsb_bits → bits 7,5,3,1 are the per-pixel draw flags
//
// compact4: squeezes those 4 draw flags from bit positions 7,5,3,1
// into the top nibble (bits 7,6,5,4 → pixels 0,1,2,3).
//
// Two bytes b0 (pixels 03) and b1 (pixels 47) are combined:
// mask = compact4(draw(b0)) | (compact4(draw(b1)) >> 4)
//
// This avoids the 8-iteration per-pixel loop in build2BitRowMask and
// processes the full 8-pixel chunk in ~16 ALU ops instead of ~56.
// The caller is responsible for only calling this when pixelStart is
// 4-pixel (1-byte) aligned (pixelStart & 3 == 0) and count == 8.
template <GfxRenderer::RenderMode mode>
static inline uint8_t build2BitRowMaskFromTwoBytes(const uint8_t b0, const uint8_t b1) {
const uint8_t msb0 = b0 & 0xAA;
const uint8_t lsb0 = (b0 & 0x55) << 1;
const uint8_t msb1 = b1 & 0xAA;
const uint8_t lsb1 = (b1 & 0x55) << 1;
uint8_t draw0, draw1;
if constexpr (mode == GfxRenderer::BW) {
draw0 = msb0 | lsb0;
draw1 = msb1 | lsb1;
} else if constexpr (mode == GfxRenderer::GRAYSCALE_MSB) {
draw0 = msb0 ^ lsb0;
draw1 = msb1 ^ lsb1;
} else { // GRAYSCALE_LSB
draw0 = msb0 & ~lsb0;
draw1 = msb1 & ~lsb1;
}
// Compact each nibble's draw flags from bit positions 7,5,3,1 → 7,6,5,4.
auto compact4 = [](const uint8_t d) -> uint8_t {
return (d & 0x80) | ((d & 0x20) << 1) | ((d & 0x08) << 2) | ((d & 0x02) << 3);
};
return compact4(draw0) | (compact4(draw1) >> 4);
}
// 2-bit pipeline — fused gather+threshold (Y axis): column-direction counterpart to build2BitRowMask.
// Samples count pixels down glyph column glyphX starting at row glyphYStart; reverseRows implements
// a negative-stride view along Y (reads bottom-to-top), needed for PortraitInverted.
template <GfxRenderer::RenderMode mode>
static inline uint8_t build2BitColMask(const uint8_t* const bitmap, const int glyphWidth, const int glyphX,
const int glyphYStart, const int count, const bool reverseRows) {
constexpr uint8_t drawMask = drawMaskFor2BitMode<mode>();
uint8_t mask = 0;
for (int i = 0; i < count; i++) {
const int row = reverseRows ? (glyphYStart + count - 1 - i) : (glyphYStart + i);
const uint8_t raw = get2BitPixel(bitmap, glyphWidth, row, glyphX);
if ((drawMask >> raw) & 0x01) mask |= static_cast<uint8_t>(1u << (7 - i));
}
return mask;
}
// Shared body for Portrait and PortraitInverted 2-bit rendering.
// inverted=false → Portrait (phyY counts down, phyBitPos counts up).
// inverted=true → PortraitInverted (phyY counts up, phyBitPos counts down).
// Both template params are compile-time constants; all ternaries fold away.
template <GfxRenderer::RenderMode mode, bool inverted>
static void renderGlyphFast2BitPortrait(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth,
const int glyphHeight, const int screenXBase, const int screenYBase,
const bool writeState) {
for (int glyphX = 0; glyphX < glyphWidth; glyphX++) {
const int phyY = inverted ? (screenXBase + glyphX) : (HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX));
if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) {
const int count = std::min(8, glyphHeight - glyphY);
const uint8_t mask = build2BitColMask<mode>(bitmap, glyphWidth, glyphX, glyphY, count, inverted);
if (mask == 0) continue;
const int phyBitPos =
inverted ? (HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + count - 1)) : (screenYBase + glyphY);
if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue;
writeRowBits(row, phyBitPos, mask, writeState);
}
}
}
template <GfxRenderer::RenderMode mode>
static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth,
const int glyphHeight, const int screenXBase, const int screenYBase,
const bool pixelState, const GfxRenderer::Orientation orientation) {
// Non-rotated text fast path for 2-bit glyphs. Writes compact masks directly to framebuffer rows.
// TextRotation::Rotated90CW keeps the legacy per-pixel fallback path for safety and readability.
const bool writeState = (mode == GfxRenderer::BW) ? pixelState : false;
switch (orientation) {
case GfxRenderer::LandscapeCounterClockwise: {
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = screenYBase + glyphY;
if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
const int rowStartPixel = glyphY * glyphWidth;
for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) {
const int count = std::min(8, glyphWidth - glyphX);
const int pixelStart = rowStartPixel + glyphX;
uint8_t mask;
if (count == 8 && (pixelStart & 3) == 0) {
const int srcByteIdx = pixelStart >> 2;
mask = build2BitRowMaskFromTwoBytes<mode>(bitmap[srcByteIdx], bitmap[srcByteIdx + 1]);
} else {
mask = build2BitRowMask<mode>(bitmap, rowStartPixel, glyphX, count, false);
}
if (mask == 0) continue;
const int phyBitPos = screenXBase + glyphX;
if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue;
writeRowBits(row, phyBitPos, mask, writeState);
}
}
break;
}
case GfxRenderer::LandscapeClockwise: {
// Row-outer/chunk-inner: framebuffer rows are written at stride -DISPLAY_WIDTH_BYTES
// (phyY decreases as glyphY increases). Keeping row-outer preserves sequential access
// within each row, which is more cache-friendly than the chunk-outer alternative.
for (int glyphY = 0; glyphY < glyphHeight; glyphY++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY);
if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
const int rowStartPixel = glyphY * glyphWidth;
for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) {
const int chunkStart = std::max(0, chunkEnd - 7);
const int count = chunkEnd - chunkStart + 1;
const int pixelStart = rowStartPixel + chunkStart;
uint8_t mask;
if (count == 8 && (pixelStart & 3) == 0) {
const int srcByteIdx = pixelStart >> 2;
mask = reverseBits8(build2BitRowMaskFromTwoBytes<mode>(bitmap[srcByteIdx], bitmap[srcByteIdx + 1]));
} else {
mask = build2BitRowMask<mode>(bitmap, rowStartPixel, chunkEnd, count, true);
}
if (mask == 0) continue;
const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd;
if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue;
writeRowBits(row, phyBitPos, mask, writeState);
}
}
break;
}
case GfxRenderer::Portrait:
renderGlyphFast2BitPortrait<mode, false>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, screenYBase,
writeState);
break;
case GfxRenderer::PortraitInverted:
renderGlyphFast2BitPortrait<mode, true>(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, screenYBase,
writeState);
break;
}
}
// Shared glyph rendering logic for normal and rotated text.
// Coordinate mapping and cursor advance direction are selected at compile time via the template parameter.
template <TextRotation rotation>
@@ -108,6 +614,27 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
}
if (is2Bit) {
if constexpr (rotation == TextRotation::None) {
// Fast path for normal text orientation. Handles all device orientations via renderGlyphFast2Bit.
// Dispatch on renderMode at compile time so each specialization gets a constant drawMask.
switch (renderMode) {
case GfxRenderer::BW:
renderGlyphFast2Bit<GfxRenderer::BW>(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase,
pixelState, renderer.getOrientation());
break;
case GfxRenderer::GRAYSCALE_MSB:
renderGlyphFast2Bit<GfxRenderer::GRAYSCALE_MSB>(renderer.getFrameBuffer(), bitmap, width, height, innerBase,
outerBase, pixelState, renderer.getOrientation());
break;
case GfxRenderer::GRAYSCALE_LSB:
renderGlyphFast2Bit<GfxRenderer::GRAYSCALE_LSB>(renderer.getFrameBuffer(), bitmap, width, height, innerBase,
outerBase, pixelState, renderer.getOrientation());
break;
}
return;
}
// Rotated text fallback: keep explicit per-pixel behavior.
int pixelPosition = 0;
for (int glyphY = 0; glyphY < height; glyphY++) {
const int outerCoord = outerBase + glyphY;
@@ -143,6 +670,15 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
}
}
} else {
// Fast path: 1-bit BW mode, non-rotated text — byte-level framebuffer writes, no drawPixel() per pixel.
if constexpr (rotation == TextRotation::None) {
if (renderMode == GfxRenderer::BW) {
renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState,
renderer.getOrientation());
return;
}
}
// Fallback: rotated text or non-BW render mode — per-pixel drawPixel().
int pixelPosition = 0;
for (int glyphY = 0; glyphY < height; glyphY++) {
const int outerCoord = outerBase + glyphY;
@@ -275,21 +811,129 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
}
}
#ifdef ENABLE_RENDERCHAR_BENCHMARK
// Legacy per-pixel rendering path — mirrors the old renderCharImpl 1-bit BW loop.
// Used only by the renderChar benchmark to establish the baseline.
void GfxRenderer::drawTextBWLegacy(const int fontId, const int x, const int y, const char* text) const {
if (text == nullptr || *text == '\0') return;
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return;
const auto& fontFamily = fontIt->second;
int yPos = y + getFontAscenderSize(fontId);
int xPos = x;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, EpdFontFamily::REGULAR);
if (!glyph) glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, EpdFontFamily::REGULAR);
if (!glyph) continue;
const EpdFontData* fontData = fontFamily.getData(EpdFontFamily::REGULAR);
if (fontData->is2Bit) {
xPos += glyph->advanceX;
continue;
}
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
const int screenYBase = yPos - glyph->top;
const int screenXBase = xPos + glyph->left;
int pixelPosition = 0;
for (int glyphY = 0; glyphY < glyph->height; glyphY++) {
for (int glyphX = 0; glyphX < glyph->width; glyphX++, pixelPosition++) {
const uint8_t bit = (bitmap[pixelPosition >> 3] >> (7 - (pixelPosition & 7))) & 1;
if (!bit) continue;
// Inline drawPixel without OOB logging — mirrors the old per-pixel path but clips silently,
// matching the fast path's behaviour so the benchmark measures rendering cost only.
int phyX, phyY;
rotateCoordinates(orientation, screenXBase + glyphX, screenYBase + glyphY, &phyX, &phyY);
if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8);
const uint8_t bitPosition = 7 - (phyX % 8);
frameBuffer[byteIndex] &= ~(1 << bitPosition); // black pixel
}
}
}
xPos += glyph->advanceX;
}
}
// Legacy per-pixel rendering path — mirrors the old renderCharImpl 2-bit BW loop.
// Used only by the renderChar benchmark to establish the baseline for antialiased fonts.
void GfxRenderer::drawText2BitLegacy(const int fontId, const int x, const int y, const char* text) const {
if (text == nullptr || *text == '\0') return;
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) return;
const auto& fontFamily = fontIt->second;
int yPos = y + getFontAscenderSize(fontId);
int xPos = x;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, EpdFontFamily::REGULAR);
if (!glyph) glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, EpdFontFamily::REGULAR);
if (!glyph) continue;
const EpdFontData* fontData = fontFamily.getData(EpdFontFamily::REGULAR);
if (!fontData->is2Bit) {
xPos += glyph->advanceX;
continue;
}
const uint8_t* bitmap = getGlyphBitmap(fontData, glyph);
if (bitmap != nullptr) {
const int screenYBase = yPos - glyph->top;
const int screenXBase = xPos + glyph->left;
int pixelPosition = 0;
for (int glyphY = 0; glyphY < glyph->height; glyphY++) {
for (int glyphX = 0; glyphX < glyph->width; glyphX++, pixelPosition++) {
// 2-bit: each pixel occupies 2 bits; MSB first within each byte
const uint8_t raw = (bitmap[pixelPosition >> 2] >> (6 - ((pixelPosition & 3) << 1))) & 3;
if (!raw) continue;
int phyX, phyY;
rotateCoordinates(orientation, screenXBase + glyphX, screenYBase + glyphY, &phyX, &phyY);
if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue;
const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8);
const uint8_t bitPosition = 7 - (phyX % 8);
frameBuffer[byteIndex] &= ~(1 << bitPosition); // black pixel
}
}
}
xPos += glyph->advanceX;
}
}
#endif // ENABLE_RENDERCHAR_BENCHMARK
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const {
if (fontCacheManager_ && fontCacheManager_->isScanning()) return;
if (x1 == x2) {
if (y2 < y1) {
std::swap(y1, y2);
}
for (int y = y1; y <= y2; y++) {
drawPixel(x1, y, state);
// In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span.
switch (orientation) {
case Portrait:
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - x1, y1, y2, state);
return;
case PortraitInverted:
fillPhysicalHSpan(x1, HalDisplay::DISPLAY_WIDTH - 1 - y2, HalDisplay::DISPLAY_WIDTH - 1 - y1, state);
return;
default:
for (int y = y1; y <= y2; y++) drawPixel(x1, y, state);
return;
}
} else if (y1 == y2) {
if (x2 < x1) {
std::swap(x1, x2);
}
for (int x = x1; x <= x2; x++) {
drawPixel(x, y1, state);
// In Landscape a logical horizontal line maps to a physical horizontal span.
switch (orientation) {
case LandscapeCounterClockwise:
fillPhysicalHSpan(y1, x1, x2, state);
return;
case LandscapeClockwise:
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - y1, HalDisplay::DISPLAY_WIDTH - 1 - x2,
HalDisplay::DISPLAY_WIDTH - 1 - x1, state);
return;
default:
for (int x = x1; x <= x2; x++) drawPixel(x, y1, state);
return;
}
} else {
// Bresenham's line algorithm — integer arithmetic only
@@ -345,17 +989,40 @@ void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const
const int lineWidth, const bool state) const {
const int stroke = std::min(lineWidth, maxRadius);
const int innerRadius = std::max(maxRadius - stroke, 0);
const int outerRadiusSq = maxRadius * maxRadius;
const int outerRadius = maxRadius;
if (outerRadius <= 0) {
return;
}
const int outerRadiusSq = outerRadius * outerRadius;
const int innerRadiusSq = innerRadius * innerRadius;
for (int dy = 0; dy <= maxRadius; ++dy) {
for (int dx = 0; dx <= maxRadius; ++dx) {
const int distSq = dx * dx + dy * dy;
if (distSq > outerRadiusSq || distSq < innerRadiusSq) {
continue;
}
const int px = cx + xDir * dx;
const int py = cy + yDir * dy;
drawPixel(px, py, state);
int xOuter = outerRadius;
int xInner = innerRadius;
for (int dy = 0; dy <= outerRadius; ++dy) {
while (xOuter > 0 && (xOuter * xOuter + dy * dy) > outerRadiusSq) {
--xOuter;
}
// Keep the smallest x that still lies outside/at the inner radius,
// i.e. (x^2 + y^2) >= innerRadiusSq.
while (xInner > 0 && ((xInner - 1) * (xInner - 1) + dy * dy) >= innerRadiusSq) {
--xInner;
}
if (xOuter < xInner) {
continue;
}
const int x0 = cx + xDir * xInner;
const int x1 = cx + xDir * xOuter;
const int left = std::min(x0, x1);
const int width = std::abs(x1 - x0) + 1;
const int py = cy + yDir * dy;
if (width > 0) {
fillRect(left, py, width, 1, state);
}
}
};
@@ -418,9 +1085,85 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con
}
}
// Write a patterned horizontal span directly into the physical framebuffer with byte-level operations.
// patternByte is repeated across the full span; partial edge bytes are blended with existing content.
// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); 0 bits = dark pixel, 1 bits = white pixel.
void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, const int phyX_end,
const uint8_t patternByte) const {
const int cX0 = std::max(phyX_start, 0);
const int cX1 = std::min(phyX_end, (int)HalDisplay::DISPLAY_WIDTH - 1);
if (cX0 > cX1 || phyY < 0 || phyY >= (int)HalDisplay::DISPLAY_HEIGHT) return;
uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES;
const int startByte = cX0 >> 3;
const int endByte = cX1 >> 3;
const int leftBits = cX0 & 7; // first bit index within startByte
const int rightBits = cX1 & 7; // last bit index within endByte
if (startByte == endByte) {
// Both endpoints in the same byte
const uint8_t fillMask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1));
row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask);
return;
}
// Left partial byte
if (leftBits != 0) {
const uint8_t fillMask = 0xFF >> leftBits;
row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask);
}
// Full bytes in the middle
const int fullStart = (leftBits == 0) ? startByte : startByte + 1;
const int fullEnd = (rightBits == 7) ? endByte : endByte - 1;
if (fullStart <= fullEnd) {
memset(row + fullStart, patternByte, fullEnd - fullStart + 1);
}
// Right partial byte
if (rightBits != 7) {
const uint8_t fillMask = ~(0xFF >> (rightBits + 1));
row[endByte] = (row[endByte] & ~fillMask) | (patternByte & fillMask);
}
}
// Thin wrapper: state=true → 0x00 (all dark), false → 0xFF (all white).
void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const {
fillPhysicalHSpanByte(phyY, phyX_start, phyX_end, state ? 0x00 : 0xFF);
}
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
for (int fillY = y; fillY < y + height; fillY++) {
drawLine(x, fillY, x + width - 1, fillY, state);
if (width <= 0 || height <= 0) return;
// For each orientation, one logical dimension maps to a constant physical row, allowing the
// perpendicular dimension to be written as a byte-level span — eliminating per-pixel overhead.
switch (orientation) {
case Portrait:
// Logical column x → physical row (479-x); logical y range → physical x span
for (int lx = x; lx < x + width; lx++) {
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - lx, y, y + height - 1, state);
}
return;
case PortraitInverted:
// Logical column x → physical row x; logical y range → physical x span (mirrored)
for (int lx = x; lx < x + width; lx++) {
fillPhysicalHSpan(lx, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), HalDisplay::DISPLAY_WIDTH - 1 - y,
state);
}
return;
case LandscapeCounterClockwise:
// Logical row y → physical row y; logical x range → physical x span
for (int ly = y; ly < y + height; ly++) {
fillPhysicalHSpan(ly, x, x + width - 1, state);
}
return;
case LandscapeClockwise:
// Logical row y → physical row (479-y); logical x range → physical x span (mirrored)
for (int ly = y; ly < y + height; ly++) {
fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - ly, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
HalDisplay::DISPLAY_WIDTH - 1 - x, state);
}
return;
}
}
@@ -457,32 +1200,120 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons
fillRect(x, y, width, height, true);
} else if (color == Color::White) {
fillRect(x, y, width, height, false);
} else if (color == Color::LightGray) {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither<Color::LightGray>(fillX, fillY);
}
}
} else if (color == Color::DarkGray) {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither<Color::DarkGray>(fillX, fillY);
}
// Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard).
// Byte patterns (phyY even / phyY odd):
// Portrait / PortraitInverted: 0xAA / 0x55
// LandscapeCW / LandscapeCCW: 0x55 / 0xAA
switch (orientation) {
case Portrait:
for (int lx = x; lx < x + width; lx++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx;
const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55;
fillPhysicalHSpanByte(phyY, y, y + height - 1, pb);
}
return;
case PortraitInverted:
for (int lx = x; lx < x + width; lx++) {
const int phyY = lx;
const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55;
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1),
HalDisplay::DISPLAY_WIDTH - 1 - y, pb);
}
return;
case LandscapeCounterClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = ly;
const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA;
fillPhysicalHSpanByte(phyY, x, x + width - 1, pb);
}
return;
case LandscapeClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly;
const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA;
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
HalDisplay::DISPLAY_WIDTH - 1 - x, pb);
}
return;
}
} else if (color == Color::LightGray) {
// Pattern: dark where phyX % 2 == 0 && phyY % 2 == 0 (1-in-4 pixels dark).
// Byte patterns (phyY even / phyY odd) — 0xFF rows write no dark pixels and are skipped:
// Portrait: 0xFF (skip) / 0x55
// PortraitInverted: 0xAA / 0xFF (skip)
// LandscapeCCW: 0x55 / 0xFF (skip)
// LandscapeCW: 0xFF (skip) / 0xAA
switch (orientation) {
case Portrait:
for (int lx = x; lx < x + width; lx++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx;
if (phyY % 2 == 0) continue; // all-white row — no dark pixels to write
fillPhysicalHSpanByte(phyY, y, y + height - 1, 0x55);
}
return;
case PortraitInverted:
for (int lx = x; lx < x + width; lx++) {
const int phyY = lx;
if (phyY % 2 != 0) continue; // all-white row
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1),
HalDisplay::DISPLAY_WIDTH - 1 - y, 0xAA);
}
return;
case LandscapeCounterClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = ly;
if (phyY % 2 != 0) continue; // all-white row
fillPhysicalHSpanByte(phyY, x, x + width - 1, 0x55);
}
return;
case LandscapeClockwise:
for (int ly = y; ly < y + height; ly++) {
const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly;
if (phyY % 2 == 0) continue; // all-white row
fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1),
HalDisplay::DISPLAY_WIDTH - 1 - x, 0xAA);
}
return;
}
}
}
template <Color color>
void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir) const {
if (maxRadius <= 0) return;
if constexpr (color == Color::Clear) {
return;
}
const int radiusSq = maxRadius * maxRadius;
// Avoid sqrt by scanning from outer radius inward while y grows.
int x = maxRadius;
for (int dy = 0; dy <= maxRadius; ++dy) {
for (int dx = 0; dx <= maxRadius; ++dx) {
const int distSq = dx * dx + dy * dy;
const int px = cx + xDir * dx;
const int py = cy + yDir * dy;
if (distSq <= radiusSq) {
drawPixelDither<color>(px, py);
}
while (x > 0 && (x * x + dy * dy) > radiusSq) {
--x;
}
if (x < 0) break;
const int py = cy + yDir * dy;
if (py < 0 || py >= getScreenHeight()) continue;
int x0 = cx;
int x1 = cx + xDir * x;
if (x0 > x1) std::swap(x0, x1);
const int width = x1 - x0 + 1;
if (width <= 0) continue;
if constexpr (color == Color::Black) {
fillRect(x0, py, width, 1, true);
} else if constexpr (color == Color::White) {
fillRect(x0, py, width, 1, false);
} else {
// LightGray / DarkGray: use existing dithered fill path.
fillRectDither(x0, py, width, 1, color);
}
}
}
+14
View File
@@ -55,6 +55,14 @@ class GfxRenderer {
void drawPixelDither(int x, int y) const;
template <Color color>
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const;
// Write a patterned horizontal span directly to the physical framebuffer using byte-level operations.
// phyY: physical row; phyX_start/phyX_end: inclusive physical column range.
// patternByte is repeated across the span; partial edge bytes are blended with existing content.
// Bit layout: MSB-first (bit 7 = phyX=0); 0 bits = dark pixel, 1 bits = white pixel.
void fillPhysicalHSpanByte(int phyY, int phyX_start, int phyX_end, uint8_t patternByte) const;
// Write a solid horizontal span directly to the physical framebuffer using byte-level operations.
// Thin wrapper around fillPhysicalHSpanByte: state=true → 0x00 (dark), false → 0xFF (white).
void fillPhysicalHSpan(int phyY, int phyX_start, int phyX_end, bool state) const;
public:
explicit GfxRenderer(HalDisplay& halDisplay)
@@ -157,4 +165,10 @@ class GfxRenderer {
// Low level functions
uint8_t* getFrameBuffer() const;
size_t getBufferSize() const;
#ifdef ENABLE_RENDERCHAR_BENCHMARK
// Legacy per-pixel paths — used only by the renderChar benchmark to establish baselines.
void drawTextBWLegacy(int fontId, int x, int y, const char* text) const;
void drawText2BitLegacy(int fontId, int x, int y, const char* text) const;
#endif
};
+52 -12
View File
@@ -4,13 +4,15 @@
#include <HardwareSerial.h>
#include <Serialization.h>
#include <string>
#include "I18nStrings.h"
using namespace i18n_strings;
// Settings file path
static constexpr const char* SETTINGS_FILE = "/.crosspoint/language.bin";
static constexpr uint8_t SETTINGS_VERSION = 1;
static constexpr uint8_t SETTINGS_VERSION = 2;
I18n& I18n::getInstance() {
static I18n instance;
@@ -24,8 +26,8 @@ const char* I18n::get(StrId id) const {
}
// Use generated helper function - no hardcoded switch needed!
const char* const* strings = getStringArray(_language);
return strings[index];
const LangStrings lang = getLanguageStrings(_language);
return lang.data + lang.offsets[index];
}
void I18n::setLanguage(Language lang) {
@@ -44,6 +46,14 @@ const char* I18n::getLanguageName(Language lang) const {
return LANGUAGE_NAMES[index];
}
const char* I18n::getLanguageCode(Language lang) const {
const auto index = static_cast<size_t>(lang);
if (index >= static_cast<size_t>(Language::_COUNT)) {
return LANGUAGE_CODES[0];
}
return LANGUAGE_CODES[index];
}
void I18n::saveSettings() {
Storage.mkdir("/.crosspoint");
@@ -54,10 +64,11 @@ void I18n::saveSettings() {
}
serialization::writePod(file, SETTINGS_VERSION);
serialization::writePod(file, static_cast<uint8_t>(_language));
serialization::writeString(file, getLanguageCode(_language));
file.close();
Serial.printf("[I18N] Settings saved: language=%d\n", static_cast<int>(_language));
Serial.printf("[I18N] Settings saved: language=%d code=%s\n", static_cast<int>(_language),
getLanguageCode(_language));
}
void I18n::loadSettings() {
@@ -69,19 +80,48 @@ void I18n::loadSettings() {
uint8_t version;
serialization::readPod(file, version);
if (version != SETTINGS_VERSION) {
Serial.printf("[I18N] Settings version mismatch\n");
if (version == SETTINGS_VERSION) {
std::string code;
serialization::readString(file, code);
bool found = false;
for (uint8_t i = 0; i < getLanguageCount(); i++) {
if (code == LANGUAGE_CODES[i]) {
_language = static_cast<Language>(i);
found = true;
break;
}
}
if (found) {
Serial.printf("[I18N] Loaded language code: %s (%d)\n", code.c_str(), static_cast<int>(_language));
} else {
Serial.printf("[I18N] Unknown language code in settings: %s\n", code.c_str());
}
file.close();
return;
}
uint8_t lang;
serialization::readPod(file, lang);
if (lang < static_cast<size_t>(Language::_COUNT)) {
_language = static_cast<Language>(lang);
Serial.printf("[I18N] Loaded language: %d\n", static_cast<int>(_language));
// Legacy migration path: version 1 stored language enum index directly.
if (version == 1) {
uint8_t lang;
serialization::readPod(file, lang);
if (lang < static_cast<size_t>(Language::_COUNT)) {
_language = static_cast<Language>(lang);
Serial.printf("[I18N] Migrating v1 language index: %d -> %s\n", static_cast<int>(_language),
getLanguageCode(_language));
file.close();
saveSettings();
return;
}
file.close();
Serial.printf("[I18N] Invalid v1 language index: %d\n", static_cast<int>(lang));
return;
}
Serial.printf("[I18N] Settings version mismatch: %d\n", static_cast<int>(version));
file.close();
}
+1
View File
@@ -22,6 +22,7 @@ class I18n {
Language getLanguage() const { return _language; }
void setLanguage(Language lang);
const char* getLanguageCode(Language lang) const;
const char* getLanguageName(Language lang) const;
void saveSettings();
+156 -2
View File
@@ -70,6 +70,7 @@ STR_IMAGES: "Images"
STR_IMAGES_DISPLAY: "Display"
STR_IMAGES_PLACEHOLDER: "Placeholder"
STR_IMAGES_SUPPRESS: "Suppress"
STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Create fallback for invalid TOC"
STR_SHORT_PWR_BTN: "Short Power Button Click"
STR_ORIENTATION: "Reading Orientation"
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
@@ -82,6 +83,43 @@ STR_PARA_ALIGNMENT: "Reader Paragraph Alignment"
STR_HYPHENATION: "Hyphenation"
STR_TIME_TO_SLEEP: "Time to Sleep"
STR_SHOW_HIDDEN_FILES: "Show Hidden Files"
STR_USE_CLOCK: "Use Clock"
STR_CLOCK_SETTINGS: "Clock Settings"
STR_CLOCK_SETTINGS_WARNING: "Uses more battery; clock may drift"
STR_CLOCK: "Clock"
STR_CLOCK_FORMAT: "Clock Format"
STR_TIMEZONE: "Timezone"
STR_24H: "24h"
STR_12H: "12h"
STR_TZ_UTC: "UTC (GMT/BST)"
STR_TZ_CET: "Central Europe (CET/CEST)"
STR_TZ_EET: "Eastern Europe (EET/EEST)"
STR_TZ_EST: "US Eastern (EST/EDT)"
STR_TZ_CST: "US Central (CST/CDT)"
STR_TZ_MST: "US Mountain (MST/MDT)"
STR_TZ_PST: "US Pacific (PST/PDT)"
STR_TZ_AEST: "Australia Eastern (AEST/AEDT)"
STR_TZ_NZST: "New Zealand (NZST/NZDT)"
STR_TZ_MSK: "Russia (MSK)"
STR_TZ_UTC_MINUS3: "South America (UTC-3)"
STR_TZ_UTC_PLUS4: "Gulf (UTC+4)"
STR_TZ_IST: "India (UTC+5:30)"
STR_TZ_UTC_PLUS7: "SE Asia (UTC+7)"
STR_TZ_UTC_PLUS8: "China/SE Asia (UTC+8)"
STR_TZ_UTC_PLUS9: "Japan/Korea (UTC+9)"
STR_SYNC_TIME: "Sync Time"
STR_DETECT_TIMEZONE: "Detect Timezone"
STR_SYNCING_CLOCK: "Syncing clock..."
STR_DETECTING_TIMEZONE: "Detecting timezone..."
STR_TIME_SYNCED: "Time synced"
STR_TIME_SYNC_FAILED: "Time sync failed"
STR_CLOCK_DRIFT: "Drift: %s"
STR_LAST_NTP_SYNC: "Last sync: %s"
STR_TIMEZONE_DETECTED: "Timezone detected"
STR_TIMEZONE_DETECT_FAILED: "Timezone detect failed"
STR_DST_ACTIVE: "DST: active"
STR_DST_INACTIVE: "DST: inactive"
STR_DST_UNKNOWN: "DST: unknown"
STR_REFRESH_FREQ: "Refresh Frequency"
STR_KOREADER_SYNC: "KOReader Sync"
STR_CHECK_UPDATES: "Check for updates"
@@ -102,14 +140,19 @@ STR_AUTHENTICATING: "Authenticating..."
STR_AUTH_SUCCESS: "Successfully authenticated!"
STR_KOREADER_AUTH: "KOReader Auth"
STR_SYNC_READY: "KOReader sync is ready to use"
STR_AUTH_FAILED: "Authentication Failed"
STR_AUTH_FAILED: "Authentication failed"
STR_REGISTER: "Register"
STR_REGISTERING: "Registering..."
STR_REGISTER_SUCCESS: "Account created successfully!"
STR_REGISTER_FAILED: "Registration failed"
STR_USERNAME_TAKEN: "Username already taken"
STR_DONE: "Done"
STR_CLEAR_CACHE_WARNING_1: "This will clear all cached book data."
STR_CLEAR_CACHE_WARNING_2: "All reading progress will be lost!"
STR_CLEAR_CACHE_WARNING_3: "Books will need to be re-indexed"
STR_CLEAR_CACHE_WARNING_4: "when opened again."
STR_CLEARING_CACHE: "Clearing cache..."
STR_CACHE_CLEARED: "Cache Cleared"
STR_CACHE_CLEARED: "Cache cleared"
STR_ITEMS_REMOVED: "items removed"
STR_FAILED_LOWER: "failed"
STR_CLEAR_CACHE_FAILED: "Failed to clear cache"
@@ -230,6 +273,8 @@ STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix"
STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons"
STR_OPDS_BROWSER: "OPDS Browser"
STR_COVER_CUSTOM: "Cover + Custom"
STR_PAGE_OVERLAY: "Page overlay"
STR_RECENTS: "Recents"
STR_MENU_RECENT_BOOKS: "Recent Books"
STR_NO_RECENT_BOOKS: "No recent books"
STR_CALIBRE_DESC: "Use Calibre wireless device transfers"
@@ -254,6 +299,7 @@ STR_GO_HOME_BUTTON: "Go Home"
STR_SYNC_PROGRESS: "Sync Progress"
STR_DELETE_CACHE: "Delete Book Cache"
STR_DELETE: "Delete"
STR_REMOVE: "Remove"
STR_DISPLAY_QR: "Show page as QR"
STR_CHAPTER_PREFIX: "Chapter: "
STR_PAGES_SEPARATOR: " pages | "
@@ -264,6 +310,8 @@ STR_SYNCING_TIME: "Syncing time..."
STR_CALC_HASH: "Calculating document hash..."
STR_HASH_FAILED: "Failed to calculate document hash"
STR_FETCH_PROGRESS: "Fetching remote progress..."
STR_MAPPING_REMOTE: "Mapping remote position..."
STR_MAPPING_LOCAL: "Calculating local position..."
STR_UPLOAD_PROGRESS: "Uploading progress..."
STR_NO_CREDENTIALS_MSG: "No credentials configured"
STR_KOREADER_SETUP_HINT: "Set up KOReader account in Settings"
@@ -290,3 +338,109 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Take screenshot"
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
STR_WEATHER: "Weather"
STR_WEATHER_LOCATION: "Location"
STR_WEATHER_NO_LOCATION: "No location set for weather"
STR_WEATHER_FETCH_FAILED: "Failed to fetch weather"
STR_WEATHER_SETTINGS: "Weather Settings"
STR_WEATHER_SETTINGS_SHORT: "Settings"
STR_WEATHER_REFRESH: "Refresh"
STR_WEATHER_FEELS_LIKE: "Feels like"
STR_WEATHER_HUMIDITY: "Humidity"
STR_WEATHER_WIND: "Wind"
STR_WEATHER_PRESSURE: "Pressure"
STR_WEATHER_LAST_UPDATED: "Last updated"
STR_WEATHER_PRECIP: "Precip."
STR_WEATHER_PRECIP_UNIT: "Precipitation Unit"
STR_WEATHER_WIND_UNIT: "Wind Speed Unit"
STR_WEATHER_TEMP_UNIT: "Temperature Unit"
STR_WEATHER_48H_FORECAST: "48-Hour Forecast"
STR_WEATHER_SEARCH_CITY: "Search City"
STR_WEATHER_LONGITUDE: "Longitude"
STR_WEATHER_LATITUDE: "Latitude"
STR_WEATHER_SEARCH_RESULTS: "Search Results"
STR_WEATHER_DAY_MON: "Mon"
STR_WEATHER_DAY_TUE: "Tue"
STR_WEATHER_DAY_WED: "Wed"
STR_WEATHER_DAY_THU: "Thu"
STR_WEATHER_DAY_FRI: "Fri"
STR_WEATHER_DAY_SAT: "Sat"
STR_WEATHER_DAY_SUN: "Sun"
STR_WEATHER_MONTH_JAN: "Jan"
STR_WEATHER_MONTH_FEB: "Feb"
STR_WEATHER_MONTH_MAR: "Mar"
STR_WEATHER_MONTH_APR: "Apr"
STR_WEATHER_MONTH_MAY: "May"
STR_WEATHER_MONTH_JUN: "Jun"
STR_WEATHER_MONTH_JUL: "Jul"
STR_WEATHER_MONTH_AUG: "Aug"
STR_WEATHER_MONTH_SEP: "Sep"
STR_WEATHER_MONTH_OCT: "Oct"
STR_WEATHER_MONTH_NOV: "Nov"
STR_WEATHER_MONTH_DEC: "Dec"
STR_WEATHER_MOON_NEW: "New Moon"
STR_WEATHER_MOON_WAXING_CRESCENT: "Waxing Crescent"
STR_WEATHER_MOON_FIRST_QUARTER: "First Quarter"
STR_WEATHER_MOON_WAXING_GIBBOUS: "Waxing Gibbous"
STR_WEATHER_MOON_FULL: "Full Moon"
STR_WEATHER_MOON_WANING_GIBBOUS: "Waning Gibbous"
STR_WEATHER_MOON_LAST_QUARTER: "Last Quarter"
STR_WEATHER_MOON_WANING_CRESCENT: "Waning Crescent"
STR_WEATHER_DESC_CLEAR_SKY: "Clear sky"
STR_WEATHER_DESC_MAINLY_CLEAR: "Mainly clear"
STR_WEATHER_DESC_PARTLY_CLOUDY: "Partly cloudy"
STR_WEATHER_DESC_OVERCAST: "Overcast"
STR_WEATHER_DESC_FOG: "Fog"
STR_WEATHER_DESC_RIME_FOG: "Rime fog"
STR_WEATHER_DESC_LIGHT_DRIZZLE: "Light drizzle"
STR_WEATHER_DESC_DRIZZLE: "Drizzle"
STR_WEATHER_DESC_DENSE_DRIZZLE: "Dense drizzle"
STR_WEATHER_DESC_FREEZING_DRIZZLE: "Freezing drizzle"
STR_WEATHER_DESC_DENSE_FREEZING_DRIZZLE: "Dense freezing drizzle"
STR_WEATHER_DESC_SLIGHT_RAIN: "Slight rain"
STR_WEATHER_DESC_MODERATE_RAIN: "Moderate rain"
STR_WEATHER_DESC_HEAVY_RAIN: "Heavy rain"
STR_WEATHER_DESC_FREEZING_RAIN: "Freezing rain"
STR_WEATHER_DESC_HEAVY_FREEZING_RAIN: "Heavy freezing rain"
STR_WEATHER_DESC_SLIGHT_SNOW: "Slight snow"
STR_WEATHER_DESC_MODERATE_SNOW: "Moderate snow"
STR_WEATHER_DESC_HEAVY_SNOW: "Heavy snow"
STR_WEATHER_DESC_SNOW_GRAINS: "Snow grains"
STR_WEATHER_DESC_SLIGHT_SHOWERS: "Slight showers"
STR_WEATHER_DESC_MODERATE_SHOWERS: "Moderate showers"
STR_WEATHER_DESC_VIOLENT_SHOWERS: "Violent showers"
STR_WEATHER_DESC_SNOW_SHOWERS: "Snow showers"
STR_WEATHER_DESC_HEAVY_SNOW_SHOWERS: "Heavy snow showers"
STR_WEATHER_DESC_THUNDERSTORM: "Thunderstorm"
STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Thunderstorm, hail"
STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Thunderstorm, heavy hail"
STR_WEATHER_DESC_UNKNOWN: "Unknown"
STR_INFO: "Info"
STR_AUTHOR: "Author"
STR_SERIES: "Series"
STR_FILE_SIZE: "Size"
STR_SLEEP_COVER_OVERLAY: "Sleep Screen Info Overlay"
STR_OVERLAY_WHITE: "White"
STR_OVERLAY_GRAY: "Gray"
STR_OVERLAY_BLACK: "Black"
STR_OVERLAY_OFF: "Off"
STR_OVERLAY_READING_PROGRESS: "Reading progress: Page %lu/%u - %.0f%%"
STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "Reading progress: Page %lu"
STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Page %d/%d - %.0f%% read"
STR_SYSTEM_INFO: "System Information"
STR_LOAD_XTC_FAILED: "Failed to load XTC file"
STR_LOAD_EPUB_FAILED: "Failed to load EPUB file"
STR_FW_VERSION: "FW version"
STR_CHIP: "Chip"
STR_CPU: "CPU"
STR_MHZ: "MHz"
STR_FREE_RAM: "Free RAM"
STR_MIN_FREE: "Min free"
STR_MAX_BLOCK: "Max block"
STR_FLASH_USED: "Flash used"
STR_UPTIME: "Uptime"
STR_CHARGING: "Charging"
STR_GATHERING_DATA: "Gathering data..."
STR_READING: "Reading..."
STR_WEATHER_MOON_INFO: "Moon"
STR_WEATHER_SUN_INFO: "Sun"
+127
View File
@@ -320,3 +320,130 @@ STR_UPTIME: "Laufzeit"
STR_CHARGING: "Lädt"
STR_GATHERING_DATA: "Daten werden gesammelt..."
STR_READING: "Lese..."
STR_WEATHER: "Wetter"
STR_WEATHER_LOCATION: "Ort"
STR_WEATHER_NO_LOCATION: "Kein Ort für Wetter festgelegt"
STR_WEATHER_FETCH_FAILED: "Wetterdaten konnten nicht geladen werden"
STR_WEATHER_SETTINGS: "Wettereinstellungen"
STR_WEATHER_SETTINGS_SHORT: "Einstell."
STR_WEATHER_REFRESH: "Aktual."
STR_WEATHER_FEELS_LIKE: "Gefühlt"
STR_WEATHER_HUMIDITY: "Luftfeuchte"
STR_WEATHER_WIND: "Wind"
STR_WEATHER_PRESSURE: "Luftdruck"
STR_WEATHER_LAST_UPDATED: "Zuletzt aktualisiert"
STR_WEATHER_PRECIP: "Niederschl."
STR_WEATHER_PRECIP_UNIT: "Niederschlagseinheit"
STR_WEATHER_WIND_UNIT: "Windgeschwindigkeitseinheit"
STR_WEATHER_TEMP_UNIT: "Temperatureinheit"
STR_WEATHER_48H_FORECAST: "48-Stunden-Vorhersage"
STR_WEATHER_SEARCH_CITY: "Stadt suchen"
STR_WEATHER_LONGITUDE: "Längengrad"
STR_WEATHER_LATITUDE: "Breitengrad"
STR_WEATHER_SEARCH_RESULTS: "Suchergebnisse"
STR_WEATHER_DAY_MON: "Mo"
STR_WEATHER_DAY_TUE: "Di"
STR_WEATHER_DAY_WED: "Mi"
STR_WEATHER_DAY_THU: "Do"
STR_WEATHER_DAY_FRI: "Fr"
STR_WEATHER_DAY_SAT: "Sa"
STR_WEATHER_DAY_SUN: "So"
STR_WEATHER_MONTH_JAN: "Jan"
STR_WEATHER_MONTH_FEB: "Feb"
STR_WEATHER_MONTH_MAR: "Mär"
STR_WEATHER_MONTH_APR: "Apr"
STR_WEATHER_MONTH_MAY: "Mai"
STR_WEATHER_MONTH_JUN: "Jun"
STR_WEATHER_MONTH_JUL: "Jul"
STR_WEATHER_MONTH_AUG: "Aug"
STR_WEATHER_MONTH_SEP: "Sep"
STR_WEATHER_MONTH_OCT: "Okt"
STR_WEATHER_MONTH_NOV: "Nov"
STR_WEATHER_MONTH_DEC: "Dez"
STR_WEATHER_SUN_INFO: "Sonne"
STR_WEATHER_MOON_NEW: "Neumond"
STR_WEATHER_MOON_WAXING_CRESCENT: "zunehm. Halbmond"
STR_WEATHER_MOON_FIRST_QUARTER: "1. Viertel"
STR_WEATHER_MOON_WAXING_GIBBOUS: "zunehm. Mond"
STR_WEATHER_MOON_FULL: "Vollmond"
STR_WEATHER_MOON_WANING_GIBBOUS: "abnehm. Mond"
STR_WEATHER_MOON_LAST_QUARTER: "3. Viertel"
STR_WEATHER_MOON_WANING_CRESCENT: "abneh. Halbmond"
STR_WEATHER_DESC_CLEAR_SKY: "Klarer Himmel"
STR_WEATHER_DESC_MAINLY_CLEAR: "Überwiegend klar"
STR_WEATHER_DESC_PARTLY_CLOUDY: "Teilweise bewölkt"
STR_WEATHER_DESC_OVERCAST: "Bedeckt"
STR_WEATHER_DESC_FOG: "Nebel"
STR_WEATHER_DESC_RIME_FOG: "Raureifnebel"
STR_WEATHER_DESC_LIGHT_DRIZZLE: "Leichter Nieselregen"
STR_WEATHER_DESC_DRIZZLE: "Nieselregen"
STR_WEATHER_DESC_DENSE_DRIZZLE: "Dichter Nieselregen"
STR_WEATHER_DESC_FREEZING_DRIZZLE: "Gefrierender Nieselregen"
STR_WEATHER_DESC_DENSE_FREEZING_DRIZZLE: "Dichter gefrierender Nieselregen"
STR_WEATHER_DESC_SLIGHT_RAIN: "Leichter Regen"
STR_WEATHER_DESC_MODERATE_RAIN: "Mäßiger Regen"
STR_WEATHER_DESC_HEAVY_RAIN: "Starker Regen"
STR_WEATHER_DESC_FREEZING_RAIN: "Gefrierender Regen"
STR_WEATHER_DESC_HEAVY_FREEZING_RAIN: "Starker gefrierender Regen"
STR_WEATHER_DESC_SLIGHT_SNOW: "Leichter Schneefall"
STR_WEATHER_DESC_MODERATE_SNOW: "Mäßiger Schneefall"
STR_WEATHER_DESC_HEAVY_SNOW: "Starker Schneefall"
STR_WEATHER_DESC_SNOW_GRAINS: "Schneegriesel"
STR_WEATHER_DESC_SLIGHT_SHOWERS: "Leichte Schauer"
STR_WEATHER_DESC_MODERATE_SHOWERS: "Mäßige Schauer"
STR_WEATHER_DESC_VIOLENT_SHOWERS: "Heftige Schauer"
STR_WEATHER_DESC_SNOW_SHOWERS: "Schneeschauer"
STR_WEATHER_DESC_HEAVY_SNOW_SHOWERS: "Starke Schneeschauer"
STR_WEATHER_DESC_THUNDERSTORM: "Gewitter"
STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Gewitter mit Hagel"
STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Gewitter mit starkem Hagel"
STR_WEATHER_DESC_UNKNOWN: "Unbekannt"
STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Ersatz für ungültiges Inhaltsverzeichnis erstellen"
STR_USE_CLOCK: "Uhr anzeigen"
STR_CLOCK_SETTINGS: "Uhr-Einstellungen"
STR_CLOCK_SETTINGS_WARNING: "Verbraucht mehr Akku; Uhr kann abweichen"
STR_CLOCK: "Uhr"
STR_CLOCK_FORMAT: "Uhrformat"
STR_TIMEZONE: "Zeitzone"
STR_24H: "24 Std."
STR_12H: "12 Std."
STR_TZ_UTC: "UTC (GMT/BST)"
STR_TZ_CET: "Mitteleuropa (CET/CEST)"
STR_TZ_EET: "Osteuropa (EET/EEST)"
STR_TZ_EST: "USA Ostküste (EST/EDT)"
STR_TZ_CST: "USA Zentral (CST/CDT)"
STR_TZ_MST: "USA Gebirge (MST/MDT)"
STR_TZ_PST: "USA Westküste (PST/PDT)"
STR_TZ_AEST: "Australien Ost (AEST/AEDT)"
STR_TZ_NZST: "Neuseeland (NZST/NZDT)"
STR_TZ_MSK: "Russland (MSK)"
STR_TZ_UTC_MINUS3: "Südamerika (UTC-3)"
STR_TZ_UTC_PLUS4: "Golfregion (UTC+4)"
STR_TZ_IST: "Indien (UTC+5:30)"
STR_TZ_UTC_PLUS7: "Südostasien (UTC+7)"
STR_TZ_UTC_PLUS8: "China/Südostasien (UTC+8)"
STR_TZ_UTC_PLUS9: "Japan/Korea (UTC+9)"
STR_SYNC_TIME: "Uhrzeit synchronisieren"
STR_DETECT_TIMEZONE: "Zeitzone erkennen"
STR_SYNCING_CLOCK: "Uhr wird synchronisiert..."
STR_DETECTING_TIMEZONE: "Zeitzone wird erkannt..."
STR_TIME_SYNCED: "Uhrzeit synchronisiert"
STR_TIME_SYNC_FAILED: "Zeitsynchronisierung fehlgeschlagen"
STR_CLOCK_DRIFT: "Abweichung: %s"
STR_LAST_NTP_SYNC: "Letzte Synchronisierung: %s"
STR_TIMEZONE_DETECTED: "Zeitzone erkannt"
STR_TIMEZONE_DETECT_FAILED: "Zeitzone konnte nicht erkannt werden"
STR_DST_ACTIVE: "Sommerzeit: aktiv"
STR_DST_INACTIVE: "Sommerzeit: inaktiv"
STR_DST_UNKNOWN: "Sommerzeit: unbekannt"
STR_OVERLAY_WHITE: "Weiß"
STR_OVERLAY_GRAY: "Grau"
STR_OVERLAY_BLACK: "Schwarz"
STR_OVERLAY_OFF: "Aus"
STR_OVERLAY_READING_PROGRESS: "%d/%d (%d%%)"
STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "%d (%d%%)"
STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Seite %d/%d - %.0f%%"
+1 -1
View File
@@ -1,6 +1,6 @@
_language_name: "Magyar"
_language_code: "HU"
_order: "19"
_order: "22"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "INDÍTÁS"
@@ -1,5 +1,5 @@
_language_name: "Português (Brasil)"
_language_code: "PT"
_language_code: "PO"
_order: "5"
STR_CROSSPOINT: "CrossPoint"
@@ -19,7 +19,7 @@ STR_END_OF_BOOK: "Fim do livro"
STR_EMPTY_CHAPTER: "Capítulo vazio"
STR_INDEXING: "Indexando"
STR_MEMORY_ERROR: "Erro de memória"
STR_PAGE_LOAD_ERROR: "Erro página"
STR_PAGE_LOAD_ERROR: "Erro ao carregar a página"
STR_EMPTY_FILE: "Arquivo vazio"
STR_OUT_OF_BOUNDS: "Fora dos limites"
STR_LOADING: "Carregando..."
@@ -31,17 +31,17 @@ STR_SCANNING: "Procurando..."
STR_CONNECTING: "Conectando..."
STR_CONNECTED: "Conectado!"
STR_CONNECTION_FAILED: "Falha na conexão"
STR_FORGET_NETWORK: "Esquecer rede?"
STR_SAVE_PASSWORD: "Salvar senha a próxima vez?"
STR_PRESS_OK_SCAN: "Pressione OK procurar novamente"
STR_FORGET_NETWORK: "Esquecer a rede?"
STR_SAVE_PASSWORD: "Salvar a senha para a próxima vez?"
STR_PRESS_OK_SCAN: "Pressione OK para procurar novamente"
STR_JOIN_NETWORK: "Entrar em uma rede"
STR_CREATE_HOTSPOT: "Criar hotspot"
STR_JOIN_DESC: "Conecte-se a uma rede WiFi existente"
STR_HOTSPOT_DESC: "Crie uma rede WiFi outras pessoas entrarem"
STR_HOTSPOT_DESC: "Crie uma rede WiFi à qual outras pessoas possam se conectar"
STR_STARTING_HOTSPOT: "Iniciando hotspot..."
STR_HOTSPOT_MODE: "Modo hotspot"
STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede WiFi"
STR_OPEN_URL_HINT: "Abra este URL seu navegador"
STR_OPEN_URL_HINT: "Abra este URL no seu navegador"
STR_OR_HTTP_PREFIX: "ou http://"
STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:"
STR_CALIBRE_WIRELESS: "Calibre sem fio"
@@ -54,37 +54,42 @@ STR_TO_PREFIX: "para"
STR_CALIBRE_RECEIVING: "Recebendo:"
STR_CALIBRE_RECEIVED: "Recebido:"
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Esteja mesma rede WiFi"
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\""
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede WiFi"
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar para o dispositivo\""
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\""
STR_CAT_DISPLAY: "Tela"
STR_CAT_READER: "Leitor"
STR_CAT_CONTROLS: "Controles"
STR_CAT_SYSTEM: "Sistema"
STR_SLEEP_SCREEN: "Tela de repouso"
STR_SLEEP_COVER_MODE: "Modo capa tela repouso"
STR_SLEEP_COVER_MODE: "Modo de capa da tela de repouso"
STR_HIDE_BATTERY: "Ocultar % da bateria"
STR_EXTRA_SPACING: "Espaço de parágrafos extra"
STR_EXTRA_SPACING: "Espaçamento extra entre parágrafos"
STR_TEXT_AA: "Suavização de texto"
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
STR_IMAGES: "Imagens"
STR_IMAGES_DISPLAY: "Exibir"
STR_IMAGES_PLACEHOLDER: "Marcador"
STR_IMAGES_SUPPRESS: "Ocultar"
STR_SHORT_PWR_BTN: "Clique curto no botão de ligar"
STR_ORIENTATION: "Orientação de leitura"
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
STR_SIDE_BTN_LAYOUT: "Disposição dos botões laterais"
STR_LONG_PRESS_SKIP: "Pular capítulo com pressão longa"
STR_FONT_FAMILY: "Fonte do leitor"
STR_FONT_SIZE: "Tam. fonte UI"
STR_FONT_SIZE: "Tam. da fonte da UI"
STR_LINE_SPACING: "Espaçamento entre linhas"
STR_SCREEN_MARGIN: "Margens da tela"
STR_PARA_ALIGNMENT: "Alinhamento parágrafo"
STR_PARA_ALIGNMENT: "Alinhamento do parágrafo"
STR_HYPHENATION: "Hifenização"
STR_TIME_TO_SLEEP: "Tempo para repousar"
STR_REFRESH_FREQ: "Frequência atualização"
STR_TIME_TO_SLEEP: "Tempo para entrar em repouso"
STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos"
STR_REFRESH_FREQ: "Frequência de atualização"
STR_KOREADER_SYNC: "Sincronização KOReader"
STR_CHECK_UPDATES: "Verificar atualizações"
STR_LANGUAGE: "Idioma"
STR_CLEAR_READING_CACHE: "Limpar cache de leitura"
STR_USERNAME: "Nome de usuário"
STR_PASSWORD: "Senha"
STR_SYNC_SERVER_URL: "URL servidor sincronização"
STR_SYNC_SERVER_URL: "URL do servidor de sincronização"
STR_DOCUMENT_MATCHING: "Documento correspondente"
STR_AUTHENTICATE: "Autenticar"
STR_KOREADER_USERNAME: "Usuário do KOReader"
@@ -95,11 +100,11 @@ STR_SET_CREDENTIALS_FIRST: "Defina as credenciais primeiro"
STR_WIFI_CONN_FAILED: "Falha na conexão WiFi"
STR_AUTHENTICATING: "Autenticando..."
STR_AUTH_SUCCESS: "Autenticado com sucesso!"
STR_KOREADER_AUTH: "Autenticação KOReader"
STR_SYNC_READY: "A sincronização KOReader está pronta uso"
STR_KOREADER_AUTH: "Autenticação do KOReader"
STR_SYNC_READY: "A sincronização do KOReader está pronta para uso"
STR_AUTH_FAILED: "Falha na autenticação"
STR_DONE: "Feito"
STR_CLEAR_CACHE_WARNING_1: "Isso vai limpar todos os dados livros em cache."
STR_CLEAR_CACHE_WARNING_1: "Isso vai limpar todos os dados de livros em cache."
STR_CLEAR_CACHE_WARNING_2: "Todo o progresso de leitura será perdido!"
STR_CLEAR_CACHE_WARNING_3: "Os livros precisarão ser reindexados"
STR_CLEAR_CACHE_WARNING_4: "quando forem abertos novamente."
@@ -108,7 +113,7 @@ STR_CACHE_CLEARED: "Cache limpo"
STR_ITEMS_REMOVED: "itens removidos"
STR_FAILED_LOWER: "falhou"
STR_CLEAR_CACHE_FAILED: "Falha ao limpar o cache"
STR_CHECK_SERIAL_OUTPUT: "Ver saída serial"
STR_CHECK_SERIAL_OUTPUT: "Verifique a saída serial"
STR_DARK: "Escuro"
STR_LIGHT: "Claro"
STR_CUSTOM: "Personalizado"
@@ -161,25 +166,26 @@ STR_UPDATING: "Atualizando..."
STR_NO_UPDATE: "Nenhuma atualização disponível"
STR_UPDATE_FAILED: "Falha na atualização"
STR_UPDATE_COMPLETE: "Atualização concluída"
STR_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente"
STR_NO_ENTRIES: "Nenhum entries encontrado"
STR_POWER_ON_HINT: "Pressione e segure o botão de energia para ligar novamente"
STR_NO_ENTRIES: "Nenhuma entrada encontrada"
STR_DOWNLOADING: "Baixando..."
STR_DOWNLOAD_FAILED: "Falha no download"
STR_ERROR_MSG: "Erro:"
STR_UNNAMED: "Sem nome"
STR_NO_SERVER_URL: "Nenhum URL servidor configurado"
STR_NO_SERVER_URL: "Nenhum URL de servidor configurado"
STR_FETCH_FEED_FAILED: "Falha ao buscar o feed"
STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed"
STR_NETWORK_PREFIX: "Rede:"
STR_IP_ADDRESS_PREFIX: "Endereço IP:"
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão"
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite de conexão"
STR_SD_CARD: "Cartão SD"
STR_BACK: "« Voltar"
STR_EXIT: "« Sair"
STR_HOME: "« Início"
STR_SELECT: "Escolher"
STR_SELECTED: "Selecionado"
STR_TOGGLE: "Alternar"
STR_CONFIRM: "Confirmar"
STR_CANCEL: "Cancelar"
@@ -189,6 +195,8 @@ STR_DOWNLOAD: "Baixar"
STR_RETRY: "Tentar novamente"
STR_YES: "Sim"
STR_NO: "Não"
STR_SHOW: "Mostrar"
STR_HIDE: "Ocultar"
STR_STATE_ON: "LIG."
STR_STATE_OFF: "DESL."
STR_NOT_SET: "Não definido"
@@ -197,8 +205,23 @@ STR_DIR_RIGHT: "Direita"
STR_DIR_UP: "Cima"
STR_DIR_DOWN: "Baixo"
STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso"
STR_SLEEP_COVER_FILTER: "Filtro da capa da tela de repouso"
STR_FILTER_CONTRAST: "Contraste"
STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de status"
STR_CHAPTER_PAGE_COUNT: "Contagem de páginas do capítulo"
STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de progresso do livro"
STR_PROGRESS_BAR: "Barra de progresso"
STR_PROGRESS_BAR_THICKNESS: "Espessura da barra de progresso"
STR_PROGRESS_BAR_THIN: "Fina"
STR_PROGRESS_BAR_MEDIUM: "Média"
STR_PROGRESS_BAR_THICK: "Grossa"
STR_BOOK: "Livro"
STR_CHAPTER: "Capítulo"
STR_EXAMPLE_CHAPTER: "Capítulo 21"
STR_EXAMPLE_BOOK: "Título do livro"
STR_PREVIEW: "Pré-visualização"
STR_TITLE: "Título"
STR_BATTERY: "Bateria"
STR_UI_THEME: "Tema da interface"
STR_THEME_CLASSIC: "Clássico"
STR_THEME_LYRA: "Lyra"
@@ -209,19 +232,19 @@ STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Capa + personalizado"
STR_MENU_RECENT_BOOKS: "Livros recentes"
STR_NO_RECENT_BOOKS: "Sem livros recentes"
STR_CALIBRE_DESC: "Usar transferências sem fio Calibre"
STR_CALIBRE_DESC: "Usar transferências sem fio do Calibre"
STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a senha salva?"
STR_FORGET_BUTTON: "Esquecer"
STR_CALIBRE_STARTING: "Iniciando Calibre..."
STR_CALIBRE_STARTING: "Iniciando o Calibre..."
STR_CALIBRE_SETUP: "Configuração"
STR_CALIBRE_STATUS: "Status"
STR_CLEAR_BUTTON: "Limpar"
STR_DEFAULT_VALUE: "Padrão"
STR_REMAP_PROMPT: "Pressione um botão frontal cada função"
STR_REMAP_PROMPT: "Pressione um botão frontal para cada função"
STR_UNASSIGNED: "Não atribuído"
STR_ALREADY_ASSIGNED: "Já atribuído"
STR_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão"
STR_REMAP_CANCEL_HINT: "Botão lateral baixo: cancelar remapeamento"
STR_REMAP_RESET_HINT: "Botão lateral de cima: redefinir a disposição padrão"
STR_REMAP_CANCEL_HINT: "Botão lateral de baixo: cancelar o remapeamento"
STR_HW_BACK_LABEL: "Voltar (1º botão)"
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
@@ -231,18 +254,19 @@ STR_GO_HOME_BUTTON: "Ir para o início"
STR_SYNC_PROGRESS: "Sincronizar progresso"
STR_DELETE_CACHE: "Excluir cache do livro"
STR_DELETE: "Excluir"
STR_DISPLAY_QR: "Mostrar página como QR"
STR_CHAPTER_PREFIX: "Capítulo:"
STR_PAGES_SEPARATOR: "páginas |"
STR_BOOK_PREFIX: "Livro:"
STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL"
STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%"
STR_SYNCING_TIME: "Sincronizando horário..."
STR_CALC_HASH: "Calculando hash documento..."
STR_HASH_FAILED: "Falha ao calcular o hash documento"
STR_SYNCING_TIME: "Sincronizando o horário..."
STR_CALC_HASH: "Calculando hash do documento..."
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
STR_FETCH_PROGRESS: "Buscando progresso remoto..."
STR_UPLOAD_PROGRESS: "Enviando progresso..."
STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada"
STR_KOREADER_SETUP_HINT: "Configure a conta do KOReader em Config."
STR_KOREADER_SETUP_HINT: "Configure a conta do KOReader em Configurações."
STR_PROGRESS_FOUND: "Progresso encontrado!"
STR_REMOTE_LABEL: "Remoto:"
STR_LOCAL_LABEL: "Local:"
@@ -260,4 +284,9 @@ STR_UPLOAD: "Enviar"
STR_BOOK_S_STYLE: "Estilo do livro"
STR_EMBEDDED_STYLE: "Estilo embutido"
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
STR_FOOTNOTES: "Notas de rodapé"
STR_NO_FOOTNOTES: "Sem notas de rodapé nesta página"
STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Capturar tela"
STR_AUTO_TURN_ENABLED: "Virada automática ativada: "
STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)"
+292
View File
@@ -0,0 +1,292 @@
_language_name: "Português (Portugal)"
_language_code: "PT"
_order: "19"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "INICIANDO"
STR_SLEEPING: "EM REPOUSO"
STR_ENTERING_SLEEP: "A entrar em repouso"
STR_BROWSE_FILES: "Ficheiros"
STR_FILE_TRANSFER: "Transferência de ficheiros"
STR_SETTINGS_TITLE: "Definições"
STR_CONTINUE_READING: "Continuar a ler"
STR_NO_OPEN_BOOK: "Nenhum livro aberto"
STR_START_READING: "Comece a ler abaixo"
STR_NO_FILES_FOUND: "Nenhum ficheiro encontrado"
STR_SELECT_CHAPTER: "Escolher capítulo"
STR_NO_CHAPTERS: "Sem capítulos"
STR_END_OF_BOOK: "Fim do livro"
STR_EMPTY_CHAPTER: "Capítulo vazio"
STR_INDEXING: "A indexar"
STR_MEMORY_ERROR: "Erro de memória"
STR_PAGE_LOAD_ERROR: "Erro ao carregar a página"
STR_EMPTY_FILE: "Ficheiro vazio"
STR_OUT_OF_BOUNDS: "Fora dos limites"
STR_LOADING: "A carregar..."
STR_LOADING_POPUP: "A carregar"
STR_WIFI_NETWORKS: "Redes WiFi"
STR_NO_NETWORKS: "Sem redes"
STR_NETWORKS_FOUND: "%zu redes encontradas"
STR_SCANNING: "A procurar..."
STR_CONNECTING: "A ligar..."
STR_CONNECTED: "Ligado!"
STR_CONNECTION_FAILED: "Falha na ligação"
STR_FORGET_NETWORK: "Esquecer a rede?"
STR_SAVE_PASSWORD: "Guardar palavra-passe para a próxima vez?"
STR_PRESS_OK_SCAN: "Prima OK para procurar novamente"
STR_JOIN_NETWORK: "Ligar a uma rede"
STR_CREATE_HOTSPOT: "Criar hotspot"
STR_JOIN_DESC: "Ligue-se a uma rede WiFi existente"
STR_HOTSPOT_DESC: "Crie uma rede WiFi à qual outras pessoas se possam ligar"
STR_STARTING_HOTSPOT: "A iniciar hotspot..."
STR_HOTSPOT_MODE: "Modo hotspot"
STR_CONNECT_WIFI_HINT: "Ligue o seu dispositivo a esta rede WiFi"
STR_OPEN_URL_HINT: "Abra este URL no seu navegador"
STR_OR_HTTP_PREFIX: "ou http://"
STR_SCAN_QR_HINT: "ou leia o código QR com o seu telemóvel:"
STR_CALIBRE_WIRELESS: "Calibre sem fios"
STR_CALIBRE_WEB_URL: "URL do Calibre Web"
STR_NETWORK_LEGEND: "* = Encriptada | + = Guardada"
STR_MAC_ADDRESS: "Endereço MAC:"
STR_CHECKING_WIFI: "A verificar WiFi..."
STR_ENTER_WIFI_PASSWORD: "Introduza a palavra-passe do WiFi"
STR_TO_PREFIX: "para"
STR_CALIBRE_RECEIVING: "A receber:"
STR_CALIBRE_RECEIVED: "Recebido:"
STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede WiFi"
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar para o dispositivo\""
STR_CALIBRE_INSTRUCTION_4: "\"Mantenha este ecrã aberto durante o envio\""
STR_CAT_DISPLAY: "Ecrã"
STR_CAT_READER: "Leitor"
STR_CAT_CONTROLS: "Controlos"
STR_CAT_SYSTEM: "Sistema"
STR_SLEEP_SCREEN: "Ecrã de repouso"
STR_SLEEP_COVER_MODE: "Modo de capa do ecrã de repouso"
STR_HIDE_BATTERY: "Ocultar % da bateria"
STR_EXTRA_SPACING: "Espaço extra entre parágrafos"
STR_TEXT_AA: "Suavização do texto"
STR_SHORT_PWR_BTN: "Pressão curta do botão de energia"
STR_ORIENTATION: "Orientação de leitura"
STR_SIDE_BTN_LAYOUT: "Disposição dos botões laterais"
STR_LONG_PRESS_SKIP: "Saltar capítulo com pressão longa"
STR_FONT_FAMILY: "Tipo de letra do leitor"
STR_FONT_SIZE: "Tamanho da letra do leitor"
STR_LINE_SPACING: "Espaçamento entre linhas"
STR_SCREEN_MARGIN: "Margens do ecrã"
STR_PARA_ALIGNMENT: "Alinhamento do parágrafo"
STR_HYPHENATION: "Hifenização"
STR_TIME_TO_SLEEP: "Tempo até entrar em repouso"
STR_REFRESH_FREQ: "Frequência de atualização"
STR_KOREADER_SYNC: "Sincronização KOReader"
STR_CHECK_UPDATES: "Procurar atualizações"
STR_LANGUAGE: "Idioma"
STR_CLEAR_READING_CACHE: "Limpar cache de leitura"
STR_USERNAME: "Nome de utilizador"
STR_PASSWORD: "Palavra-passe"
STR_SYNC_SERVER_URL: "URL do servidor de sincronização"
STR_DOCUMENT_MATCHING: "Correspondência de documentos"
STR_AUTHENTICATE: "Autenticar"
STR_KOREADER_USERNAME: "Utilizador do KOReader"
STR_KOREADER_PASSWORD: "Palavra-passe do KOReader"
STR_FILENAME: "Nome do ficheiro"
STR_BINARY: "Binário"
STR_SET_CREDENTIALS_FIRST: "Defina primeiro as credenciais"
STR_WIFI_CONN_FAILED: "Falha na ligação WiFi"
STR_AUTHENTICATING: "A autenticar..."
STR_AUTH_SUCCESS: "Autenticação bem-sucedida!"
STR_KOREADER_AUTH: "Autenticação do KOReader"
STR_SYNC_READY: "A sincronização do KOReader está pronta a ser utilizada"
STR_AUTH_FAILED: "Falha na autenticação"
STR_DONE: "Concluído"
STR_CLEAR_CACHE_WARNING_1: "Isto irá limpar todos os dados de livros em cache."
STR_CLEAR_CACHE_WARNING_2: "Todo o progresso de leitura será perdido!"
STR_CLEAR_CACHE_WARNING_3: "Os livros terão de ser reindexados"
STR_CLEAR_CACHE_WARNING_4: "quando forem abertos novamente."
STR_CLEARING_CACHE: "A limpar cache..."
STR_CACHE_CLEARED: "Cache limpa"
STR_ITEMS_REMOVED: "itens removidos"
STR_FAILED_LOWER: "falhou"
STR_CLEAR_CACHE_FAILED: "Falha ao limpar a cache"
STR_CHECK_SERIAL_OUTPUT: "Verifique a saída série"
STR_DARK: "Escuro"
STR_LIGHT: "Claro"
STR_CUSTOM: "Personalizado"
STR_COVER: "Capa"
STR_NONE_OPT: "Nenhum"
STR_FIT: "Ajustar"
STR_CROP: "Recortar"
STR_NEVER: "Nunca"
STR_IN_READER: "No leitor"
STR_ALWAYS: "Sempre"
STR_IGNORE: "Ignorar"
STR_SLEEP: "Repouso"
STR_PAGE_TURN: "Virar página"
STR_PORTRAIT: "Retrato"
STR_LANDSCAPE_CW: "Paisagem H"
STR_INVERTED: "Invertido"
STR_LANDSCAPE_CCW: "Paisagem AH"
STR_PREV_NEXT: "Ant./Próx."
STR_NEXT_PREV: "Próx./Ant."
STR_BOOKERLY: "Bookerly"
STR_NOTO_SANS: "Noto Sans"
STR_OPEN_DYSLEXIC: "Open Dyslexic"
STR_SMALL: "Pequeno"
STR_MEDIUM: "Médio"
STR_LARGE: "Grande"
STR_X_LARGE: "Extra grande"
STR_TIGHT: "Apertado"
STR_NORMAL: "Normal"
STR_WIDE: "Largo"
STR_JUSTIFY: "Justificar"
STR_ALIGN_LEFT: "Esquerda"
STR_CENTER: "Centro"
STR_ALIGN_RIGHT: "Direita"
STR_MIN_1: "1 min"
STR_MIN_5: "5 min"
STR_MIN_10: "10 min"
STR_MIN_15: "15 min"
STR_MIN_30: "30 min"
STR_PAGES_1: "1 página"
STR_PAGES_5: "5 páginas"
STR_PAGES_10: "10 páginas"
STR_PAGES_15: "15 páginas"
STR_PAGES_30: "30 páginas"
STR_UPDATE: "Atualizar"
STR_CHECKING_UPDATE: "A procurar atualização..."
STR_NEW_UPDATE: "Nova atualização disponível!"
STR_CURRENT_VERSION: "Versão atual:"
STR_NEW_VERSION: "Nova versão:"
STR_UPDATING: "A atualizar..."
STR_NO_UPDATE: "Não há atualizações disponíveis"
STR_UPDATE_FAILED: "Falha na atualização"
STR_UPDATE_COMPLETE: "Atualização concluída"
STR_POWER_ON_HINT: "Prima continuamente o botão de energia para voltar a ligar"
STR_NO_ENTRIES: "Nenhuma entrada encontrada"
STR_DOWNLOADING: "A transferir..."
STR_DOWNLOAD_FAILED: "Falha na transferência"
STR_ERROR_MSG: "Erro:"
STR_UNNAMED: "Sem nome"
STR_NO_SERVER_URL: "Nenhum URL de servidor configurado"
STR_FETCH_FEED_FAILED: "Falha ao obter o feed"
STR_PARSE_FEED_FAILED: "Falha ao analisar o feed"
STR_NETWORK_PREFIX: "Rede:"
STR_IP_ADDRESS_PREFIX: "Endereço IP:"
STR_ERROR_GENERAL_FAILURE: "Erro: falha geral"
STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada"
STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite de ligação"
STR_SD_CARD: "Cartão SD"
STR_BACK: "« Voltar"
STR_EXIT: "« Sair"
STR_HOME: "« Início"
STR_SELECT: "Selecionar"
STR_TOGGLE: "Alternar"
STR_CONFIRM: "Confirmar"
STR_CANCEL: "Cancelar"
STR_CONNECT: "Ligar"
STR_OPEN: "Abrir"
STR_DOWNLOAD: "Transferir"
STR_RETRY: "Tentar de novo"
STR_YES: "Sim"
STR_NO: "Não"
STR_STATE_ON: "LIG."
STR_STATE_OFF: "DESL."
STR_NOT_SET: "Não definido"
STR_DIR_LEFT: "Esquerda"
STR_DIR_RIGHT: "Direita"
STR_DIR_UP: "Cima"
STR_DIR_DOWN: "Baixo"
STR_OK_BUTTON: "OK"
STR_SLEEP_COVER_FILTER: "Filtro da capa do ecrã de repouso"
STR_FILTER_CONTRAST: "Contraste"
STR_UI_THEME: "Tema da interface"
STR_THEME_CLASSIC: "Clássico"
STR_THEME_LYRA: "Lyra"
STR_THEME_LYRA_EXTENDED: "Lyra Extended"
STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol"
STR_REMAP_FRONT_BUTTONS: "Reatribuir botões frontais"
STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Capa + personalizado"
STR_MENU_RECENT_BOOKS: "Livros recentes"
STR_NO_RECENT_BOOKS: "Sem livros recentes"
STR_CALIBRE_DESC: "Usar transferências sem fios do Calibre"
STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a palavra-passe guardada?"
STR_FORGET_BUTTON: "Esquecer"
STR_CALIBRE_STARTING: "A iniciar o Calibre..."
STR_CALIBRE_SETUP: "Configuração"
STR_CALIBRE_STATUS: "Estado"
STR_CLEAR_BUTTON: "Limpar"
STR_DEFAULT_VALUE: "Predefinição"
STR_REMAP_PROMPT: "Prima um botão frontal para cada função"
STR_UNASSIGNED: "Não atribuído"
STR_ALREADY_ASSIGNED: "Já atribuído"
STR_REMAP_RESET_HINT: "Botão lateral superior: repor a disposição predefinida"
STR_REMAP_CANCEL_HINT: "Botão lateral inferior: cancelar reatribuição"
STR_HW_BACK_LABEL: "Voltar (1º botão)"
STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)"
STR_HW_LEFT_LABEL: "Esquerda (3º botão)"
STR_HW_RIGHT_LABEL: "Direita (4º botão)"
STR_GO_TO_PERCENT: "Ir para %"
STR_GO_HOME_BUTTON: "Ir para o início"
STR_SYNC_PROGRESS: "Sincronizar progresso"
STR_DELETE_CACHE: "Eliminar cache do livro"
STR_DELETE: "Eliminar"
STR_CHAPTER_PREFIX: "Capítulo:"
STR_PAGES_SEPARATOR: "páginas |"
STR_BOOK_PREFIX: "Livro:"
STR_CALIBRE_URL_HINT: "No Calibre, adicione /opds ao seu URL"
STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%"
STR_SYNCING_TIME: "A sincronizar hora..."
STR_CALC_HASH: "A calcular hash do documento..."
STR_HASH_FAILED: "Falha ao calcular o hash do documento"
STR_FETCH_PROGRESS: "A obter progresso remoto..."
STR_UPLOAD_PROGRESS: "A enviar progresso..."
STR_NO_CREDENTIALS_MSG: "Não há credenciais configuradas"
STR_KOREADER_SETUP_HINT: "Configure a conta do KOReader nas Definições"
STR_PROGRESS_FOUND: "Progresso encontrado!"
STR_REMOTE_LABEL: "Remoto:"
STR_LOCAL_LABEL: "Local:"
STR_PAGE_OVERALL_FORMAT: "Página %d, %.2f%% do total"
STR_PAGE_TOTAL_OVERALL_FORMAT: "Página %d/%d, %.2f%% do total"
STR_DEVICE_FROM_FORMAT: "De: %s"
STR_APPLY_REMOTE: "Aplicar progresso remoto"
STR_UPLOAD_LOCAL: "Enviar progresso local"
STR_NO_REMOTE_MSG: "Nenhum progresso remoto encontrado"
STR_UPLOAD_PROMPT: "Enviar posição atual?"
STR_UPLOAD_SUCCESS: "Progresso enviado!"
STR_SYNC_FAILED_MSG: "Falha na sincronização"
STR_SECTION_PREFIX: "Secção"
STR_UPLOAD: "Enviar"
STR_BOOK_S_STYLE: "Estilo do livro"
STR_EMBEDDED_STYLE: "Estilo incorporado"
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
STR_SCREENSHOT_BUTTON: "Capturar ecrã"
STR_SHOW_HIDDEN_FILES: "Mostrar itens ocultos"
STR_IMAGES: "Imagens"
STR_IMAGES_DISPLAY: "Mostrar"
STR_IMAGES_PLACEHOLDER: "Substituir"
STR_IMAGES_SUPPRESS: "Ocultar"
STR_SELECTED: "Selecionado"
STR_SHOW: "Mostrar"
STR_HIDE: "Ocultar"
STR_CUSTOMISE_STATUS_BAR: "Customizar barra estado"
STR_CHAPTER_PAGE_COUNT: "Págs. no cap."
STR_BOOK_PROGRESS_PERCENTAGE: "% do livro"
STR_PROGRESS_BAR: "Barra progresso"
STR_PROGRESS_BAR_THICKNESS: "Espessura Barra progresso"
STR_PROGRESS_BAR_THIN: "Fina"
STR_PROGRESS_BAR_MEDIUM: "Média"
STR_PROGRESS_BAR_THICK: "Grossa"
STR_BOOK: "Livro"
STR_CHAPTER: "Capítulo"
STR_EXAMPLE_CHAPTER: "Capítulo 21"
STR_EXAMPLE_BOOK: "Título do livro"
STR_PREVIEW: "Pré-visualizar"
STR_TITLE: "Título"
STR_BATTERY: "Bateria"
STR_DISPLAY_QR: "Mostrar como QR"
STR_LINK: "[ligação]"
STR_FOOTNOTES: "Notas rodapé"
STR_NO_FOOTNOTES: "Sem notas rodapé na pág."
STR_AUTO_TURN_ENABLED: "Auto-virar ligado: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto-virar (Págs./min)"
+292
View File
@@ -0,0 +1,292 @@
_language_name: "Slovenščina"
_language_code: "SI"
_order: "21"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "ZAGON"
STR_SLEEPING: "SPANJE"
STR_ENTERING_SLEEP: "Prehajanje v spanje"
STR_BROWSE_FILES: "Prebrskaj datoteke"
STR_FILE_TRANSFER: "Prenos datotek"
STR_SETTINGS_TITLE: "Nastavitve"
STR_CONTINUE_READING: "Nadaljuj z branjem"
STR_NO_OPEN_BOOK: "Ni odprte knjige"
STR_START_READING: "Začni brati spodaj"
STR_NO_FILES_FOUND: "Ni najdenih datotek"
STR_SELECT_CHAPTER: "Izberi poglavje"
STR_NO_CHAPTERS: "Ni poglavij"
STR_END_OF_BOOK: "Konec knjige"
STR_EMPTY_CHAPTER: "Prazno poglavje"
STR_INDEXING: "Indeksiranje"
STR_MEMORY_ERROR: "Napaka pomnilnika"
STR_PAGE_LOAD_ERROR: "Napaka pri nalaganju strani"
STR_EMPTY_FILE: "Prazna datoteka"
STR_OUT_OF_BOUNDS: "Izven meja"
STR_LOADING: "Nalaganje..."
STR_LOADING_POPUP: "Nalaganje"
STR_WIFI_NETWORKS: "WiFi omrežja"
STR_NO_NETWORKS: "Ni najdenih omrežij"
STR_NETWORKS_FOUND: "Najdenih omrežij: %zu"
STR_SCANNING: "Iskanje..."
STR_CONNECTING: "Povezovanje..."
STR_CONNECTED: "Povezano!"
STR_CONNECTION_FAILED: "Povezava ni uspela"
STR_FORGET_NETWORK: "Pozabi omrežje?"
STR_SAVE_PASSWORD: "Shranim geslo za naslednjič?"
STR_PRESS_OK_SCAN: "Pritisni OK za ponovno iskanje"
STR_JOIN_NETWORK: "Poveži se v omrežje"
STR_CREATE_HOTSPOT: "Ustvari dostopno točko"
STR_JOIN_DESC: "Poveži se v obstoječe WiFi omrežje"
STR_HOTSPOT_DESC: "Ustvari WiFi omrežje, v katerega se lahko povežejo drugi"
STR_STARTING_HOTSPOT: "Zaganjanje dostopne točke..."
STR_HOTSPOT_MODE: "Način dostopne točke"
STR_CONNECT_WIFI_HINT: "Poveži svojo napravo v to WiFi omrežje"
STR_OPEN_URL_HINT: "Odpri ta URL v svojem brskalniku"
STR_OR_HTTP_PREFIX: "ali http://"
STR_SCAN_QR_HINT: "ali skeniraj QR kodo s telefonom:"
STR_CALIBRE_WIRELESS: "Brezžični Calibre"
STR_CALIBRE_WEB_URL: "Calibre Web URL"
STR_NETWORK_LEGEND: "* = Šifrirano | + = Shranjeno"
STR_MAC_ADDRESS: "MAC naslov:"
STR_CHECKING_WIFI: "Preverjanje WiFi-ja..."
STR_ENTER_WIFI_PASSWORD: "Vnesi WiFi geslo"
STR_TO_PREFIX: "v "
STR_CALIBRE_RECEIVING: "Prejemanje: "
STR_CALIBRE_RECEIVED: "Prejeto: "
STR_CALIBRE_INSTRUCTION_1: "1) Namesti vtičnik CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Bodi v istem WiFi omrežju"
STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: \"Pošlji v napravo\""
STR_CALIBRE_INSTRUCTION_4: "\"Med pošiljanjem pusti ta zaslon odprt\""
STR_CAT_DISPLAY: "Zaslon"
STR_CAT_READER: "Bralnik"
STR_CAT_CONTROLS: "Kontrole"
STR_CAT_SYSTEM: "Sistem"
STR_SLEEP_SCREEN: "Zaslon za spanje"
STR_SLEEP_COVER_MODE: "Način naslovnice v spanju"
STR_HIDE_BATTERY: "Skrij % baterije"
STR_EXTRA_SPACING: "Dodaten razmik med odstavki"
STR_TEXT_AA: "Glajenje besedila (AA)"
STR_IMAGES: "Slike"
STR_IMAGES_DISPLAY: "Prikaži"
STR_IMAGES_PLACEHOLDER: "Oznaka mesta"
STR_IMAGES_SUPPRESS: "Zatdi"
STR_SHORT_PWR_BTN: "Kratek pritisk na gumb za vklop"
STR_ORIENTATION: "Orientacija branja"
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
STR_FONT_FAMILY: "Pisava bralnika"
STR_FONT_SIZE: "Velikost pisave"
STR_LINE_SPACING: "Razmik med vrsticami"
STR_SCREEN_MARGIN: "Robovi zaslona"
STR_PARA_ALIGNMENT: "Poravnava odstavkov"
STR_HYPHENATION: "Deljenje besed"
STR_TIME_TO_SLEEP: "Čas do spanja"
STR_SHOW_HIDDEN_FILES: "Prikaži skrite datoteke"
STR_REFRESH_FREQ: "Pogostost osveževanja"
STR_KOREADER_SYNC: "KOReader sinhronizacija"
STR_CHECK_UPDATES: "Preveri posodobitve"
STR_LANGUAGE: "Jezik"
STR_CLEAR_READING_CACHE: "Počisti predpomnilnik branja"
STR_USERNAME: "Uporabniško ime"
STR_PASSWORD: "Geslo"
STR_SYNC_SERVER_URL: "URL strežnika za sinhronizacijo"
STR_DOCUMENT_MATCHING: "Ujemanje dokumentov"
STR_AUTHENTICATE: "Avtentikacija"
STR_KOREADER_USERNAME: "KOReader uporabnik"
STR_KOREADER_PASSWORD: "KOReader geslo"
STR_FILENAME: "Ime datoteke"
STR_BINARY: "Binarno"
STR_SET_CREDENTIALS_FIRST: "Najprej nastavi podatke za prijavo"
STR_WIFI_CONN_FAILED: "WiFi povezava ni uspela"
STR_AUTHENTICATING: "Preverjanje..."
STR_AUTH_SUCCESS: "Uspešna prijava!"
STR_KOREADER_AUTH: "KOReader avtentikacija"
STR_SYNC_READY: "KOReader sinhronizacija je pripravljena"
STR_AUTH_FAILED: "Prijava ni uspela"
STR_DONE: "Končano"
STR_CLEAR_CACHE_WARNING_1: "To bo izbrisalo vse predpomnjene podatke o knjigah."
STR_CLEAR_CACHE_WARNING_2: "Ves napredek pri branju bo izgubljen!"
STR_CLEAR_CACHE_WARNING_3: "Knjige bo treba ob ponovnem odpiranju"
STR_CLEAR_CACHE_WARNING_4: "ponovno indeksirati."
STR_CLEARING_CACHE: "Čiščenje predpomnilnika..."
STR_CACHE_CLEARED: "Predpomnilnik očiščen"
STR_ITEMS_REMOVED: "elementov odstranjenih"
STR_FAILED_LOWER: "ni uspelo"
STR_CLEAR_CACHE_FAILED: "Čiščenje predpomnilnika ni uspelo"
STR_CHECK_SERIAL_OUTPUT: "Za podrobnosti preveri serijski izhod"
STR_DARK: "Temno"
STR_LIGHT: "Svetlo"
STR_CUSTOM: "Po meri"
STR_COVER: "Naslovnica"
STR_NONE_OPT: "Brez"
STR_FIT: "Prilagodi"
STR_CROP: "Obreži"
STR_NEVER: "Nikoli"
STR_IN_READER: "V bralniku"
STR_ALWAYS: "Vedno"
STR_IGNORE: "Prezri"
STR_SLEEP: "Spanje"
STR_PAGE_TURN: "Obračanje strani"
STR_PORTRAIT: "Pokončno"
STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)"
STR_INVERTED: "Obrnjeno"
STR_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)"
STR_PREV_NEXT: "Nazaj/Naprej"
STR_NEXT_PREV: "Naprej/Nazaj"
STR_BOOKERLY: "Bookerly"
STR_NOTO_SANS: "Noto Sans"
STR_OPEN_DYSLEXIC: "Open Dyslexic"
STR_SMALL: "Majhno"
STR_MEDIUM: "Srednje"
STR_LARGE: "Veliko"
STR_X_LARGE: "Zelo veliko"
STR_TIGHT: "Tesno"
STR_NORMAL: "Normalno"
STR_WIDE: "Široko"
STR_JUSTIFY: "Obojestransko"
STR_ALIGN_LEFT: "Levo"
STR_CENTER: "Sredinsko"
STR_ALIGN_RIGHT: "Desno"
STR_MIN_1: "1 min"
STR_MIN_5: "5 min"
STR_MIN_10: "10 min"
STR_MIN_15: "15 min"
STR_MIN_30: "30 min"
STR_PAGES_1: "1 stran"
STR_PAGES_5: "5 strani"
STR_PAGES_10: "10 strani"
STR_PAGES_15: "15 strani"
STR_PAGES_30: "30 strani"
STR_UPDATE: "Posodobi"
STR_CHECKING_UPDATE: "Preverjanje posodobitev..."
STR_NEW_UPDATE: "Na voljo je nova posodobitev!"
STR_CURRENT_VERSION: "Trenutna različica: "
STR_NEW_VERSION: "Nova različica: "
STR_UPDATING: "Posodabljanje..."
STR_NO_UPDATE: "Ni novih posodobitev"
STR_UPDATE_FAILED: "Posodobitev ni uspela"
STR_UPDATE_COMPLETE: "Posodobitev končana"
STR_POWER_ON_HINT: "Pridrži gumb za vklop, da napravo znova vklopiš"
STR_NO_ENTRIES: "Ni najdenih vnosov"
STR_DOWNLOADING: "Prenašanje..."
STR_DOWNLOAD_FAILED: "Prenos ni uspel"
STR_ERROR_MSG: "Napaka:"
STR_UNNAMED: "Neimenovano"
STR_NO_SERVER_URL: "URL strežnika ni nastavljen"
STR_FETCH_FEED_FAILED: "Nalaganje vira ni uspelo"
STR_PARSE_FEED_FAILED: "Razčlenjevanje vira ni uspelo"
STR_NETWORK_PREFIX: "Omrežje: "
STR_IP_ADDRESS_PREFIX: "IP naslov: "
STR_ERROR_GENERAL_FAILURE: "Napaka: Splošna napaka"
STR_ERROR_NETWORK_NOT_FOUND: "Napaka: Omrežje ni najdeno"
STR_ERROR_CONNECTION_TIMEOUT: "Napaka: Časovna omejitev povezave"
STR_SD_CARD: "SD kartica"
STR_BACK: "« Nazaj"
STR_EXIT: "« Izhod"
STR_HOME: "« Domov"
STR_SELECT: "Izberi"
STR_SELECTED: "Izbrano"
STR_TOGGLE: "Preklopi"
STR_CONFIRM: "Potrdi"
STR_CANCEL: "Prekliči"
STR_CONNECT: "Poveži"
STR_OPEN: "Odpri"
STR_DOWNLOAD: "Prenesi"
STR_RETRY: "Poskusi znova"
STR_YES: "Da"
STR_NO: "Ne"
STR_SHOW: "Prikaži"
STR_HIDE: "Skrij"
STR_STATE_ON: "VKLOP"
STR_STATE_OFF: "IZKLOP"
STR_NOT_SET: "Ni nastavljeno"
STR_DIR_LEFT: "Levo"
STR_DIR_RIGHT: "Desno"
STR_DIR_UP: "Gor"
STR_DIR_DOWN: "Dol"
STR_OK_BUTTON: "V redu"
STR_SLEEP_COVER_FILTER: "Filter naslovnice v spanju"
STR_FILTER_CONTRAST: "Kontrast"
STR_CUSTOMISE_STATUS_BAR: "Prilagodi vrstico stanja"
STR_CHAPTER_PAGE_COUNT: "Število strani v poglavju"
STR_BOOK_PROGRESS_PERCENTAGE: "Odstotek napredka v knjigi"
STR_PROGRESS_BAR: "Vrstica napredka"
STR_PROGRESS_BAR_THICKNESS: "Debelina vrstice napredka"
STR_PROGRESS_BAR_THIN: "Tanko"
STR_PROGRESS_BAR_MEDIUM: "Srednje"
STR_PROGRESS_BAR_THICK: "Debelo"
STR_BOOK: "Knjiga"
STR_CHAPTER: "Poglavje"
STR_EXAMPLE_CHAPTER: "Poglavje 21"
STR_EXAMPLE_BOOK: "Naslov knjige"
STR_PREVIEW: "Predogled"
STR_TITLE: "Naslov"
STR_BATTERY: "Baterija"
STR_UI_THEME: "Tema uporabniškega vmesnika"
STR_THEME_CLASSIC: "Klasična"
STR_THEME_LYRA: "Lyra"
STR_THEME_LYRA_EXTENDED: "Lyra razširjena"
STR_SUNLIGHT_FADING_FIX: "Popravek bledenja na soncu"
STR_REMAP_FRONT_BUTTONS: "Prenastavi sprednje gumbe"
STR_OPDS_BROWSER: "OPDS brskalnik"
STR_COVER_CUSTOM: "Naslovnica + po meri"
STR_MENU_RECENT_BOOKS: "Zadnje knjige"
STR_NO_RECENT_BOOKS: "Ni zadnjih knjig"
STR_CALIBRE_DESC: "Uporabi brezžični prenos Calibre"
STR_FORGET_AND_REMOVE: "Pozabi omrežje in odstrani shranjeno geslo?"
STR_FORGET_BUTTON: "Pozabi"
STR_CALIBRE_STARTING: "Zaganjanje Calibre..."
STR_CALIBRE_SETUP: "Nastavitev"
STR_CALIBRE_STATUS: "Stanje"
STR_CLEAR_BUTTON: "Počisti"
STR_DEFAULT_VALUE: "Privzeto"
STR_REMAP_PROMPT: "Pritisni sprednji gumb za vsako vlogo"
STR_UNASSIGNED: "Nedodeljeno"
STR_ALREADY_ASSIGNED: "Že dodeljeno"
STR_REMAP_RESET_HINT: "Stranski gumb gor: Ponastavi na privzeto"
STR_REMAP_CANCEL_HINT: "Stranski gumb dol: Prekliči nastavljanje"
STR_HW_BACK_LABEL: "Nazaj (1. gumb)"
STR_HW_CONFIRM_LABEL: "Potrdi (2. gumb)"
STR_HW_LEFT_LABEL: "Levo (3. gumb)"
STR_HW_RIGHT_LABEL: "Desno (4. gumb)"
STR_GO_TO_PERCENT: "Pojdi na %"
STR_GO_HOME_BUTTON: "Pojdi domov"
STR_SYNC_PROGRESS: "Sinhroniziraj napredek"
STR_DELETE_CACHE: "Izbriši predpomnilnik knjige"
STR_DELETE: "Izbriši"
STR_DISPLAY_QR: "Prikaži stran kot QR"
STR_CHAPTER_PREFIX: "Poglavje: "
STR_PAGES_SEPARATOR: " strani | "
STR_BOOK_PREFIX: "Knjiga: "
STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju"
STR_PERCENT_STEP_HINT: "Levo/desno: 1% Gor/dol: 10%"
STR_SYNCING_TIME: "Sinhronizacija časa..."
STR_CALC_HASH: "Izračunavanje podpisa dokumenta..."
STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel"
STR_FETCH_PROGRESS: "Pridobivanje napredka iz oblaka..."
STR_UPLOAD_PROGRESS: "Nalaganje napredka..."
STR_NO_CREDENTIALS_MSG: "Podatki za prijavo niso nastavljeni"
STR_KOREADER_SETUP_HINT: "Nastavi KOReader račun v nastavitvah"
STR_PROGRESS_FOUND: "Najden napredek!"
STR_REMOTE_LABEL: "Oddaljeno:"
STR_LOCAL_LABEL: "Lokalno:"
STR_PAGE_OVERALL_FORMAT: "Stran %d, %.2f%% skupno"
STR_PAGE_TOTAL_OVERALL_FORMAT: "Stran %d/%d, %.2f%% skupno"
STR_DEVICE_FROM_FORMAT: " Iz: %s"
STR_APPLY_REMOTE: "Uporabi oddaljen napredek"
STR_UPLOAD_LOCAL: "Naloži lokalni napredek"
STR_NO_REMOTE_MSG: "Oddaljen napredek ni bil najden"
STR_UPLOAD_PROMPT: "Naložim trenutno pozicijo?"
STR_UPLOAD_SUCCESS: "Napredek naložen!"
STR_SYNC_FAILED_MSG: "Sinhronizacija ni uspela"
STR_SECTION_PREFIX: "Razdelek "
STR_UPLOAD: "Naloži"
STR_BOOK_S_STYLE: "Slog knjige"
STR_EMBEDDED_STYLE: "Vgrajen slog"
STR_OPDS_SERVER_URL: "URL OPDS strežnika"
STR_FOOTNOTES: "Opombe"
STR_NO_FOOTNOTES: "Na tej strani ni opomb"
STR_LINK: "[povezava]"
STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona"
STR_AUTO_TURN_ENABLED: "Samodejno obračanje: "
STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)"
+305 -324
View File
@@ -2,22 +2,15 @@
#include <HalDisplay.h>
#include <HalStorage.h>
#include <JPEGDEC.h>
#include <Logging.h>
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
#include <new>
#include "BitmapHelpers.h"
// Context structure for picojpeg callback
struct JpegReadContext {
FsFile& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
};
// ============================================================================
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
// ============================================================================
@@ -165,103 +158,292 @@ static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height)
}
}
// Callback function for picojpeg to read JPEG data
unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
auto* context = static_cast<JpegReadContext*>(pCallback_data);
namespace {
if (!context || !context->file) {
return PJPG_STREAM_READ_ERROR;
// Max MCU height supported by any JPEG (4:2:0 chroma = 16 rows, 4:4:4 = 8 rows)
constexpr int MAX_MCU_HEIGHT = 16;
constexpr size_t JPEG_DECODER_SIZE = 20 * 1024;
constexpr size_t MIN_FREE_HEAP = JPEG_DECODER_SIZE + 32 * 1024;
// Static file pointer for JPEGDEC open callback.
// Safe in single-threaded embedded context; never accessed concurrently.
static FsFile* s_jpegFile = nullptr;
void* bmpJpegOpen(const char* /*filename*/, int32_t* size) {
if (!s_jpegFile || !*s_jpegFile) return nullptr;
s_jpegFile->seek(0);
*size = static_cast<int32_t>(s_jpegFile->size());
return s_jpegFile;
}
void bmpJpegClose(void* /*handle*/) {
// Caller owns the file — do not close it here
}
int32_t bmpJpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
auto* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f) return 0;
int32_t n = f->read(pBuf, len);
if (n < 0) n = 0;
pFile->iPos += n;
return n;
}
int32_t bmpJpegSeek(JPEGFILE* pFile, int32_t pos) {
auto* f = reinterpret_cast<FsFile*>(pFile->fHandle);
if (!f || !f->seek(pos)) return -1;
pFile->iPos = pos;
return pos;
}
// Context passed to the JPEGDEC draw callback via setUserPointer()
struct BmpConvertCtx {
Print* bmpOut;
int srcWidth;
int srcHeight;
int outWidth;
int outHeight;
bool oneBit;
int bytesPerRow;
bool needsScaling;
uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point
uint32_t scaleY_fp;
// Accumulates one MCU row (up to MAX_MCU_HEIGHT source rows × srcWidth pixels)
// Filled column-by-column as JPEGDEC callbacks arrive for the same MCU row
uint8_t* mcuBuf;
// Y-axis area averaging accumulators (needsScaling only)
int currentOutY;
uint32_t nextOutY_srcStart; // 16.16 fixed-point boundary for the next output row
uint32_t* rowAccum;
uint32_t* rowCount;
uint8_t* bmpRow;
AtkinsonDitherer* atkinsonDitherer;
FloydSteinbergDitherer* fsDitherer;
Atkinson1BitDitherer* atkinson1BitDitherer;
bool error;
};
// Write a fully-assembled output row (grayscale bytes, length outWidth) to BMP
static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) {
memset(ctx->bmpRow, 0, ctx->bytesPerRow);
if (USE_8BIT_OUTPUT && !ctx->oneBit) {
for (int x = 0; x < ctx->outWidth; x++) {
ctx->bmpRow[x] = adjustPixel(srcRow[x]);
}
} else if (ctx->oneBit) {
for (int x = 0; x < ctx->outWidth; x++) {
const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(srcRow[x], x)
: quantize1bit(srcRow[x], x, outY);
ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8)));
}
if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow();
} else {
for (int x = 0; x < ctx->outWidth; x++) {
const uint8_t gray = adjustPixel(srcRow[x]);
uint8_t twoBit;
if (ctx->atkinsonDitherer) {
twoBit = ctx->atkinsonDitherer->processPixel(gray, x);
} else if (ctx->fsDitherer) {
twoBit = ctx->fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, outY);
}
ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8)));
}
if (ctx->atkinsonDitherer)
ctx->atkinsonDitherer->nextRow();
else if (ctx->fsDitherer)
ctx->fsDitherer->nextRow();
}
// Check if we need to refill our context buffer
if (context->bufferPos >= context->bufferFilled) {
context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer));
context->bufferPos = 0;
ctx->bmpOut->write(ctx->bmpRow, ctx->bytesPerRow);
}
if (context->bufferFilled == 0) {
// EOF or error
*pBytes_actually_read = 0;
return 0; // Success (EOF is normal)
// Flush one scaled output row from Y-axis accumulators and advance currentOutY
static void flushScaledRow(BmpConvertCtx* ctx) {
memset(ctx->bmpRow, 0, ctx->bytesPerRow);
if (USE_8BIT_OUTPUT && !ctx->oneBit) {
for (int x = 0; x < ctx->outWidth; x++) {
const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0;
ctx->bmpRow[x] = adjustPixel(gray);
}
} else if (ctx->oneBit) {
for (int x = 0; x < ctx->outWidth; x++) {
const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0;
const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(gray, x)
: quantize1bit(gray, x, ctx->currentOutY);
ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8)));
}
if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow();
} else {
for (int x = 0; x < ctx->outWidth; x++) {
const uint8_t gray = adjustPixel((ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0);
uint8_t twoBit;
if (ctx->atkinsonDitherer) {
twoBit = ctx->atkinsonDitherer->processPixel(gray, x);
} else if (ctx->fsDitherer) {
twoBit = ctx->fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, ctx->currentOutY);
}
ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8)));
}
if (ctx->atkinsonDitherer)
ctx->atkinsonDitherer->nextRow();
else if (ctx->fsDitherer)
ctx->fsDitherer->nextRow();
}
ctx->bmpOut->write(ctx->bmpRow, ctx->bytesPerRow);
ctx->currentOutY++;
}
// JPEGDEC draw callback — receives one MCU-width × MCU-height block at a time,
// in left-to-right, top-to-bottom order (baseline JPEG).
// Accumulates columns into mcuBuf; once the last column arrives (completing the MCU
// row), applies scaling + dithering and writes packed BMP rows to bmpOut.
int bmpDrawCallback(JPEGDRAW* pDraw) {
auto* ctx = reinterpret_cast<BmpConvertCtx*>(pDraw->pUser);
if (!ctx || ctx->error) return 0;
const uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
const int stride = pDraw->iWidth;
const int validW = pDraw->iWidthUsed;
const int blockH = pDraw->iHeight;
const int blockX = pDraw->x;
const int blockY = pDraw->y;
// Copy block pixels into MCU row buffer
for (int r = 0; r < blockH && r < MAX_MCU_HEIGHT; r++) {
const int copyW = (blockX + validW <= ctx->srcWidth) ? validW : (ctx->srcWidth - blockX);
if (copyW <= 0) continue;
memcpy(ctx->mcuBuf + r * ctx->srcWidth + blockX, pixels + r * stride, copyW);
}
// Wait for the last MCU column before processing any rows
if (blockX + validW < ctx->srcWidth) return 1;
// Process each complete source row in this MCU row
const int endRow = blockY + blockH;
for (int y = blockY; y < endRow && y < ctx->srcHeight; y++) {
const uint8_t* srcRow = ctx->mcuBuf + (y - blockY) * ctx->srcWidth;
if (!ctx->needsScaling) {
// 1:1 — outWidth == srcWidth, write directly
writeOutputRow(ctx, srcRow, y);
} else {
// Fixed-point area averaging on X axis
for (int outX = 0; outX < ctx->outWidth; outX++) {
const int srcXStart = (static_cast<uint32_t>(outX) * ctx->scaleX_fp) >> 16;
const int srcXEnd = (static_cast<uint32_t>(outX + 1) * ctx->scaleX_fp) >> 16;
int sum = 0;
int count = 0;
for (int srcX = srcXStart; srcX < srcXEnd && srcX < ctx->srcWidth; srcX++) {
sum += srcRow[srcX];
count++;
}
if (count == 0 && srcXStart < ctx->srcWidth) {
sum = srcRow[srcXStart];
count = 1;
}
ctx->rowAccum[outX] += sum;
ctx->rowCount[outX] += count;
}
// Flush output row(s) whose Y boundary we've crossed
const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
while (srcY_fp >= ctx->nextOutY_srcStart && ctx->currentOutY < ctx->outHeight) {
flushScaledRow(ctx);
ctx->nextOutY_srcStart = static_cast<uint32_t>(ctx->currentOutY + 1) * ctx->scaleY_fp;
if (srcY_fp >= ctx->nextOutY_srcStart) continue;
memset(ctx->rowAccum, 0, ctx->outWidth * sizeof(uint32_t));
memset(ctx->rowCount, 0, ctx->outWidth * sizeof(uint32_t));
}
}
}
// Copy available bytes to picojpeg's buffer
const size_t available = context->bufferFilled - context->bufferPos;
const size_t toRead = available < buf_size ? available : buf_size;
memcpy(pBuf, context->buffer + context->bufferPos, toRead);
context->bufferPos += toRead;
*pBytes_actually_read = static_cast<unsigned char>(toRead);
return 0; // Success
return ctx->error ? 0 : 1;
}
} // namespace
// Internal implementation with configurable target size and bit depth
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit, bool crop) {
LOG_DBG("JPG", "Converting JPEG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight);
// Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
// Initialize picojpeg decoder
pjpeg_image_info_t imageInfo;
const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
LOG_ERR("JPG", "JPEG decode init failed with error code: %d", status);
if (ESP.getFreeHeap() < MIN_FREE_HEAP) {
LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", ESP.getFreeHeap(), MIN_FREE_HEAP);
return false;
}
LOG_DBG("JPG", "JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d", imageInfo.m_width, imageInfo.m_height,
imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
s_jpegFile = &jpegFile;
JPEGDEC* jpeg = new (std::nothrow) JPEGDEC();
if (!jpeg) {
LOG_ERR("JPG", "Failed to allocate JPEG decoder");
return false;
}
int rc = jpeg->open("", bmpJpegOpen, bmpJpegClose, bmpJpegRead, bmpJpegSeek, bmpDrawCallback);
if (rc != 1) {
LOG_ERR("JPG", "JPEG open failed (err=%d)", jpeg->getLastError());
delete jpeg;
return false;
}
const int srcWidth = jpeg->getWidth();
const int srcHeight = jpeg->getHeight();
LOG_DBG("JPG", "JPEG dimensions: %dx%d", srcWidth, srcHeight);
// Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
constexpr int MAX_MCU_ROW_BYTES = 65536;
if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
LOG_DBG("JPG", "Image too large (%dx%d), max supported: %dx%d", imageInfo.m_width, imageInfo.m_height,
MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
if (srcWidth <= 0 || srcHeight <= 0 || srcWidth > MAX_IMAGE_WIDTH || srcHeight > MAX_IMAGE_HEIGHT) {
LOG_DBG("JPG", "Image too large or invalid (%dx%d), max supported: %dx%d", srcWidth, srcHeight, MAX_IMAGE_WIDTH,
MAX_IMAGE_HEIGHT);
jpeg->close();
delete jpeg;
return false;
}
// Calculate output dimensions (pre-scale to fit display exactly)
int outWidth = imageInfo.m_width;
int outHeight = imageInfo.m_height;
// Use fixed-point scaling (16.16) for sub-pixel accuracy
int outWidth = srcWidth;
int outHeight = srcHeight;
uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point
uint32_t scaleY_fp = 65536;
bool needsScaling = false;
if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width != targetWidth || imageInfo.m_height != targetHeight)) {
// Calculate scale to fit/fill target dimensions while maintaining aspect ratio
const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
// We scale to the smaller dimension, so we can potentially crop later.
float scale = 1.0;
if (crop) { // if we will crop, scale to the smaller dimension
if (targetWidth > 0 && targetHeight > 0 && (srcWidth != targetWidth || srcHeight != targetHeight)) {
const float scaleToFitWidth = static_cast<float>(targetWidth) / srcWidth;
const float scaleToFitHeight = static_cast<float>(targetHeight) / srcHeight;
float scale = 1.0f;
if (crop) {
scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
} else { // else, scale to the larger dimension to fit
} else {
scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
}
outWidth = static_cast<int>(imageInfo.m_width * scale);
outHeight = static_cast<int>(imageInfo.m_height * scale);
// Ensure at least 1 pixel
outWidth = static_cast<int>(srcWidth * scale);
outHeight = static_cast<int>(srcHeight * scale);
if (outWidth < 1) outWidth = 1;
if (outHeight < 1) outHeight = 1;
// Calculate fixed-point scale factors (source pixels per output pixel)
// scaleX_fp = (srcWidth << 16) / outWidth
scaleX_fp = (static_cast<uint32_t>(imageInfo.m_width) << 16) / outWidth;
scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
scaleX_fp = (static_cast<uint32_t>(srcWidth) << 16) / outWidth;
scaleY_fp = (static_cast<uint32_t>(srcHeight) << 16) / outHeight;
needsScaling = true;
LOG_DBG("JPG", "Scaling %dx%d -> %dx%d (target %dx%d)", imageInfo.m_width, imageInfo.m_height, outWidth, outHeight,
targetWidth, targetHeight);
LOG_DBG("JPG", "Scaling %dx%d -> %dx%d (target %dx%d)", srcWidth, srcHeight, outWidth, outHeight, targetWidth,
targetHeight);
}
// Write BMP header with output dimensions
@@ -271,285 +453,84 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
bytesPerRow = (outWidth + 3) / 4 * 4;
} else if (oneBit) {
writeBmpHeader1bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel
bytesPerRow = (outWidth + 31) / 32 * 4;
} else {
writeBmpHeader2bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
}
uint8_t* rowBuffer = nullptr;
uint8_t* mcuRowBuffer = nullptr;
AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr;
Atkinson1BitDitherer* atkinson1BitDitherer = nullptr;
uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums)
uint32_t* rowCount = nullptr; // Count of source pixels accumulated per output X
BmpConvertCtx ctx = {};
ctx.bmpOut = &bmpOut;
ctx.srcWidth = srcWidth;
ctx.srcHeight = srcHeight;
ctx.outWidth = outWidth;
ctx.outHeight = outHeight;
ctx.oneBit = oneBit;
ctx.bytesPerRow = bytesPerRow;
ctx.needsScaling = needsScaling;
ctx.scaleX_fp = scaleX_fp;
ctx.scaleY_fp = scaleY_fp;
ctx.error = false;
// RAII guard: frees all heap resources on any return path, including early exits.
// Holds references so it always sees the latest pointer values assigned below.
// RAII guard: frees all heap resources on any return path
struct Cleanup {
uint8_t*& rowBuffer;
uint8_t*& mcuRowBuffer;
AtkinsonDitherer*& atkinsonDitherer;
FloydSteinbergDitherer*& fsDitherer;
Atkinson1BitDitherer*& atkinson1BitDitherer;
uint32_t*& rowAccum;
uint32_t*& rowCount;
BmpConvertCtx& ctx;
JPEGDEC* jpeg;
~Cleanup() {
delete[] rowAccum;
delete[] rowCount;
delete atkinsonDitherer;
delete fsDitherer;
delete atkinson1BitDitherer;
free(mcuRowBuffer);
free(rowBuffer);
delete[] ctx.rowAccum;
delete[] ctx.rowCount;
delete ctx.atkinsonDitherer;
delete ctx.fsDitherer;
delete ctx.atkinson1BitDitherer;
free(ctx.mcuBuf);
free(ctx.bmpRow);
jpeg->close();
delete jpeg;
}
} cleanup{rowBuffer, mcuRowBuffer, atkinsonDitherer, fsDitherer, atkinson1BitDitherer, rowAccum, rowCount};
} cleanup{ctx, jpeg};
// Allocate row buffer
rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!rowBuffer) {
LOG_ERR("JPG", "Failed to allocate row buffer");
// MCU row buffer: MAX_MCU_HEIGHT rows × srcWidth columns of grayscale
ctx.mcuBuf = static_cast<uint8_t*>(malloc(MAX_MCU_HEIGHT * srcWidth));
if (!ctx.mcuBuf) {
LOG_ERR("JPG", "Failed to allocate MCU buffer (%d bytes)", MAX_MCU_HEIGHT * srcWidth);
return false;
}
memset(ctx.mcuBuf, 0, MAX_MCU_HEIGHT * srcWidth);
// Allocate a buffer for one MCU row worth of grayscale pixels
// This is the minimal memory needed for streaming conversion
const int mcuPixelHeight = imageInfo.m_MCUHeight;
const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight;
// Validate MCU row buffer size before allocation
if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
LOG_DBG("JPG", "MCU row buffer too large (%d bytes), max: %d", mcuRowPixels, MAX_MCU_ROW_BYTES);
ctx.bmpRow = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!ctx.bmpRow) {
LOG_ERR("JPG", "Failed to allocate BMP row buffer");
return false;
}
mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
if (!mcuRowBuffer) {
LOG_ERR("JPG", "Failed to allocate MCU row buffer (%d bytes)", mcuRowPixels);
return false;
}
// Create ditherer if enabled
// Use OUTPUT dimensions for dithering (after prescaling)
if (oneBit) {
// For 1-bit output, use Atkinson dithering for better quality
atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth);
} else if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
fsDitherer = new FloydSteinbergDitherer(outWidth);
}
}
// For scaling: accumulate source rows into scaled output rows
// We need to track which source Y maps to which output Y
// Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format)
int currentOutY = 0; // Current output row being accumulated
uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point)
if (needsScaling) {
rowAccum = new uint32_t[outWidth]();
rowCount = new uint32_t[outWidth]();
nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1)
ctx.rowAccum = new (std::nothrow) uint32_t[outWidth]();
ctx.rowCount = new (std::nothrow) uint32_t[outWidth]();
if (!ctx.rowAccum || !ctx.rowCount) {
LOG_ERR("JPG", "Failed to allocate scaling buffers");
return false;
}
ctx.nextOutY_srcStart = scaleY_fp;
}
// Process MCUs row-by-row and write to BMP as we go (top-down)
const int mcuPixelWidth = imageInfo.m_MCUWidth;
for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) {
// Clear the MCU row buffer
memset(mcuRowBuffer, 0, mcuRowPixels);
// Decode one row of MCUs
for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) {
const unsigned char mcuStatus = pjpeg_decode_mcu();
if (mcuStatus != 0) {
if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
LOG_ERR("JPG", "Unexpected end of blocks at MCU (%d, %d)", mcuX, mcuY);
} else {
LOG_ERR("JPG", "JPEG decode MCU failed at (%d, %d) with error code: %d", mcuX, mcuY, mcuStatus);
}
return false;
}
// picojpeg stores MCU data in 8x8 blocks
// Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128
for (int blockY = 0; blockY < mcuPixelHeight; blockY++) {
for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
const int pixelX = mcuX * mcuPixelWidth + blockX;
if (pixelX >= imageInfo.m_width) continue;
// Calculate proper block offset for picojpeg buffer
const int blockCol = blockX / 8;
const int blockRow = blockY / 8;
const int localX = blockX % 8;
const int localY = blockY % 8;
const int blocksPerRow = mcuPixelWidth / 8;
const int blockIndex = blockRow * blocksPerRow + blockCol;
const int pixelOffset = blockIndex * 64 + localY * 8 + localX;
uint8_t gray;
if (imageInfo.m_comps == 1) {
gray = imageInfo.m_pMCUBufR[pixelOffset];
} else {
const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset];
const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset];
const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset];
gray = (r * 25 + g * 50 + b * 25) / 100;
}
mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray;
}
}
if (oneBit) {
ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(outWidth);
} else if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
ctx.fsDitherer = new (std::nothrow) FloydSteinbergDitherer(outWidth);
}
}
// Process source rows from this MCU row
const int startRow = mcuY * mcuPixelHeight;
const int endRow = (mcuY + 1) * mcuPixelHeight;
jpeg->setPixelType(EIGHT_BIT_GRAYSCALE);
jpeg->setUserPointer(&ctx);
for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) {
const int bufferY = y - startRow;
rc = jpeg->decode(0, 0, 0);
if (!needsScaling) {
// No scaling - direct output (1:1 mapping)
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
rowBuffer[x] = adjustPixel(gray);
}
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
const uint8_t bit =
atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]);
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, y);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
} else {
// Fixed-point area averaging for exact fit scaling
// For each output pixel X, accumulate source pixels that map to it
// srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16)
const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width;
for (int outX = 0; outX < outWidth; outX++) {
// Calculate source X range for this output pixel
const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16;
const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16;
// Accumulate all source pixels in this range
int sum = 0;
int count = 0;
for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) {
sum += srcRow[srcX];
count++;
}
// Handle edge case: if no pixels in range, use nearest
if (count == 0 && srcXStart < imageInfo.m_width) {
sum = srcRow[srcXStart];
count = 1;
}
rowAccum[outX] += sum;
rowCount[outX] += count;
}
// Check if we've crossed into the next output row(s)
// Current source Y in fixed point: y << 16
const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
// Output all rows whose boundaries we've crossed (handles both up and downscaling)
// For upscaling, one source row may produce multiple output rows
while (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT && !oneBit) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray);
}
} else if (oneBit) {
// 1-bit output with Atkinson dithering for better quality
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x)
: quantize1bit(gray, x, currentOutY);
// Pack 1-bit value: MSB first, 8 pixels per byte
const int byteIndex = x / 8;
const int bitOffset = 7 - (x % 8);
rowBuffer[byteIndex] |= (bit << bitOffset);
}
if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow();
} else {
// 2-bit output
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0);
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x);
} else {
twoBit = quantize(gray, x, currentOutY);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
currentOutY++;
// Update boundary for next output row
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
// For upscaling: don't reset accumulators if next output row uses same source data
// Only reset when we'll move to a new source row
if (srcY_fp >= nextOutY_srcStart) {
// More output rows to emit from same source - keep accumulator data
continue;
}
// Moving to next source row - reset accumulators
memset(rowAccum, 0, outWidth * sizeof(uint32_t));
memset(rowCount, 0, outWidth * sizeof(uint32_t));
}
}
}
if (rc != 1 || ctx.error) {
LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError());
return false;
}
LOG_DBG("JPG", "Successfully converted JPEG to BMP");
@@ -6,8 +6,6 @@ class Print;
class ZipFile;
class JpegToBmpConverter {
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
static bool jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
bool oneBit, bool crop = true);
@@ -0,0 +1,170 @@
#include "ChapterXPathForwardMapper.h"
#include <HalStorage.h>
#include <Logging.h>
#include <expat.h>
#include <algorithm>
#include <string>
#include <unordered_map>
#include "ChapterXPathIndexerInternal.h"
#include "ChapterXPathIndexerState.h"
namespace ChapterXPathIndexerInternal {
namespace {
// Forward mapper: translate intra-spine progress to a KOReader-compatible XPath.
// Strategy:
// 1) Count total visible text bytes in chapter.
// 2) Stream parse again and stop when target byte offset is reached.
// 3) Emit either an element path or /text()[N].M when at body text-node level.
struct ForwardState : StackState {
int spineIndex;
size_t targetOffset;
std::string result;
bool found = false;
XML_Parser parser = nullptr;
int bodyTextNodeCount = 0;
size_t codepointsInBodyTextNode = 0;
bool inBodyTextNode = false;
ForwardState(const int spineIndex, const size_t targetOffset) : spineIndex(spineIndex), targetOffset(targetOffset) {}
void onStartElement(const XML_Char* rawName) {
inBodyTextNode = false;
pushElement(rawName);
}
void onEndElement() {
inBodyTextNode = false;
popElement();
}
void onCharData(const XML_Char* text, const int len) {
if (shouldSkipText(len) || found) {
return;
}
const bool atBodyLevel = bodyIdx() + 1 == static_cast<int>(stack.size());
if (atBodyLevel && !inBodyTextNode) {
inBodyTextNode = true;
bodyTextNodeCount++;
codepointsInBodyTextNode = 0;
}
if (isWhitespaceOnly(text, len)) {
if (atBodyLevel) {
codepointsInBodyTextNode += countUtf8Codepoints(text, len);
}
return;
}
const size_t visible = countVisibleBytes(text, len);
if (totalTextBytes + visible >= targetOffset) {
if (atBodyLevel && bodyTextNodeCount > 0) {
// KOReader/crengine text-point semantics use codepoint offsets.
const size_t targetVisibleByteInChunk = targetOffset - totalTextBytes;
const size_t cpInChunk = codepointAtVisibleByte(text, len, targetVisibleByteInChunk);
const size_t charOff = codepointsInBodyTextNode + cpInChunk;
result =
currentXPath(spineIndex) + "/text()[" + std::to_string(bodyTextNodeCount) + "]." + std::to_string(charOff);
} else {
result = currentXPath(spineIndex);
}
found = true;
if (parser) {
XML_StopParser(parser, XML_FALSE);
}
return;
}
totalTextBytes += visible;
if (atBodyLevel) {
codepointsInBodyTextNode += countUtf8Codepoints(text, len);
}
}
};
std::string makeSpineCacheKey(const std::shared_ptr<Epub>& epub, const int spineIndex) {
if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) {
return "";
}
const auto spineItem = epub->getSpineItem(spineIndex);
return epub->getCachePath() + "|" + std::to_string(spineIndex) + "|" + spineItem.href;
}
size_t getTotalTextBytesCached(const std::shared_ptr<Epub>& epub, const int spineIndex, const std::string& tmpPath) {
static std::unordered_map<std::string, size_t> sTotalBytesBySpine;
static std::string sCachedBookPath;
const std::string currentBookPath = epub ? epub->getCachePath() : std::string();
if (currentBookPath != sCachedBookPath) {
sTotalBytesBySpine.clear();
sCachedBookPath = currentBookPath;
}
const std::string key = makeSpineCacheKey(epub, spineIndex);
if (!key.empty()) {
const auto it = sTotalBytesBySpine.find(key);
if (it != sTotalBytesBySpine.end()) {
return it->second;
}
}
const size_t totalTextBytes = countTotalTextBytes(tmpPath);
if (!key.empty()) {
sTotalBytesBySpine[key] = totalTextBytes;
}
return totalTextBytes;
}
} // namespace
std::string findXPathForProgressInternal(const std::shared_ptr<Epub>& epub, const int spineIndex,
const float intraSpineProgress) {
const std::string tmpPath = decompressToTempFile(epub, spineIndex);
if (tmpPath.empty()) {
return "";
}
const size_t totalTextBytes = getTotalTextBytesCached(epub, spineIndex, tmpPath);
if (totalTextBytes == 0) {
Storage.remove(tmpPath.c_str());
const std::string base = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
LOG_DBG("KOX", "Forward: spine=%d no text, returning base xpath", spineIndex);
return base;
}
const float clamped = std::max(0.0f, std::min(1.0f, intraSpineProgress));
const size_t targetOffset = static_cast<size_t>(clamped * static_cast<float>(totalTextBytes));
ForwardState state(spineIndex, targetOffset);
XML_Parser parser = XML_ParserCreate(nullptr);
if (!parser) {
Storage.remove(tmpPath.c_str());
return "";
}
state.parser = parser;
XML_SetUserData(parser, &state);
XML_SetElementHandler(parser, parserStartCb<ForwardState>, parserEndCb<ForwardState>);
XML_SetCharacterDataHandler(parser, parserCharCb<ForwardState>);
XML_SetDefaultHandlerExpand(parser, parserDefaultCb<ForwardState>);
runParse(parser, tmpPath);
XML_ParserFree(parser);
Storage.remove(tmpPath.c_str());
if (state.result.empty()) {
state.result = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
}
LOG_DBG("KOX", "Forward: spine=%d progress=%.3f target=%zu/%zu -> %s", spineIndex, intraSpineProgress, targetOffset,
totalTextBytes, state.result.c_str());
return state.result;
}
} // namespace ChapterXPathIndexerInternal
@@ -0,0 +1,12 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <string>
namespace ChapterXPathIndexerInternal {
std::string findXPathForProgressInternal(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
} // namespace ChapterXPathIndexerInternal
+103
View File
@@ -0,0 +1,103 @@
#include "ChapterXPathIndexer.h"
#include <Logging.h>
#include <cctype>
#include <cstdlib>
#include <limits>
#include <string>
#include "ChapterXPathForwardMapper.h"
#include "ChapterXPathIndexerInternal.h"
#include "ChapterXPathReverseMapper.h"
using namespace ChapterXPathIndexerInternal;
// Public facade used by ProgressMapper. It intentionally stays thin and delegates
// heavy parsing/mapping work to the internal forward/reverse modules.
std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr<Epub>& epub, const int spineIndex,
const float intraSpineProgress) {
return findXPathForProgressInternal(epub, spineIndex, intraSpineProgress);
}
bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr<Epub>& epub, const int spineIndex,
const std::string& xpath, float& outIntraSpineProgress,
bool& outExactMatch) {
return findProgressForXPathInternal(epub, spineIndex, xpath, outIntraSpineProgress, outExactMatch);
}
bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex) {
outSpineIndex = -1;
if (xpath.empty()) {
return false;
}
const std::string normalized = normalizeXPath(xpath);
const std::string key = "/docfragment[";
const size_t pos = normalized.find(key);
if (pos == std::string::npos) {
LOG_DBG("KOX", "No DocFragment in xpath: '%s'", xpath.c_str());
return false;
}
const size_t start = pos + key.size();
size_t end = start;
while (end < normalized.size() && std::isdigit(static_cast<unsigned char>(normalized[end]))) {
end++;
}
if (end == start || end >= normalized.size() || normalized[end] != ']') {
return false;
}
const std::string value = normalized.substr(start, end - start);
const long parsed = std::strtol(value.c_str(), nullptr, 10);
// XPath uses 1-based predicates; internal spine indexing is 0-based.
if (parsed < 1 || parsed > std::numeric_limits<int>::max()) {
return false;
}
outSpineIndex = static_cast<int>(parsed) - 1;
return true;
}
bool ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(const std::string& xpath, uint16_t& outParagraphIndex) {
outParagraphIndex = 0;
if (xpath.empty()) {
return false;
}
const std::string normalized = normalizeXPath(xpath);
const std::string bodyKey = "/body";
size_t secondBody = normalized.find(bodyKey);
if (secondBody != std::string::npos) {
secondBody = normalized.find(bodyKey, secondBody + bodyKey.size());
}
const std::string pKey = "/p[";
const size_t pos = normalized.find(pKey, secondBody != std::string::npos ? secondBody : 0);
if (pos == std::string::npos) {
return false;
}
const size_t start = pos + pKey.size();
size_t end = start;
while (end < normalized.size() && std::isdigit(static_cast<unsigned char>(normalized[end]))) {
end++;
}
if (end == start || end >= normalized.size() || normalized[end] != ']') {
return false;
}
const long parsed = std::strtol(normalized.substr(start, end - start).c_str(), nullptr, 10);
// Paragraph index is preserved as 1-based to match XPath p[N] convention.
if (parsed < 1 || parsed > UINT16_MAX) {
return false;
}
outParagraphIndex = static_cast<uint16_t>(parsed);
return true;
}
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <string>
/**
* Lightweight XPath/progress bridge for KOReader sync.
*
* Why this exists:
* - CrossPoint stores reading position as chapter/page.
* - KOReader sync uses XPath + percentage.
*
* This utility reparses exactly one spine XHTML item with Expat to translate
* between the two formats. It streams through the parse using O(1) memory
* (no anchor list), so it handles arbitrarily large chapters without OOM.
*
* Design constraints (ESP32-C3):
* - No persistent full-book structures.
* - Parse-on-demand and free memory immediately.
* - Keep fallback behavior deterministic if parsing/matching fails.
*/
class ChapterXPathIndexer {
public:
/**
* Convert an intra-spine progress ratio to the nearest element-level XPath.
*
* @param epub Loaded EPUB instance
* @param spineIndex Current spine item index
* @param intraSpineProgress Position within the spine item [0.0, 1.0]
* @return Best matching XPath for KOReader, or empty string on failure
*/
static std::string findXPathForProgress(const std::shared_ptr<Epub>& epub, int spineIndex, float intraSpineProgress);
/**
* Resolve a KOReader XPath to an intra-spine progress ratio.
*
* Matching strategy:
* 1) exact anchor path match,
* 2) index-insensitive path match,
* 3) ancestor fallback.
*
* @param epub Loaded EPUB instance
* @param spineIndex Spine item index to parse
* @param xpath Incoming KOReader XPath
* @param outIntraSpineProgress Resolved position within spine [0.0, 1.0]
* @param outExactMatch True only for full exact path match
* @return true if any match was resolved; false means caller should fallback
*/
static bool findProgressForXPath(const std::shared_ptr<Epub>& epub, int spineIndex, const std::string& xpath,
float& outIntraSpineProgress, bool& outExactMatch);
/**
* Parse DocFragment index from KOReader-style path segment:
* /body/DocFragment[N]/body/...
*
* KOReader uses 1-based DocFragment indices; N is converted to the 0-based
* spine index stored in outSpineIndex (i.e. outSpineIndex = N - 1).
*
* @param xpath KOReader XPath
* @param outSpineIndex 0-based spine index derived from DocFragment[N]
* @return true when DocFragment[N] exists and N is a valid integer >= 1
* (converted to 0-based outSpineIndex); false otherwise
*/
static bool tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex);
/**
* Extract the paragraph index from a KOReader XPath.
* Looks for the first /p[N] segment after /body/ and returns N (1-based).
*
* Example: "/body/DocFragment[7]/body/p[685]/text().96" → outParagraphIndex = 685
*
* @param xpath KOReader XPath
* @param outParagraphIndex 1-based paragraph index
* @return true if a /p[N] segment was found
*/
static bool tryExtractParagraphIndexFromXPath(const std::string& xpath, uint16_t& outParagraphIndex);
};
@@ -0,0 +1,350 @@
#include "ChapterXPathIndexerInternal.h"
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
#include <cctype>
#include <unordered_map>
#include <vector>
namespace ChapterXPathIndexerInternal {
std::string toLowerStr(std::string value) {
std::transform(value.begin(), value.end(), value.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return value;
}
bool isSkippableTag(const std::string& tag) { return tag == "head" || tag == "script" || tag == "style"; }
bool isWhitespaceOnly(const XML_Char* text, const int len) {
for (int i = 0; i < len; i++) {
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
return false;
}
}
return true;
}
size_t countVisibleBytes(const XML_Char* text, const int len) {
size_t count = 0;
for (int i = 0; i < len; i++) {
if (!std::isspace(static_cast<unsigned char>(text[i]))) {
count++;
}
}
return count;
}
size_t countUtf8Codepoints(const XML_Char* text, const int len) {
size_t count = 0;
for (int i = 0; i < len; i++) {
if ((static_cast<unsigned char>(text[i]) & 0xC0) != 0x80) {
count++;
}
}
return count;
}
size_t codepointAtVisibleByte(const XML_Char* text, const int len, const size_t targetVisibleByte) {
size_t codepoints = 0;
size_t visibleBytes = 0;
for (int i = 0; i < len; i++) {
const unsigned char uc = static_cast<unsigned char>(text[i]);
const bool isLeadByte = (uc & 0xC0) != 0x80;
if (isLeadByte) {
codepoints++;
}
if (!std::isspace(uc)) {
if (visibleBytes == targetVisibleByte) {
return codepoints - 1;
}
visibleBytes++;
}
}
return codepoints;
}
size_t visibleBytesBeforeCodepoint(const XML_Char* text, const int len, const size_t targetCodepointOffset) {
size_t visibleBytes = 0;
size_t codepointIndex = 0;
int i = 0;
while (i < len) {
if (codepointIndex >= targetCodepointOffset) {
break;
}
const int cpStart = i;
i++;
while (i < len && (static_cast<unsigned char>(text[i]) & 0xC0) == 0x80) {
i++;
}
for (int j = cpStart; j < i; j++) {
if (!std::isspace(static_cast<unsigned char>(text[j]))) {
visibleBytes++;
}
}
codepointIndex++;
}
return visibleBytes;
}
std::string normalizeXPath(const std::string& input) {
if (input.empty()) {
return "";
}
std::string out;
out.reserve(input.size());
for (const char c : input) {
const unsigned char uc = static_cast<unsigned char>(c);
if (std::isspace(uc)) {
continue;
}
out.push_back(static_cast<char>(std::tolower(uc)));
}
const std::string textTag = "/text()";
const size_t textPos = out.rfind(textTag);
if (textPos != std::string::npos) {
const size_t afterText = textPos + textTag.size();
if (afterText == out.size() || out[afterText] == '.' || out[afterText] == '[') {
out.erase(textPos);
}
}
const size_t lastSlash = out.rfind('/');
if (lastSlash != std::string::npos) {
const size_t dotPos = out.find('.', lastSlash + 1);
if (dotPos != std::string::npos && dotPos + 1 < out.size()) {
bool allDigits = true;
for (size_t i = dotPos + 1; i < out.size(); i++) {
if (!std::isdigit(static_cast<unsigned char>(out[i]))) {
allDigits = false;
break;
}
}
if (allDigits) {
out.erase(dotPos);
}
}
}
while (!out.empty() && out.back() == '/') {
out.pop_back();
}
// KOReader sometimes omits the [1] predicate for elements that are the sole
// child of their type (e.g. /body/div/p[55] instead of /body/div[1]/p[55]).
// In XPath, an unqualified name is equivalent to name[1] when there is only
// one sibling of that type, but our parser always generates explicit indices.
// Insert [1] for any bare element path segment so comparisons match.
std::string normalized;
normalized.reserve(out.size() + 16);
size_t i = 0;
while (i < out.size()) {
if (out[i] == '/') {
normalized.push_back('/');
i++;
// Copy element name (letters, digits, hyphens, underscores, dots)
const size_t nameStart = i;
while (i < out.size() && out[i] != '/' && out[i] != '[') {
i++;
}
normalized.append(out, nameStart, i - nameStart);
if (i < out.size() && out[i] == '[') {
// Already has a predicate copy it verbatim
while (i < out.size() && out[i] != ']') {
normalized.push_back(out[i++]);
}
if (i < out.size()) {
normalized.push_back(out[i++]); // ']'
}
} else if (i - nameStart > 0) {
// Bare element name insert implicit [1]
normalized.append("[1]");
}
} else {
normalized.push_back(out[i++]);
}
}
return normalized;
}
std::string removeIndices(const std::string& xpath) {
std::string out;
out.reserve(xpath.size());
bool inBracket = false;
for (const char c : xpath) {
if (c == '[') {
inBracket = true;
continue;
}
if (c == ']') {
inBracket = false;
continue;
}
if (!inBracket) {
out.push_back(c);
}
}
return out;
}
int pathDepth(const std::string& xpath) {
int depth = 0;
for (const char c : xpath) {
if (c == '/') {
depth++;
}
}
return depth;
}
bool isAncestorPath(const std::string& prefix, const std::string& path) {
return path.size() > prefix.size() && path.compare(0, prefix.size(), prefix) == 0 && path[prefix.size()] == '/';
}
std::string decompressToTempFile(const std::shared_ptr<Epub>& epub, const int spineIndex) {
if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) {
return "";
}
const auto spineItem = epub->getSpineItem(spineIndex);
if (spineItem.href.empty()) {
return "";
}
const std::string tmpPath = epub->getCachePath() + "/.tmp_kox_" + std::to_string(spineIndex) + ".html";
if (Storage.exists(tmpPath.c_str())) {
Storage.remove(tmpPath.c_str());
}
FsFile tmpFile;
if (!Storage.openFileForWrite("KOX", tmpPath, tmpFile)) {
LOG_ERR("KOX", "Failed to create temp file for spine=%d", spineIndex);
return "";
}
constexpr size_t kChunkSize = 1024;
const bool ok = epub->readItemContentsToStream(spineItem.href, tmpFile, kChunkSize);
tmpFile.close();
if (!ok) {
Storage.remove(tmpPath.c_str());
LOG_ERR("KOX", "Failed to decompress spine=%d to temp file", spineIndex);
return "";
}
return tmpPath;
}
bool runParse(XML_Parser parser, const std::string& path) {
FsFile file;
if (!Storage.openFileForRead("KOX", path, file)) {
return false;
}
constexpr size_t kBufSize = 1024;
bool ok = true;
int done;
do {
void* const buf = XML_GetBuffer(parser, kBufSize);
if (!buf) {
ok = false;
break;
}
const size_t len = file.read(buf, kBufSize);
done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
ok = (XML_GetErrorCode(parser) == XML_ERROR_ABORTED);
break;
}
} while (!done);
file.close();
return ok;
}
bool isEntityRef(const XML_Char* text, const int len) {
if (len < 3 || text[0] != '&' || text[len - 1] != ';') {
return false;
}
for (int i = 1; i < len - 1; ++i) {
if (text[i] == '<' || text[i] == '>') {
return false;
}
}
return true;
}
namespace {
struct ByteCounter {
int skipDepth = -1;
int bodyStartDepth = -1;
int depth = 0;
size_t totalTextBytes = 0;
};
void XMLCALL bcStart(void* ud, const XML_Char* name, const XML_Char**) {
auto* s = static_cast<ByteCounter*>(ud);
const std::string tag = toLowerStr(name ? name : "");
if (tag == "body" && s->bodyStartDepth < 0) {
s->bodyStartDepth = s->depth;
}
if (s->skipDepth < 0 && isSkippableTag(tag)) {
s->skipDepth = s->depth;
}
s->depth++;
}
void XMLCALL bcEnd(void* ud, const XML_Char*) {
auto* s = static_cast<ByteCounter*>(ud);
s->depth--;
if (s->depth == s->skipDepth) {
s->skipDepth = -1;
}
if (s->depth == s->bodyStartDepth) {
s->bodyStartDepth = -1;
}
}
void XMLCALL bcChar(void* ud, const XML_Char* text, const int len) {
auto* s = static_cast<ByteCounter*>(ud);
if (s->skipDepth >= 0 || s->bodyStartDepth < 0 || len <= 0 || isWhitespaceOnly(text, len)) {
return;
}
s->totalTextBytes += countVisibleBytes(text, len);
}
void XMLCALL bcDefault(void* ud, const XML_Char* text, const int len) {
if (isEntityRef(text, len)) {
bcChar(ud, text, len);
}
}
} // namespace
size_t countTotalTextBytes(const std::string& tmpPath) {
ByteCounter state;
XML_Parser parser = XML_ParserCreate(nullptr);
if (!parser) {
return 0;
}
XML_SetUserData(parser, &state);
XML_SetElementHandler(parser, bcStart, bcEnd);
XML_SetCharacterDataHandler(parser, bcChar);
XML_SetDefaultHandlerExpand(parser, bcDefault);
runParse(parser, tmpPath);
XML_ParserFree(parser);
return state.totalTextBytes;
}
} // namespace ChapterXPathIndexerInternal
@@ -0,0 +1,31 @@
#pragma once
#include <Epub.h>
#include <expat.h>
#include <memory>
#include <string>
namespace ChapterXPathIndexerInternal {
std::string toLowerStr(std::string value);
bool isSkippableTag(const std::string& tag);
bool isWhitespaceOnly(const XML_Char* text, int len);
size_t countVisibleBytes(const XML_Char* text, int len);
size_t countUtf8Codepoints(const XML_Char* text, int len);
size_t codepointAtVisibleByte(const XML_Char* text, int len, size_t targetVisibleByte);
size_t visibleBytesBeforeCodepoint(const XML_Char* text, int len, size_t targetCodepointOffset);
std::string normalizeXPath(const std::string& input);
std::string removeIndices(const std::string& xpath);
int pathDepth(const std::string& xpath);
bool isAncestorPath(const std::string& prefix, const std::string& path);
std::string decompressToTempFile(const std::shared_ptr<Epub>& epub, int spineIndex);
bool runParse(XML_Parser parser, const std::string& path);
bool isEntityRef(const XML_Char* text, int len);
size_t countTotalTextBytes(const std::string& tmpPath);
} // namespace ChapterXPathIndexerInternal
+105
View File
@@ -0,0 +1,105 @@
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
#include "ChapterXPathIndexerInternal.h"
namespace ChapterXPathIndexerInternal {
// Shared parser state used by both forward and reverse mappers.
// It centralizes DOM-stack bookkeeping and XPath reconstruction so each mapper
// only implements its own match/emit logic.
struct StackNode {
std::string tag;
int index = 1;
// Reserved for future text-node heuristics; intentionally unused for now.
bool hasText = false;
};
struct StackState {
int skipDepth = -1;
size_t totalTextBytes = 0;
std::vector<StackNode> stack;
std::vector<std::unordered_map<std::string, int>> siblingCounters;
StackState() { siblingCounters.emplace_back(); }
void pushElement(const XML_Char* rawName) {
std::string name = toLowerStr(rawName ? rawName : "");
const size_t depth = stack.size();
if (siblingCounters.size() <= depth) {
siblingCounters.resize(depth + 1);
}
const int sibIdx = ++siblingCounters[depth][name];
stack.push_back({name, sibIdx, false});
siblingCounters.emplace_back();
if (skipDepth < 0 && isSkippableTag(name)) {
skipDepth = static_cast<int>(stack.size()) - 1;
}
}
void popElement() {
if (stack.empty()) {
return;
}
if (skipDepth == static_cast<int>(stack.size()) - 1) {
skipDepth = -1;
}
stack.pop_back();
if (!siblingCounters.empty()) {
siblingCounters.pop_back();
}
}
int bodyIdx() const {
for (int i = static_cast<int>(stack.size()) - 1; i >= 0; i--) {
if (stack[i].tag == "body") {
return i;
}
}
return -1;
}
bool insideBody() const { return bodyIdx() >= 0; }
std::string currentXPath(const int spineIndex) const {
const int bi = bodyIdx();
std::string xpath = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
if (bi < 0) {
return xpath;
}
for (size_t i = static_cast<size_t>(bi + 1); i < stack.size(); i++) {
xpath += "/" + stack[i].tag + "[" + std::to_string(stack[i].index) + "]";
}
return xpath;
}
bool shouldSkipText(const int len) const { return skipDepth >= 0 || len <= 0 || !insideBody(); }
};
template <typename StateT>
void XMLCALL parserStartCb(void* ud, const XML_Char* name, const XML_Char**) {
static_cast<StateT*>(ud)->onStartElement(name);
}
template <typename StateT>
void XMLCALL parserEndCb(void* ud, const XML_Char*) {
static_cast<StateT*>(ud)->onEndElement();
}
template <typename StateT>
void XMLCALL parserCharCb(void* ud, const XML_Char* text, const int len) {
static_cast<StateT*>(ud)->onCharData(text, len);
}
template <typename StateT>
void XMLCALL parserDefaultCb(void* ud, const XML_Char* text, const int len) {
if (isEntityRef(text, len)) {
static_cast<StateT*>(ud)->onCharData(text, len);
}
}
} // namespace ChapterXPathIndexerInternal
@@ -0,0 +1,248 @@
#include "ChapterXPathReverseMapper.h"
#include <HalStorage.h>
#include <Logging.h>
#include <expat.h>
#include <algorithm>
#include <cstdlib>
#include <string>
#include "ChapterXPathIndexerInternal.h"
#include "ChapterXPathIndexerState.h"
namespace ChapterXPathIndexerInternal {
namespace {
// Reverse mapper: translate KOReader XPath to intra-spine progress.
// Matching preference order is strict and deterministic:
// exact > exact-no-index > ancestor > ancestor-no-index.
// For /text()[N].M, M is treated as codepoint offset and converted back to
// internal visible-byte progress.
enum class MatchTier : int {
NONE = 0,
ANCESTOR_NO_IDX = 1,
ANCESTOR = 2,
EXACT_NO_IDX = 3,
EXACT = 4,
};
struct ReverseState : StackState {
int spineIndex;
std::string targetNorm;
std::string targetNoIndex;
int targetTextNodeIndex = 0;
int targetCharOffset = 0;
bool inParentTextNode = false;
size_t codepointsInCurrentTextNode = 0;
int currentTextNodeCount = 0;
MatchTier bestTier = MatchTier::NONE;
int bestDepth = -1;
size_t bestOffset = 0;
bool bestExact = false;
const char* bestTierName = nullptr;
ReverseState(const int spineIndex, const std::string& xpath) : spineIndex(spineIndex) {
// Parse optional /text()[N].M suffix before normalizing for element matching.
std::string raw = xpath;
for (char& c : raw) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const std::string tnPat = "/text()[";
const size_t tnPos = raw.rfind(tnPat);
if (tnPos != std::string::npos) {
const size_t numStart = tnPos + tnPat.size();
size_t numEnd = numStart;
while (numEnd < raw.size() && std::isdigit(static_cast<unsigned char>(raw[numEnd]))) {
numEnd++;
}
if (numEnd > numStart && numEnd < raw.size() && raw[numEnd] == ']') {
const long nodeIdx = std::strtol(raw.substr(numStart, numEnd - numStart).c_str(), nullptr, 10);
if (nodeIdx >= 1) {
targetTextNodeIndex = static_cast<int>(nodeIdx);
size_t after = numEnd + 1;
if (after < raw.size() && raw[after] == '.') {
after++;
size_t charEnd = after;
while (charEnd < raw.size() && std::isdigit(static_cast<unsigned char>(raw[charEnd]))) {
charEnd++;
}
if (charEnd > after) {
const long charOff = std::strtol(raw.substr(after, charEnd - after).c_str(), nullptr, 10);
if (charOff >= 0) {
targetCharOffset = static_cast<int>(charOff);
}
}
}
}
}
}
targetNorm = normalizeXPath(xpath);
targetNoIndex = removeIndices(targetNorm);
}
void onStartElement(const XML_Char* rawName) {
inParentTextNode = false;
pushElement(rawName);
}
void onEndElement() {
// Empty/textless elements can still be a valid anchor location.
if (!stack.empty() && !stack.back().hasText) {
checkMatch();
}
inParentTextNode = false;
popElement();
}
void onCharData(const XML_Char* text, const int len) {
if (shouldSkipText(len)) {
return;
}
const size_t visible = countVisibleBytes(text, len);
const size_t codepoints = countUtf8Codepoints(text, len);
if (targetTextNodeIndex > 0 && !stack.empty()) {
const std::string xpath = normalizeXPath(currentXPath(spineIndex));
if (xpath == targetNorm) {
stack.back().hasText = true;
if (!inParentTextNode) {
inParentTextNode = true;
currentTextNodeCount++;
codepointsInCurrentTextNode = 0;
}
if (currentTextNodeCount == targetTextNodeIndex && bestTier < MatchTier::EXACT) {
const size_t charOff = static_cast<size_t>(targetCharOffset);
if (charOff >= codepointsInCurrentTextNode && charOff <= codepointsInCurrentTextNode + codepoints) {
const size_t cpInChunk = charOff - codepointsInCurrentTextNode;
const size_t pos = totalTextBytes + visibleBytesBeforeCodepoint(text, len, cpInChunk);
bestTier = MatchTier::EXACT;
bestDepth = pathDepth(xpath);
bestOffset = pos;
bestExact = true;
bestTierName = "text-node-exact";
}
}
codepointsInCurrentTextNode += codepoints;
totalTextBytes += visible;
return;
}
}
if (isWhitespaceOnly(text, len)) {
return;
}
if (!stack.empty() && !stack.back().hasText) {
stack.back().hasText = true;
checkMatch();
}
totalTextBytes += visible;
}
void checkMatch() {
const std::string xpath = normalizeXPath(currentXPath(spineIndex));
const int depth = pathDepth(xpath);
const bool targetIsTextSelector = targetTextNodeIndex > 0;
if (xpath == targetNorm) {
// For /text()[N].M targets, the normalized parent element path is equal to
// targetNorm. Treat that as an ancestor-level anchor so text-node exact
// matching can still determine the real intra-node offset.
if (targetIsTextSelector) {
tryUpdate(MatchTier::ANCESTOR, depth, "text-parent", false);
} else {
tryUpdate(MatchTier::EXACT, depth, "exact", true);
}
return;
}
if (isAncestorPath(xpath, targetNorm)) {
tryUpdate(MatchTier::ANCESTOR, depth, "ancestor", false);
return;
}
const std::string xpathNoIdx = removeIndices(xpath);
if (xpathNoIdx == targetNoIndex) {
tryUpdate(MatchTier::EXACT_NO_IDX, depth, "index-insensitive", false);
} else if (isAncestorPath(xpathNoIdx, targetNoIndex)) {
tryUpdate(MatchTier::ANCESTOR_NO_IDX, depth, "index-insensitive-ancestor", false);
}
}
void tryUpdate(const MatchTier tier, const int depth, const char* tierName, const bool isExact) {
if (tier > bestTier || (tier == bestTier && depth > bestDepth)) {
bestTier = tier;
bestDepth = depth;
bestOffset = totalTextBytes;
bestExact = isExact;
bestTierName = tierName;
}
}
};
} // namespace
bool findProgressForXPathInternal(const std::shared_ptr<Epub>& epub, const int spineIndex, const std::string& xpath,
float& outIntraSpineProgress, bool& outExactMatch) {
outIntraSpineProgress = 0.0f;
outExactMatch = false;
if (xpath.empty()) {
return false;
}
const std::string tmpPath = decompressToTempFile(epub, spineIndex);
if (tmpPath.empty()) {
return false;
}
ReverseState state(spineIndex, xpath);
XML_Parser parser = XML_ParserCreate(nullptr);
if (!parser) {
Storage.remove(tmpPath.c_str());
return false;
}
XML_SetUserData(parser, &state);
XML_SetElementHandler(parser, parserStartCb<ReverseState>, parserEndCb<ReverseState>);
XML_SetCharacterDataHandler(parser, parserCharCb<ReverseState>);
XML_SetDefaultHandlerExpand(parser, parserDefaultCb<ReverseState>);
const bool parseOk = runParse(parser, tmpPath);
if (!parseOk) {
LOG_ERR("KOX", "XPath parse failed for spine=%d at line %lu: %s", spineIndex, XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
}
XML_ParserFree(parser);
Storage.remove(tmpPath.c_str());
if (!parseOk || state.bestTier == MatchTier::NONE) {
LOG_DBG("KOX", "Reverse: spine=%d no match for '%s'", spineIndex, xpath.c_str());
return false;
}
outExactMatch = state.bestExact;
if (state.totalTextBytes == 0) {
outIntraSpineProgress = 0.0f;
} else {
outIntraSpineProgress = static_cast<float>(state.bestOffset) / static_cast<float>(state.totalTextBytes);
outIntraSpineProgress = std::max(0.0f, std::min(1.0f, outIntraSpineProgress));
}
if (state.targetTextNodeIndex > 0) {
LOG_DBG("KOX", "Reverse: spine=%d %s match textNode=%d char=%d offset=%zu/%zu -> progress=%.3f for '%s'",
spineIndex, state.bestTierName, state.targetTextNodeIndex, state.targetCharOffset, state.bestOffset,
state.totalTextBytes, outIntraSpineProgress, xpath.c_str());
} else {
LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu/%zu -> progress=%.3f for '%s'", spineIndex,
state.bestTierName, state.bestOffset, state.totalTextBytes, outIntraSpineProgress, xpath.c_str());
}
return true;
}
} // namespace ChapterXPathIndexerInternal
@@ -0,0 +1,13 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <string>
namespace ChapterXPathIndexerInternal {
bool findProgressForXPathInternal(const std::shared_ptr<Epub>& epub, int spineIndex, const std::string& xpath,
float& outIntraSpineProgress, bool& outExactMatch);
} // namespace ChapterXPathIndexerInternal
+11 -5
View File
@@ -153,16 +153,22 @@ void KOReaderCredentialStore::setServerUrl(const std::string& url) {
}
std::string KOReaderCredentialStore::getBaseUrl() const {
std::string url;
if (serverUrl.empty()) {
return DEFAULT_SERVER_URL;
url = DEFAULT_SERVER_URL;
} else if (serverUrl.find("://") == std::string::npos) {
// Normalize URL: add http:// if no protocol specified (local servers typically don't have SSL)
url = "http://" + serverUrl;
} else {
url = serverUrl;
}
// Normalize URL: add http:// if no protocol specified (local servers typically don't have SSL)
if (serverUrl.find("://") == std::string::npos) {
return "http://" + serverUrl;
// Strip trailing slashes to avoid double-slash in API paths
while (!url.empty() && url.back() == '/') {
url.pop_back();
}
return serverUrl;
return url;
}
void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) {
+152
View File
@@ -4,6 +4,8 @@
#include <Logging.h>
#include <MD5Builder.h>
#include <functional>
namespace {
// Extract filename from path (everything after last '/')
std::string getFilename(const std::string& path) {
@@ -15,6 +17,130 @@ std::string getFilename(const std::string& path) {
}
} // namespace
std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) {
// Mirror the Epub cache directory convention so the hash file shares the
// same per-book folder as other cached data.
return std::string("/.crosspoint/epub_") + std::to_string(std::hash<std::string>{}(filePath)) + "/koreader_docid.txt";
}
std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, const size_t fileSize,
const std::string& currentFingerprint) {
if (!Storage.exists(cacheFilePath.c_str())) {
return "";
}
const String content = Storage.readFile(cacheFilePath.c_str());
if (content.isEmpty()) {
return "";
}
// Format: "<filesize>:<fingerprint>\n<32-char-hex-hash>"
const int newlinePos = content.indexOf('\n');
if (newlinePos < 0) {
return "";
}
const String header = content.substring(0, newlinePos);
const int colonPos = header.indexOf(':');
if (colonPos < 0) {
LOG_DBG("KODoc", "Hash cache invalidated: header missing fingerprint");
return "";
}
const String sizeTok = header.substring(0, colonPos);
const String fpTok = header.substring(colonPos + 1);
// Validate the filesize token it must consist of ASCII digits and parse
// correctly to the expected size.
bool digitsOnly = true;
for (size_t i = 0; i < sizeTok.length(); ++i) {
const char ch = sizeTok[i];
if (ch < '0' || ch > '9') {
digitsOnly = false;
break;
}
}
if (!digitsOnly) {
LOG_DBG("KODoc", "Hash cache invalidated: size token not numeric ('%s')", sizeTok.c_str());
return "";
}
const long parsed = sizeTok.toInt();
if (parsed < 0) {
LOG_DBG("KODoc", "Hash cache invalidated: size token parse error ('%s')", sizeTok.c_str());
return "";
}
const size_t cachedSize = static_cast<size_t>(parsed);
if (cachedSize != fileSize) {
LOG_DBG("KODoc", "Hash cache invalidated: file size or fingerprint changed (%zu -> %zu)", cachedSize, fileSize);
return "";
}
// Validate stored fingerprint format (8 hex characters)
if (fpTok.length() != 8) {
LOG_DBG("KODoc", "Hash cache invalidated: bad fingerprint length (%zu)", fpTok.length());
return "";
}
for (size_t i = 0; i < fpTok.length(); ++i) {
char c = fpTok[i];
bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) {
LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in fingerprint", c);
return "";
}
}
{
String currentFpStr(currentFingerprint.c_str());
if (fpTok != currentFpStr) {
LOG_DBG("KODoc", "Hash cache invalidated: fingerprint changed (%s != %s)", fpTok.c_str(),
currentFingerprint.c_str());
return "";
}
}
std::string hash = content.substring(newlinePos + 1).c_str();
// Trim any trailing whitespace / line endings
while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) {
hash.pop_back();
}
// Hash must be exactly 32 hex characters.
if (hash.size() != 32) {
LOG_DBG("KODoc", "Hash cache invalidated: wrong hash length (%zu)", hash.size());
return "";
}
for (char c : hash) {
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in hash", c);
return "";
}
}
LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str());
return hash;
}
void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, const size_t fileSize,
const std::string& fingerprint, const std::string& hash) {
// Ensure the book's cache directory exists before writing
const size_t lastSlash = cacheFilePath.rfind('/');
if (lastSlash != std::string::npos) {
Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str());
}
// Format: "<filesize>:<fingerprint>\n<hash>"
String content(std::to_string(fileSize).c_str());
content += ':';
content += fingerprint.c_str();
content += '\n';
content += hash.c_str();
if (!Storage.writeFile(cacheFilePath.c_str(), content)) {
LOG_DBG("KODoc", "Failed to write hash cache to %s", cacheFilePath.c_str());
}
}
std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) {
const std::string filename = getFilename(filePath);
if (filename.empty()) {
@@ -49,6 +175,30 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
}
const size_t fileSize = file.fileSize();
// Compute a lightweight fingerprint from the file's modification time.
// The underlying FsFile API provides getModifyDateTime which returns two
// packed 16-bit values (date and time). Concatenate these as eight hex
// digits to produce the token stored in the cache header.
uint16_t date = 0, time = 0;
if (!file.getModifyDateTime(&date, &time)) {
// If timestamp isn't available for some reason, fall back to a sentinel.
date = 0;
time = 0;
}
char fpBuf[9];
// two 16-bit numbers => 4 hex digits each
sprintf(fpBuf, "%04x%04x", date, time);
const std::string fingerprintTok(fpBuf);
// Return persisted hash if the file size and fingerprint haven't changed.
const std::string cacheFilePath = getCacheFilePath(filePath);
const std::string cached = loadCachedHash(cacheFilePath, fileSize, fingerprintTok);
if (!cached.empty()) {
file.close();
return cached;
}
LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize);
// Initialize MD5 builder
@@ -92,5 +242,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead);
saveCachedHash(cacheFilePath, fileSize, fingerprintTok, result);
return result;
}
+27
View File
@@ -42,4 +42,31 @@ class KOReaderDocumentId {
// Calculate offset for index i: 1024 << (2*i)
static size_t getOffset(int i);
// Hash cache helpers
// Returns the path to the per-book cache file that stores the precomputed hash.
// Uses the same directory convention as the Epub cache (/.crosspoint/epub_<hash>/).
static std::string getCacheFilePath(const std::string& filePath);
// Returns the cached hash if the file size and fingerprint match, or empty
// string on miss/invalidation.
//
// The fingerprint is derived from the file's modification timestamp. We
// call `FsFile::getModifyDateTime` to retrieve two 16bit packed values
// supplied by the filesystem: one for the date and one for the time. These
// are concatenated and represented as eight hexadecimal digits in the form
// <date><time> (high 16 bits = packed date, low 16 bits = packed time).
//
// The resulting string serves as a lightweight change signal; any modification
// to the file's mtime will alter the packed date/time combo and invalidate
// the cache entry. Since the full document hash is expensive to compute,
// using the packed timestamp gives us a quick way to detect modifications
// without reading file contents.
static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize,
const std::string& currentFingerprint);
// Persists the computed hash alongside the file size and fingerprint (the
// modification-timestamp token) used to generate it.
static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& fingerprint,
const std::string& hash);
};
+192 -89
View File
@@ -1,33 +1,167 @@
#include "KOReaderSyncClient.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <Logging.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <esp_crt_bundle.h>
#include <esp_http_client.h>
#include <algorithm>
#include <cctype>
#include <ctime>
#include "KOReaderCredentialStore.h"
int KOReaderSyncClient::lastHttpCode = 0;
namespace {
// Device identifier for CrossPoint reader
constexpr char DEVICE_NAME[] = "CrossPoint";
constexpr char DEVICE_ID[] = "crosspoint-reader";
void addAuthHeaders(HTTPClient& http) {
http.addHeader("Accept", "application/vnd.koreader.v1+json");
http.addHeader("x-auth-user", KOREADER_STORE.getUsername().c_str());
http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password().c_str());
// Small TLS buffers to fit in ESP32-C3's limited heap (~46KB free after WiFi).
// KOSync payloads are tiny JSON (<1KB), so 2KB buffers are sufficient.
// Default 16KB buffers cause OOM during TLS handshake.
constexpr int HTTP_BUF_SIZE = 2048;
// HTTP Basic Auth (RFC 7617) header. This is needed to support koreader sync server embedded in Calibre Web Automated
// (https://github.com/crocodilestick/Calibre-Web-Automated/blob/main/cps/progress_syncing/protocols/kosync.py)
http.setAuthorization(KOREADER_STORE.getUsername().c_str(), KOREADER_STORE.getPassword().c_str());
// Response buffer for reading HTTP body
struct ResponseBuffer {
char* data = nullptr;
int len = 0;
int capacity = 0;
~ResponseBuffer() { free(data); }
bool ensure(int size) {
if (size <= capacity) return true;
char* newData = (char*)realloc(data, size);
if (!newData) return false;
data = newData;
capacity = size;
return true;
}
};
// HTTP event handler to collect response body
esp_err_t httpEventHandler(esp_http_client_event_t* evt) {
auto* buf = static_cast<ResponseBuffer*>(evt->user_data);
if (evt->event_id == HTTP_EVENT_ON_DATA && buf) {
if (buf->ensure(buf->len + evt->data_len + 1)) {
memcpy(buf->data + buf->len, evt->data, evt->data_len);
buf->len += evt->data_len;
buf->data[buf->len] = '\0';
} else {
LOG_ERR("KOSync", "Response buffer allocation failed (%d bytes)", evt->data_len);
}
}
return ESP_OK;
}
bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; }
// Base64 encode for HTTP Basic Auth
std::string base64Encode(const std::string& input) {
static const char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((input.size() + 2) / 3) * 4);
int val = 0, valb = -6;
for (unsigned char c : input) {
val = (val << 8) + c;
valb += 8;
while (valb >= 0) {
out.push_back(table[(val >> valb) & 0x3F]);
valb -= 6;
}
}
if (valb > -6) out.push_back(table[((val << 8) >> (valb + 8)) & 0x3F]);
while (out.size() % 4) out.push_back('=');
return out;
}
// Create configured esp_http_client with small TLS buffers
esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
esp_http_client_method_t method = HTTP_METHOD_GET) {
esp_http_client_config_t config = {};
config.url = url;
config.event_handler = httpEventHandler;
config.user_data = buf;
config.method = method;
config.timeout_ms = 15000;
config.buffer_size = HTTP_BUF_SIZE;
config.buffer_size_tx = HTTP_BUF_SIZE;
config.crt_bundle_attach = esp_crt_bundle_attach;
esp_http_client_handle_t client = esp_http_client_init(&config);
if (!client) return nullptr;
// KOSync auth headers
esp_http_client_set_header(client, "Accept", "application/vnd.koreader.v1+json");
esp_http_client_set_header(client, "x-auth-user", KOREADER_STORE.getUsername().c_str());
esp_http_client_set_header(client, "x-auth-key", KOREADER_STORE.getMd5Password().c_str());
// HTTP Basic Auth for Calibre-Web-Automated compatibility
std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
std::string authHeader = "Basic " + base64Encode(credentials);
esp_http_client_set_header(client, "Authorization", authHeader.c_str());
return client;
}
} // namespace
KOReaderSyncClient::Error KOReaderSyncClient::registerUser() {
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS;
}
std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
LOG_DBG("KOSync", "Registering user: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
JsonDocument doc;
doc["username"] = KOREADER_STORE.getUsername();
doc["password"] = KOREADER_STORE.getMd5Password();
std::string body;
serializeJson(doc, body);
LOG_DBG("KOSync", "Register request body: <redacted credentials>");
ResponseBuffer buf;
esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_POST);
if (!client) return NETWORK_ERROR;
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, body.c_str(), body.length());
esp_err_t err = esp_http_client_perform(client);
const int httpCode = esp_http_client_get_status_code(client);
lastHttpCode = httpCode;
esp_http_client_cleanup(client);
LOG_DBG("KOSync", "Register response: %d (err: %d) | body: %s", httpCode, err, buf.data ? buf.data : "");
if (err != ESP_OK) {
return NETWORK_ERROR;
}
if (httpCode == 201) {
return OK;
} else if (httpCode == 200) {
// Some server implementations return 200 when the user already exists
return USER_EXISTS;
} else if (httpCode == 402) {
// Both "user already exists" (error 2002) and "registration disabled" (error 2005)
// return HTTP 402 on the original kosync server. Distinguish them by body text.
std::string lowerBody = buf.data ? buf.data : "";
std::transform(lowerBody.begin(), lowerBody.end(), lowerBody.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lowerBody.find("already") != std::string::npos) {
return USER_EXISTS;
}
return REGISTRATION_DISABLED;
} else if (httpCode == 409) {
// korrosync returns 409 for existing users
return USER_EXISTS;
}
return SERVER_ERROR;
}
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
if (!KOREADER_STORE.hasCredentials()) {
LOG_DBG("KOSync", "No credentials configured");
@@ -35,33 +169,22 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
}
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
LOG_DBG("KOSync", "Authenticating: %s", url.c_str());
LOG_DBG("KOSync", "Authenticating: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient;
WiFiClient plainClient;
ResponseBuffer buf;
esp_http_client_handle_t client = createClient(url.c_str(), &buf);
if (!client) return NETWORK_ERROR;
if (isHttpsUrl(url)) {
secureClient.reset(new WiFiClientSecure);
secureClient->setInsecure();
http.begin(*secureClient, url.c_str());
} else {
http.begin(plainClient, url.c_str());
}
addAuthHeaders(http);
esp_err_t err = esp_http_client_perform(client);
const int httpCode = esp_http_client_get_status_code(client);
lastHttpCode = httpCode;
esp_http_client_cleanup(client);
const int httpCode = http.GET();
http.end();
LOG_DBG("KOSync", "Auth response: %d (err: %d)", httpCode, err);
LOG_DBG("KOSync", "Auth response: %d", httpCode);
if (httpCode == 200) {
return OK;
} else if (httpCode == 401) {
return AUTH_FAILED;
} else if (httpCode < 0) {
return NETWORK_ERROR;
}
if (err != ESP_OK) return NETWORK_ERROR;
if (httpCode == 200) return OK;
if (httpCode == 401) return AUTH_FAILED;
return SERVER_ERROR;
}
@@ -73,30 +196,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
}
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
LOG_DBG("KOSync", "Getting progress: %s", url.c_str());
LOG_DBG("KOSync", "Getting progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient;
WiFiClient plainClient;
ResponseBuffer buf;
esp_http_client_handle_t client = createClient(url.c_str(), &buf);
if (!client) return NETWORK_ERROR;
if (isHttpsUrl(url)) {
secureClient.reset(new WiFiClientSecure);
secureClient->setInsecure();
http.begin(*secureClient, url.c_str());
} else {
http.begin(plainClient, url.c_str());
}
addAuthHeaders(http);
esp_err_t err = esp_http_client_perform(client);
const int httpCode = esp_http_client_get_status_code(client);
lastHttpCode = httpCode;
esp_http_client_cleanup(client);
const int httpCode = http.GET();
LOG_DBG("KOSync", "Get progress response: %d (err: %d)", httpCode, err);
if (httpCode == 200) {
// Parse JSON response from response string
String responseBody = http.getString();
http.end();
if (err != ESP_OK) return NETWORK_ERROR;
if (httpCode == 200 && buf.data) {
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, responseBody);
const DeserializationError error = deserializeJson(doc, buf.data);
if (error) {
LOG_ERR("KOSync", "JSON parse failed: %s", error.c_str());
@@ -114,17 +231,8 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
return OK;
}
http.end();
LOG_DBG("KOSync", "Get progress response: %d", httpCode);
if (httpCode == 401) {
return AUTH_FAILED;
} else if (httpCode == 404) {
return NOT_FOUND;
} else if (httpCode < 0) {
return NETWORK_ERROR;
}
if (httpCode == 401) return AUTH_FAILED;
if (httpCode == 404) return NOT_FOUND;
return SERVER_ERROR;
}
@@ -135,23 +243,9 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
}
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
LOG_DBG("KOSync", "Updating progress: %s", url.c_str());
LOG_DBG("KOSync", "Updating progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient;
WiFiClient plainClient;
if (isHttpsUrl(url)) {
secureClient.reset(new WiFiClientSecure);
secureClient->setInsecure();
http.begin(*secureClient, url.c_str());
} else {
http.begin(plainClient, url.c_str());
}
addAuthHeaders(http);
http.addHeader("Content-Type", "application/json");
// Build JSON body (timestamp not required per API spec)
// Build JSON body
JsonDocument doc;
doc["document"] = progress.document;
doc["progress"] = progress.progress;
@@ -164,18 +258,23 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
LOG_DBG("KOSync", "Request body: %s", body.c_str());
const int httpCode = http.PUT(body.c_str());
http.end();
ResponseBuffer buf;
esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_PUT);
if (!client) return NETWORK_ERROR;
LOG_DBG("KOSync", "Update progress response: %d", httpCode);
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, body.c_str(), body.length());
if (httpCode == 200 || httpCode == 202) {
return OK;
} else if (httpCode == 401) {
return AUTH_FAILED;
} else if (httpCode < 0) {
return NETWORK_ERROR;
}
esp_err_t err = esp_http_client_perform(client);
const int httpCode = esp_http_client_get_status_code(client);
lastHttpCode = httpCode;
esp_http_client_cleanup(client);
LOG_DBG("KOSync", "Update progress response: %d (err: %d)", httpCode, err);
if (err != ESP_OK) return NETWORK_ERROR;
if (httpCode == 200 || httpCode == 202) return OK;
if (httpCode == 401) return AUTH_FAILED;
return SERVER_ERROR;
}
@@ -195,6 +294,10 @@ const char* KOReaderSyncClient::errorString(Error error) {
return "JSON parse error";
case NOT_FOUND:
return "No progress found";
case USER_EXISTS:
return "Username is already taken";
case REGISTRATION_DISABLED:
return "Registration is disabled on this server";
default:
return "Unknown error";
}
+25 -4
View File
@@ -19,9 +19,10 @@ struct KOReaderProgress {
* Base URL: https://sync.koreader.rocks:443/
*
* API Endpoints:
* GET /users/auth - Authenticate (validate credentials)
* GET /syncs/progress/:document - Get progress for a document
* PUT /syncs/progress - Update progress for a document
* POST /users/create - Register a new user
* GET /users/auth - Authenticate (validate credentials)
* GET /syncs/progress/:document - Get progress for a document
* PUT /syncs/progress - Update progress for a document
*
* Authentication:
* x-auth-user: username
@@ -29,7 +30,24 @@ struct KOReaderProgress {
*/
class KOReaderSyncClient {
public:
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND };
enum Error {
OK = 0,
NO_CREDENTIALS,
NETWORK_ERROR,
AUTH_FAILED,
SERVER_ERROR,
JSON_ERROR,
NOT_FOUND,
USER_EXISTS,
REGISTRATION_DISABLED
};
/**
* Register a new user account with the sync server.
* Uses credentials already stored in KOReaderCredentialStore.
* @return OK on success, USER_EXISTS if taken, REGISTRATION_DISABLED if server disallows it
*/
static Error registerUser();
/**
* Authenticate with the sync server (validate credentials).
@@ -56,4 +74,7 @@ class KOReaderSyncClient {
* Get human-readable error message.
*/
static const char* errorString(Error error);
/** HTTP status code from the last request (for diagnostics). */
static int lastHttpCode;
};
+126 -33
View File
@@ -2,8 +2,49 @@
#include <Logging.h>
#include <algorithm>
#include <cmath>
#include "ChapterXPathIndexer.h"
namespace {
bool resolveFromPercentage(const std::shared_ptr<Epub>& epub, const float percentage, const int spineCount,
int& outSpineIndex, float& outIntraSpineProgress) {
if (!std::isfinite(percentage) || !epub || spineCount <= 0) {
return false;
}
const size_t bookSize = epub->getBookSize();
if (bookSize == 0) {
return false;
}
const float sanitizedPercentage = std::clamp(percentage, 0.0f, 1.0f);
const size_t targetBytes = static_cast<size_t>(bookSize * sanitizedPercentage);
outSpineIndex = spineCount - 1;
for (int i = 0; i < spineCount; i++) {
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
if (cumulativeSize >= targetBytes) {
outSpineIndex = i;
break;
}
}
outIntraSpineProgress = 0.0f;
const size_t prevCumSize = (outSpineIndex > 0) ? epub->getCumulativeSpineItemSize(outSpineIndex - 1) : 0;
const size_t currentCumSize = epub->getCumulativeSpineItemSize(outSpineIndex);
const size_t spineSize = currentCumSize - prevCumSize;
if (spineSize > 0) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
outIntraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
outIntraSpineProgress = std::clamp(outIntraSpineProgress, 0.0f, 1.0f);
}
return true;
}
} // namespace
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
KOReaderPosition result;
@@ -16,8 +57,18 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
// Calculate overall book progress (0.0-1.0)
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
// Generate XPath with estimated paragraph position based on page
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages);
// Generate XPath for the current position.
// Prefer paragraph index from the section cache LUT (exact element mapping) over
// byte-offset estimation (which can drift in chapters with non-uniform content density).
if (pos.hasParagraphIndex && pos.paragraphIndex > 0) {
result.xpath = "/body/DocFragment[" + std::to_string(pos.spineIndex + 1) + "]/body/p[" +
std::to_string(pos.paragraphIndex) + "]";
} else {
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
if (result.xpath.empty()) {
result.xpath = generateXPath(pos.spineIndex);
}
}
// Get chapter info for logging
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
@@ -36,34 +87,71 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.pageNumber = 0;
result.totalPages = 0;
const size_t bookSize = epub->getBookSize();
if (bookSize == 0) {
if (!epub || epub->getSpineItemsCount() <= 0) {
return result;
}
// Use percentage-based lookup for both spine and page positioning
// XPath parsing is unreliable since CrossPoint doesn't preserve detailed HTML structure
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage);
// Find the spine item that contains this byte position
const int spineCount = epub->getSpineItemsCount();
bool spineFound = false;
for (int i = 0; i < spineCount; i++) {
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
if (cumulativeSize >= targetBytes) {
result.spineIndex = i;
spineFound = true;
break;
float resolvedIntraSpineProgress = -1.0f;
bool xpathExactMatch = false;
bool usedXPathMapping = false;
bool usedPercentageReconcile = false;
int xpathSpineIndex = -1;
if (ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) && xpathSpineIndex >= 0 &&
xpathSpineIndex < spineCount) {
float intraFromXPath = 0.0f;
if (ChapterXPathIndexer::findProgressForXPath(epub, xpathSpineIndex, koPos.xpath, intraFromXPath,
xpathExactMatch)) {
result.spineIndex = xpathSpineIndex;
resolvedIntraSpineProgress = intraFromXPath;
usedXPathMapping = true;
// KOReader's text-node indexing can differ across renderers/parsers in some
// XHTML shapes. When an XPath-resolved position disagrees materially with
// KOReader's percentage but points to the same spine, use percentage-derived
// intra-spine progress as a safer tie-breaker.
if (std::isfinite(koPos.percentage) && resolvedIntraSpineProgress >= 0.0f) {
const float sanitizedPercentage = std::clamp(koPos.percentage, 0.0f, 1.0f);
const float mappedPercentage = epub->calculateProgress(result.spineIndex, resolvedIntraSpineProgress);
const float delta = std::fabs(mappedPercentage - sanitizedPercentage);
constexpr float kReconcileThreshold = 0.01f; // 1% absolute book progress
if (delta > kReconcileThreshold) {
int percentageSpineIndex = -1;
float percentageIntraSpine = -1.0f;
if (resolveFromPercentage(epub, koPos.percentage, spineCount, percentageSpineIndex, percentageIntraSpine) &&
percentageSpineIndex == result.spineIndex && percentageIntraSpine >= 0.0f) {
LOG_DBG("ProgressMapper",
"Reconciling XPath position with percentage: spine=%d xpath=%.3f pct=%.3f delta=%.3f -> %.3f",
result.spineIndex, resolvedIntraSpineProgress, sanitizedPercentage, delta, percentageIntraSpine);
resolvedIntraSpineProgress = percentageIntraSpine;
usedPercentageReconcile = true;
}
}
}
}
// Extract paragraph index from XPath for direct page lookup via section cache
uint16_t pIndex = 0;
if (ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(koPos.xpath, pIndex)) {
result.paragraphIndex = pIndex;
result.hasParagraphIndex = true;
}
}
// If no spine item was found (e.g., targetBytes beyond last cumulative size),
// default to the last spine item so we map to the end of the book instead of the beginning.
if (!spineFound && spineCount > 0) {
result.spineIndex = spineCount - 1;
if (!usedXPathMapping) {
int percentageSpineIndex = -1;
float percentageIntraSpine = -1.0f;
if (!resolveFromPercentage(epub, koPos.percentage, spineCount, percentageSpineIndex, percentageIntraSpine)) {
return result;
}
result.spineIndex = percentageSpineIndex;
resolvedIntraSpineProgress = percentageIntraSpine;
}
// Estimate page number within the spine item using percentage
// Estimate page number within the selected spine item
if (result.spineIndex < epub->getSpineItemsCount()) {
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
@@ -91,24 +179,29 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
result.totalPages = estimatedTotalPages;
if (spineSize > 0 && estimatedTotalPages > 0) {
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
result.pageNumber = static_cast<int>(clampedProgress * estimatedTotalPages);
if (estimatedTotalPages > 0 && resolvedIntraSpineProgress >= 0.0f) {
const float clampedProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
result.pageNumber = static_cast<int>(clampedProgress * static_cast<float>(estimatedTotalPages));
result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1));
} else if (spineSize > 0 && estimatedTotalPages > 0) {
result.pageNumber = 0;
}
}
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
LOG_DBG("ProgressMapper", "Resolved KOReader position: spine=%d intra=%.3f hasPIdx=%s pIdx=%u", result.spineIndex,
resolvedIntraSpineProgress, result.hasParagraphIndex ? "yes" : "no", result.paragraphIndex);
const char* mappingSource =
usedXPathMapping ? (usedPercentageReconcile ? "xpath+percentage" : "xpath") : "percentage";
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d (%s, exact=%s)",
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber, mappingSource,
xpathExactMatch ? "yes" : "no");
return result;
}
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
// Use 0-based DocFragment indices for KOReader
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning within it
// Avoid specifying paragraph numbers as they may not exist in the target document
return "/body/DocFragment[" + std::to_string(spineIndex) + "]/body";
std::string ProgressMapper::generateXPath(int spineIndex) {
// Fallback path when element-level XPath extraction is unavailable.
// KOReader uses 1-based XPath predicates; spineIndex is 0-based internally.
return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
}
+20 -11
View File
@@ -8,9 +8,11 @@
* CrossPoint position representation.
*/
struct CrossPointPosition {
int spineIndex; // Current spine item (chapter) index
int pageNumber; // Current page within the spine item
int totalPages; // Total pages in the current spine item
int spineIndex; // Current spine item (chapter) index
int pageNumber; // Current page within the spine item (estimated if no paragraph LUT)
int totalPages; // Total pages in the current spine item
uint16_t paragraphIndex = 0; // 1-based <p> index from XPath (0 if unavailable)
bool hasParagraphIndex = false; // True when paragraphIndex was resolved from XPath
};
/**
@@ -27,9 +29,16 @@ struct KOReaderPosition {
* CrossPoint tracks position as (spineIndex, pageNumber).
* KOReader uses XPath-like strings + percentage.
*
* Since CrossPoint discards HTML structure during parsing, we generate
* synthetic XPath strings based on spine index, using percentage as the
* primary sync mechanism.
* Forward mapping (CrossPoint -> KOReader):
* - Prefer element-level XPath extracted from current spine XHTML.
* - Fallback to synthetic chapter XPath if extraction fails.
*
* Reverse mapping (KOReader -> CrossPoint):
* - Prefer incoming XPath (DocFragment + element path) when resolvable.
* - Fallback to percentage-based approximation when XPath is missing/invalid.
*
* This keeps behavior stable on low-memory devices while improving round-trip
* sync precision when KOReader provides detailed paths.
*/
class ProgressMapper {
public:
@@ -45,8 +54,9 @@ class ProgressMapper {
/**
* Convert KOReader position to CrossPoint format.
*
* Note: The returned pageNumber may be approximate since different
* rendering settings produce different page counts.
* Uses XPath-first resolution when possible and percentage fallback otherwise.
* Returned pageNumber can still be approximate because page counts differ
* across renderer/font/layout settings.
*
* @param epub The EPUB book
* @param koPos KOReader position
@@ -60,8 +70,7 @@ class ProgressMapper {
private:
/**
* Generate XPath for KOReader compatibility.
* Format: /body/DocFragment[spineIndex+1]/body
* Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning.
* Fallback format: /body/DocFragment[spineIndex + 1]/body
*/
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages);
static std::string generateXPath(int spineIndex);
};
+10 -1
View File
@@ -1,5 +1,7 @@
#include "Logging.h"
#include <HalClock.h>
#include <string>
#define MAX_ENTRY_LEN 256
@@ -41,7 +43,14 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
// add the timestamp
{
unsigned long ms = millis();
int len = snprintf(c, sizeof(buf), "[%lu] ", ms);
char wallClock[12];
HalClock::formatLogTime(wallClock, sizeof(wallClock));
int len;
if (wallClock[0] != '\0') {
len = snprintf(c, sizeof(buf), "[%lu %s] ", ms, wallClock);
} else {
len = snprintf(c, sizeof(buf), "[%lu] ", ms);
}
if (len < 0) {
return; // encoding error, skip logging
}
+10
View File
@@ -0,0 +1,10 @@
name=QRCode
version=1.8.0
author=Nayuki
maintainer=Nayuki
sentence=QR Code generator library (C port)
paragraph=High-quality QR Code generator library with ECI support. Ported from https://github.com/nayuki/QR-Code-generator
category=Other
url=https://github.com/nayuki/QR-Code-generator
architectures=*
includes=qrcodegen.h
+977
View File
@@ -0,0 +1,977 @@
/*
* QR Code generator library (C)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
#include "qrcodegen.h"
#include <assert.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h>
#ifndef QRCODEGEN_TEST
#define testable static // Keep functions private
#else
#define testable // Expose private functions
#endif
/*---- Forward declarations for private functions ----*/
// Regarding all public and private functions defined in this source file:
// - They require all pointer/array arguments to be not null unless the array length is zero.
// - They only read input scalar/array arguments, write to output pointer/array
// arguments, and return scalar values; they are "pure" functions.
// - They don't read mutable global variables or write to any global variables.
// - They don't perform I/O, read the clock, print to console, etc.
// - They allocate a small and constant amount of stack memory.
// - They don't allocate or free any memory on the heap.
// - They don't recurse or mutually recurse. All the code
// could be inlined into the top-level public functions.
// - They run in at most quadratic time with respect to input arguments.
// Most functions run in linear time, and some in constant time.
// There are no unbounded loops or non-obvious termination conditions.
// - They are completely thread-safe if the caller does not give the
// same writable buffer to concurrent calls to these functions.
testable void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int* bitLen);
testable void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]);
testable int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl);
testable int getNumRawDataModules(int ver);
testable void reedSolomonComputeDivisor(int degree, uint8_t result[]);
testable void reedSolomonComputeRemainder(const uint8_t data[], int dataLen, const uint8_t generator[], int degree,
uint8_t result[]);
testable uint8_t reedSolomonMultiply(uint8_t x, uint8_t y);
testable void initializeFunctionModules(int version, uint8_t qrcode[]);
static void drawLightFunctionModules(uint8_t qrcode[], int version);
static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]);
testable int getAlignmentPatternPositions(int version, uint8_t result[7]);
static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]);
static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]);
static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask);
static long getPenaltyScore(const uint8_t qrcode[]);
static int finderPenaltyCountPatterns(const int runHistory[7], int qrsize);
static int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, int runHistory[7], int qrsize);
static void finderPenaltyAddHistory(int currentRunLength, int runHistory[7], int qrsize);
testable bool getModuleBounded(const uint8_t qrcode[], int x, int y);
testable void setModuleBounded(uint8_t qrcode[], int x, int y, bool isDark);
testable void setModuleUnbounded(uint8_t qrcode[], int x, int y, bool isDark);
static bool getBit(int x, int i);
testable int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars);
testable int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version);
static int numCharCountBits(enum qrcodegen_Mode mode, int version);
/*---- Private tables of constants ----*/
// The set of all legal characters in alphanumeric mode, where each character
// value maps to the index in the string. For checking text and encoding segments.
static const char* ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
// Sentinel value for use in only some functions.
#define LENGTH_OVERFLOW -1
// For generating error correction codes.
testable const int8_t ECC_CODEWORDS_PER_BLOCK[4][41] = {
// Version: (note that index 0 is for padding, and is set to an illegal value)
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
// 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
{-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28,
28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Low
{-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26,
26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, // Medium
{-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30,
28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Quartile
{-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28,
30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // High
};
#define qrcodegen_REED_SOLOMON_DEGREE_MAX 30 // Based on the table above
// For generating error correction codes.
testable const int8_t NUM_ERROR_CORRECTION_BLOCKS[4][41] = {
// Version: (note that index 0 is for padding, and is set to an illegal value)
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
// 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
{-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8,
8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25}, // Low
{-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16,
17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49}, // Medium
{-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20,
23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68}, // Quartile
{-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25,
25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81}, // High
};
// For automatic mask pattern selection.
static const int PENALTY_N1 = 3;
static const int PENALTY_N2 = 3;
static const int PENALTY_N3 = 40;
static const int PENALTY_N4 = 10;
/*---- High-level QR Code encoding functions ----*/
// Public function - see documentation comment in header file.
bool qrcodegen_encodeText(const char* text, uint8_t tempBuffer[], uint8_t qrcode[], enum qrcodegen_Ecc ecl,
int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) {
size_t textLen = strlen(text);
if (textLen == 0)
return qrcodegen_encodeSegmentsAdvanced(NULL, 0, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode);
size_t bufLen = (size_t)qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion);
struct qrcodegen_Segment seg;
if (qrcodegen_isNumeric(text)) {
if (qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_NUMERIC, textLen) > bufLen) goto fail;
seg = qrcodegen_makeNumeric(text, tempBuffer);
} else if (qrcodegen_isAlphanumeric(text)) {
if (qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_ALPHANUMERIC, textLen) > bufLen) goto fail;
seg = qrcodegen_makeAlphanumeric(text, tempBuffer);
} else {
if (textLen > bufLen) goto fail;
for (size_t i = 0; i < textLen; i++) tempBuffer[i] = (uint8_t)text[i];
seg.mode = qrcodegen_Mode_BYTE;
seg.bitLength = calcSegmentBitLength(seg.mode, textLen);
if (seg.bitLength == LENGTH_OVERFLOW) goto fail;
seg.numChars = (int)textLen;
seg.data = tempBuffer;
}
return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode);
fail:
qrcode[0] = 0; // Set size to invalid value for safety
return false;
}
// Public function - see documentation comment in header file.
bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], enum qrcodegen_Ecc ecl,
int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) {
struct qrcodegen_Segment seg;
seg.mode = qrcodegen_Mode_BYTE;
seg.bitLength = calcSegmentBitLength(seg.mode, dataLen);
if (seg.bitLength == LENGTH_OVERFLOW) {
qrcode[0] = 0; // Set size to invalid value for safety
return false;
}
seg.numChars = (int)dataLen;
seg.data = dataAndTemp;
return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, dataAndTemp, qrcode);
}
// Appends the given number of low-order bits of the given value to the given byte-based
// bit buffer, increasing the bit length. Requires 0 <= numBits <= 16 and val < 2^numBits.
testable void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int* bitLen) {
assert(0 <= numBits && numBits <= 16 && (unsigned long)val >> numBits == 0);
for (int i = numBits - 1; i >= 0; i--, (*bitLen)++) buffer[*bitLen >> 3] |= ((val >> i) & 1) << (7 - (*bitLen & 7));
}
/*---- Low-level QR Code encoding functions ----*/
// Public function - see documentation comment in header file.
bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl,
uint8_t tempBuffer[], uint8_t qrcode[]) {
return qrcodegen_encodeSegmentsAdvanced(segs, len, ecl, qrcodegen_VERSION_MIN, qrcodegen_VERSION_MAX,
qrcodegen_Mask_AUTO, true, tempBuffer, qrcode);
}
// Public function - see documentation comment in header file.
bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl,
int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl,
uint8_t tempBuffer[], uint8_t qrcode[]) {
assert(segs != NULL || len == 0);
assert(qrcodegen_VERSION_MIN <= minVersion && minVersion <= maxVersion && maxVersion <= qrcodegen_VERSION_MAX);
assert(0 <= (int)ecl && (int)ecl <= 3 && -1 <= (int)mask && (int)mask <= 7);
// Find the minimal version number to use
int version, dataUsedBits;
for (version = minVersion;; version++) {
int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; // Number of data bits available
dataUsedBits = getTotalBits(segs, len, version);
if (dataUsedBits != LENGTH_OVERFLOW && dataUsedBits <= dataCapacityBits)
break; // This version number is found to be suitable
if (version >= maxVersion) { // All versions in the range could not fit the given data
qrcode[0] = 0; // Set size to invalid value for safety
return false;
}
}
assert(dataUsedBits != LENGTH_OVERFLOW);
// Increase the error correction level while the data still fits in the current version number
for (int i = (int)qrcodegen_Ecc_MEDIUM; i <= (int)qrcodegen_Ecc_HIGH; i++) { // From low to high
if (boostEcl && dataUsedBits <= getNumDataCodewords(version, (enum qrcodegen_Ecc)i) * 8)
ecl = (enum qrcodegen_Ecc)i;
}
// Concatenate all segments to create the data bit string
memset(qrcode, 0, (size_t)qrcodegen_BUFFER_LEN_FOR_VERSION(version) * sizeof(qrcode[0]));
int bitLen = 0;
for (size_t i = 0; i < len; i++) {
const struct qrcodegen_Segment* seg = &segs[i];
appendBitsToBuffer((unsigned int)seg->mode, 4, qrcode, &bitLen);
appendBitsToBuffer((unsigned int)seg->numChars, numCharCountBits(seg->mode, version), qrcode, &bitLen);
for (int j = 0; j < seg->bitLength; j++) {
int bit = (seg->data[j >> 3] >> (7 - (j & 7))) & 1;
appendBitsToBuffer((unsigned int)bit, 1, qrcode, &bitLen);
}
}
assert(bitLen == dataUsedBits);
// Add terminator and pad up to a byte if applicable
int dataCapacityBits = getNumDataCodewords(version, ecl) * 8;
assert(bitLen <= dataCapacityBits);
int terminatorBits = dataCapacityBits - bitLen;
if (terminatorBits > 4) terminatorBits = 4;
appendBitsToBuffer(0, terminatorBits, qrcode, &bitLen);
appendBitsToBuffer(0, (8 - bitLen % 8) % 8, qrcode, &bitLen);
assert(bitLen % 8 == 0);
// Pad with alternating bytes until data capacity is reached
for (uint8_t padByte = 0xEC; bitLen < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
appendBitsToBuffer(padByte, 8, qrcode, &bitLen);
// Compute ECC, draw modules
addEccAndInterleave(qrcode, version, ecl, tempBuffer);
initializeFunctionModules(version, qrcode);
drawCodewords(tempBuffer, getNumRawDataModules(version) / 8, qrcode);
drawLightFunctionModules(qrcode, version);
initializeFunctionModules(version, tempBuffer);
// Do masking
if (mask == qrcodegen_Mask_AUTO) { // Automatically choose best mask
long minPenalty = LONG_MAX;
for (int i = 0; i < 8; i++) {
enum qrcodegen_Mask msk = (enum qrcodegen_Mask)i;
applyMask(tempBuffer, qrcode, msk);
drawFormatBits(ecl, msk, qrcode);
long penalty = getPenaltyScore(qrcode);
if (penalty < minPenalty) {
mask = msk;
minPenalty = penalty;
}
applyMask(tempBuffer, qrcode, msk); // Undoes the mask due to XOR
}
}
assert(0 <= (int)mask && (int)mask <= 7);
applyMask(tempBuffer, qrcode, mask); // Apply the final choice of mask
drawFormatBits(ecl, mask, qrcode); // Overwrite old format bits
return true;
}
/*---- Error correction code generation functions ----*/
// Appends error correction bytes to each block of the given data array, then interleaves
// bytes from the blocks and stores them in the result array. data[0 : dataLen] contains
// the input data. data[dataLen : rawCodewords] is used as a temporary work area and will
// be clobbered by this function. The final answer is stored in result[0 : rawCodewords].
testable void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]) {
// Calculate parameter numbers
assert(0 <= (int)ecl && (int)ecl < 4 && qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX);
int numBlocks = NUM_ERROR_CORRECTION_BLOCKS[(int)ecl][version];
int blockEccLen = ECC_CODEWORDS_PER_BLOCK[(int)ecl][version];
int rawCodewords = getNumRawDataModules(version) / 8;
int dataLen = getNumDataCodewords(version, ecl);
int numShortBlocks = numBlocks - rawCodewords % numBlocks;
int shortBlockDataLen = rawCodewords / numBlocks - blockEccLen;
// Split data into blocks, calculate ECC, and interleave
// (not concatenate) the bytes into a single sequence
uint8_t rsdiv[qrcodegen_REED_SOLOMON_DEGREE_MAX];
reedSolomonComputeDivisor(blockEccLen, rsdiv);
const uint8_t* dat = data;
for (int i = 0; i < numBlocks; i++) {
int datLen = shortBlockDataLen + (i < numShortBlocks ? 0 : 1);
uint8_t* ecc = &data[dataLen]; // Temporary storage
reedSolomonComputeRemainder(dat, datLen, rsdiv, blockEccLen, ecc);
for (int j = 0, k = i; j < datLen; j++, k += numBlocks) { // Copy data
if (j == shortBlockDataLen) k -= numShortBlocks;
result[k] = dat[j];
}
for (int j = 0, k = dataLen + i; j < blockEccLen; j++, k += numBlocks) // Copy ECC
result[k] = ecc[j];
dat += datLen;
}
}
// Returns the number of 8-bit codewords that can be used for storing data (not ECC),
// for the given version number and error correction level. The result is in the range [9, 2956].
testable int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl) {
int v = version, e = (int)ecl;
assert(0 <= e && e < 4);
return getNumRawDataModules(v) / 8 - ECC_CODEWORDS_PER_BLOCK[e][v] * NUM_ERROR_CORRECTION_BLOCKS[e][v];
}
// Returns the number of data bits that can be stored in a QR Code of the given version number, after
// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
testable int getNumRawDataModules(int ver) {
assert(qrcodegen_VERSION_MIN <= ver && ver <= qrcodegen_VERSION_MAX);
int result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
int numAlign = ver / 7 + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7) result -= 36;
}
assert(208 <= result && result <= 29648);
return result;
}
/*---- Reed-Solomon ECC generator functions ----*/
// Computes a Reed-Solomon ECC generator polynomial for the given degree, storing in result[0 : degree].
// This could be implemented as a lookup table over all possible parameter values, instead of as an algorithm.
testable void reedSolomonComputeDivisor(int degree, uint8_t result[]) {
assert(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX);
// Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
// For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array {255, 8, 93}.
memset(result, 0, (size_t)degree * sizeof(result[0]));
result[degree - 1] = 1; // Start off with the monomial x^0
// Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
// drop the highest monomial term which is always 1x^degree.
// Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
uint8_t root = 1;
for (int i = 0; i < degree; i++) {
// Multiply the current product by (x - r^i)
for (int j = 0; j < degree; j++) {
result[j] = reedSolomonMultiply(result[j], root);
if (j + 1 < degree) result[j] ^= result[j + 1];
}
root = reedSolomonMultiply(root, 0x02);
}
}
// Computes the Reed-Solomon error correction codeword for the given data and divisor polynomials.
// The remainder when data[0 : dataLen] is divided by divisor[0 : degree] is stored in result[0 : degree].
// All polynomials are in big endian, and the generator has an implicit leading 1 term.
testable void reedSolomonComputeRemainder(const uint8_t data[], int dataLen, const uint8_t generator[], int degree,
uint8_t result[]) {
assert(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX);
memset(result, 0, (size_t)degree * sizeof(result[0]));
for (int i = 0; i < dataLen; i++) { // Polynomial division
uint8_t factor = data[i] ^ result[0];
memmove(&result[0], &result[1], (size_t)(degree - 1) * sizeof(result[0]));
result[degree - 1] = 0;
for (int j = 0; j < degree; j++) result[j] ^= reedSolomonMultiply(generator[j], factor);
}
}
#undef qrcodegen_REED_SOLOMON_DEGREE_MAX
// Returns the product of the two given field elements modulo GF(2^8/0x11D).
// All inputs are valid. This could be implemented as a 256*256 lookup table.
testable uint8_t reedSolomonMultiply(uint8_t x, uint8_t y) {
// Russian peasant multiplication
uint8_t z = 0;
for (int i = 7; i >= 0; i--) {
z = (uint8_t)((z << 1) ^ ((z >> 7) * 0x11D));
z ^= ((y >> i) & 1) * x;
}
return z;
}
/*---- Drawing function modules ----*/
// Clears the given QR Code grid with light modules for the given
// version's size, then marks every function module as dark.
testable void initializeFunctionModules(int version, uint8_t qrcode[]) {
// Initialize QR Code
int qrsize = version * 4 + 17;
memset(qrcode, 0, (size_t)((qrsize * qrsize + 7) / 8 + 1) * sizeof(qrcode[0]));
qrcode[0] = (uint8_t)qrsize;
// Fill horizontal and vertical timing patterns
fillRectangle(6, 0, 1, qrsize, qrcode);
fillRectangle(0, 6, qrsize, 1, qrcode);
// Fill 3 finder patterns (all corners except bottom right) and format bits
fillRectangle(0, 0, 9, 9, qrcode);
fillRectangle(qrsize - 8, 0, 8, 9, qrcode);
fillRectangle(0, qrsize - 8, 9, 8, qrcode);
// Fill numerous alignment patterns
uint8_t alignPatPos[7];
int numAlign = getAlignmentPatternPositions(version, alignPatPos);
for (int i = 0; i < numAlign; i++) {
for (int j = 0; j < numAlign; j++) {
// Don't draw on the three finder corners
if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)))
fillRectangle(alignPatPos[i] - 2, alignPatPos[j] - 2, 5, 5, qrcode);
}
}
// Fill version blocks
if (version >= 7) {
fillRectangle(qrsize - 11, 0, 3, 6, qrcode);
fillRectangle(0, qrsize - 11, 6, 3, qrcode);
}
}
// Draws light function modules and possibly some dark modules onto the given QR Code, without changing
// non-function modules. This does not draw the format bits. This requires all function modules to be previously
// marked dark (namely by initializeFunctionModules()), because this may skip redrawing dark function modules.
static void drawLightFunctionModules(uint8_t qrcode[], int version) {
// Draw horizontal and vertical timing patterns
int qrsize = qrcodegen_getSize(qrcode);
for (int i = 7; i < qrsize - 7; i += 2) {
setModuleBounded(qrcode, 6, i, false);
setModuleBounded(qrcode, i, 6, false);
}
// Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
for (int dy = -4; dy <= 4; dy++) {
for (int dx = -4; dx <= 4; dx++) {
int dist = abs(dx);
if (abs(dy) > dist) dist = abs(dy);
if (dist == 2 || dist == 4) {
setModuleUnbounded(qrcode, 3 + dx, 3 + dy, false);
setModuleUnbounded(qrcode, qrsize - 4 + dx, 3 + dy, false);
setModuleUnbounded(qrcode, 3 + dx, qrsize - 4 + dy, false);
}
}
}
// Draw numerous alignment patterns
uint8_t alignPatPos[7];
int numAlign = getAlignmentPatternPositions(version, alignPatPos);
for (int i = 0; i < numAlign; i++) {
for (int j = 0; j < numAlign; j++) {
if ((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))
continue; // Don't draw on the three finder corners
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++)
setModuleBounded(qrcode, alignPatPos[i] + dx, alignPatPos[j] + dy, dx == 0 && dy == 0);
}
}
}
// Draw version blocks
if (version >= 7) {
// Calculate error correction code and pack bits
int rem = version; // version is uint6, in the range [7, 40]
for (int i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1F25);
long bits = (long)version << 12 | rem; // uint18
assert(bits >> 18 == 0);
// Draw two copies
for (int i = 0; i < 6; i++) {
for (int j = 0; j < 3; j++) {
int k = qrsize - 11 + j;
setModuleBounded(qrcode, k, i, (bits & 1) != 0);
setModuleBounded(qrcode, i, k, (bits & 1) != 0);
bits >>= 1;
}
}
}
}
// Draws two copies of the format bits (with its own error correction code) based
// on the given mask and error correction level. This always draws all modules of
// the format bits, unlike drawLightFunctionModules() which might skip dark modules.
static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]) {
// Calculate error correction code and pack bits
assert(0 <= (int)mask && (int)mask <= 7);
static const int table[] = {1, 0, 3, 2};
int data = table[(int)ecl] << 3 | (int)mask; // errCorrLvl is uint2, mask is uint3
int rem = data;
for (int i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537);
int bits = (data << 10 | rem) ^ 0x5412; // uint15
assert(bits >> 15 == 0);
// Draw first copy
for (int i = 0; i <= 5; i++) setModuleBounded(qrcode, 8, i, getBit(bits, i));
setModuleBounded(qrcode, 8, 7, getBit(bits, 6));
setModuleBounded(qrcode, 8, 8, getBit(bits, 7));
setModuleBounded(qrcode, 7, 8, getBit(bits, 8));
for (int i = 9; i < 15; i++) setModuleBounded(qrcode, 14 - i, 8, getBit(bits, i));
// Draw second copy
int qrsize = qrcodegen_getSize(qrcode);
for (int i = 0; i < 8; i++) setModuleBounded(qrcode, qrsize - 1 - i, 8, getBit(bits, i));
for (int i = 8; i < 15; i++) setModuleBounded(qrcode, 8, qrsize - 15 + i, getBit(bits, i));
setModuleBounded(qrcode, 8, qrsize - 8, true); // Always dark
}
// Calculates and stores an ascending list of positions of alignment patterns
// for this version number, returning the length of the list (in the range [0,7]).
// Each position is in the range [0,177), and are used on both the x and y axes.
// This could be implemented as lookup table of 40 variable-length lists of unsigned bytes.
testable int getAlignmentPatternPositions(int version, uint8_t result[7]) {
if (version == 1) return 0;
int numAlign = version / 7 + 2;
int step = (version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4) * 2;
for (int i = numAlign - 1, pos = version * 4 + 10; i >= 1; i--, pos -= step) result[i] = (uint8_t)pos;
result[0] = 6;
return numAlign;
}
// Sets every module in the range [left : left + width] * [top : top + height] to dark.
static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]) {
for (int dy = 0; dy < height; dy++) {
for (int dx = 0; dx < width; dx++) setModuleBounded(qrcode, left + dx, top + dy, true);
}
}
/*---- Drawing data modules and masking ----*/
// Draws the raw codewords (including data and ECC) onto the given QR Code. This requires the initial state of
// the QR Code to be dark at function modules and light at codeword modules (including unused remainder bits).
static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]) {
int qrsize = qrcodegen_getSize(qrcode);
int i = 0; // Bit index into the data
// Do the funny zigzag scan
for (int right = qrsize - 1; right >= 1; right -= 2) { // Index of right column in each column pair
if (right == 6) right = 5;
for (int vert = 0; vert < qrsize; vert++) { // Vertical counter
for (int j = 0; j < 2; j++) {
int x = right - j; // Actual x coordinate
bool upward = ((right + 1) & 2) == 0;
int y = upward ? qrsize - 1 - vert : vert; // Actual y coordinate
if (!getModuleBounded(qrcode, x, y) && i < dataLen * 8) {
bool dark = getBit(data[i >> 3], 7 - (i & 7));
setModuleBounded(qrcode, x, y, dark);
i++;
}
// If this QR Code has any remainder bits (0 to 7), they were assigned as
// 0/false/light by the constructor and are left unchanged by this method
}
}
}
assert(i == dataLen * 8);
}
// XORs the codeword modules in this QR Code with the given mask pattern
// and given pattern of function modules. The codeword bits must be drawn
// before masking. Due to the arithmetic of XOR, calling applyMask() with
// the same mask value a second time will undo the mask. A final well-formed
// QR Code needs exactly one (not zero, two, etc.) mask applied.
static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask) {
assert(0 <= (int)mask && (int)mask <= 7); // Disallows qrcodegen_Mask_AUTO
int qrsize = qrcodegen_getSize(qrcode);
for (int y = 0; y < qrsize; y++) {
for (int x = 0; x < qrsize; x++) {
if (getModuleBounded(functionModules, x, y)) continue;
bool invert;
switch ((int)mask) {
case 0:
invert = (x + y) % 2 == 0;
break;
case 1:
invert = y % 2 == 0;
break;
case 2:
invert = x % 3 == 0;
break;
case 3:
invert = (x + y) % 3 == 0;
break;
case 4:
invert = (x / 3 + y / 2) % 2 == 0;
break;
case 5:
invert = x * y % 2 + x * y % 3 == 0;
break;
case 6:
invert = (x * y % 2 + x * y % 3) % 2 == 0;
break;
case 7:
invert = ((x + y) % 2 + x * y % 3) % 2 == 0;
break;
default:
assert(false);
return;
}
bool val = getModuleBounded(qrcode, x, y);
setModuleBounded(qrcode, x, y, val ^ invert);
}
}
}
// Calculates and returns the penalty score based on state of the given QR Code's current modules.
// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
static long getPenaltyScore(const uint8_t qrcode[]) {
int qrsize = qrcodegen_getSize(qrcode);
long result = 0;
// Adjacent modules in row having same color, and finder-like patterns
for (int y = 0; y < qrsize; y++) {
bool runColor = false;
int runX = 0;
int runHistory[7] = {0};
for (int x = 0; x < qrsize; x++) {
if (getModuleBounded(qrcode, x, y) == runColor) {
runX++;
if (runX == 5)
result += PENALTY_N1;
else if (runX > 5)
result++;
} else {
finderPenaltyAddHistory(runX, runHistory, qrsize);
if (!runColor) result += finderPenaltyCountPatterns(runHistory, qrsize) * PENALTY_N3;
runColor = getModuleBounded(qrcode, x, y);
runX = 1;
}
}
result += finderPenaltyTerminateAndCount(runColor, runX, runHistory, qrsize) * PENALTY_N3;
}
// Adjacent modules in column having same color, and finder-like patterns
for (int x = 0; x < qrsize; x++) {
bool runColor = false;
int runY = 0;
int runHistory[7] = {0};
for (int y = 0; y < qrsize; y++) {
if (getModuleBounded(qrcode, x, y) == runColor) {
runY++;
if (runY == 5)
result += PENALTY_N1;
else if (runY > 5)
result++;
} else {
finderPenaltyAddHistory(runY, runHistory, qrsize);
if (!runColor) result += finderPenaltyCountPatterns(runHistory, qrsize) * PENALTY_N3;
runColor = getModuleBounded(qrcode, x, y);
runY = 1;
}
}
result += finderPenaltyTerminateAndCount(runColor, runY, runHistory, qrsize) * PENALTY_N3;
}
// 2*2 blocks of modules having same color
for (int y = 0; y < qrsize - 1; y++) {
for (int x = 0; x < qrsize - 1; x++) {
bool color = getModuleBounded(qrcode, x, y);
if (color == getModuleBounded(qrcode, x + 1, y) && color == getModuleBounded(qrcode, x, y + 1) &&
color == getModuleBounded(qrcode, x + 1, y + 1))
result += PENALTY_N2;
}
}
// Balance of dark and light modules
int dark = 0;
for (int y = 0; y < qrsize; y++) {
for (int x = 0; x < qrsize; x++) {
if (getModuleBounded(qrcode, x, y)) dark++;
}
}
int total = qrsize * qrsize; // Note that size is odd, so dark/total != 1/2
// Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
int k = (int)((labs(dark * 20L - total * 10L) + total - 1) / total) - 1;
assert(0 <= k && k <= 9);
result += k * PENALTY_N4;
assert(0 <= result && result <= 2568888L); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
return result;
}
// Can only be called immediately after a light run is added, and
// returns either 0, 1, or 2. A helper function for getPenaltyScore().
static int finderPenaltyCountPatterns(const int runHistory[7], int qrsize) {
int n = runHistory[1];
assert(n <= qrsize * 3);
(void)qrsize;
bool core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n;
// The maximum QR Code size is 177, hence the dark run length n <= 177.
// Arithmetic is promoted to int, so n*4 will not overflow.
return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) +
(core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0);
}
// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
static int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, int runHistory[7], int qrsize) {
if (currentRunColor) { // Terminate dark run
finderPenaltyAddHistory(currentRunLength, runHistory, qrsize);
currentRunLength = 0;
}
currentRunLength += qrsize; // Add light border to final run
finderPenaltyAddHistory(currentRunLength, runHistory, qrsize);
return finderPenaltyCountPatterns(runHistory, qrsize);
}
// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
static void finderPenaltyAddHistory(int currentRunLength, int runHistory[7], int qrsize) {
if (runHistory[0] == 0) currentRunLength += qrsize; // Add light border to initial run
memmove(&runHistory[1], &runHistory[0], 6 * sizeof(runHistory[0]));
runHistory[0] = currentRunLength;
}
/*---- Basic QR Code information ----*/
// Public function - see documentation comment in header file.
int qrcodegen_getSize(const uint8_t qrcode[]) {
assert(qrcode != NULL);
int result = qrcode[0];
assert((qrcodegen_VERSION_MIN * 4 + 17) <= result && result <= (qrcodegen_VERSION_MAX * 4 + 17));
return result;
}
// Public function - see documentation comment in header file.
bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y) {
assert(qrcode != NULL);
int qrsize = qrcode[0];
return (0 <= x && x < qrsize && 0 <= y && y < qrsize) && getModuleBounded(qrcode, x, y);
}
// Returns the color of the module at the given coordinates, which must be in bounds.
testable bool getModuleBounded(const uint8_t qrcode[], int x, int y) {
int qrsize = qrcode[0];
assert(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize);
int index = y * qrsize + x;
return getBit(qrcode[(index >> 3) + 1], index & 7);
}
// Sets the color of the module at the given coordinates, which must be in bounds.
testable void setModuleBounded(uint8_t qrcode[], int x, int y, bool isDark) {
int qrsize = qrcode[0];
assert(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize);
int index = y * qrsize + x;
int bitIndex = index & 7;
int byteIndex = (index >> 3) + 1;
if (isDark)
qrcode[byteIndex] |= 1 << bitIndex;
else
qrcode[byteIndex] &= (1 << bitIndex) ^ 0xFF;
}
// Sets the color of the module at the given coordinates, doing nothing if out of bounds.
testable void setModuleUnbounded(uint8_t qrcode[], int x, int y, bool isDark) {
int qrsize = qrcode[0];
if (0 <= x && x < qrsize && 0 <= y && y < qrsize) setModuleBounded(qrcode, x, y, isDark);
}
// Returns true iff the i'th bit of x is set to 1. Requires x >= 0 and 0 <= i <= 14.
static bool getBit(int x, int i) { return ((x >> i) & 1) != 0; }
/*---- Segment handling ----*/
// Public function - see documentation comment in header file.
bool qrcodegen_isNumeric(const char* text) {
assert(text != NULL);
for (; *text != '\0'; text++) {
if (*text < '0' || *text > '9') return false;
}
return true;
}
// Public function - see documentation comment in header file.
bool qrcodegen_isAlphanumeric(const char* text) {
assert(text != NULL);
for (; *text != '\0'; text++) {
if (strchr(ALPHANUMERIC_CHARSET, *text) == NULL) return false;
}
return true;
}
// Public function - see documentation comment in header file.
size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars) {
int temp = calcSegmentBitLength(mode, numChars);
if (temp == LENGTH_OVERFLOW) return SIZE_MAX;
assert(0 <= temp && temp <= INT16_MAX);
return ((size_t)temp + 7) / 8;
}
// Returns the number of data bits needed to represent a segment
// containing the given number of characters using the given mode. Notes:
// - Returns LENGTH_OVERFLOW on failure, i.e. numChars > INT16_MAX
// or the number of needed bits exceeds INT16_MAX (i.e. 32767).
// - Otherwise, all valid results are in the range [0, INT16_MAX].
// - For byte mode, numChars measures the number of bytes, not Unicode code points.
// - For ECI mode, numChars must be 0, and the worst-case number of bits is returned.
// An actual ECI segment can have shorter data. For non-ECI modes, the result is exact.
testable int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars) {
// All calculations are designed to avoid overflow on all platforms
if (numChars > (unsigned int)INT16_MAX) return LENGTH_OVERFLOW;
long result = (long)numChars;
if (mode == qrcodegen_Mode_NUMERIC)
result = (result * 10 + 2) / 3; // ceil(10/3 * n)
else if (mode == qrcodegen_Mode_ALPHANUMERIC)
result = (result * 11 + 1) / 2; // ceil(11/2 * n)
else if (mode == qrcodegen_Mode_BYTE)
result *= 8;
else if (mode == qrcodegen_Mode_KANJI)
result *= 13;
else if (mode == qrcodegen_Mode_ECI && numChars == 0)
result = 3 * 8;
else { // Invalid argument
assert(false);
return LENGTH_OVERFLOW;
}
assert(result >= 0);
if (result > INT16_MAX) return LENGTH_OVERFLOW;
return (int)result;
}
// Public function - see documentation comment in header file.
struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]) {
assert(data != NULL || len == 0);
struct qrcodegen_Segment result;
result.mode = qrcodegen_Mode_BYTE;
result.bitLength = calcSegmentBitLength(result.mode, len);
assert(result.bitLength != LENGTH_OVERFLOW);
result.numChars = (int)len;
if (len > 0) memcpy(buf, data, len * sizeof(buf[0]));
result.data = buf;
return result;
}
// Public function - see documentation comment in header file.
struct qrcodegen_Segment qrcodegen_makeNumeric(const char* digits, uint8_t buf[]) {
assert(digits != NULL);
struct qrcodegen_Segment result;
size_t len = strlen(digits);
result.mode = qrcodegen_Mode_NUMERIC;
int bitLen = calcSegmentBitLength(result.mode, len);
assert(bitLen != LENGTH_OVERFLOW);
result.numChars = (int)len;
if (bitLen > 0) memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0]));
result.bitLength = 0;
unsigned int accumData = 0;
int accumCount = 0;
for (; *digits != '\0'; digits++) {
char c = *digits;
assert('0' <= c && c <= '9');
accumData = accumData * 10 + (unsigned int)(c - '0');
accumCount++;
if (accumCount == 3) {
appendBitsToBuffer(accumData, 10, buf, &result.bitLength);
accumData = 0;
accumCount = 0;
}
}
if (accumCount > 0) // 1 or 2 digits remaining
appendBitsToBuffer(accumData, accumCount * 3 + 1, buf, &result.bitLength);
assert(result.bitLength == bitLen);
result.data = buf;
return result;
}
// Public function - see documentation comment in header file.
struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char* text, uint8_t buf[]) {
assert(text != NULL);
struct qrcodegen_Segment result;
size_t len = strlen(text);
result.mode = qrcodegen_Mode_ALPHANUMERIC;
int bitLen = calcSegmentBitLength(result.mode, len);
assert(bitLen != LENGTH_OVERFLOW);
result.numChars = (int)len;
if (bitLen > 0) memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0]));
result.bitLength = 0;
unsigned int accumData = 0;
int accumCount = 0;
for (; *text != '\0'; text++) {
const char* temp = strchr(ALPHANUMERIC_CHARSET, *text);
assert(temp != NULL);
accumData = accumData * 45 + (unsigned int)(temp - ALPHANUMERIC_CHARSET);
accumCount++;
if (accumCount == 2) {
appendBitsToBuffer(accumData, 11, buf, &result.bitLength);
accumData = 0;
accumCount = 0;
}
}
if (accumCount > 0) // 1 character remaining
appendBitsToBuffer(accumData, 6, buf, &result.bitLength);
assert(result.bitLength == bitLen);
result.data = buf;
return result;
}
// Public function - see documentation comment in header file.
struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]) {
struct qrcodegen_Segment result;
result.mode = qrcodegen_Mode_ECI;
result.numChars = 0;
result.bitLength = 0;
if (assignVal < 0)
assert(false);
else if (assignVal < (1 << 7)) {
memset(buf, 0, 1 * sizeof(buf[0]));
appendBitsToBuffer((unsigned int)assignVal, 8, buf, &result.bitLength);
} else if (assignVal < (1 << 14)) {
memset(buf, 0, 2 * sizeof(buf[0]));
appendBitsToBuffer(2, 2, buf, &result.bitLength);
appendBitsToBuffer((unsigned int)assignVal, 14, buf, &result.bitLength);
} else if (assignVal < 1000000L) {
memset(buf, 0, 3 * sizeof(buf[0]));
appendBitsToBuffer(6, 3, buf, &result.bitLength);
appendBitsToBuffer((unsigned int)(assignVal >> 10), 11, buf, &result.bitLength);
appendBitsToBuffer((unsigned int)(assignVal & 0x3FF), 10, buf, &result.bitLength);
} else
assert(false);
result.data = buf;
return result;
}
// Calculates the number of bits needed to encode the given segments at the given version.
// Returns a non-negative number if successful. Otherwise returns LENGTH_OVERFLOW if a segment
// has too many characters to fit its length field, or the total bits exceeds INT16_MAX.
testable int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version) {
assert(segs != NULL || len == 0);
long result = 0;
for (size_t i = 0; i < len; i++) {
int numChars = segs[i].numChars;
int bitLength = segs[i].bitLength;
assert(0 <= numChars && numChars <= INT16_MAX);
assert(0 <= bitLength && bitLength <= INT16_MAX);
int ccbits = numCharCountBits(segs[i].mode, version);
assert(0 <= ccbits && ccbits <= 16);
if (numChars >= (1L << ccbits)) return LENGTH_OVERFLOW; // The segment's length doesn't fit the field's bit width
result += 4L + ccbits + bitLength;
if (result > INT16_MAX) return LENGTH_OVERFLOW; // The sum might overflow an int type
}
assert(0 <= result && result <= INT16_MAX);
return (int)result;
}
// Returns the bit width of the character count field for a segment in the given mode
// in a QR Code at the given version number. The result is in the range [0, 16].
static int numCharCountBits(enum qrcodegen_Mode mode, int version) {
assert(qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX);
int i = (version + 7) / 17;
switch (mode) {
case qrcodegen_Mode_NUMERIC: {
static const int temp[] = {10, 12, 14};
return temp[i];
}
case qrcodegen_Mode_ALPHANUMERIC: {
static const int temp[] = {9, 11, 13};
return temp[i];
}
case qrcodegen_Mode_BYTE: {
static const int temp[] = {8, 16, 16};
return temp[i];
}
case qrcodegen_Mode_KANJI: {
static const int temp[] = {8, 10, 12};
return temp[i];
}
case qrcodegen_Mode_ECI:
return 0;
default:
assert(false);
return -1; // Dummy value
}
}
#undef LENGTH_OVERFLOW
+363
View File
@@ -0,0 +1,363 @@
/*
* QR Code generator library (C)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* This library creates QR Code symbols, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* A QR Code structure is an immutable square grid of dark and light cells.
* The library provides functions to create a QR Code from text or binary data.
* The library covers the QR Code Model 2 specification, supporting all versions (sizes)
* from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
*
* Ways to create a QR Code object:
* - High level: Take the payload data and call qrcodegen_encodeText() or qrcodegen_encodeBinary().
* - Low level: Custom-make the list of segments and call
* qrcodegen_encodeSegments() or qrcodegen_encodeSegmentsAdvanced().
* (Note that all ways require supplying the desired error correction level and various byte buffers.)
*/
/*---- Enum and struct types----*/
/*
* The error correction level in a QR Code symbol.
*/
enum qrcodegen_Ecc {
// Must be declared in ascending order of error protection
// so that an internal qrcodegen function works properly
qrcodegen_Ecc_LOW = 0, // The QR Code can tolerate about 7% erroneous codewords
qrcodegen_Ecc_MEDIUM, // The QR Code can tolerate about 15% erroneous codewords
qrcodegen_Ecc_QUARTILE, // The QR Code can tolerate about 25% erroneous codewords
qrcodegen_Ecc_HIGH, // The QR Code can tolerate about 30% erroneous codewords
};
/*
* The mask pattern used in a QR Code symbol.
*/
enum qrcodegen_Mask {
// A special value to tell the QR Code encoder to
// automatically select an appropriate mask pattern
qrcodegen_Mask_AUTO = -1,
// The eight actual mask patterns
qrcodegen_Mask_0 = 0,
qrcodegen_Mask_1,
qrcodegen_Mask_2,
qrcodegen_Mask_3,
qrcodegen_Mask_4,
qrcodegen_Mask_5,
qrcodegen_Mask_6,
qrcodegen_Mask_7,
};
/*
* Describes how a segment's data bits are interpreted.
*/
enum qrcodegen_Mode {
qrcodegen_Mode_NUMERIC = 0x1,
qrcodegen_Mode_ALPHANUMERIC = 0x2,
qrcodegen_Mode_BYTE = 0x4,
qrcodegen_Mode_KANJI = 0x8,
qrcodegen_Mode_ECI = 0x7,
};
/*
* A segment of character/binary/control data in a QR Code symbol.
* The mid-level way to create a segment is to take the payload data
* and call a factory function such as qrcodegen_makeNumeric().
* The low-level way to create a segment is to custom-make the bit buffer
* and initialize a qrcodegen_Segment struct with appropriate values.
* Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
* Any segment longer than this is meaningless for the purpose of generating QR Codes.
* Moreover, the maximum allowed bit length is 32767 because
* the largest QR Code (version 40) has 31329 modules.
*/
struct qrcodegen_Segment {
// The mode indicator of this segment.
enum qrcodegen_Mode mode;
// The length of this segment's unencoded data. Measured in characters for
// numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
// Always zero or positive. Not the same as the data's bit length.
int numChars;
// The data bits of this segment, packed in bitwise big endian.
// Can be null if the bit length is zero.
uint8_t* data;
// The number of valid data bits used in the buffer. Requires
// 0 <= bitLength <= 32767, and bitLength <= (capacity of data array) * 8.
// The character count (numChars) must agree with the mode and the bit buffer length.
int bitLength;
};
/*---- Macro constants and functions ----*/
#define qrcodegen_VERSION_MIN 1 // The minimum version number supported in the QR Code Model 2 standard
#define qrcodegen_VERSION_MAX 40 // The maximum version number supported in the QR Code Model 2 standard
// Calculates the number of bytes needed to store any QR Code up to and including the given version number,
// as a compile-time constant. For example, 'uint8_t buffer[qrcodegen_BUFFER_LEN_FOR_VERSION(25)];'
// can store any single QR Code from version 1 to 25 (inclusive). The result fits in an int (or int16).
// Requires qrcodegen_VERSION_MIN <= n <= qrcodegen_VERSION_MAX.
#define qrcodegen_BUFFER_LEN_FOR_VERSION(n) ((((n) * 4 + 17) * ((n) * 4 + 17) + 7) / 8 + 1)
// The worst-case number of bytes needed to store one QR Code, up to and including
// version 40. This value equals 3918, which is just under 4 kilobytes.
// Use this more convenient value to avoid calculating tighter memory bounds for buffers.
#define qrcodegen_BUFFER_LEN_MAX qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX)
/*---- Functions (high level) to generate QR Codes ----*/
/*
* Encodes the given text string to a QR Code, returning true if successful.
* If the data is too long to fit in any version in the given range
* at the given ECC level, then false is returned.
*
* The input text must be encoded in UTF-8 and contain no NULs.
* Requires 1 <= minVersion <= maxVersion <= 40.
*
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or
* qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow).
*
* About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion):
* - Before calling the function:
* - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The initial state of both ranges can be uninitialized
* because the function always writes before reading.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - tempBuffer contains no useful data and should be treated as entirely uninitialized.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* If successful, the resulting QR Code may use numeric,
* alphanumeric, or byte mode to encode the text.
*
* In the most optimistic case, a QR Code at version 40 with low ECC
* can hold any UTF-8 string up to 2953 bytes, or any alphanumeric string
* up to 4296 characters, or any digit string up to 7089 characters.
* These numbers represent the hard upper limit of the QR Code standard.
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*/
bool qrcodegen_encodeText(const char* text, uint8_t tempBuffer[], uint8_t qrcode[], enum qrcodegen_Ecc ecl,
int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl);
/*
* Encodes the given binary data to a QR Code, returning true if successful.
* If the data is too long to fit in any version in the given range
* at the given ECC level, then false is returned.
*
* Requires 1 <= minVersion <= maxVersion <= 40.
*
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or
* qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow).
*
* About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion):
* - Before calling the function:
* - The array ranges dataAndTemp[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The input array range dataAndTemp[0 : dataLen] should normally be
* valid UTF-8 text, but is not required by the QR Code standard.
* - The initial state of dataAndTemp[dataLen : len] and qrcode[0 : len]
* can be uninitialized because the function always writes before reading.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - dataAndTemp contains no useful data and should be treated as entirely uninitialized.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* If successful, the resulting QR Code will use byte mode to encode the data.
*
* In the most optimistic case, a QR Code at version 40 with low ECC can hold any byte
* sequence up to length 2953. This is the hard upper limit of the QR Code standard.
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*/
bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], enum qrcodegen_Ecc ecl,
int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl);
/*---- Functions (low level) to generate QR Codes ----*/
/*
* Encodes the given segments to a QR Code, returning true if successful.
* If the data is too long to fit in any version at the given ECC level,
* then false is returned.
*
* The smallest possible QR Code version is automatically chosen for
* the output. The ECC level of the result may be higher than the
* ecl argument if it can be done without increasing the version.
*
* About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX):
* - Before calling the function:
* - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The initial state of both ranges can be uninitialized
* because the function always writes before reading.
* - The input array segs can contain segments whose data buffers overlap with tempBuffer.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - tempBuffer contains no useful data and should be treated as entirely uninitialized.
* - Any segment whose data buffer overlaps with tempBuffer[0 : len]
* must be treated as having invalid values in that array.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*
* This function allows the user to create a custom sequence of segments that switches
* between modes (such as alphanumeric and byte) to encode text in less space.
* This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary().
*/
bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl,
uint8_t tempBuffer[], uint8_t qrcode[]);
/*
* Encodes the given segments to a QR Code, returning true if successful.
* If the data is too long to fit in any version in the given range
* at the given ECC level, then false is returned.
*
* Requires 1 <= minVersion <= maxVersion <= 40.
*
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or
* qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow).
*
* About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX):
* - Before calling the function:
* - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow
* reading and writing; hence each array must have a length of at least len.
* - The two ranges must not overlap (aliasing).
* - The initial state of both ranges can be uninitialized
* because the function always writes before reading.
* - The input array segs can contain segments whose data buffers overlap with tempBuffer.
* - After the function returns:
* - Both ranges have no guarantee on which elements are initialized and what values are stored.
* - tempBuffer contains no useful data and should be treated as entirely uninitialized.
* - Any segment whose data buffer overlaps with tempBuffer[0 : len]
* must be treated as having invalid values in that array.
* - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule().
*
* Please consult the QR Code specification for information on
* data capacities per version, ECC level, and text encoding mode.
*
* This function allows the user to create a custom sequence of segments that switches
* between modes (such as alphanumeric and byte) to encode text in less space.
* This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary().
*/
bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl,
int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl,
uint8_t tempBuffer[], uint8_t qrcode[]);
/*
* Tests whether the given string can be encoded as a segment in numeric mode.
* A string is encodable iff each character is in the range 0 to 9.
*/
bool qrcodegen_isNumeric(const char* text);
/*
* Tests whether the given string can be encoded as a segment in alphanumeric mode.
* A string is encodable iff each character is in the following set: 0 to 9, A to Z
* (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
bool qrcodegen_isAlphanumeric(const char* text);
/*
* Returns the number of bytes (uint8_t) needed for the data buffer of a segment
* containing the given number of characters using the given mode. Notes:
* - Returns SIZE_MAX on failure, i.e. numChars > INT16_MAX or the internal
* calculation of the number of needed bits exceeds INT16_MAX (i.e. 32767).
* - Otherwise, all valid results are in the range [0, ceil(INT16_MAX / 8)], i.e. at most 4096.
* - It is okay for the user to allocate more bytes for the buffer than needed.
* - For byte mode, numChars measures the number of bytes, not Unicode code points.
* - For ECI mode, numChars must be 0, and the worst-case number of bytes is returned.
* An actual ECI segment can have shorter data. For non-ECI modes, the result is exact.
*/
size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars);
/*
* Returns a segment representing the given binary data encoded in
* byte mode. All input byte arrays are acceptable. Any text string
* can be converted to UTF-8 bytes and encoded as a byte mode segment.
*/
struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]);
/*
* Returns a segment representing the given string of decimal digits encoded in numeric mode.
*/
struct qrcodegen_Segment qrcodegen_makeNumeric(const char* digits, uint8_t buf[]);
/*
* Returns a segment representing the given text string encoded in alphanumeric mode.
* The characters allowed are: 0 to 9, A to Z (uppercase only), space,
* dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char* text, uint8_t buf[]);
/*
* Returns a segment representing an Extended Channel Interpretation
* (ECI) designator with the given assignment value.
*/
struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]);
/*---- Functions to extract raw data from QR Codes ----*/
/*
* Returns the side length of the given QR Code, assuming that encoding succeeded.
* The result is in the range [21, 177]. Note that the length of the array buffer
* is related to the side length - every 'uint8_t qrcode[]' must have length at least
* qrcodegen_BUFFER_LEN_FOR_VERSION(version), which equals ceil(size^2 / 8 + 1).
*/
int qrcodegen_getSize(const uint8_t qrcode[]);
/*
* Returns the color of the module (pixel) at the given coordinates, which is false
* for light or true for dark. The top left corner has the coordinates (x=0, y=0).
* If the given coordinates are out of bounds, then false (light) is returned.
*/
bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y);
#ifdef __cplusplus
}
#endif
+17 -3
View File
@@ -36,17 +36,31 @@ static void writeString(FsFile& file, const std::string& s) {
file.write(reinterpret_cast<const uint8_t*>(s.data()), len);
}
static void readString(std::istream& is, std::string& s) {
constexpr uint32_t MAX_STRING_LENGTH = 4096;
static bool readString(std::istream& is, std::string& s) {
uint32_t len;
readPod(is, len);
if (len > MAX_STRING_LENGTH) {
is.seekg(len, std::ios::cur); // skip payload to keep stream aligned
return false;
}
s.resize(len);
is.read(&s[0], len);
return true;
}
static void readString(FsFile& file, std::string& s) {
static bool readString(FsFile& file, std::string& s) {
uint32_t len;
readPod(file, len);
if (len > MAX_STRING_LENGTH) {
if (!file.seekCur(static_cast<int64_t>(len))) { // skip payload to keep file position aligned
return false;
}
return false;
}
s.resize(len);
file.read(&s[0], len);
file.read(reinterpret_cast<uint8_t*>(&s[0]), len);
return true;
}
} // namespace serialization
+391
View File
@@ -0,0 +1,391 @@
#include "WeatherClient.h"
#include <ArduinoJson.h>
#include <HalStorage.h>
#include <Logging.h>
#include <cstdio>
#include <ctime>
#include "../../src/network/HttpDownloader.h"
namespace {
constexpr char WEATHER_CACHE_FILE[] = "/.crosspoint/weather_cache.json";
std::string buildForecastUrl(const WeatherSettingsStore& settings) {
std::string url = "https://api.open-meteo.com/v1/forecast?";
url += "latitude=" + std::to_string(settings.getLatitude());
url += "&longitude=" + std::to_string(settings.getLongitude());
url +=
"&current=temperature_2m,relative_humidity_2m,apparent_temperature,"
"weather_code,wind_speed_10m,wind_direction_10m,is_day,precipitation,uv_index";
url += "&hourly=temperature_2m,precipitation,precipitation_probability,weather_code,is_day";
url +=
"&daily=temperature_2m_max,temperature_2m_min,weather_code,"
"precipitation_sum,sunrise,sunset,uv_index_max";
url += "&timezone=auto&timeformat=unixtime";
url += std::string("&temperature_unit=") + settings.getTempUnitParam();
url += std::string("&wind_speed_unit=") + settings.getWindUnitParam();
url += std::string("&precipitation_unit=") + settings.getPrecipUnitParam();
url += "&forecast_days=" + std::to_string(settings.getForecastDays());
// Request 48 hours of hourly data
url += "&forecast_hours=48";
return url;
}
std::string urlEncode(const std::string& value) {
std::string out;
out.reserve(value.size() * 3);
for (unsigned char c : value) {
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(c);
} else {
char buf[4];
snprintf(buf, sizeof(buf), "%%%02X", c);
out.append(buf);
}
}
return out;
}
std::string buildRequestSignature(const WeatherSettingsStore& settings) {
char latitude[24];
char longitude[24];
snprintf(latitude, sizeof(latitude), "%.6f", settings.getLatitude());
snprintf(longitude, sizeof(longitude), "%.6f", settings.getLongitude());
std::string signature = "lat=";
signature += latitude;
signature += "|lon=";
signature += longitude;
signature += "|temp=";
signature += settings.getTempUnitParam();
signature += "|wind=";
signature += settings.getWindUnitParam();
signature += "|precip=";
signature += settings.getPrecipUnitParam();
signature += "|days=";
signature += std::to_string(settings.getForecastDays());
return signature;
}
} // namespace
std::string WeatherClient::buildRequestSignature(const WeatherSettingsStore& settings) {
return ::buildRequestSignature(settings);
}
WeatherData WeatherClient::getWeather(const WeatherSettingsStore& settings, bool forceRefresh) {
if (!settings.hasLocation()) {
WeatherData data;
data.errorMessage = "No location configured";
return data;
}
const std::string requestSignature = buildRequestSignature(settings);
if (!forceRefresh) {
WeatherData cached;
if (loadCache(cached) && cached.valid) {
if (cached.requestSignature != requestSignature) {
LOG_DBG("WEA", "Ignoring cache with mismatched request signature");
Storage.remove(WEATHER_CACHE_FILE);
} else {
time_t now;
time(&now);
if (now - cached.fetchedAt < CACHE_TTL_SECONDS) {
LOG_DBG("WEA", "Using cached weather data (age: %ld s)", (long)(now - cached.fetchedAt));
return cached;
}
LOG_DBG("WEA", "Cache expired (age: %ld s)", (long)(now - cached.fetchedAt));
// Cache-first behavior: return stale cache and let the caller decide
// whether/when to perform a network refresh.
return cached;
}
}
LOG_DBG("WEA", "No cache available; caller should establish network and force refresh");
WeatherData data;
data.errorMessage = "No cache";
return data;
}
return fetchFromApi(settings);
}
WeatherData WeatherClient::fetchFromApi(const WeatherSettingsStore& settings) {
WeatherData data;
data.requestSignature = buildRequestSignature(settings);
std::string url = buildForecastUrl(settings);
LOG_DBG("WEA", "fetchFromApi[1] start");
LOG_DBG("WEA", "fetchFromApi[2] url length=%zu", url.size());
LOG_DBG("WEA", "Fetching weather from API");
std::string response;
LOG_DBG("WEA", "fetchFromApi[3] HttpDownloader::fetchUrl before");
if (!HttpDownloader::fetchUrl(url, response)) {
data.errorMessage = "Network error";
LOG_ERR("WEA", "Failed to fetch weather data");
return data;
}
LOG_DBG("WEA", "fetchFromApi[4] HttpDownloader::fetchUrl after; bytes=%zu", response.size());
LOG_DBG("WEA", "fetchFromApi[5] parseWeatherJson before");
if (!parseWeatherJson(response, data)) {
LOG_ERR("WEA", "Failed to parse weather JSON");
return data;
}
LOG_DBG("WEA", "fetchFromApi[6] parseWeatherJson after");
data.valid = true;
time(&data.fetchedAt);
saveCache(data);
LOG_DBG("WEA", "fetchFromApi[7] Weather data fetched and cached");
return data;
}
bool WeatherClient::parseWeatherJson(const std::string& json, WeatherData& data) {
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
data.errorMessage = "JSON parse error";
LOG_ERR("WEA", "JSON parse error: %s", error.c_str());
return false;
}
// Check for API error
if (doc["error"] | false) {
data.errorMessage = doc["reason"] | "API error";
LOG_ERR("WEA", "API error: %s", data.errorMessage.c_str());
return false;
}
data.timezone = doc["timezone"] | std::string("");
data.utcOffsetSeconds = doc["utc_offset_seconds"] | 0;
// Parse current weather
JsonObject current = doc["current"];
if (current) {
data.current.temperature = current["temperature_2m"] | 0.0f;
data.current.apparentTemperature = current["apparent_temperature"] | 0.0f;
data.current.humidity = current["relative_humidity_2m"] | 0;
data.current.weatherCode = current["weather_code"] | 0;
data.current.windSpeed = current["wind_speed_10m"] | 0.0f;
data.current.windDirection = current["wind_direction_10m"] | 0;
data.current.precipitation = current["precipitation"] | 0.0f;
data.current.uvIndex = current["uv_index"] | 0.0f;
data.current.isDay = (current["is_day"] | 1) != 0;
}
// Parse daily forecast
JsonObject daily = doc["daily"];
if (daily) {
JsonArray times = daily["time"];
JsonArray tempMax = daily["temperature_2m_max"];
JsonArray tempMin = daily["temperature_2m_min"];
JsonArray codes = daily["weather_code"];
JsonArray precip = daily["precipitation_sum"];
JsonArray sunrise = daily["sunrise"];
JsonArray sunset = daily["sunset"];
JsonArray uvMax = daily["uv_index_max"];
size_t count = times.size();
data.daily.reserve(count);
for (size_t i = 0; i < count; i++) {
DailyForecast day;
day.date = times[i] | (time_t)0;
day.tempMax = tempMax[i] | 0.0f;
day.tempMin = tempMin[i] | 0.0f;
day.weatherCode = codes[i] | 0;
day.precipSum = precip[i] | 0.0f;
day.sunrise = sunrise[i] | (time_t)0;
day.sunset = sunset[i] | (time_t)0;
day.uvIndexMax = uvMax[i] | 0.0f;
day.moonPhase = daily["moon_phase"] ? (daily["moon_phase"][i] | -1.0f) : -1.0f;
data.daily.push_back(day);
}
}
// Parse hourly forecast
JsonObject hourly = doc["hourly"];
if (hourly) {
JsonArray times = hourly["time"];
JsonArray temp = hourly["temperature_2m"];
JsonArray precip = hourly["precipitation"];
JsonArray precipProb = hourly["precipitation_probability"];
JsonArray codes = hourly["weather_code"];
JsonArray isDay = hourly["is_day"];
size_t count = times.size();
data.hourly.reserve(count);
for (size_t i = 0; i < count; i++) {
HourlyForecast hour;
hour.time = times[i] | (time_t)0;
hour.temperature = temp[i] | 0.0f;
hour.precipitation = precip[i] | 0.0f;
hour.precipitationProbability = precipProb[i] | 0;
hour.weatherCode = codes[i] | 0;
hour.isDay = (isDay[i] | 1) != 0;
data.hourly.push_back(hour);
}
}
return true;
}
bool WeatherClient::saveCache(const WeatherData& data) {
Storage.mkdir("/.crosspoint");
JsonDocument doc;
doc["requestSignature"] = data.requestSignature;
doc["fetchedAt"] = data.fetchedAt;
doc["timezone"] = data.timezone;
doc["utcOffsetSeconds"] = data.utcOffsetSeconds;
// Current
JsonObject cur = doc["current"].to<JsonObject>();
cur["temperature"] = data.current.temperature;
cur["apparentTemperature"] = data.current.apparentTemperature;
cur["humidity"] = data.current.humidity;
cur["weatherCode"] = data.current.weatherCode;
cur["windSpeed"] = data.current.windSpeed;
cur["windDirection"] = data.current.windDirection;
cur["precipitation"] = data.current.precipitation;
cur["uvIndex"] = data.current.uvIndex;
cur["isDay"] = data.current.isDay;
// Daily
JsonArray dailyArr = doc["daily"].to<JsonArray>();
for (const auto& day : data.daily) {
JsonObject d = dailyArr.add<JsonObject>();
d["date"] = day.date;
d["tempMax"] = day.tempMax;
d["tempMin"] = day.tempMin;
d["weatherCode"] = day.weatherCode;
d["precipSum"] = day.precipSum;
d["uvIndexMax"] = day.uvIndexMax;
d["sunrise"] = day.sunrise;
d["sunset"] = day.sunset;
d["moonPhase"] = day.moonPhase;
d["moonPhaseApiName"] = "moon_phase"; // for compatibility/tracing
}
// Hourly
JsonArray hourlyArr = doc["hourly"].to<JsonArray>();
for (const auto& hour : data.hourly) {
JsonObject h = hourlyArr.add<JsonObject>();
h["time"] = hour.time;
h["temperature"] = hour.temperature;
h["precipitation"] = hour.precipitation;
h["precipProb"] = hour.precipitationProbability;
h["weatherCode"] = hour.weatherCode;
h["isDay"] = hour.isDay;
}
String json;
serializeJson(doc, json);
return Storage.writeFile(WEATHER_CACHE_FILE, json);
}
bool WeatherClient::loadCache(WeatherData& data) {
if (!Storage.exists(WEATHER_CACHE_FILE)) {
return false;
}
String json = Storage.readFile(WEATHER_CACHE_FILE);
if (json.isEmpty()) {
return false;
}
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("WEA", "Cache parse error: %s", error.c_str());
return false;
}
data.fetchedAt = doc["fetchedAt"] | (time_t)0;
data.requestSignature = doc["requestSignature"] | std::string("");
data.timezone = doc["timezone"] | std::string("");
data.utcOffsetSeconds = doc["utcOffsetSeconds"] | 0;
// Current
JsonObject cur = doc["current"];
if (cur) {
data.current.temperature = cur["temperature"] | 0.0f;
data.current.apparentTemperature = cur["apparentTemperature"] | 0.0f;
data.current.humidity = cur["humidity"] | 0;
data.current.weatherCode = cur["weatherCode"] | 0;
data.current.windSpeed = cur["windSpeed"] | 0.0f;
data.current.windDirection = cur["windDirection"] | 0;
data.current.precipitation = cur["precipitation"] | 0.0f;
data.current.uvIndex = cur["uvIndex"] | 0.0f;
data.current.isDay = cur["isDay"] | true;
}
// Daily
JsonArray dailyArr = doc["daily"].as<JsonArray>();
for (JsonObject d : dailyArr) {
DailyForecast day;
day.date = d["date"] | (time_t)0;
day.tempMax = d["tempMax"] | 0.0f;
day.tempMin = d["tempMin"] | 0.0f;
day.weatherCode = d["weatherCode"] | 0;
day.precipSum = d["precipSum"] | 0.0f;
day.uvIndexMax = d["uvIndexMax"] | 0.0f;
day.sunrise = d["sunrise"] | (time_t)0;
day.sunset = d["sunset"] | (time_t)0;
day.moonPhase = d["moonPhase"] | -1.0f;
data.daily.push_back(day);
}
// Hourly
JsonArray hourlyArr = doc["hourly"].as<JsonArray>();
for (JsonObject h : hourlyArr) {
HourlyForecast hour;
hour.time = h["time"] | (time_t)0;
hour.temperature = h["temperature"] | 0.0f;
hour.precipitation = h["precipitation"] | 0.0f;
hour.precipitationProbability = h["precipProb"] | 0;
hour.weatherCode = h["weatherCode"] | 0;
hour.isDay = h["isDay"] | true;
data.hourly.push_back(hour);
}
data.valid = true;
LOG_DBG("WEA", "Cache loaded, fetchedAt=%ld", (long)data.fetchedAt);
return true;
}
std::vector<GeocodingResult> WeatherClient::searchCity(const std::string& query) {
std::vector<GeocodingResult> results;
std::string url = "https://geocoding-api.open-meteo.com/v1/search?name=" + urlEncode(query);
url += "&count=5&language=en";
std::string response;
if (!HttpDownloader::fetchUrl(url, response)) {
LOG_ERR("WEA", "Geocoding request failed");
return results;
}
JsonDocument doc;
auto error = deserializeJson(doc, response);
if (error) {
LOG_ERR("WEA", "Geocoding parse error: %s", error.c_str());
return results;
}
JsonArray arr = doc["results"].as<JsonArray>();
for (JsonObject obj : arr) {
GeocodingResult r;
r.name = obj["name"] | std::string("");
r.country = obj["country"] | std::string("");
r.admin1 = obj["admin1"] | std::string("");
r.latitude = obj["latitude"] | 0.0f;
r.longitude = obj["longitude"] | 0.0f;
results.push_back(r);
}
LOG_DBG("WEA", "Geocoding found %zu results for '%s'", results.size(), query.c_str());
return results;
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <string>
#include "WeatherData.h"
#include "WeatherSettingsStore.h"
class WeatherClient {
public:
/// Fetch weather data, using cache if valid (< 30 min old).
/// If forceRefresh is true, always fetches from the API.
static WeatherData getWeather(const WeatherSettingsStore& settings, bool forceRefresh = false);
/// Search for cities by name via Open-Meteo geocoding API.
/// Returns up to 5 results.
static std::vector<GeocodingResult> searchCity(const std::string& query);
private:
static WeatherData fetchFromApi(const WeatherSettingsStore& settings);
static bool parseWeatherJson(const std::string& json, WeatherData& data);
static std::string buildRequestSignature(const WeatherSettingsStore& settings);
static bool saveCache(const WeatherData& data);
static bool loadCache(WeatherData& data);
static constexpr int CACHE_TTL_SECONDS = 30 * 60; // 30 minutes
};
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <cstdint>
#include <ctime>
#include <string>
#include <vector>
struct CurrentWeather {
float temperature = 0;
float apparentTemperature = 0;
int humidity = 0;
int weatherCode = 0;
float windSpeed = 0;
int windDirection = 0;
float precipitation = 0;
float uvIndex = 0;
bool isDay = true;
};
struct DailyForecast {
time_t date = 0;
float tempMax = 0;
float tempMin = 0;
int weatherCode = 0;
float precipSum = 0;
float uvIndexMax = 0;
time_t sunrise = 0;
time_t sunset = 0;
float moonPhase = -1.0f; // -1 means unknown, 0.0 new, 0.5 full
};
struct HourlyForecast {
time_t time = 0;
float temperature = 0;
float precipitation = 0;
int precipitationProbability = 0;
int weatherCode = 0;
bool isDay = true;
};
struct WeatherData {
CurrentWeather current;
std::vector<DailyForecast> daily;
std::vector<HourlyForecast> hourly;
std::string requestSignature;
std::string timezone;
int utcOffsetSeconds = 0;
time_t fetchedAt = 0;
bool valid = false;
std::string errorMessage;
};
struct GeocodingResult {
std::string name;
std::string country;
std::string admin1; // State/region
float latitude = 0;
float longitude = 0;
};
+58
View File
@@ -0,0 +1,58 @@
#include "WeatherIcons.h"
#include "WeatherIconsLarge.h"
// Weather icon glyph source attribution:
// https://github.com/erikflowers/weather-icons
// Large icons are provided by generated WI_LARGE_* arrays in WeatherIconsLarge.h.
// These large icons are 64x64, and the code uses WEATHER_ICON_SIZE=64.
// ============================================================================
// 24x24 Small Weather Icons (1-bit, MSB-first)
// Each row = 24 pixels = 3 bytes. Total = 24 * 3 = 72 bytes per icon.
// ============================================================================
// ============================================================================
// Lookup functions
// ============================================================================
const uint8_t* getWeatherIconLarge(WeatherIconType type) {
switch (type) {
case WeatherIconType::CLEAR_DAY:
return WI_LARGE_CLEAR_DAY;
case WeatherIconType::CLEAR_NIGHT:
return WI_LARGE_CLEAR_NIGHT;
case WeatherIconType::PARTLY_CLOUDY_DAY:
return WI_LARGE_PARTLY_CLOUDY_DAY;
case WeatherIconType::PARTLY_CLOUDY_NIGHT:
return WI_LARGE_PARTLY_CLOUDY_NIGHT;
case WeatherIconType::OVERCAST:
return WI_LARGE_OVERCAST;
case WeatherIconType::FOG:
return WI_LARGE_FOG;
case WeatherIconType::DRIZZLE:
return WI_LARGE_DRIZZLE;
case WeatherIconType::RAIN:
case WeatherIconType::SHOWERS:
return WI_LARGE_RAIN;
case WeatherIconType::SNOW:
return WI_LARGE_SNOW;
case WeatherIconType::THUNDERSTORM:
return WI_LARGE_THUNDERSTORM;
default:
return WI_LARGE_OVERCAST;
}
}
const char* getWindDirectionText(int degrees) {
// Normalize to 0-360
degrees = ((degrees % 360) + 360) % 360;
if (degrees >= 338 || degrees < 23) return "N";
if (degrees < 68) return "NE";
if (degrees < 113) return "E";
if (degrees < 158) return "SE";
if (degrees < 203) return "S";
if (degrees < 248) return "SW";
if (degrees < 293) return "W";
return "NW";
}
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <cstdint>
// Weather icon size constants
#include "WeatherIconsLarge.h"
// WMO weather code to icon category mapping
enum class WeatherIconType {
CLEAR_DAY,
CLEAR_NIGHT,
PARTLY_CLOUDY_DAY,
PARTLY_CLOUDY_NIGHT,
OVERCAST,
FOG,
DRIZZLE,
RAIN,
SNOW,
SHOWERS,
THUNDERSTORM,
UNKNOWN
};
// Map WMO weather code + day/night to icon type
inline WeatherIconType getWeatherIconType(int wmoCode, bool isDay) {
switch (wmoCode) {
case 0:
return isDay ? WeatherIconType::CLEAR_DAY : WeatherIconType::CLEAR_NIGHT;
case 1:
case 2:
return isDay ? WeatherIconType::PARTLY_CLOUDY_DAY : WeatherIconType::PARTLY_CLOUDY_NIGHT;
case 3:
return WeatherIconType::OVERCAST;
case 45:
case 48:
return WeatherIconType::FOG;
case 51:
case 53:
case 55:
case 56:
case 57:
return WeatherIconType::DRIZZLE;
case 61:
case 63:
case 65:
case 66:
case 67:
return WeatherIconType::RAIN;
case 71:
case 73:
case 75:
case 77:
return WeatherIconType::SNOW;
case 80:
case 81:
case 82:
return WeatherIconType::SHOWERS;
case 85:
case 86:
return WeatherIconType::SNOW;
case 95:
case 96:
case 99:
return WeatherIconType::THUNDERSTORM;
default:
return WeatherIconType::UNKNOWN;
}
}
// Get the appropriate large icon bitmap (64x64, 1-bit, MSB first)
const uint8_t* getWeatherIconLarge(WeatherIconType type);
// Get the appropriate small icon bitmap (24x24, 1-bit, MSB first)
#if WEATHER_ENABLE_SMALL_ICONS
const uint8_t* getWeatherIconSmall(WeatherIconType type);
#endif
// Wind direction arrow text (N, NE, E, SE, S, SW, W, NW)
const char* getWindDirectionText(int degrees);
+359
View File
@@ -0,0 +1,359 @@
#pragma once
#include <cstdint>
// Generated from erikflowers/weather-icons SVGs.
// 64x64, 1-bit, MSB-first, row-major.
// Regenerate with: python scripts/generate_weather_icons.py --fetch
// clang-format off
constexpr int WEATHER_ICON_SIZE = 64; // Large icons for weather
static const uint8_t WI_LARGE_CLEAR_DAY[] = {
0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFC, 0x3F, 0xFF, 0xE1, 0xFF,
0xFF, 0x83, 0xFF, 0xFC, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF,
0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFE, 0x03, 0xFF,
0xFF, 0xE0, 0x3F, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF,
0xFF, 0xF8, 0x3F, 0xF8, 0x3F, 0xFC, 0x1F, 0xFF, 0xFF, 0xFC, 0x7F, 0xC0, 0x03, 0xFE, 0x3F, 0xFF,
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0xFF,
0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x01, 0x80, 0x0F, 0xFF, 0xFF,
0xFF, 0xFF, 0xF0, 0x1F, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x3F, 0xFC, 0x07, 0xFF, 0xFF,
0xFF, 0xFF, 0xC0, 0x7F, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x03, 0xFF, 0xFF,
0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF,
0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xC0, 0x3F, 0x83, 0xFF, 0xFF, 0xC1, 0xFC, 0x03,
0x80, 0x1F, 0x03, 0xFF, 0xFF, 0xE1, 0xF8, 0x01, 0x00, 0x1F, 0x07, 0xFF, 0xFF, 0xE0, 0xF8, 0x00,
0x00, 0x1F, 0x07, 0xFF, 0xFF, 0xE0, 0xF8, 0x00, 0x80, 0x1F, 0x03, 0xFF, 0xFF, 0xE1, 0xF8, 0x01,
0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF,
0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF,
0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFE, 0x03, 0xFF, 0xFF,
0xFF, 0xFF, 0xE0, 0x3F, 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xF8, 0x0F, 0xFF, 0xFF,
0xFF, 0xFF, 0xF0, 0x01, 0x80, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x3F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
0xFF, 0xFC, 0x7F, 0xC0, 0x03, 0xFE, 0x3F, 0xFF, 0xFF, 0xF8, 0x3F, 0xFE, 0x7F, 0xFC, 0x1F, 0xFF,
0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFF, 0xE0, 0x3F, 0xFF, 0xFF, 0xFC, 0x07, 0xFF,
0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFF,
0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x83, 0xFF, 0xFC, 0x3F, 0xFF, 0xC1, 0xFF,
0xFF, 0xC7, 0xFF, 0xFC, 0x3F, 0xFF, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_CLEAR_NIGHT[] = {
0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x03, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xF0, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF,
0xFF, 0xF8, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x06, 0x03, 0xFF, 0xFF, 0xFF,
0xFF, 0xC0, 0x00, 0x7E, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0xFE, 0x01, 0xFF, 0xFF, 0xFF,
0xFF, 0x00, 0x07, 0xFE, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xFF, 0x01, 0xFF, 0xFF, 0xFF,
0xFE, 0x00, 0x3F, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0x00, 0xFF, 0xFF, 0xFF,
0xF8, 0x00, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF,
0xF0, 0x03, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x3F, 0xFF, 0xFF,
0xE0, 0x0F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF,
0xC0, 0x1F, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF,
0xC0, 0x3F, 0xFF, 0xFF, 0xF8, 0x00, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFF,
0x80, 0x3F, 0xFF, 0xFF, 0xFE, 0x00, 0x1F, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x03, 0xFF,
0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x03, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x01,
0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x01, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01,
0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x01,
0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01,
0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01,
0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x01,
0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0x80, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03,
0x80, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03, 0xC0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x03,
0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07,
0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x0F,
0xF0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F,
0xF8, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x3F, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x3F,
0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFE, 0x00, 0x1F, 0xFF, 0xFF, 0xF8, 0x00, 0xFF,
0xFF, 0x00, 0x0F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0x80, 0x01, 0xFF,
0xFF, 0xC0, 0x00, 0x7F, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xE0, 0x00, 0x07, 0xC0, 0x00, 0x07, 0xFF,
0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF,
0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF,
0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x1F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_PARTLY_CLOUDY_DAY[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF,
0xFF, 0xFF, 0xF9, 0xFF, 0xF8, 0x7F, 0xFC, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xF8, 0xFF, 0xF8, 0x7F,
0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, 0xFF, 0xE0, 0xFF,
0xFF, 0xFF, 0xFC, 0x7F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xC3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x0F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0C, 0x00, 0x01, 0xFF, 0xFF,
0xFF, 0xFF, 0x00, 0x00, 0x0F, 0x80, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x3F, 0xE0, 0x7F, 0xFF,
0xFF, 0xF8, 0x00, 0x00, 0x7F, 0xF0, 0x7F, 0xFF, 0xFF, 0xF0, 0x1F, 0xE0, 0x3F, 0xF8, 0x3F, 0xFF,
0xFF, 0xE0, 0x7F, 0xF8, 0x3F, 0xFC, 0x3F, 0xFF, 0xFF, 0xE0, 0xFF, 0xFC, 0x1F, 0xFC, 0x1F, 0xFF,
0xFF, 0xC1, 0xFF, 0xFE, 0x0F, 0xFE, 0x1F, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0x0F, 0xFE, 0x1E, 0x00,
0xFF, 0x83, 0xFF, 0xFF, 0x0F, 0xFE, 0x1E, 0x00, 0xFF, 0x87, 0xFF, 0xFF, 0x87, 0xFE, 0x1E, 0x00,
0xFE, 0x07, 0xFF, 0xFF, 0x80, 0x3E, 0x1F, 0xEF, 0xF8, 0x07, 0xFF, 0xFF, 0x80, 0x0C, 0x1F, 0xFF,
0xF0, 0x07, 0xFF, 0xFF, 0x80, 0x04, 0x3F, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xDC, 0x00, 0x3F, 0xFF,
0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFF,
0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF,
0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF,
0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x71, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x70, 0xFF,
0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x70, 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xF8, 0x7F,
0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0xFC, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF,
0xE0, 0x7F, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF,
0xF8, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xFF,
0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_PARTLY_CLOUDY_NIGHT[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x03, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xF0, 0x0F, 0x83, 0xFF,
0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x3F, 0x83, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x7F, 0x83, 0xFF,
0xFF, 0xFF, 0xC0, 0x00, 0x00, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0xFF, 0xC1, 0xFF,
0xFF, 0xFF, 0x00, 0x1C, 0x00, 0x7F, 0xC0, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0x80, 0x3F, 0xE0, 0xFF,
0xFF, 0xFC, 0x07, 0xFF, 0xE0, 0x1F, 0xE0, 0x7F, 0xFF, 0xFC, 0x0F, 0xFF, 0xF8, 0x1F, 0xF0, 0x3F,
0xFF, 0xF8, 0x1F, 0xFF, 0xFC, 0x0F, 0xF0, 0x0F, 0xFF, 0xF8, 0x3F, 0xFF, 0xFC, 0x0F, 0xF8, 0x01,
0xFF, 0xF0, 0x3F, 0xFF, 0xFE, 0x07, 0xFC, 0x00, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0x07, 0xFE, 0x00,
0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0x03, 0xFF, 0x81, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xC1,
0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x07, 0x81, 0xFC, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0x03,
0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x03, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x07,
0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F,
0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F,
0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F,
0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07,
0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F,
0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F,
0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xE0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x3F,
0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F,
0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF,
0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_OVERCAST[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03, 0xC0, 0x3F, 0xFF,
0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xF0, 0x3F, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0x3F, 0xFC, 0x1F, 0xFF,
0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFE, 0x0F, 0xFF,
0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFC, 0x01, 0x80, 0x3F, 0xFF, 0x00, 0xFF,
0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0x00, 0x1F, 0xFF, 0xF0, 0x3F, 0xFC, 0x0F, 0xFF, 0x80, 0x0F,
0xFF, 0xE0, 0xFF, 0xFE, 0x07, 0xFF, 0x80, 0x07, 0xFF, 0xE0, 0xFF, 0xFF, 0x07, 0xFF, 0xFE, 0x03,
0xFF, 0xC1, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xC3, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1,
0xFF, 0xC3, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xE1, 0xFE, 0x03, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xE0,
0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xF0, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xF0,
0xE0, 0x0F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, 0xF0, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xF0,
0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xE0, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0xE1,
0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xC1, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0x83,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1C, 0x03, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x07,
0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x3F,
0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF,
0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF,
0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFF,
0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF,
0xF8, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF,
0xFF, 0xC0, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_FOG[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x3F, 0xFF, 0xFF,
0xFF, 0xFF, 0xF0, 0x1F, 0xF0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFC, 0x0F, 0xFF, 0xFF,
0xFF, 0xFF, 0xC0, 0xFF, 0xFE, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0x07, 0xFF, 0xFF,
0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xFF,
0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF,
0xFF, 0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xC0, 0x01, 0xFF,
0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0xC0, 0x00, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F,
0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F,
0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F,
0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_DRIZZLE[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x00, 0x1F, 0xFF, 0xFF,
0xFF, 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xFF,
0xFF, 0xFC, 0x07, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF,
0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF,
0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF,
0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xE0, 0x01, 0xFF,
0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F,
0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, 0xF0, 0x0F, 0xFF, 0xFF, 0xCF, 0xFF, 0xE0, 0x0F,
0xE0, 0x3F, 0xFF, 0xFF, 0x87, 0xFF, 0xFC, 0x07, 0xC0, 0x7F, 0xFF, 0xFF, 0x87, 0xFF, 0xFE, 0x03,
0xC0, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0x81,
0x83, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xC1, 0x83, 0xFF, 0xFC, 0xFF, 0x87, 0xFF, 0xFF, 0xC1,
0x03, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xFF, 0xE0,
0x07, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xE0,
0x03, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xC0,
0x83, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xC1, 0x81, 0xFF, 0xE0, 0x1F, 0xCF, 0xFF, 0xFF, 0xC1,
0x81, 0xFF, 0xF0, 0x3F, 0x87, 0xFF, 0xFF, 0x81, 0xC0, 0xFF, 0xF8, 0x7F, 0x03, 0xFF, 0xFF, 0x03,
0xC0, 0x7F, 0xFF, 0xFE, 0x03, 0xFF, 0xFE, 0x03, 0xE0, 0x3F, 0xFF, 0xFC, 0x01, 0xFF, 0xFC, 0x07,
0xF0, 0x0F, 0xFF, 0xFC, 0x00, 0xFF, 0xF0, 0x0F, 0xF8, 0x01, 0xFF, 0xF8, 0x00, 0xFF, 0x80, 0x0F,
0xFC, 0x01, 0xFF, 0xF0, 0x00, 0x7F, 0x80, 0x3F, 0xFE, 0x01, 0xFF, 0xF0, 0x00, 0x7F, 0x80, 0x7F,
0xFF, 0x81, 0xFF, 0xF0, 0x00, 0x3F, 0x80, 0xFF, 0xFF, 0xE1, 0xFF, 0xF0, 0x00, 0x3F, 0x87, 0xFF,
0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x7F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x01, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_RAIN[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF,
0xFF, 0xFF, 0x80, 0x0F, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF,
0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFC, 0x07, 0xFF, 0xFF,
0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF,
0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF,
0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF,
0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xE0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F,
0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F,
0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07,
0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03,
0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81,
0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0,
0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFC, 0x3F, 0x8F, 0xE1, 0xFF, 0xE0,
0x03, 0xFF, 0xFC, 0x1F, 0x07, 0xC1, 0xFF, 0xC0, 0x03, 0xFF, 0xF8, 0x1F, 0x07, 0xC1, 0xFF, 0xC0,
0x83, 0xFF, 0xF8, 0x1F, 0x07, 0xC1, 0xFF, 0xC1, 0x81, 0xFF, 0xF8, 0x3E, 0x07, 0xC1, 0xFF, 0x81,
0x80, 0xFF, 0xF8, 0x3E, 0x0F, 0x81, 0xFF, 0x01, 0xC0, 0xFF, 0xF0, 0x3E, 0x0F, 0x83, 0xFF, 0x03,
0xE0, 0x3F, 0xF0, 0x3E, 0x0F, 0x83, 0xFC, 0x07, 0xE0, 0x1F, 0xF0, 0x7C, 0x0F, 0x03, 0xF8, 0x07,
0xF0, 0x01, 0xE0, 0x7C, 0x1F, 0x07, 0x80, 0x0F, 0xF8, 0x01, 0xE0, 0x7C, 0x1F, 0x07, 0x80, 0x1F,
0xFC, 0x01, 0xE0, 0xF8, 0x1F, 0x07, 0x80, 0x3F, 0xFF, 0x01, 0xE0, 0xF8, 0x1E, 0x07, 0x80, 0xFF,
0xFF, 0xC1, 0xC0, 0xF8, 0x3E, 0x0F, 0x83, 0xFF, 0xFF, 0xFD, 0xC0, 0xF8, 0x3E, 0x0F, 0xBF, 0xFF,
0xFF, 0xFF, 0xC1, 0xF0, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF,
0xFF, 0xFF, 0x81, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xE0, 0x7C, 0x1F, 0xFF, 0xFF,
0xFF, 0xFF, 0x83, 0xE0, 0x78, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xE0, 0xF8, 0x3F, 0xFF, 0xFF,
0xFF, 0xFF, 0x87, 0xE0, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_SNOW[] = {
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF,
0xFF, 0xFF, 0x80, 0x1F, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF,
0xFF, 0xFE, 0x03, 0xFF, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFC, 0x07, 0xFF, 0xFF,
0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF,
0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF,
0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF,
0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF,
0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F,
0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07,
0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03,
0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81,
0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1,
0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x81, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0x81,
0x81, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0x01, 0xC0, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0x03,
0xC0, 0x7F, 0xFF, 0xFC, 0x1F, 0xFF, 0xFC, 0x07, 0xE0, 0x1F, 0xFF, 0xFC, 0x3F, 0xFF, 0xF8, 0x07,
0xF0, 0x01, 0xFD, 0xFF, 0xFF, 0x9F, 0x80, 0x0F, 0xF8, 0x01, 0xF0, 0xFF, 0xFF, 0x0F, 0x80, 0x1F,
0xFC, 0x01, 0xF0, 0x7F, 0xFE, 0x07, 0x80, 0x3F, 0xFE, 0x01, 0xF0, 0x7F, 0xFE, 0x0F, 0x80, 0xFF,
0xFF, 0x81, 0xF0, 0xFF, 0xFF, 0x0F, 0x83, 0xFF, 0xFF, 0xFD, 0xFD, 0xFF, 0xFF, 0xFF, 0xBF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFE, 0x0F, 0xFF, 0xFF,
0xFF, 0xFF, 0xF0, 0x7F, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF,
0xFF, 0xFF, 0xFD, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
static const uint8_t WI_LARGE_THUNDERSTORM[] = {
0xFF, 0xFF, 0xFF, 0xC0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x7F, 0xFF, 0xFF,
0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x80, 0x1F, 0xFF, 0xFF,
0xFF, 0xFF, 0x00, 0xFF, 0xF0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xFF,
0xFF, 0xFC, 0x07, 0xFF, 0xFE, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF,
0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF,
0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF,
0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xE0, 0x01, 0xFF,
0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F,
0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, 0xF0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x0F,
0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03,
0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81,
0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1,
0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0,
0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0,
0x03, 0xFF, 0x00, 0x3F, 0x87, 0xE1, 0xFF, 0xE0, 0x03, 0xFF, 0x00, 0x3F, 0x07, 0xE1, 0xFF, 0xC0,
0x03, 0xFE, 0x00, 0x7F, 0x07, 0xC0, 0xFF, 0xC0, 0x83, 0xFE, 0x00, 0x7F, 0x07, 0xC1, 0xFF, 0xC1,
0x81, 0xFE, 0x00, 0xFE, 0x07, 0xC1, 0xFF, 0x81, 0xC0, 0xFC, 0x00, 0xFE, 0x0F, 0x81, 0xFF, 0x01,
0xC0, 0x7C, 0x01, 0xFE, 0x0F, 0x81, 0xFE, 0x03, 0xE0, 0x3C, 0x01, 0xFE, 0x0F, 0x83, 0xFC, 0x07,
0xE0, 0x08, 0x03, 0xFC, 0x0F, 0x83, 0xF0, 0x07, 0xF0, 0x00, 0x03, 0xFC, 0x1F, 0x03, 0x80, 0x0F,
0xF8, 0x00, 0x07, 0xFC, 0x1F, 0x07, 0x80, 0x1F, 0xFC, 0x00, 0x07, 0xFC, 0x1F, 0x07, 0x80, 0x3F,
0xFF, 0x00, 0x0F, 0xF8, 0x1F, 0x07, 0x80, 0xFF, 0xFF, 0xC0, 0x1F, 0xF8, 0x3E, 0x07, 0x83, 0xFF,
0xFF, 0xE0, 0x1F, 0xF8, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x38, 0x3E, 0x0F, 0xFF, 0xFF,
0xFF, 0xC0, 0x00, 0x70, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0xF0, 0x7C, 0x0F, 0xFF, 0xFF,
0xFF, 0x80, 0x00, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0xE0, 0x7C, 0x1F, 0xFF, 0xFF,
0xFF, 0xFF, 0x03, 0xE0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0xE0, 0xFE, 0x3F, 0xFF, 0xFF,
0xFF, 0xFE, 0x07, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFE, 0x0F, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFE, 0x3F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0x81, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFC, 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFC, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xFF, 0xEF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFB, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
};
+106
View File
@@ -0,0 +1,106 @@
#include "WeatherSettingsStore.h"
#include <ArduinoJson.h>
#include <HalStorage.h>
#include <Logging.h>
WeatherSettingsStore WeatherSettingsStore::instance;
namespace {
constexpr char WEATHER_SETTINGS_FILE[] = "/.crosspoint/weather_settings.json";
}
bool WeatherSettingsStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
JsonDocument doc;
doc["latitude"] = latitude;
doc["longitude"] = longitude;
doc["locationConfigured"] = locationConfigured;
doc["locationName"] = locationName;
doc["tempUnit"] = static_cast<uint8_t>(tempUnit);
doc["windUnit"] = static_cast<uint8_t>(windUnit);
doc["precipUnit"] = static_cast<uint8_t>(precipUnit);
doc["forecastDays"] = forecastDays;
String json;
serializeJson(doc, json);
return Storage.writeFile(WEATHER_SETTINGS_FILE, json);
}
bool WeatherSettingsStore::loadFromFile() {
if (!Storage.exists(WEATHER_SETTINGS_FILE)) {
LOG_DBG("WEA", "No weather settings file found");
return false;
}
String json = Storage.readFile(WEATHER_SETTINGS_FILE);
if (json.isEmpty()) {
return false;
}
JsonDocument doc;
auto error = deserializeJson(doc, json);
if (error) {
LOG_ERR("WEA", "JSON parse error: %s", error.c_str());
return false;
}
latitude = doc["latitude"] | 0.0f;
longitude = doc["longitude"] | 0.0f;
locationConfigured = doc["locationConfigured"] | (latitude != 0.0f || longitude != 0.0f);
locationName = doc["locationName"] | std::string("");
tempUnit = static_cast<WeatherTempUnit>(doc["tempUnit"] | (uint8_t)0);
windUnit = static_cast<WeatherWindUnit>(doc["windUnit"] | (uint8_t)0);
precipUnit = static_cast<WeatherPrecipUnit>(doc["precipUnit"] | (uint8_t)0);
forecastDays = doc["forecastDays"] | (uint8_t)3;
if (forecastDays < 1) forecastDays = 1;
if (forecastDays > 5) forecastDays = 5;
LOG_DBG("WEA", "Loaded weather settings: %s (%.2f, %.2f)", locationName.c_str(), latitude, longitude);
return true;
}
void WeatherSettingsStore::setLocation(float lat, float lon, const std::string& name) {
latitude = lat;
longitude = lon;
locationConfigured = true;
locationName = name;
LOG_DBG("WEA", "Set location: %s (%.4f, %.4f)", name.c_str(), lat, lon);
}
void WeatherSettingsStore::clearLocation() {
latitude = 0;
longitude = 0;
locationConfigured = false;
locationName.clear();
}
void WeatherSettingsStore::setForecastDays(uint8_t days) {
if (days < 1) days = 1;
if (days > 5) days = 5;
forecastDays = days;
}
const char* WeatherSettingsStore::getTempUnitParam() const {
return tempUnit == WeatherTempUnit::FAHRENHEIT ? "fahrenheit" : "celsius";
}
const char* WeatherSettingsStore::getWindUnitParam() const {
switch (windUnit) {
case WeatherWindUnit::MS:
return "ms";
case WeatherWindUnit::MPH:
return "mph";
case WeatherWindUnit::KNOTS:
return "kn";
case WeatherWindUnit::KMH:
default:
return "kmh";
}
}
const char* WeatherSettingsStore::getPrecipUnitParam() const {
return precipUnit == WeatherPrecipUnit::INCH ? "inch" : "mm";
}
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <cstdint>
#include <string>
enum class WeatherTempUnit : uint8_t { CELSIUS = 0, FAHRENHEIT = 1 };
enum class WeatherWindUnit : uint8_t { KMH = 0, MS = 1, MPH = 2, KNOTS = 3 };
enum class WeatherPrecipUnit : uint8_t { MM = 0, INCH = 1 };
class WeatherSettingsStore {
private:
static WeatherSettingsStore instance;
float latitude = 0;
float longitude = 0;
bool locationConfigured = false;
std::string locationName;
WeatherTempUnit tempUnit = WeatherTempUnit::CELSIUS;
WeatherWindUnit windUnit = WeatherWindUnit::KMH;
WeatherPrecipUnit precipUnit = WeatherPrecipUnit::MM;
uint8_t forecastDays = 3;
WeatherSettingsStore() = default;
public:
WeatherSettingsStore(const WeatherSettingsStore&) = delete;
WeatherSettingsStore& operator=(const WeatherSettingsStore&) = delete;
static WeatherSettingsStore& getInstance() { return instance; }
bool saveToFile() const;
bool loadFromFile();
// Location
void setLocation(float lat, float lon, const std::string& name);
void clearLocation();
float getLatitude() const { return latitude; }
float getLongitude() const { return longitude; }
const std::string& getLocationName() const { return locationName; }
bool hasLocation() const { return locationConfigured; }
// Units
void setTempUnit(WeatherTempUnit unit) { tempUnit = unit; }
WeatherTempUnit getTempUnit() const { return tempUnit; }
void setWindUnit(WeatherWindUnit unit) { windUnit = unit; }
WeatherWindUnit getWindUnit() const { return windUnit; }
void setPrecipUnit(WeatherPrecipUnit unit) { precipUnit = unit; }
WeatherPrecipUnit getPrecipUnit() const { return precipUnit; }
// Forecast
void setForecastDays(uint8_t days);
uint8_t getForecastDays() const { return forecastDays; }
// API parameter strings
const char* getTempUnitParam() const;
const char* getWindUnitParam() const;
const char* getPrecipUnitParam() const;
};
#define WEATHER_SETTINGS WeatherSettingsStore::getInstance()
+12 -2
View File
@@ -13,11 +13,14 @@
bool Xtc::load() {
LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str());
// Ensure the per-book cache exists before the parser tries to create page_table.bin.
setupCacheDir();
// Initialize parser
parser.reset(new xtc::XtcParser());
// Open XTC file
xtc::XtcError err = parser->open(filepath.c_str());
// Open XTC file and initialize its cache-backed page table
xtc::XtcError err = parser->open(filepath.c_str(), cachePath.c_str());
if (err != xtc::XtcError::OK) {
LOG_ERR("XTC", "Failed to load: %s", xtc::errorToString(err));
parser.reset();
@@ -619,3 +622,10 @@ xtc::XtcError Xtc::getLastError() const {
}
return parser->getLastError();
}
void Xtc::prefetchPages(uint32_t pageIndex) const {
if (!loaded || !parser) {
return;
}
parser->prefetchWindow(pageIndex);
}
+5
View File
@@ -50,6 +50,11 @@ class Xtc {
*/
void setupCacheDir() const;
/**
* Preload window around specified page (for page turn optimization)
*/
void prefetchPages(uint32_t pageIndex) const;
// Path accessors
const std::string& getCachePath() const { return cachePath; }
const std::string& getPath() const { return filepath; }
+524 -93
View File
@@ -10,11 +10,50 @@
#include <FsHelpers.h>
#include <HalStorage.h>
#include <Logging.h>
#include <esp_heap_caps.h>
#include <cstring>
#include <limits>
namespace xtc {
namespace {
constexpr size_t MAX_CHAPTERS = 4096;
bool canSeekToOffset(const uint64_t offset) {
return offset <= static_cast<uint64_t>(std::numeric_limits<size_t>::max());
}
bool seekToOffset(FsFile& file, const uint64_t offset) {
if (!canSeekToOffset(offset)) {
return false;
}
return file.seek(static_cast<size_t>(offset));
}
} // namespace
void XtcParser::safeDeserializeHeader(const uint8_t* buf, PageTableCacheHeader& header) {
memcpy(&header.magic, buf + 0, 4);
memcpy(&header.version, buf + 4, 4);
memcpy(&header.pageCount, buf + 8, 4);
memcpy(&header.originalHash, buf + 12, 4);
memcpy(&header.originalSize, buf + 16, 8);
memcpy(&header.entrySize, buf + 24, 4);
memcpy(&header.reserved, buf + 28, 4);
}
void XtcParser::safeSerializeHeader(uint8_t* buf, const PageTableCacheHeader& header) {
memcpy(buf + 0, &header.magic, 4);
memcpy(buf + 4, &header.version, 4);
memcpy(buf + 8, &header.pageCount, 4);
memcpy(buf + 12, &header.originalHash, 4);
memcpy(buf + 16, &header.originalSize, 8);
memcpy(buf + 24, &header.entrySize, 4);
memcpy(buf + 28, &header.reserved, 4);
}
XtcParser::XtcParser()
: m_isOpen(false),
m_defaultWidth(DISPLAY_WIDTH),
@@ -23,17 +62,28 @@ XtcParser::XtcParser()
m_hasChapters(false),
m_lastError(XtcError::OK) {
memset(&m_header, 0, sizeof(m_header));
for (auto& entry : m_l1Cache) {
entry.pageIndex = 0xFFFFFFFF;
entry.lastAccess = 0;
}
}
XtcParser::~XtcParser() { close(); }
XtcError XtcParser::open(const char* filepath) {
// Close if already open
XtcError XtcParser::open(const char* filepath, const char* cacheDir) {
// Close any previous file state before reopening
if (m_isOpen) {
close();
}
// Open file
m_originalPath = filepath;
m_cacheDir = cacheDir;
uint32_t fileHash = calculateFileHash(filepath);
m_cacheFilePath = std::string(cacheDir) + "/xtc_" + std::to_string(fileHash) + "/page_table.bin";
// Open the original XTC file just long enough to read metadata and validate the header.
if (!Storage.openFileForRead("XTC", filepath, m_file)) {
m_lastError = XtcError::FILE_NOT_FOUND;
return m_lastError;
@@ -47,57 +97,416 @@ XtcError XtcParser::open(const char* filepath) {
return m_lastError;
}
// Read title & author if available
if (m_header.pageCount == 0) {
LOG_ERR("XTC", "File has no pages");
m_file.close();
m_lastError = XtcError::CORRUPTED_HEADER;
return m_lastError;
}
// Metadata strings are small, so keep them in memory even when the page table is moved to cache.
if (m_header.hasMetadata) {
m_lastError = readTitle();
readTitle();
readAuthor();
m_title.shrink_to_fit();
m_author.shrink_to_fit();
LOG_INF("XTC", "Metadata strings: titleLen=%u cap=%u, authorLen=%u cap=%u",
static_cast<unsigned int>(m_title.size()), static_cast<unsigned int>(m_title.capacity()),
static_cast<unsigned int>(m_author.size()), static_cast<unsigned int>(m_author.capacity()));
}
// Defer chapter parsing until the reader actually needs the table of contents.
m_pageTableOffset = m_header.pageTableOffset;
m_hasChapters = (m_header.hasChapters == 1) && (m_header.chapterOffset != 0);
LOG_INF("XTC", "Chapter metadata deferred: available=%s", m_hasChapters ? "yes" : "no");
m_file.close();
// Build or reuse the on-disk page table cache before marking the parser open.
if (!isPageTableCacheValid()) {
LOG_INF("XTC", "Building page table cache for %u pages", m_header.pageCount);
m_lastError = buildPageTableCache();
if (m_lastError != XtcError::OK) {
LOG_DBG("XTC", "Failed to read title: %s", errorToString(m_lastError));
m_file.close();
LOG_ERR("XTC", "Failed to build page table cache");
return m_lastError;
}
m_lastError = readAuthor();
if (m_lastError != XtcError::OK) {
LOG_DBG("XTC", "Failed to read author: %s", errorToString(m_lastError));
m_file.close();
return m_lastError;
const size_t heapBefore = ESP.getMaxAllocHeap();
LOG_DBG("XTC", "Cache built, heap before defrag: free=%zu, maxAlloc=%zu", ESP.getFreeHeap(), heapBefore);
// Defragment heap: small delay allows heap coalescing after file handles are closed
// This typically improves MaxAlloc by 10-20KB, enabling 96KB page buffer for grayscale
LOG_DBG("XTC", "Defragmenting heap (waiting 50ms)...");
vTaskDelay(pdMS_TO_TICKS(50));
const size_t heapAfter = ESP.getMaxAllocHeap();
const size_t heapGain = heapAfter > heapBefore ? (heapAfter - heapBefore) : 0;
if (heapGain > 0) {
LOG_INF("XTC", "Heap defragmented: +%zu bytes contiguous (now %zu)", heapGain, heapAfter);
} else {
LOG_DBG("XTC", "Heap after defrag: free=%zu, maxAlloc=%zu", ESP.getFreeHeap(), heapAfter);
}
}
// Read page table
m_lastError = readPageTable();
if (m_lastError != XtcError::OK) {
LOG_DBG("XTC", "Failed to read page table: %s", errorToString(m_lastError));
m_file.close();
if (!openCacheFile()) {
LOG_ERR("XTC", "Failed to open cache file");
m_lastError = XtcError::FILE_NOT_FOUND;
return m_lastError;
}
// Read chapters if present
m_lastError = readChapters();
if (m_lastError != XtcError::OK) {
LOG_DBG("XTC", "Failed to read chapters: %s", errorToString(m_lastError));
m_file.close();
return m_lastError;
}
// Prime the sliding L2 window with the first chunk of page metadata.
loadL2Window(0);
LOG_DBG("XTC", "File opened, heap: free=%zu, maxAlloc=%zu", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
m_isOpen = true;
LOG_DBG("XTC", "Opened file: %s (%u pages, %dx%d)", filepath, m_header.pageCount, m_defaultWidth, m_defaultHeight);
LOG_DBG("XTC", "Opened file: %s (%u pages, cache: %s)", filepath, m_header.pageCount, m_cacheFilePath.c_str());
return XtcError::OK;
}
void XtcParser::close() {
if (m_isOpen) {
closeCacheFile();
if (m_isOpen && m_file.isOpen()) {
m_file.close();
m_isOpen = false;
}
m_pageTable.clear();
m_isOpen = false;
m_l2Valid = false;
m_l2WindowCount = 0;
m_chaptersLoaded = false;
for (auto& entry : m_l1Cache) {
entry.pageIndex = 0xFFFFFFFF;
}
m_chapters.clear();
m_title.clear();
m_hasChapters = false;
m_author.clear();
memset(&m_header, 0, sizeof(m_header));
}
void XtcParser::ensureChaptersLoaded() {
if (m_chaptersLoaded || !m_hasChapters) {
return;
}
// Chapter parsing allocates variable-length strings, so keep it lazy.
const XtcError err = readChapters();
if (err != XtcError::OK) {
LOG_ERR("XTC", "Failed to lazy-load chapters: %s", errorToString(err));
m_hasChapters = false;
m_chapters.clear();
m_chapters.shrink_to_fit();
}
m_chaptersLoaded = true;
}
bool XtcParser::openCacheFile() {
if (m_cacheFile.isOpen()) {
return true;
}
return Storage.openFileForRead("XTC", m_cacheFilePath.c_str(), m_cacheFile);
}
void XtcParser::closeCacheFile() {
if (m_cacheFile.isOpen()) {
m_cacheFile.close();
}
}
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) {
if (pageIndex >= m_header.pageCount) {
return false;
}
// L1 is the hot cache for the most recently used pages.
if (lookupL1(pageIndex, info)) {
LOG_DBG("XTC", "L1 hit: page %u", pageIndex);
return true;
}
// L2 is the sliding window around the reader's current position.
if (lookupL2(pageIndex, info)) {
updateL1(pageIndex, info);
LOG_DBG("XTC", "L2 hit: page %u", pageIndex);
return true;
}
// Fall back to the SD-backed cache file, then refresh L2/L1.
LOG_DBG("XTC", "L3 load: page %u", pageIndex);
loadL2Window(pageIndex);
if (lookupL2(pageIndex, info)) {
updateL1(pageIndex, info);
return true;
}
return false;
}
void XtcParser::prefetchWindow(uint32_t pageIndex) {
if (pageIndex >= m_header.pageCount) {
return;
}
// Avoid reloading the same window when the requested page is already covered.
if (m_l2Valid && pageIndex >= m_l2WindowStart && pageIndex < m_l2WindowStart + m_l2WindowCount) {
return;
}
loadL2Window(pageIndex);
}
bool XtcParser::lookupL1(uint32_t pageIndex, PageInfo& info) {
for (const auto& entry : m_l1Cache) {
if (entry.pageIndex == pageIndex) {
info = entry.info;
return true;
}
}
return false;
}
void XtcParser::updateL1(uint32_t pageIndex, const PageInfo& info) {
for (auto& entry : m_l1Cache) {
if (entry.pageIndex == pageIndex) {
entry.lastAccess = ++m_accessCounter;
return;
}
}
// Replace the least-recently-used entry, or fill the first empty slot.
uint32_t oldestAccess = m_accessCounter;
size_t oldestIndex = 0;
bool foundEmpty = false;
for (size_t i = 0; i < m_l1Cache.size(); i++) {
if (m_l1Cache[i].pageIndex == 0xFFFFFFFF) {
oldestIndex = i;
foundEmpty = true;
break;
}
if (m_l1Cache[i].lastAccess < oldestAccess) {
oldestAccess = m_l1Cache[i].lastAccess;
oldestIndex = i;
}
}
m_l1Cache[oldestIndex].pageIndex = pageIndex;
m_l1Cache[oldestIndex].info = info;
m_l1Cache[oldestIndex].lastAccess = ++m_accessCounter;
}
bool XtcParser::lookupL2(uint32_t pageIndex, PageInfo& info) {
if (!m_l2Valid) {
return false;
}
if (pageIndex >= m_l2WindowStart && pageIndex < m_l2WindowStart + m_l2WindowCount) {
size_t idx = pageIndex - m_l2WindowStart;
info = m_l2Window[idx];
return true;
}
return false;
}
void XtcParser::loadL2Window(uint32_t centerPage) {
// Center the sliding window around the requested page when possible.
uint32_t halfWindow = L2_WINDOW_SIZE / 2;
uint32_t windowStart = (centerPage > halfWindow) ? centerPage - halfWindow : 0;
uint32_t windowEnd = windowStart + L2_WINDOW_SIZE;
if (windowEnd > m_header.pageCount) {
windowEnd = m_header.pageCount;
windowStart = (windowEnd > L2_WINDOW_SIZE) ? windowEnd - L2_WINDOW_SIZE : 0;
}
size_t windowSize = windowEnd - windowStart;
if (windowSize == 0) {
m_l2Valid = false;
m_l2WindowCount = 0;
return;
}
if (!m_cacheFile.isOpen() && !openCacheFile()) {
LOG_ERR("XTC", "Cache file not available");
m_l2Valid = false;
m_l2WindowCount = 0;
return;
}
size_t entryOffset = sizeof(PageTableCacheHeader) + windowStart * sizeof(PageInfo);
if (!m_cacheFile.seek(entryOffset)) {
LOG_ERR("XTC", "Failed to seek in page table cache");
m_l2Valid = false;
m_l2WindowCount = 0;
return;
}
size_t readCount = 0;
for (size_t i = 0; i < windowSize; i++) {
PageInfo info;
if (m_cacheFile.read(reinterpret_cast<uint8_t*>(&info), sizeof(PageInfo)) != sizeof(PageInfo)) {
LOG_ERR("XTC", "Failed to read page info %zu", windowStart + i);
break;
}
m_l2Window[i] = info;
readCount++;
}
m_l2WindowStart = windowStart;
m_l2WindowCount = readCount;
m_l2Valid = (readCount > 0);
LOG_DBG("XTC", "L2 window loaded: [%u, %u] (%zu pages)", windowStart, windowStart + readCount - 1, readCount);
}
bool XtcParser::isPageTableCacheValid() const {
if (!Storage.exists(m_cacheFilePath.c_str())) {
return false;
}
FsFile cacheFile;
if (!Storage.openFileForRead("XTC", m_cacheFilePath.c_str(), cacheFile)) {
return false;
}
uint8_t headerBuf[sizeof(PageTableCacheHeader)];
if (cacheFile.read(headerBuf, sizeof(headerBuf)) != sizeof(headerBuf)) {
cacheFile.close();
return false;
}
PageTableCacheHeader header;
safeDeserializeHeader(headerBuf, header);
if (header.magic != PAGE_TABLE_CACHE_MAGIC || header.version != PAGE_TABLE_CACHE_VERSION) {
cacheFile.close();
return false;
}
// The cache must match both the page count and the original file size.
if (header.pageCount != m_header.pageCount) {
cacheFile.close();
return false;
}
uint32_t expectedSize = sizeof(PageTableCacheHeader) + header.pageCount * sizeof(PageInfo);
if (cacheFile.size() < expectedSize) {
cacheFile.close();
return false;
}
if (header.originalSize > 0) {
FsFile originalFile;
if (Storage.openFileForRead("XTC", m_originalPath.c_str(), originalFile)) {
uint64_t currentSize = originalFile.size();
originalFile.close();
if (currentSize != header.originalSize) {
LOG_INF("XTC", "Cache invalidated: file size changed");
cacheFile.close();
return false;
}
}
}
cacheFile.close();
return true;
}
XtcError XtcParser::buildPageTableCache() {
FsFile originalFile;
if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), originalFile)) {
return XtcError::FILE_NOT_FOUND;
}
size_t lastSlash = m_cacheFilePath.find_last_of('/');
if (lastSlash != std::string::npos) {
std::string cacheDir = m_cacheFilePath.substr(0, lastSlash);
Storage.mkdir(cacheDir.c_str());
}
FsFile cacheFile;
if (!Storage.openFileForWrite("XTC", m_cacheFilePath.c_str(), cacheFile)) {
originalFile.close();
return XtcError::WRITE_ERROR;
}
// Persist a compact PageInfo array so we do not need to hold the full table in RAM.
PageTableCacheHeader header;
header.magic = PAGE_TABLE_CACHE_MAGIC;
header.version = PAGE_TABLE_CACHE_VERSION;
header.pageCount = m_header.pageCount;
header.originalHash = calculateFileHash(m_originalPath.c_str());
header.originalSize = originalFile.size();
header.entrySize = sizeof(PageInfo);
header.reserved = 0;
uint8_t headerBuf[sizeof(PageTableCacheHeader)];
safeSerializeHeader(headerBuf, header);
if (cacheFile.write(headerBuf, sizeof(headerBuf)) != sizeof(headerBuf)) {
cacheFile.close();
originalFile.close();
return XtcError::WRITE_ERROR;
}
if (!seekToOffset(originalFile, m_pageTableOffset)) {
cacheFile.close();
originalFile.close();
return XtcError::READ_ERROR;
}
// Convert the source page table entries into the cached PageInfo layout.
for (uint16_t i = 0; i < m_header.pageCount; i++) {
PageTableEntry entry;
if (originalFile.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry)) != sizeof(PageTableEntry)) {
LOG_ERR("XTC", "Failed to read page table entry %u", i);
cacheFile.close();
originalFile.close();
return XtcError::READ_ERROR;
}
PageInfo info;
info.offset = entry.dataOffset;
info.size = entry.dataSize;
info.width = entry.width;
info.height = entry.height;
info.bitDepth = m_bitDepth;
info.padding = 0;
if (cacheFile.write(reinterpret_cast<const uint8_t*>(&info), sizeof(info)) != sizeof(info)) {
cacheFile.close();
originalFile.close();
return XtcError::WRITE_ERROR;
}
}
cacheFile.close();
originalFile.close();
LOG_INF("XTC", "Page table cache built: %u entries", m_header.pageCount);
return XtcError::OK;
}
uint32_t XtcParser::calculateFileHash(const char* filepath) const {
uint32_t hash = 0;
size_t len = strlen(filepath);
for (size_t i = 0; i < len; i++) {
hash = hash * 31 + static_cast<uint8_t>(filepath[i]);
}
FsFile file;
if (Storage.openFileForRead("XTC", filepath, file)) {
uint64_t size = file.size();
hash ^= static_cast<uint32_t>(size);
hash ^= static_cast<uint32_t>(size >> 32);
file.close();
}
return hash;
}
XtcError XtcParser::readHeader() {
// Read first 56 bytes of header
// Read the fixed-size XTC header first.
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&m_header), sizeof(XtcHeader));
if (bytesRead != sizeof(XtcHeader)) {
return XtcError::READ_ERROR;
@@ -163,49 +572,17 @@ XtcError XtcParser::readAuthor() {
return XtcError::OK;
}
XtcError XtcParser::readPageTable() {
if (m_header.pageTableOffset == 0) {
LOG_DBG("XTC", "Page table offset is 0, cannot read");
return XtcError::CORRUPTED_HEADER;
}
// Seek to page table
if (!m_file.seek(m_header.pageTableOffset)) {
LOG_DBG("XTC", "Failed to seek to page table at %llu", m_header.pageTableOffset);
return XtcError::READ_ERROR;
}
m_pageTable.resize(m_header.pageCount);
// Read page table entries
for (uint16_t i = 0; i < m_header.pageCount; i++) {
PageTableEntry entry;
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
if (bytesRead != sizeof(PageTableEntry)) {
LOG_DBG("XTC", "Failed to read page table entry %u", i);
return XtcError::READ_ERROR;
}
m_pageTable[i].offset = static_cast<uint32_t>(entry.dataOffset);
m_pageTable[i].size = entry.dataSize;
m_pageTable[i].width = entry.width;
m_pageTable[i].height = entry.height;
m_pageTable[i].bitDepth = m_bitDepth;
// Update default dimensions from first page
if (i == 0) {
m_defaultWidth = entry.width;
m_defaultHeight = entry.height;
}
}
LOG_DBG("XTC", "Read %u page table entries", m_header.pageCount);
return XtcError::OK;
}
XtcError XtcParser::readChapters() {
m_hasChapters = false;
m_chapters.clear();
m_chapters.shrink_to_fit();
// Reopen the original file on demand because open() closes it after cache initialization.
if (!m_file.isOpen()) {
if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), m_file)) {
return XtcError::FILE_NOT_FOUND;
}
}
uint8_t hasChaptersFlag = 0;
if (!m_file.seek(0x0B)) {
@@ -232,7 +609,13 @@ XtcError XtcParser::readChapters() {
}
const uint64_t fileSize = m_file.size();
if (chapterOffset < sizeof(XtcHeader) || chapterOffset >= fileSize || chapterOffset + 96 > fileSize) {
constexpr size_t chapterSize = 96;
if (chapterOffset < sizeof(XtcHeader) || chapterOffset >= fileSize) {
return XtcError::OK;
}
if (fileSize - chapterOffset < chapterSize) {
return XtcError::OK;
}
@@ -249,18 +632,32 @@ XtcError XtcParser::readChapters() {
return XtcError::OK;
}
constexpr size_t chapterSize = 96;
const uint64_t available = maxOffset - chapterOffset;
const size_t chapterCount = static_cast<size_t>(available / chapterSize);
const uint64_t chapterCount64 = available / chapterSize;
if (chapterCount64 == 0) {
return XtcError::OK;
}
if (chapterCount64 > MAX_CHAPTERS || chapterCount64 > std::numeric_limits<size_t>::max()) {
LOG_ERR("XTC", "Chapter table too large: available=%llu chapterCount=%llu",
static_cast<unsigned long long>(available), static_cast<unsigned long long>(chapterCount64));
return XtcError::CORRUPTED_HEADER;
}
const size_t chapterCount = static_cast<size_t>(chapterCount64);
if (chapterCount == 0) {
return XtcError::OK;
}
if (!m_file.seek(chapterOffset)) {
const size_t freeHeapBefore = ESP.getFreeHeap();
const size_t maxAllocBefore = ESP.getMaxAllocHeap();
if (!seekToOffset(m_file, chapterOffset)) {
return XtcError::READ_ERROR;
}
std::vector<uint8_t> chapterBuf(chapterSize);
m_chapters.reserve(chapterCount);
for (size_t i = 0; i < chapterCount; i++) {
if (m_file.read(chapterBuf.data(), chapterSize) != chapterSize) {
return XtcError::READ_ERROR;
@@ -304,17 +701,27 @@ XtcError XtcParser::readChapters() {
m_chapters.push_back(std::move(chapter));
}
m_chapters.shrink_to_fit();
m_hasChapters = !m_chapters.empty();
LOG_DBG("XTC", "Chapters: %u", static_cast<unsigned int>(m_chapters.size()));
size_t chapterNameBytes = 0;
for (const auto& chapter : m_chapters) {
chapterNameBytes += chapter.name.capacity() + 1;
}
const size_t chapterVectorBytes = m_chapters.capacity() * sizeof(ChapterInfo);
const size_t totalChapterBytes = chapterVectorBytes + chapterNameBytes;
const size_t freeHeapAfter = ESP.getFreeHeap();
const size_t maxAllocAfter = ESP.getMaxAllocHeap();
const int heapDelta = static_cast<int>(freeHeapBefore) - static_cast<int>(freeHeapAfter);
const int maxAllocDelta = static_cast<int>(maxAllocBefore) - static_cast<int>(maxAllocAfter);
LOG_INF("XTC", "Chapter metadata: count=%u, vector~=%zu, names~=%zu, total~=%zu, heapDelta=%d, maxAllocDelta=%d",
static_cast<unsigned int>(m_chapters.size()), chapterVectorBytes, chapterNameBytes, totalChapterBytes,
heapDelta, maxAllocDelta);
return XtcError::OK;
}
bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const {
if (pageIndex >= m_pageTable.size()) {
return false;
}
info = m_pageTable[pageIndex];
return true;
const std::vector<ChapterInfo>& XtcParser::getChapters() {
ensureChaptersLoaded();
return m_chapters;
}
size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) {
@@ -328,11 +735,23 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
return 0;
}
const PageInfo& page = m_pageTable[pageIndex];
// Resolve the page location through the cache hierarchy before touching the data file.
PageInfo info;
if (!getPageInfo(pageIndex, info)) {
m_lastError = XtcError::READ_ERROR;
return 0;
}
// Seek to page data
if (!m_file.seek(page.offset)) {
LOG_DBG("XTC", "Failed to seek to page %u at offset %lu", pageIndex, page.offset);
// Reopen the source file lazily because normal parser open() does not keep it pinned.
if (!m_file.isOpen()) {
if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), m_file)) {
m_lastError = XtcError::FILE_NOT_FOUND;
return 0;
}
}
if (!seekToOffset(m_file, info.offset)) {
LOG_DBG("XTC", "Failed to seek to page %u at offset %llu", pageIndex, static_cast<unsigned long long>(info.offset));
m_lastError = XtcError::READ_ERROR;
return 0;
}
@@ -366,14 +785,14 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
}
// Check buffer size
// The caller owns the buffer, so fail early if it is too small.
if (bufferSize < bitmapSize) {
LOG_DBG("XTC", "Buffer too small: need %u, have %u", bitmapSize, bufferSize);
m_lastError = XtcError::MEMORY_ERROR;
return 0;
}
// Read bitmap data
// Read the bitmap payload into the caller-provided buffer.
size_t bytesRead = m_file.read(buffer, bitmapSize);
if (bytesRead != bitmapSize) {
LOG_DBG("XTC", "Page read error: expected %u, got %u", bitmapSize, bytesRead);
@@ -396,14 +815,25 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
return XtcError::PAGE_OUT_OF_RANGE;
}
const PageInfo& page = m_pageTable[pageIndex];
// Seek to page data
if (!m_file.seek(page.offset)) {
// Streaming uses the same cache lookup path but reads the payload in chunks.
PageInfo info;
if (!getPageInfo(pageIndex, info)) {
return XtcError::READ_ERROR;
}
// Read and skip page header (XTG for 1-bit, XTH for 2-bit)
// Reopen the source file on demand for streaming reads as well.
if (!m_file.isOpen()) {
if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), m_file)) {
return XtcError::FILE_NOT_FOUND;
}
}
if (!seekToOffset(m_file, info.offset)) {
LOG_DBG("XTC", "Failed to seek to page %u at offset %llu", pageIndex, static_cast<unsigned long long>(info.offset));
return XtcError::READ_ERROR;
}
// Read and validate the page header before yielding any bitmap bytes.
XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
@@ -414,6 +844,7 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
// Calculate bitmap size based on bit depth
// XTG (1-bit): Row-major, ((width+7)/8) * height bytes
// XTH (2-bit): Two bit planes, ((width * height + 7) / 8) * 2 bytes
// Match the bitmap sizing rules used by the non-streaming path.
size_t bitmapSize;
if (m_bitDepth == 2) {
bitmapSize = ((static_cast<size_t>(pageHeader.width) * pageHeader.height + 7) / 8) * 2;
@@ -421,7 +852,7 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height;
}
// Read in chunks
// Feed the bitmap to the callback in bounded chunks to keep peak memory low.
std::vector<uint8_t> chunk(chunkSize);
size_t totalRead = 0;
+52 -26
View File
@@ -9,6 +9,8 @@
#include <HalStorage.h>
#include <array>
#include <cstring>
#include <functional>
#include <memory>
#include <string>
@@ -30,7 +32,7 @@ class XtcParser {
~XtcParser();
// File open/close
XtcError open(const char* filepath);
XtcError open(const char* filepath, const char* cacheDir);
void close();
bool isOpen() const { return m_isOpen; }
@@ -41,28 +43,14 @@ class XtcParser {
uint16_t getHeight() const { return m_defaultHeight; }
uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH
// Page information
bool getPageInfo(uint32_t pageIndex, PageInfo& info) const;
// Page information - three-tier cache interface
bool getPageInfo(uint32_t pageIndex, PageInfo& info);
/**
* Load page bitmap (raw 1-bit data, skipping XTG header)
*
* @param pageIndex Page index (0-based)
* @param buffer Output buffer (caller allocated)
* @param bufferSize Buffer size
* @return Number of bytes read on success, 0 on failure
*/
// Preload window around specified page (optimize sequential page turns)
void prefetchWindow(uint32_t pageIndex);
// Load page bitmap (unchanged)
size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize);
/**
* Streaming page load
* Memory-efficient method that reads page data in chunks.
*
* @param pageIndex Page index
* @param callback Callback function to receive data chunks
* @param chunkSize Chunk size (default: 1024 bytes)
* @return Error code
*/
XtcError loadPageStreaming(uint32_t pageIndex,
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
size_t chunkSize = 1024);
@@ -72,7 +60,7 @@ class XtcParser {
std::string getAuthor() const { return m_author; }
bool hasChapters() const { return m_hasChapters; }
const std::vector<ChapterInfo>& getChapters() const { return m_chapters; }
const std::vector<ChapterInfo>& getChapters();
// Validation
static bool isValidXtcFile(const char* filepath);
@@ -82,24 +70,62 @@ class XtcParser {
private:
FsFile m_file;
FsFile m_cacheFile;
bool m_isOpen;
XtcHeader m_header;
std::vector<PageInfo> m_pageTable;
std::vector<ChapterInfo> m_chapters;
std::string m_cacheDir;
std::string m_cacheFilePath;
std::string m_originalPath;
std::string m_title;
std::string m_author;
uint16_t m_defaultWidth;
uint16_t m_defaultHeight;
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
uint8_t m_bitDepth;
bool m_hasChapters;
bool m_chaptersLoaded = false;
XtcError m_lastError;
uint32_t m_accessCounter = 0;
// L1: Hot cache (fixed 4 entries)
std::array<L1CacheEntry, L1_CACHE_SIZE> m_l1Cache;
// L2: Sliding window (fixed size array)
std::array<PageInfo, L2_WINDOW_SIZE> m_l2Window;
uint32_t m_l2WindowStart = 0;
size_t m_l2WindowCount = 0;
bool m_l2Valid = false;
// Chapters (usually few, keep in memory)
std::vector<ChapterInfo> m_chapters;
// Original Page Table offset (for rebuilding cache)
uint64_t m_pageTableOffset = 0;
// Internal helper functions
XtcError readHeader();
XtcError readPageTable();
XtcError readTitle();
XtcError readAuthor();
XtcError readChapters();
void ensureChaptersLoaded();
// L3 cache management
bool isPageTableCacheValid() const;
XtcError buildPageTableCache();
bool openCacheFile();
void closeCacheFile();
// L1/L2 cache operations
bool lookupL1(uint32_t pageIndex, PageInfo& info);
void updateL1(uint32_t pageIndex, const PageInfo& info);
bool lookupL2(uint32_t pageIndex, PageInfo& info);
void loadL2Window(uint32_t centerPage);
// Safe deserialization (alignment-safe for ESP32-C3)
static void safeDeserializeHeader(const uint8_t* buf, PageTableCacheHeader& header);
static void safeSerializeHeader(uint8_t* buf, const PageTableCacheHeader& header);
// Utility functions
uint32_t calculateFileHash(const char* filepath) const;
};
} // namespace xtc
+30 -3
View File
@@ -86,15 +86,15 @@ struct XtgPageHeader {
};
#pragma pack(pop)
// Page information (internal use, optimized for memory)
// Page information (internal use)
struct PageInfo {
uint32_t offset; // File offset to page data (max 4GB file size)
uint64_t offset; // File offset to page data
uint32_t size; // Data size (bytes)
uint16_t width; // Page width
uint16_t height; // Page height
uint8_t bitDepth; // 1 = XTG (1-bit), 2 = XTH (2-bit grayscale)
uint8_t padding; // Alignment padding
}; // 16 bytes total
}; // 20 bytes total
struct ChapterInfo {
std::string name;
@@ -102,6 +102,33 @@ struct ChapterInfo {
uint16_t endPage;
};
// Cache configuration
constexpr size_t L1_CACHE_SIZE = 4; // L1 cache entries
constexpr size_t L2_WINDOW_SIZE = 100; // L2 window size (reduced for 2-bit memory)
constexpr uint32_t PAGE_TABLE_CACHE_VERSION = 1; // Cache file version
// Cache magic number
constexpr uint32_t PAGE_TABLE_CACHE_MAGIC = 0x50435458; // "XTCP"
// Cache file header - NOTE: Do NOT read directly from file buffer!
// Use safeDeserializeHeader() function for alignment-safe access.
struct PageTableCacheHeader {
uint32_t magic; // 'XTCP' = 0x50435458
uint32_t version; // Cache version
uint32_t pageCount; // Total pages
uint32_t originalHash; // Original file hash (for validation)
uint64_t originalSize; // Original file size (for validation)
uint32_t entrySize; // PageInfo size (16)
uint32_t reserved; // Reserved
};
// L1 cache entry
struct L1CacheEntry {
uint32_t pageIndex = 0xFFFFFFFF; // 0xFFFFFFFF = invalid
PageInfo info{};
uint32_t lastAccess = 0; // Timestamp for LRU
};
// Error codes
enum class XtcError {
OK = 0,
+512
View File
@@ -0,0 +1,512 @@
#include "HalClock.h"
#include <Arduino.h>
#include <Logging.h>
#include <Preferences.h>
#include <WiFi.h>
#include <esp_private/esp_clk.h>
#include <esp_sntp.h>
#include <sys/time.h>
#include <time.h>
#include <cmath>
#include <cstdlib>
// ---- RTC-memory state (survives deep sleep, not cold boot) ----------------
static constexpr uint32_t CLOCK_RTC_MAGIC = 0xC10C4B1D;
static constexpr uint32_t CLOCK_RTC_FLAG_LP_VALID = 0x00000001u;
// Temperature drift model for ESP32 RTC-based timekeeping.
//
// The chip's low-power (slow) clock frequency depends on temperature.
// ESP32 variants can drift by about 2 minutes per day per °C from the
// initial captured operating temperature.
//
// - dt_drift ≈ 120 seconds/day/°C
// - relative frequency error per second per °C = 120 / 86400
//
// At restore() we apply a first-order correction over the sleep interval:
// corrected_interval = raw_interval × (1 + ΔT × drift_factor), where
// drift_factor = 120 / 86400.
//
// Experimental source: https://www.reddit.com/r/esp32/comments/11cikkp/the_clock_on_the_esp_is_wrong/
static constexpr float CLOCK_TEMP_DRIFT_SECONDS_PER_SECOND_PER_DEG = 120.0f / 86400.0f;
RTC_NOINIT_ATTR static uint32_t rtcClockMagic;
RTC_NOINIT_ATTR static uint32_t rtcClockFlags;
RTC_NOINIT_ATTR static time_t rtcEpoch; // last-known unix epoch
RTC_NOINIT_ATTR static uint64_t rtcLpTimeUs; // esp_clk_rtc_time() at capture
RTC_NOINIT_ATTR static uint32_t rtcSlowCal; // esp_clk_slowclk_cal_get() at capture
RTC_NOINIT_ATTR static float rtcTemperatureC; // captured chip temperature at save
static bool clockApproximate = true;
// Drift correction scale factor (learned from NTP sync results).
//
// Raw temp drift model uses 2 min/day/°C -> factor = 120/86400. This is a
// generic base model. The actual board may behave a bit differently. On each
// NTP sync we estimate how the local clock error compares to the model and
// update this scale factor slightly to converge toward real world behavior.
//
// rtcDriftScale = 1.0 means we trust 2 min/day/°C exactly. If the device is
// slower/faster than that, NTP drift calibration adjusts this factor.
static float rtcDriftScale = 1.0f;
static unsigned long lastPeriodicUpdateMs = 0;
static constexpr unsigned long PERIODIC_UPDATE_INTERVAL_MS = 10UL * 60UL * 1000UL;
struct TimeZoneEntry {
const char* tz;
};
static constexpr TimeZoneEntry TIMEZONES[] = {
{"GMT0BST,M3.5.0/1,M10.5.0/2"},
{"CET-1CEST,M3.5.0/2,M10.5.0/3"},
{"EET-2EEST,M3.5.0/3,M10.5.0/4"},
{"MSK-3"},
{"UTC-4"},
{"UTC-5:30"},
{"UTC-7"},
{"UTC-8"},
{"UTC-9"},
{"AEST-10AEDT,M10.1.0/2,M4.1.0/3"},
{"NZST-12NZDT,M9.5.0/2,M4.1.0/3"},
{"UTC+3"},
{"EST5EDT,M3.2.0/2,M11.1.0/2"},
{"CST6CDT,M3.2.0/2,M11.1.0/2"},
{"MST7MDT,M3.2.0/2,M11.1.0/2"},
{"PST8PDT,M3.2.0/2,M11.1.0/2"},
};
// ---- NVS helpers ----------------------------------------------------------
// If the last NTP sync is older than this, treat a cold-boot restore as
// unsynced rather than showing a potentially very wrong time.
static constexpr int64_t STALE_THRESHOLD_S = 72 * 3600; // 72 hours
static constexpr char NVS_NAMESPACE[] = "halclock";
static constexpr char NVS_KEY[] = "epoch";
static constexpr char NVS_SYNC_KEY[] = "lastsync";
static constexpr char NVS_DRIFT_KEY[] = "driftcoef";
static constexpr char NVS_TEMP_KEY[] = "lasttemp";
static void nvsWrite(time_t epoch) {
Preferences prefs;
if (prefs.begin(NVS_NAMESPACE, false)) {
prefs.putLong64(NVS_KEY, (int64_t)epoch);
prefs.end();
}
}
static void nvsWriteDriftScale(float driftScale) {
Preferences prefs;
if (prefs.begin(NVS_NAMESPACE, false)) {
prefs.putFloat(NVS_DRIFT_KEY, driftScale);
prefs.end();
}
}
static float nvsReadDriftScale() {
Preferences prefs;
float result = 1.0f;
if (prefs.begin(NVS_NAMESPACE, true)) {
result = prefs.getFloat(NVS_DRIFT_KEY, 1.0f);
prefs.end();
}
// Guard against NaN, Inf, or out-of-range values from corrupted NVS.
if (!std::isfinite(result) || result < 0.1f || result > 5.0f) {
result = 1.0f;
}
return result;
}
static void nvsWriteLastSyncTemp(float tempC) {
Preferences prefs;
if (prefs.begin(NVS_NAMESPACE, false)) {
prefs.putFloat(NVS_TEMP_KEY, tempC);
prefs.end();
}
}
static float nvsReadLastSyncTemp() {
Preferences prefs;
float result = 0.0f;
if (prefs.begin(NVS_NAMESPACE, true)) {
result = prefs.getFloat(NVS_TEMP_KEY, 0.0f);
prefs.end();
}
return result;
}
static void nvsWriteSyncTime(time_t syncEpoch) {
Preferences prefs;
if (prefs.begin(NVS_NAMESPACE, false)) {
prefs.putLong64(NVS_SYNC_KEY, (int64_t)syncEpoch);
prefs.end();
}
}
static time_t nvsRead() {
Preferences prefs;
time_t epoch = 0;
if (prefs.begin(NVS_NAMESPACE, true)) {
epoch = (time_t)prefs.getLong64(NVS_KEY, 0);
prefs.end();
}
return epoch;
}
static time_t nvsReadSyncTime() {
Preferences prefs;
time_t syncEpoch = 0;
if (prefs.begin(NVS_NAMESPACE, true)) {
syncEpoch = (time_t)prefs.getLong64(NVS_SYNC_KEY, 0);
prefs.end();
}
return syncEpoch;
}
// ---- internal helpers -----------------------------------------------------
static float readChipTemperatureC() {
// ESP32 and ESP32-C3 use the internal ADC temperature sensor.
return (float)temperatureRead();
}
static void setSystemClock(time_t epoch) {
struct timeval tv = {};
tv.tv_sec = epoch;
settimeofday(&tv, nullptr);
}
static bool rtcValid() { return rtcClockMagic == CLOCK_RTC_MAGIC && rtcEpoch > 0; }
/// Compute temperature-corrected elapsed seconds from LP timer delta.
/// Uses the trapezoidal rule (average of start + end temperature) as a
/// first-order approximation of the temperature integral over the interval.
/// Returns the corrected elapsed seconds and updates lpNowOut/calNowOut
/// for the caller to re-baseline.
static double computeCorrectedElapsedSec(uint64_t lpNow, float tempNow) {
uint32_t calNow = esp_clk_slowclk_cal_get();
uint64_t elapsedUs;
if (rtcSlowCal != 0 && calNow != 0) {
// rtcLpTimeUs was computed with rtcSlowCal; convert it to the
// current calibration basis so the subtraction is consistent.
uint64_t lpThenCorrected = (uint64_t)((double)rtcLpTimeUs * calNow / rtcSlowCal);
elapsedUs = lpNow - lpThenCorrected;
} else {
elapsedUs = lpNow - rtcLpTimeUs;
}
// Use the full temperature delta between the average over the interval
// and the calibration reference (which is the capture-time temperature).
// avgTemp approximates the mean temperature during the interval.
// The drift model says the RTC runs (1 + deltaT * driftRate) times
// faster/slower than nominal, so the true elapsed wall-clock time
// differs from the raw LP-derived time by that factor.
float avgTemp = (rtcTemperatureC + tempNow) * 0.5f;
// Positive when COOLED DOWN relative to capture temperature.
// ESP32 RC oscillator has a positive temperature coefficient: it runs faster
// when hotter, causing the LP timer to over-count. To recover true elapsed
// time we must REDUCE the raw LP-derived seconds when the device is warmer
// than at capture (and INCREASE them when cooler). Hence the sign inversion.
float tempDelta = rtcTemperatureC - avgTemp; // = (rtcTemperatureC - tempNow) / 2
float tempFactor = 1.0f + tempDelta * CLOCK_TEMP_DRIFT_SECONDS_PER_SECOND_PER_DEG * rtcDriftScale;
if (tempFactor < 0.5f) {
tempFactor = 0.5f;
} else if (tempFactor > 1.5f) {
tempFactor = 1.5f;
}
double elapsedSec = (double)elapsedUs / 1000000.0;
double correctedSec = elapsedSec * (double)tempFactor;
LOG_DBG("CLK", "Drift calc: startT=%.1fC nowT=%.1fC dT=%.3f factor=%.6f raw=%.3fs corr=%.3fs", rtcTemperatureC,
tempNow, tempDelta, tempFactor, elapsedSec, correctedSec);
return correctedSec;
}
/// Capture current time + LP timer into RTC memory, and epoch into NVS.
static void capture(bool lpValid) {
rtcEpoch = time(nullptr);
rtcLpTimeUs = esp_clk_rtc_time();
rtcSlowCal = esp_clk_slowclk_cal_get();
rtcTemperatureC = readChipTemperatureC();
rtcClockMagic = CLOCK_RTC_MAGIC;
rtcClockFlags = lpValid ? CLOCK_RTC_FLAG_LP_VALID : 0;
nvsWrite(rtcEpoch);
}
// ---- public API -----------------------------------------------------------
namespace HalClock {
void applyTimezone(uint8_t timeZoneSetting) {
const size_t index = timeZoneSetting < (sizeof(TIMEZONES) / sizeof(TIMEZONES[0])) ? timeZoneSetting : 0;
setenv("TZ", TIMEZONES[index].tz, 1);
tzset();
LOG_DBG("CLK", "Timezone applied: %s", TIMEZONES[index].tz);
}
bool syncNtp() {
time_t preSyncTime = time(nullptr);
time_t prevSyncTime = nvsReadSyncTime();
float prevSyncTemp = nvsReadLastSyncTemp();
if (esp_sntp_enabled()) {
esp_sntp_stop();
}
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "pool.ntp.org");
esp_sntp_init();
int retry = 0;
constexpr int maxRetries = 50; // 5 seconds
while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) {
vTaskDelay(100 / portTICK_PERIOD_MS);
retry++;
}
if (retry >= maxRetries) {
LOG_ERR("CLK", "NTP sync timeout");
return false;
}
capture(false);
nvsWriteSyncTime(rtcEpoch);
float currentTemp = rtcTemperatureC;
if (currentTemp != 0.0f) {
nvsWriteLastSyncTemp(currentTemp);
}
if (prevSyncTime > 0 && preSyncTime > 0 && rtcEpoch > prevSyncTime) {
float interval = (float)(rtcEpoch - prevSyncTime);
// error = how far the local clock was off before NTP corrected it.
// Negative means local clock was behind (NTP jumped us forward).
// Positive means local clock was ahead (NTP pulled us back).
float error = (float)(preSyncTime - rtcEpoch);
if (interval >= 60.0f) {
// Convert to seconds-of-drift per day.
float observedDriftPerDay = error * 86400.0f / interval;
// Adaptive model calibration:
// - Observed drift is derived from the difference between local clock
// reading just before NTP and the true time reported by NTP, scaled
// to a per-day rate over the interval since the previous sync.
// - The baseline model expects 120 sec/day per °C.
// - Measure temp delta since last sync (from stored NVS temp).
// - If large enough, compute an empirical scale to apply to the model
// so future drift corrections are better aligned with actual hardware.
// - The scale is persisted to NVS via saveBeforeSleep().
float effectiveScale = rtcDriftScale;
float tempDelta = currentTemp - prevSyncTemp;
if (std::fabs(tempDelta) > 0.1f) {
float modelDriftPerDay = 120.0f * tempDelta;
if (std::fabs(modelDriftPerDay) > 0.01f) {
float measuredScale = observedDriftPerDay / modelDriftPerDay;
effectiveScale = 0.9f * rtcDriftScale + 0.1f * measuredScale;
effectiveScale = std::max(0.1f, std::min(5.0f, effectiveScale));
rtcDriftScale = effectiveScale;
}
}
LOG_DBG("CLK", "NTP drift: interval=%.0fs error=%.3fs perDay=%.3f scale=%.3f deltaT=%.2f", interval, error,
observedDriftPerDay, rtcDriftScale, tempDelta);
}
}
clockApproximate = false;
LOG_INF("CLK", "NTP synced, epoch %lld", (long long)rtcEpoch);
return true;
}
void saveBeforeSleep(bool keepLpAlive) {
if (!isSynced()) {
return;
}
capture(keepLpAlive);
// Persist learned drift scale and last temperature to NVS so they survive
// cold boot. We only write here (not periodically) to minimise flash wear.
nvsWriteDriftScale(rtcDriftScale);
nvsWriteLastSyncTemp(rtcTemperatureC);
LOG_DBG("CLK", "Saved epoch %lld before sleep (driftScale=%.3f)", (long long)rtcEpoch, rtcDriftScale);
}
void restore() {
rtcDriftScale = nvsReadDriftScale();
const bool lpValid = (rtcClockFlags & CLOCK_RTC_FLAG_LP_VALID) != 0;
if (rtcValid() && lpValid) {
// RTC memory survived — we woke from deep sleep.
//
// We restore the wall clock by computing elapsed real time from the
// LP timer delta and applying both frequency calibration and temperature
// drift correction.
//
// Steps:
// 1) Read current LP timer and slow-clock calibration.
// 2) Compute raw elapsed LP ticks, on the same calibration basis used
// when capture() was called.
// 3) Convert elapsed ticks to seconds.
// 4) Apply temperature drift correction based on measured RTC memory
// capture temperature and current chip temp.
// 5) Set system time to rtcEpoch + corrected elapsed seconds.
//
// This is an approximation: we use the average of start/end measured
// temperature as a simple integral proxy. More advanced models could
// sample temperature continuously, but this is a good tradeoff for low
// cost and better accuracy vs no temperature compensation.
uint64_t lpNow = esp_clk_rtc_time();
time_t estimated = rtcEpoch;
if (lpNow > rtcLpTimeUs) {
float tempNow = readChipTemperatureC();
double correctedSec = computeCorrectedElapsedSec(lpNow, tempNow);
estimated += (time_t)correctedSec;
}
setSystemClock(estimated);
// Re-baseline LP timer and temperature for next interval.
rtcEpoch = estimated;
rtcLpTimeUs = esp_clk_rtc_time();
rtcSlowCal = esp_clk_slowclk_cal_get();
rtcTemperatureC = readChipTemperatureC();
clockApproximate = true;
LOG_INF("CLK", "Restored from RTC + LP timer, epoch %lld", (long long)estimated);
return;
}
// Cold boot — try NVS. No elapsed correction possible.
time_t epoch = nvsRead();
if (epoch > 0) {
time_t lastSync = nvsReadSyncTime();
if (lastSync > 0 && (epoch - lastSync) > STALE_THRESHOLD_S) {
LOG_ERR("CLK", "NVS epoch %lld is stale (last NTP sync %lld, %lld h ago), discarding", (long long)epoch,
(long long)lastSync, (long long)((epoch - lastSync) / 3600));
return;
}
setSystemClock(epoch);
rtcEpoch = epoch;
rtcLpTimeUs = esp_clk_rtc_time();
rtcSlowCal = esp_clk_slowclk_cal_get();
rtcTemperatureC = nvsReadLastSyncTemp();
if (rtcTemperatureC == 0.0f) {
rtcTemperatureC = readChipTemperatureC();
}
rtcClockMagic = CLOCK_RTC_MAGIC;
rtcClockFlags = 0;
clockApproximate = true;
LOG_INF("CLK", "Restored from NVS, epoch %lld (no elapsed correction)", (long long)epoch);
}
}
time_t now() {
if (!isSynced()) {
return 0;
}
return time(nullptr);
}
void updatePeriodic() {
if (!isSynced()) {
return;
}
unsigned long nowMs = millis();
if (nowMs - lastPeriodicUpdateMs < PERIODIC_UPDATE_INTERVAL_MS) {
return;
}
lastPeriodicUpdateMs = nowMs;
// Compute temperature-corrected elapsed time since last baseline and apply
// only the drift delta (correction - raw) to the system clock. The kernel
// clock already advanced by the raw amount, so we must not re-add it.
uint64_t lpNow = esp_clk_rtc_time();
if (lpNow <= rtcLpTimeUs) {
return;
}
float tempNow = readChipTemperatureC();
double correctedSec = computeCorrectedElapsedSec(lpNow, tempNow);
// Raw elapsed seconds (what the kernel clock already counted).
uint64_t rawElapsedUs = lpNow - rtcLpTimeUs;
double rawSec = (double)rawElapsedUs / 1000000.0;
// The drift delta is the difference between what really elapsed
// (temperature-corrected) and what the kernel counted (raw).
double driftDeltaSec = correctedSec - rawSec;
// Re-baseline LP timer and temperature for the next interval.
rtcLpTimeUs = lpNow;
rtcSlowCal = esp_clk_slowclk_cal_get();
rtcTemperatureC = tempNow;
// Only nudge the system clock if the drift delta is meaningful (>50 ms).
// This avoids unnecessary settimeofday calls for negligible corrections.
if (std::fabs(driftDeltaSec) > 0.05) {
rtcEpoch = time(nullptr) + (time_t)driftDeltaSec;
setSystemClock(rtcEpoch);
LOG_DBG("CLK", "Periodic drift nudge: raw=%.3fs corr=%.3fs delta=%.3fs scale=%.3f", rawSec, correctedSec,
driftDeltaSec, rtcDriftScale);
}
}
bool isSynced() {
return time(nullptr) > 1577836800; // > 2020-01-01
}
bool isApproximate() { return clockApproximate; }
time_t lastSyncTime() { return nvsReadSyncTime(); }
void formatTime(char* buf, size_t bufSize, bool use24h) {
if (!isSynced()) {
snprintf(buf, bufSize, "--:--");
return;
}
time_t t = time(nullptr);
struct tm timeinfo;
localtime_r(&t, &timeinfo);
const char* prefix = isApproximate() ? "~" : "";
if (use24h) {
snprintf(buf, bufSize, "%s%02d:%02d", prefix, timeinfo.tm_hour, timeinfo.tm_min);
} else {
int hour = timeinfo.tm_hour % 12;
if (hour == 0) hour = 12;
const char* ampm = timeinfo.tm_hour < 12 ? "AM" : "PM";
snprintf(buf, bufSize, "%s%d:%02d%s", prefix, hour, timeinfo.tm_min, ampm);
}
}
void formatLogTime(char* buf, size_t bufSize) {
if (!isSynced()) {
buf[0] = '\0';
return;
}
time_t t = time(nullptr);
struct tm timeinfo;
localtime_r(&t, &timeinfo);
snprintf(buf, bufSize, "%02d:%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
}
void wifiOff(bool skipNtpSync) {
if (!skipNtpSync && isApproximate() && WiFi.getMode() == WIFI_STA && WiFi.status() == WL_CONNECTED) {
syncNtp();
}
if (esp_sntp_enabled()) {
esp_sntp_stop();
}
WiFi.disconnect(false);
delay(100);
WiFi.mode(WIFI_OFF);
delay(100);
}
} // namespace HalClock
+85
View File
@@ -0,0 +1,85 @@
#pragma once
#include <cstdint>
#include <ctime>
/// Lightweight wall-clock facade.
///
/// The ESP32-C3 has no battery-backed RTC, so wall-clock time is lost on every
/// deep-sleep / power cycle. HalClock bridges this gap using three layers:
///
/// - **LP timer** (`esp_clk_rtc_time()`) — keeps running during deep sleep
/// when `keepClockAlive` is enabled (GPIO13 stays HIGH). Used to compute
/// elapsed time and correct the stored epoch on wake.
/// - **RTC memory** (`RTC_NOINIT_ATTR`) — survives deep sleep, lost on cold
/// boot. Stores the epoch + LP timer value captured before sleep.
/// - **NVS** (flash key-value store) — survives power cycles. Fallback when
/// RTC memory is unavailable (cold boot).
///
/// Usage:
/// 1. On boot, call `restore()` to seed the system clock from the best
/// available source (RTC memory + LP correction > NVS).
/// 2. After a successful NTP sync, call `syncNtp()`.
/// 3. Before entering deep sleep, call `saveBeforeSleep()`.
///
/// `now()` returns the best-effort epoch (0 if never synced).
namespace HalClock {
/// Perform an NTP sync (requires WiFi to be connected). Starts SNTP,
/// waits up to 5 seconds for completion, then captures the result.
/// Returns true if the sync succeeded.
bool syncNtp();
/// Apply timezone/DST rules via the POSIX TZ string for the given setting.
void applyTimezone(uint8_t timeZoneSetting);
/// Call just before deep sleep. Snapshots the current system time to RTC
/// memory and NVS so it can be restored on wake / cold boot. Pass true when
/// the LP timer is kept alive during sleep.
void saveBeforeSleep(bool keepLpAlive);
/// Call on boot to seed the system clock from the best available stored
/// value. When RTC memory is valid (deep-sleep wake) and the LP timer was
/// running, the restored time includes elapsed-time correction. Falls back
/// to NVS for cold boot (stale, but better than nothing).
void restore();
/// Returns the current best-effort wall-clock epoch, or 0 if the clock was
/// never set.
time_t now();
/// True if the clock has been set at least once (NTP or restore).
bool isSynced();
/// Periodic callback (called from main loop) to compensate temperature-induced
/// RTC drift while the device is awake. Runs at a 10-minute interval.
/// Computes the drift delta since the last baseline using the temperature
/// model and nudges the system clock by only that delta (the kernel clock
/// already advanced the raw amount). Drift state is persisted to NVS only
/// in saveBeforeSleep() to minimise flash wear.
void updatePeriodic();
/// True if the last restore was from a backup (not NTP) — i.e. the clock
/// may have drifted. Cleared on NTP sync.
bool isApproximate();
/// Returns the epoch of the last successful NTP sync (from NVS), or 0 if
/// no sync has ever been recorded.
time_t lastSyncTime();
/// Format the current time for display. Returns "--:--" if the clock was
/// never synced, prefixes with "~" if approximate.
/// When use24h is false, formats as "2:05pm" / "12:30am".
/// Output is written to `buf` (must be at least 16 bytes).
void formatTime(char* buf, size_t bufSize, bool use24h);
/// Format the current time for log timestamps. Returns "HH:MM:SS" if
/// synced, or an empty string if not.
void formatLogTime(char* buf, size_t bufSize);
/// Tear down WiFi cleanly. When skipNtpSync is false (default) and the
/// clock is approximate, performs an opportunistic NTP sync before
/// disconnecting — essentially free since we already have a connection.
void wifiOff(bool skipNtpSync = false);
} // namespace HalClock
+17 -8
View File
@@ -60,26 +60,35 @@ void HalPowerManager::setPowerSaving(bool enabled) {
// Otherwise, no change needed
}
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
void HalPowerManager::startDeepSleep(HalGPIO& gpio, bool keepClockAlive) const {
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
delay(50);
gpio.update();
}
// Pre-sleep routines from the original firmware
// GPIO13 is connected to battery latch MOSFET, we need to make sure it's low during sleep
// Note that this means the MCU will be completely powered off during sleep, including RTC
// GPIO13 is connected to the battery latch MOSFET.
// When keepClockAlive is false (default): GPIO13 goes LOW, the MCU is
// completely powered off during sleep (including the LP timer / RTC memory).
// When keepClockAlive is true: GPIO13 stays HIGH, the MCU remains powered
// at ~3-4 mA so the LP timer keeps running and RTC memory is preserved.
// This allows HalClock to accurately compute elapsed sleep time on wake.
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
// Release any GPIO hold from a previous sleep cycle (keepClockAlive=true leaves GPIO13 held after wake).
// Without this, gpio_set_level() below silently fails and GPIO13 is stuck in its prior state,
// causing the device to enter a sleep/wake loop that requires a hardware reset to escape.
gpio_hold_dis(GPIO_SPIWP);
gpio_deep_sleep_hold_dis();
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
gpio_set_level(GPIO_SPIWP, 0);
gpio_set_level(GPIO_SPIWP, keepClockAlive ? 1 : 0);
esp_sleep_config_gpio_isolate();
gpio_deep_sleep_hold_en();
gpio_hold_en(GPIO_SPIWP);
pinMode(InputManager::POWER_BUTTON_PIN, INPUT_PULLUP);
// Arm the wakeup trigger *after* the button is released
// Note: this is only useful for waking up on USB power. On battery, the MCU will be completely powered off, so the
// power button is hard-wired to briefly provide power to the MCU, waking it up regardless of the wakeup source
// configuration
// Note: when keepClockAlive is false, this is only useful for waking up on USB power. On battery, the MCU will be
// completely powered off, so the power button is hard-wired to briefly provide power to the MCU, waking it up
// regardless of the wakeup source configuration.
// When keepClockAlive is true, this is the actual wakeup mechanism since the MCU stays powered.
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep
esp_deep_sleep_start();
+5 -3
View File
@@ -37,9 +37,11 @@ class HalPowerManager {
// Control CPU frequency for power saving
void setPowerSaving(bool enabled);
// Setup wake up GPIO and enter deep sleep
// Should be called inside main loop() to handle the currentLockMode
void startDeepSleep(HalGPIO& gpio) const;
// Setup wake up GPIO and enter deep sleep.
// When keepClockAlive is true, GPIO13 stays HIGH so the LP timer keeps
// running during sleep (~3-4 mA extra). This allows HalClock to compute
// elapsed sleep time and restore the wall clock accurately on wake.
void startDeepSleep(HalGPIO& gpio, bool keepClockAlive = false) const;
// Get battery percentage (range 0-100)
uint16_t getBatteryPercentage() const;
+21
View File
@@ -4,6 +4,7 @@
#include <FS.h> // need to be included before SdFat.h for compatibility with FS.h's File class
#include <Logging.h>
#include <SDCardManager.h>
#include <SdFat.h>
#include <cassert>
@@ -54,6 +55,23 @@ bool HalStorage::writeFile(const char* path, const String& content) {
bool HalStorage::ensureDirectoryExists(const char* path) { HAL_STORAGE_WRAPPED_CALL(ensureDirectoryExists, path); }
uint64_t HalStorage::sdTotalBytes() const {
StorageLock lock;
return SDCard.sdTotalBytes();
}
uint64_t HalStorage::sdUsedBytes() {
StorageLock lock;
return SDCard.sdUsedBytes();
}
uint64_t HalStorage::sdFreeBytes() {
uint64_t total = sdTotalBytes();
uint64_t used = sdUsedBytes();
if (total <= used) return 0;
return total - used;
}
class HalFile::Impl {
public:
Impl(FsFile&& fsFile) : file(std::move(fsFile)) {}
@@ -147,6 +165,9 @@ int HalFile::read() { HAL_FILE_WRAPPED_CALL(read, ); }
size_t HalFile::write(const void* buf, size_t count) { HAL_FILE_WRAPPED_CALL(write, buf, count); }
size_t HalFile::write(uint8_t b) { HAL_FILE_WRAPPED_CALL(write, b); }
bool HalFile::rename(const char* newPath) { HAL_FILE_WRAPPED_CALL(rename, newPath); }
bool HalFile::getModifyDateTime(uint16_t* pdate, uint16_t* ptime) {
HAL_FILE_WRAPPED_CALL(getModifyDateTime, pdate, ptime);
}
bool HalFile::isDirectory() const { HAL_FILE_FORWARD_CALL(isDirectory, ); } // already thread-safe, no need to wrap
void HalFile::rewindDirectory() { HAL_FILE_WRAPPED_CALL(rewindDirectory, ); }
bool HalFile::close() { HAL_FILE_WRAPPED_CALL(close, ); }
+5
View File
@@ -45,6 +45,10 @@ class HalStorage {
bool openFileForWrite(const char* moduleName, const String& path, HalFile& file);
bool removeDir(const char* path);
uint64_t sdTotalBytes() const;
uint64_t sdUsedBytes();
uint64_t sdFreeBytes();
static HalStorage& getInstance() { return instance; }
class StorageLock; // private class, used internally
@@ -86,6 +90,7 @@ class HalFile : public Print {
size_t write(const void* buf, size_t count);
size_t write(uint8_t b) override;
bool rename(const char* newPath);
bool getModifyDateTime(uint16_t* pdate, uint16_t* ptime);
bool isDirectory() const;
void rewindDirectory();
bool close();
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
//------------------------------------------------------------------------------
// picojpeg - Public domain, Rich Geldreich <richgel99@gmail.com>
//------------------------------------------------------------------------------
#ifndef PICOJPEG_H
#define PICOJPEG_H
#ifdef __cplusplus
extern "C" {
#endif
// Error codes
enum {
PJPG_NO_MORE_BLOCKS = 1,
PJPG_BAD_DHT_COUNTS,
PJPG_BAD_DHT_INDEX,
PJPG_BAD_DHT_MARKER,
PJPG_BAD_DQT_MARKER,
PJPG_BAD_DQT_TABLE,
PJPG_BAD_PRECISION,
PJPG_BAD_HEIGHT,
PJPG_BAD_WIDTH,
PJPG_TOO_MANY_COMPONENTS,
PJPG_BAD_SOF_LENGTH,
PJPG_BAD_VARIABLE_MARKER,
PJPG_BAD_DRI_LENGTH,
PJPG_BAD_SOS_LENGTH,
PJPG_BAD_SOS_COMP_ID,
PJPG_W_EXTRA_BYTES_BEFORE_MARKER,
PJPG_NO_ARITHMITIC_SUPPORT,
PJPG_UNEXPECTED_MARKER,
PJPG_NOT_JPEG,
PJPG_UNSUPPORTED_MARKER,
PJPG_BAD_DQT_LENGTH,
PJPG_TOO_MANY_BLOCKS,
PJPG_UNDEFINED_QUANT_TABLE,
PJPG_UNDEFINED_HUFF_TABLE,
PJPG_NOT_SINGLE_SCAN,
PJPG_UNSUPPORTED_COLORSPACE,
PJPG_UNSUPPORTED_SAMP_FACTORS,
PJPG_DECODE_ERROR,
PJPG_BAD_RESTART_MARKER,
PJPG_ASSERTION_ERROR,
PJPG_BAD_SOS_SPECTRAL,
PJPG_BAD_SOS_SUCCESSIVE,
PJPG_STREAM_READ_ERROR,
PJPG_NOTENOUGHMEM,
PJPG_UNSUPPORTED_COMP_IDENT,
PJPG_UNSUPPORTED_QUANT_TABLE,
PJPG_UNSUPPORTED_MODE, // picojpeg doesn't support progressive JPEG's
};
// Scan types
typedef enum { PJPG_GRAYSCALE, PJPG_YH1V1, PJPG_YH2V1, PJPG_YH1V2, PJPG_YH2V2 } pjpeg_scan_type_t;
typedef struct {
// Image resolution
int m_width;
int m_height;
// Number of components (1 or 3)
int m_comps;
// Total number of minimum coded units (MCU's) per row/col.
int m_MCUSPerRow;
int m_MCUSPerCol;
// Scan type
pjpeg_scan_type_t m_scanType;
// MCU width/height in pixels (each is either 8 or 16 depending on the scan type)
int m_MCUWidth;
int m_MCUHeight;
// m_pMCUBufR, m_pMCUBufG, and m_pMCUBufB are pointers to internal MCU Y or RGB pixel component buffers.
// Each time pjpegDecodeMCU() is called successfully these buffers will be filled with 8x8 pixel blocks of Y or RGB
// pixels. Each MCU consists of (m_MCUWidth/8)*(m_MCUHeight/8) Y/RGB blocks: 1 for greyscale/no subsampling, 2 for
// H1V2/H2V1, or 4 blocks for H2V2 sampling factors. Each block is a contiguous array of 64 (8x8) bytes of a single
// component: either Y for grayscale images, or R, G or B components for color images.
//
// The 8x8 pixel blocks are organized in these byte arrays like this:
//
// PJPG_GRAYSCALE: Each MCU is decoded to a single block of 8x8 grayscale pixels.
// Only the values in m_pMCUBufR are valid. Each 8 bytes is a row of pixels (raster order: left to right, top to
// bottom) from the 8x8 block.
//
// PJPG_H1V1: Each MCU contains is decoded to a single block of 8x8 RGB pixels.
//
// PJPG_YH2V1: Each MCU is decoded to 2 blocks, or 16x8 pixels.
// The 2 RGB blocks are at byte offsets: 0, 64
//
// PJPG_YH1V2: Each MCU is decoded to 2 blocks, or 8x16 pixels.
// The 2 RGB blocks are at byte offsets: 0,
// 128
//
// PJPG_YH2V2: Each MCU is decoded to 4 blocks, or 16x16 pixels.
// The 2x2 block array is organized at byte offsets: 0, 64,
// 128, 192
//
// It is up to the caller to copy or blit these pixels from these buffers into the destination bitmap.
unsigned char* m_pMCUBufR;
unsigned char* m_pMCUBufG;
unsigned char* m_pMCUBufB;
} pjpeg_image_info_t;
typedef unsigned char (*pjpeg_need_bytes_callback_t)(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
// Initializes the decompressor. Returns 0 on success, or one of the above error codes on failure.
// pNeed_bytes_callback will be called to fill the decompressor's internal input buffer.
// If reduce is 1, only the first pixel of each block will be decoded. This mode is much faster because it skips the AC
// dequantization, IDCT and chroma upsampling of every image pixel. Not thread safe.
unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback,
void* pCallback_data, unsigned char reduce);
// Decompresses the file's next MCU. Returns 0 on success, PJPG_NO_MORE_BLOCKS if no more blocks are available, or an
// error code. Must be called a total of m_MCUSPerRow*m_MCUSPerCol times to completely decompress the image. Not thread
// safe.
unsigned char pjpeg_decode_mcu(void);
#ifdef __cplusplus
}
#endif
#endif // PICOJPEG_H
+2 -2
View File
@@ -3,7 +3,7 @@ default_envs = default
extra_configs = platformio.local.ini
[crosspoint]
version = 1.2.0
version = 1.2.5
[base]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
@@ -60,7 +60,7 @@ lib_deps =
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
bblanchon/ArduinoJson @ 7.4.2
ricmoo/QRCode @ 0.0.1
QRCode=symlink://lib/QRCode
bitbank2/PNGdec @ ^1.0.0
bitbank2/JPEGDEC @ ^1.8.0
links2004/WebSockets @ 2.7.3
+137 -16
View File
@@ -4,27 +4,138 @@ import gzip
SRC_DIR = "src"
def strip_js_comments(js: str) -> str:
"""Remove JS comments while preserving string literals and URLs."""
result = []
i = 0
length = len(js)
while i < length:
# String literals — pass through unchanged
if js[i] in ('"', "'", "`"):
quote = js[i]
result.append(js[i])
i += 1
while i < length:
if js[i] == "\\" and i + 1 < length:
result.append(js[i : i + 2])
i += 2
elif js[i] == quote:
result.append(js[i])
i += 1
break
else:
result.append(js[i])
i += 1
# Block comment /* ... */
elif js[i] == "/" and i + 1 < length and js[i + 1] == "*":
end = js.find("*/", i + 2)
i = end + 2 if end != -1 else length
# Line comment // ...
elif js[i] == "/" and i + 1 < length and js[i + 1] == "/":
end = js.find("\n", i)
if end == -1:
i = length
else:
# Keep the newline to preserve line structure
result.append("\n")
i = end + 1
# Regex literal — pass through unchanged
# Heuristic: / after = ( , ; ! & | ? : [ { } ~ ^ or line start
elif js[i] == "/" and i > 0:
# Look back for operator context (skip whitespace)
j = i - 1
while j >= 0 and js[j] in " \t":
j -= 1
if j >= 0 and js[j] in "=(!,;:&|?[{}>~^+-*%":
result.append(js[i])
i += 1
while i < length:
if js[i] == "\\" and i + 1 < length:
result.append(js[i : i + 2])
i += 2
elif js[i] == "/":
result.append(js[i])
i += 1
# Regex flags
while i < length and js[i].isalpha():
result.append(js[i])
i += 1
break
elif js[i] == "[":
# Character class — / doesn't end regex inside []
result.append(js[i])
i += 1
while i < length and js[i] != "]":
if js[i] == "\\" and i + 1 < length:
result.append(js[i : i + 2])
i += 2
else:
result.append(js[i])
i += 1
else:
result.append(js[i])
i += 1
else:
result.append(js[i])
i += 1
else:
result.append(js[i])
i += 1
return "".join(result)
def minify_html(html: str) -> str:
# Tags where whitespace should be preserved
preserve_tags = ['pre', 'code', 'textarea', 'script', 'style']
preserve_regex = '|'.join(preserve_tags)
preserve_tags = ["pre", "code", "textarea"]
script_style_tags = ["script", "style"]
preserve_regex = "|".join(preserve_tags)
script_style_regex = "|".join(script_style_tags)
# Protect preserve blocks with placeholders
# Protect preserve blocks (pre/code/textarea) with placeholders
preserve_blocks = []
def preserve(match):
preserve_blocks.append(match.group(0))
return f"__PRESERVE_BLOCK_{len(preserve_blocks)-1}__"
return f"__PRESERVE_BLOCK_{len(preserve_blocks) - 1}__"
html = re.sub(rf'<({preserve_regex})[\s\S]*?</\1>', preserve, html, flags=re.IGNORECASE)
html = re.sub(
rf"<({preserve_regex})[\s\S]*?</\1>", preserve, html, flags=re.IGNORECASE
)
# Strip JS/CSS comments inside <script>/<style> blocks, then protect them
def strip_and_preserve(match):
tag = match.group(1).lower()
full = match.group(0)
# Extract content between opening and closing tags
open_end = full.index(">") + 1
close_start = full.rindex("<")
opening = full[:open_end]
content = full[open_end:close_start]
closing = full[close_start:]
if tag == "script":
content = strip_js_comments(content)
elif tag == "style":
# Remove CSS comments
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
preserve_blocks.append(f"{opening}{content}{closing}")
return f"__PRESERVE_BLOCK_{len(preserve_blocks) - 1}__"
html = re.sub(
rf"<({script_style_regex})[\s\S]*?</\1>",
strip_and_preserve,
html,
flags=re.IGNORECASE,
)
# Remove HTML comments
html = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL)
html = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
# Collapse all whitespace between tags
html = re.sub(r'>\s+<', '><', html)
html = re.sub(r">\s+<", "><", html)
# Collapse multiple spaces inside tags
html = re.sub(r'\s+', ' ', html)
html = re.sub(r"\s+", " ", html)
# Restore preserved blocks
for i, block in enumerate(preserve_blocks):
@@ -32,6 +143,7 @@ def minify_html(html: str) -> str:
return html.strip()
def sanitize_identifier(name: str) -> str:
"""Sanitize a filename to create a valid C identifier.
@@ -40,12 +152,13 @@ def sanitize_identifier(name: str) -> str:
- Contain only letters, digits, and underscores
"""
# Replace non-alphanumeric characters (including hyphens) with underscores
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name)
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", name)
# Prefix with underscore if starts with a digit
if sanitized and sanitized[0].isdigit():
sanitized = f"_{sanitized}"
return sanitized
for root, _, files in os.walk(SRC_DIR):
for file in files:
if file.endswith(".html") or file.endswith(".js"):
@@ -61,7 +174,7 @@ for root, _, files in os.walk(SRC_DIR):
# Compress with gzip (compresslevel 9 is maximum compression)
# IMPORTANT: we don't use brotli because Firefox doesn't support brotli with insecured context (only supported on HTTPS)
compressed = gzip.compress(processed.encode('utf-8'), compresslevel=9)
compressed = gzip.compress(processed.encode("utf-8"), compresslevel=9)
# Create valid C identifier from filename
# Use appropriate suffix based on file type
@@ -79,15 +192,23 @@ for root, _, files in os.walk(SRC_DIR):
# Write bytes in rows of 16
for i in range(0, len(compressed), 16):
chunk = compressed[i:i+16]
hex_values = ', '.join(f'0x{b:02x}' for b in chunk)
chunk = compressed[i : i + 16]
hex_values = ", ".join(f"0x{b:02x}" for b in chunk)
h.write(f" {hex_values},\n")
h.write(f"}};\n\n")
h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n")
h.write(f"constexpr size_t {base_name}OriginalSize = {len(processed)};\n")
h.write(
f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n"
)
h.write(
f"constexpr size_t {base_name}OriginalSize = {len(processed)};\n"
)
print(f"Generated: {header_path}")
print(f" Original: {len(content)} bytes")
print(f" Minified: {len(processed)} bytes ({100*len(processed)/len(content):.1f}%)")
print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(content):.1f}%)")
print(
f" Minified: {len(processed)} bytes ({100 * len(processed) / len(content):.1f}%)"
)
print(
f" Compressed: {len(compressed)} bytes ({100 * len(compressed) / len(content):.1f}%)"
)
+51 -24
View File
@@ -4,35 +4,60 @@ from PIL import Image
import cairosvg
import io
from svg_utils import fit_inside_canvas, parse_svg_intrinsic_size
threshold = 128
def svg_to_png_bytes(svg_path, width, height):
with open(svg_path, 'rb') as f:
with open(svg_path, "rb") as f:
svg_data = f.read()
png_bytes = cairosvg.svg2png(bytestring=svg_data, output_width=width, output_height=height)
src_w, src_h = parse_svg_intrinsic_size(svg_data)
render_w, render_h = fit_inside_canvas(
src_w or width, src_h or height, width, height
)
png_bytes = cairosvg.svg2png(
bytestring=svg_data, output_width=render_w, output_height=render_h
)
return png_bytes
def center_on_canvas(img, width, height):
if img.mode != "RGBA":
img = img.convert("RGBA")
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255))
x = (width - img.width) // 2
y = (height - img.height) // 2
canvas.paste(img, (x, y), img)
return canvas
def load_image(path, width, height):
ext = os.path.splitext(path)[1].lower()
if ext == '.svg':
if ext == ".svg":
png_bytes = svg_to_png_bytes(path, width, height)
img = Image.open(io.BytesIO(png_bytes))
img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
img = center_on_canvas(img, width, height)
else:
img = Image.open(path)
img = img.convert('RGBA')
img = img.resize((width, height), Image.LANCZOS)
img = Image.open(path).convert("RGBA")
# Keep source aspect ratio and fit inside requested canvas.
fit = img.copy()
fit.thumbnail((width, height), Image.LANCZOS)
img = center_on_canvas(fit, width, height)
# Flatten alpha: paste on white background
background = Image.new('RGBA', img.size, (255, 255, 255, 255))
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
# Rotate 90 degrees counterclockwise
img = img.rotate(90, expand=True)
return img
def image_to_c_array(img, array_name):
# Convert to grayscale, then threshold to get white=1, black=0
# Convert to grayscale
img = img.convert('L')
img = img.convert("L")
width, height = img.size
pixels = list(img.getdata())
packed = []
@@ -44,37 +69,39 @@ def image_to_c_array(img, array_name):
v = pixels[y * width + x + b]
# 1 for white, 0 for black
bit = 1 if v >= threshold else 0
byte |= (bit << (7 - b))
byte |= bit << (7 - b)
packed.append(byte)
# Format as C array
c = f'#pragma once\n#include <cstdint>\n\n'
c += f'// size: {width}x{height}\n'
c += f'static const uint8_t {array_name}[] = {{\n '
c = "#pragma once\n#include <cstdint>\n\n"
c += f"// size: {width}x{height}\n"
c += f"static const uint8_t {array_name}[] = {{\n "
for i, v in enumerate(packed):
c += f'0x{v:02X}, '
c += f"0x{v:02X}, "
if (i + 1) % 16 == 0:
c += '\n '
c = c.rstrip(', \n') + '\n};\n'
c += "\n "
c = c.rstrip(", \n") + "\n};\n"
return c
def main():
if len(sys.argv) < 5:
print('Usage: python convert_image.py input.png output_name width height')
print("Usage: python convert_image.py input.png output_name width height")
sys.exit(1)
input_path, output_name, width, height = sys.argv[1:5]
array_name = output_name.capitalize() + 'Icon'
array_name = output_name.capitalize() + "Icon"
width, height = int(width), int(height)
img = load_image(input_path, width, height)
c_array = image_to_c_array(img, array_name)
# Always save to src/components/icons/[output_name].h relative to project root
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
output_dir = os.path.join(project_root, 'src', 'components', 'icons')
output_dir = os.path.join(project_root, "src", "components", "icons")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f'{output_name}.h')
with open(output_path, 'w') as f:
output_path = os.path.join(output_dir, f"{output_name}.h")
with open(output_path, "w") as f:
f.write(c_array)
print(f'Wrote {output_path}')
print(f"Wrote {output_path}")
if __name__ == '__main__':
main()
if __name__ == "__main__":
main()
+416 -104
View File
@@ -15,24 +15,32 @@ Each YAML file must contain:
The English file is the reference. Missing keys in other languages are
automatically filled from English, with a warning.
Usage:
python gen_i18n.py <translations_dir> <output_dir>
By default the script scans the src/ and lib/ trees for STR_* references and
reports any translation keys that are never used. Pass --strip-unused to
omit those keys from the generated output entirely.
Example:
Usage:
python gen_i18n.py [translations_dir [output_dir]] [options]
Examples:
python gen_i18n.py
python gen_i18n.py lib/I18n/translations lib/I18n/
python gen_i18n.py --strip-unused
python gen_i18n.py --strip-unused --src-dirs src lib/EpdFont
"""
import sys
import os
import re
from pathlib import Path
from typing import List, Dict, Tuple
from typing import Dict, List, Optional, Set, Tuple
# ---------------------------------------------------------------------------
# YAML file reading (simple key: "value" format, no PyYAML dependency)
# ---------------------------------------------------------------------------
def _unescape_yaml_value(raw: str, filepath: str = "", line_num: int = 0) -> str:
"""
Process escape sequences in a YAML value string.
@@ -51,9 +59,7 @@ def _unescape_yaml_value(raw: str, filepath: str = "", line_num: int = 0) -> str
elif nxt == "n":
result.append("\n")
else:
raise ValueError(
f"{filepath}:{line_num}: unknown escape '\\{nxt}'"
)
raise ValueError(f"{filepath}:{line_num}: unknown escape '\\{nxt}'")
i += 2
else:
result.append(raw[i])
@@ -103,9 +109,11 @@ def parse_yaml_file(filepath: str) -> Dict[str, str]:
# Load all languages from a directory of YAML files
# ---------------------------------------------------------------------------
def load_translations(
translations_dir: str,
) -> Tuple[List[str], List[str], List[str], Dict[str, List[str]]]:
verbose: bool = False,
) -> Tuple[List[str], List[str], List[str], Dict[str, List[str]], List[Set[str]]]:
"""
Read every YAML file in *translations_dir* and return:
language_codes e.g. ["EN", "ES", ...]
@@ -138,6 +146,31 @@ def load_translations(
if english_file is None:
raise ValueError("No YAML file with _language_code: EN found")
duplicate_orders: Dict[str, List[str]] = {}
order_to_files: Dict[str, List[str]] = {}
for fname, data in parsed.items():
order = data.get("_order")
if not order:
continue
order_to_files.setdefault(order, []).append(fname)
for order, files in order_to_files.items():
if len(files) > 1:
duplicate_orders[order] = sorted(files)
if duplicate_orders:
duplicate_messages = [
f"_order {order}: {', '.join(files)}"
for order, files in sorted(
duplicate_orders.items(), key=lambda item: int(item[0])
)
]
raise ValueError(
"Duplicate _order values found:\n "
+ "\n ".join(duplicate_messages)
+ "\nEach _order value must be unique to ensure a deterministic language order."
)
# Order: English first, then by _order metadata (falls back to filename)
def sort_key(fname: str) -> Tuple[int, int, str]:
"""English always first (0), then by _order, then by filename."""
@@ -174,16 +207,20 @@ def load_translations(
raise ValueError(f"Invalid C++ identifier in English file: '{key}'")
# Build translations dict, filling missing keys from English
inherited_sets: List[Set[str]] = [set() for _ in ordered_files]
translations: Dict[str, List[str]] = {}
for key in string_keys:
row: List[str] = []
for fname in ordered_files:
for lang_idx, fname in enumerate(ordered_files):
data = parsed[fname]
value = data.get(key, "")
if not value.strip() and fname != english_file:
value = english_data[key]
lang_code = parsed[fname].get("_language_code", fname)
print(f" INFO: '{key}' missing in {lang_code}, using English fallback")
inherited_sets[lang_idx].add(key)
if verbose:
print(
f" INFO: '{key}' missing in {language_codes[lang_idx]}, using English fallback"
)
row.append(value)
translations[key] = row
@@ -195,45 +232,72 @@ def load_translations(
extra = [k for k in data if not k.startswith("_") and k not in english_data]
if extra:
lang_code = data.get("_language_code", fname)
print(f" WARNING: {lang_code} has keys not in English: {', '.join(extra)}")
if verbose:
print(
f" WARNING: {lang_code} has keys not in English: {', '.join(extra)}"
)
print(f"Loaded {len(language_codes)} languages, {len(string_keys)} string keys")
return language_codes, language_names, string_keys, translations
if verbose:
print(f"Loaded {len(language_codes)} languages, {len(string_keys)} string keys")
return language_codes, language_names, string_keys, translations, inherited_sets
# ---------------------------------------------------------------------------
# Unused-string detection
# ---------------------------------------------------------------------------
_GENERATED_FILENAMES: Set[str] = {"I18nKeys.h", "I18nStrings.h", "I18nStrings.cpp"}
def find_used_string_keys(
src_dirs: List[str],
skip_filenames: Optional[Set[str]] = None,
) -> Set[str]:
"""
Scan C/C++ source files under *src_dirs* for STR_* identifiers.
Files whose basename appears in *skip_filenames* are skipped so that
the generated I18n files don't count as "usage" of themselves.
Returns the set of all STR_KEY names that appear at least once.
"""
if skip_filenames is None:
skip_filenames = _GENERATED_FILENAMES
pattern = re.compile(r"\bSTR_[A-Za-z0-9_]+\b")
used: Set[str] = set()
for src_dir in src_dirs:
p = Path(src_dir)
if not p.is_dir():
continue
for f in p.rglob("*"):
if f.suffix not in {".cpp", ".h", ".c"}:
continue
if f.name in skip_filenames:
continue
try:
text = f.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for m in pattern.finditer(text):
used.add(m.group(0))
return used
def report_unused_keys(
string_keys: List[str],
used_keys: Set[str],
) -> List[str]:
"""Return a sorted list of keys from *string_keys* absent in *used_keys*."""
return [k for k in sorted(string_keys) if k not in used_keys]
# ---------------------------------------------------------------------------
# C++ string escaping
# ---------------------------------------------------------------------------
LANG_ABBREVIATIONS = {
"english": "EN",
"español": "ES", "espanol": "ES",
"italiano": "IT",
"svenska": "SV",
"français": "FR", "francais": "FR",
"deutsch": "DE", "german": "DE",
"polski": "PL",
"português": "PT", "portugues": "PT", "português (brasil)": "PO",
"中文": "ZH", "chinese": "ZH",
"日本語": "JA", "japanese": "JA",
"한국어": "KO", "korean": "KO",
"русский": "RU", "russian": "RU",
"العربية": "AR", "arabic": "AR",
"עברית": "HE", "hebrew": "HE",
"فارسی": "FA", "persian": "FA",
"čeština": "CS",
"türkçe": "TR", "turkish": "TR",
"Қазақша": "KK", "kazakh": "KK",
}
def get_lang_abbreviation(lang_code: str, lang_name: str) -> str:
"""Return a 2-letter abbreviation for a language."""
lower = lang_name.lower()
if lower in LANG_ABBREVIATIONS:
return LANG_ABBREVIATIONS[lower]
return lang_code[:2].upper()
def escape_cpp_string(s: str) -> List[str]:
r"""
@@ -267,12 +331,12 @@ def escape_cpp_string(s: str) -> List[str]:
if ch == "\\" and i + 1 < len(s):
nxt = s[i + 1]
if nxt in "ntr\"\\":
if nxt in 'ntr"\\':
current.append(ch + nxt)
i += 2
elif nxt == "x" and i + 3 < len(s):
current.append(s[i : i + 4])
_flush() # segment break after hex
_flush() # segment break after hex
i += 4
else:
current.append("\\\\")
@@ -286,7 +350,7 @@ def escape_cpp_string(s: str) -> List[str]:
else:
for byte in ch.encode("utf-8"):
current.append(f"\\x{byte:02X}")
_flush() # segment break after hex
_flush() # segment break after hex
i += 1
# Flush remaining content
@@ -321,11 +385,11 @@ def format_cpp_string_literal(segments: List[str], indent: str = " ") -> List
last_space = -1
idx = 0
while idx <= MAX_CONTENT_LEN and idx < len(current):
if current[idx] == ' ':
if current[idx] == " ":
last_space = idx
# Handle escapes to step correctly
if current[idx] == '\\':
if current[idx] == "\\":
idx += 2
else:
idx += 1
@@ -340,7 +404,7 @@ def format_cpp_string_literal(segments: List[str], indent: str = " ") -> List
# No space, forced break at MAX_CONTENT_LEN (or slightly less)
cut_at = MAX_CONTENT_LEN
# Don't cut in the middle of an escape sequence
if current[cut_at - 1] == '\\':
if current[cut_at - 1] == "\\":
cut_at -= 1
lines.append(f'{indent}"{current[:cut_at]}"')
@@ -356,6 +420,7 @@ def format_cpp_string_literal(segments: List[str], indent: str = " ") -> List
# Character-set computation
# ---------------------------------------------------------------------------
def compute_character_set(translations: Dict[str, List[str]], lang_index: int) -> str:
"""Return a sorted string of every unique character used in a language."""
chars = set()
@@ -369,11 +434,13 @@ def compute_character_set(translations: Dict[str, List[str]], lang_index: int) -
# Code generators
# ---------------------------------------------------------------------------
def generate_keys_header(
languages: List[str],
language_names: List[str],
string_keys: List[str],
output_path: str,
verbose: bool = False,
) -> None:
"""Generate I18nKeys.h."""
lines: List[str] = [
@@ -381,14 +448,15 @@ def generate_keys_header(
"#include <cstdint>",
"",
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
"// clang-format off",
"",
"// Forward declaration for string arrays",
"// Forward declarations for flat string data blobs and offset tables",
"namespace i18n_strings {",
]
for code, name in zip(languages, language_names):
abbrev = get_lang_abbreviation(code, name)
lines.append(f"extern const char* const STRINGS_{abbrev}[];")
for code in languages:
lines.append(f"extern const char STRINGS_{code}_DATA[];")
lines.append(f"extern const uint16_t OFFSETS_{code}[];")
lines.append("} // namespace i18n_strings")
lines.append("")
@@ -403,6 +471,10 @@ def generate_keys_header(
lines.append("")
# Extern declarations
lines.append("// Language codes (defined in I18nStrings.cpp)")
lines.append("extern const char* const LANGUAGE_CODES[];")
lines.append("")
lines.append("// Language display names (defined in I18nStrings.cpp)")
lines.append("extern const char* const LANGUAGE_NAMES[];")
lines.append("")
@@ -420,17 +492,28 @@ def generate_keys_header(
lines.append("};")
lines.append("")
# getStringArray helper
lines.append("// Helper function to get string array for a language")
lines.append("inline const char* const* getStringArray(Language lang) {")
# LangStrings struct
lines.append("// Holds a flat string blob and its offset table for one language")
lines.append("struct LangStrings {")
lines.append(" const char* data;")
lines.append(" const uint16_t* offsets;")
lines.append("};")
lines.append("")
# getLanguageStrings helper
lines.append("// Helper function to get string data for a language")
lines.append("inline LangStrings getLanguageStrings(Language lang) {")
lines.append(" switch (lang) {")
for code, name in zip(languages, language_names):
abbrev = get_lang_abbreviation(code, name)
for code in languages:
lines.append(f" case Language::{code}:")
lines.append(f" return i18n_strings::STRINGS_{abbrev};")
first_abbrev = get_lang_abbreviation(languages[0], language_names[0])
lines.append(
f" return {{i18n_strings::STRINGS_{code}_DATA, i18n_strings::OFFSETS_{code}}};"
)
first_code = languages[0]
lines.append(" default:")
lines.append(f" return i18n_strings::STRINGS_{first_abbrev};")
lines.append(
f" return {{i18n_strings::STRINGS_{first_code}_DATA, i18n_strings::OFFSETS_{first_code}}};"
)
lines.append(" }")
lines.append("}")
lines.append("")
@@ -451,9 +534,9 @@ def generate_keys_header(
key=lambda i: languages[i],
)
sorted_indices = [english_idx] + rest
comment_names = ", ".join(language_names[i] for i in sorted_indices)
lines.append("// Sorted language indices by code (auto-generated by gen_i18n.py)")
lines.append(f"// Order: {comment_names}")
for rank, idx in enumerate(sorted_indices):
lines.append(f"// {rank:>2}: {languages[idx]:<4} {language_names[idx]}")
lines.append(
"constexpr uint8_t SORTED_LANGUAGE_INDICES[] = {"
f"{', '.join(str(i) for i in sorted_indices)}"
@@ -463,38 +546,36 @@ def generate_keys_header(
lines.append(
"static_assert(sizeof(SORTED_LANGUAGE_INDICES) / sizeof(SORTED_LANGUAGE_INDICES[0]) == getLanguageCount(),"
)
lines.append(
' "SORTED_LANGUAGE_INDICES size mismatch");'
)
lines.append(' "SORTED_LANGUAGE_INDICES size mismatch");')
_write_file(output_path, lines)
_write_file(output_path, lines, verbose)
def generate_strings_header(
languages: List[str],
language_names: List[str],
output_path: str,
verbose: bool = False,
) -> None:
"""Generate I18nStrings.h."""
lines: List[str] = [
"#pragma once",
'#include <string>',
"",
'#include "I18nKeys.h"',
"",
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
"// clang-format off",
"",
"namespace i18n_strings {",
"",
]
for code, name in zip(languages, language_names):
abbrev = get_lang_abbreviation(code, name)
lines.append(f"extern const char* const STRINGS_{abbrev}[];")
for code in languages:
lines.append(f"extern const char STRINGS_{code}_DATA[];")
lines.append(f"extern const uint16_t OFFSETS_{code}[];")
lines.append("")
lines.append("} // namespace i18n_strings")
_write_file(output_path, lines)
_write_file(output_path, lines, verbose)
def generate_strings_cpp(
@@ -503,15 +584,26 @@ def generate_strings_cpp(
string_keys: List[str],
translations: Dict[str, List[str]],
output_path: str,
verbose: bool = False,
) -> None:
"""Generate I18nStrings.cpp."""
lines: List[str] = [
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
"// clang-format off",
'#include "I18nStrings.h"',
"",
"// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.",
"#include <cstddef>",
"",
]
# LANGUAGE_NAMES array
lines.append("// Language codes")
lines.append("const char* const LANGUAGE_CODES[] = {")
for code in languages:
_append_string_entry(lines, code)
lines.append("};")
lines.append("")
# LANGUAGE_NAMES array
lines.append("// Language display names")
lines.append("const char* const LANGUAGE_NAMES[] = {")
@@ -529,18 +621,38 @@ def generate_strings_cpp(
lines.append("};")
lines.append("")
# Per-language string arrays
# Per-language flat string blobs and offset tables
lines.append("namespace i18n_strings {")
lines.append("")
for lang_idx, (code, name) in enumerate(zip(languages, language_names)):
abbrev = get_lang_abbreviation(code, name)
lines.append(f"const char* const STRINGS_{abbrev}[] = {{")
for lang_idx, code in enumerate(languages):
lang_strings = [translations[key][lang_idx] for key in string_keys]
for key in string_keys:
text = translations[key][lang_idx]
_append_string_entry(lines, text)
# Precompute byte offsets (UTF-8 encoded, +1 per string for null terminator)
offsets: List[int] = []
current_offset = 0
for s in lang_strings:
offsets.append(current_offset)
current_offset += len(s.encode("utf-8")) + 1
if current_offset > 65535:
raise ValueError(
f"Language {code}: total string data ({current_offset} bytes) "
"exceeds uint16_t offset range (65535)"
)
# Flat string data blob — all strings concatenated with \0 separators.
lines.append(f"const char STRINGS_{code}_DATA[] =")
for text in lang_strings:
_append_string_data_entry(lines, text)
lines.append(";")
lines.append("")
# Offset table — one uint16_t per StrId
lines.append(f"const uint16_t OFFSETS_{code}[] = {{")
chunk_size = 12
for i in range(0, len(offsets), chunk_size):
chunk = offsets[i : i + chunk_size]
lines.append(" " + ", ".join(str(o) for o in chunk) + ",")
lines.append("};")
lines.append("")
@@ -549,25 +661,108 @@ def generate_strings_cpp(
# Compile-time size checks
lines.append("// Compile-time validation of array sizes")
for code, name in zip(languages, language_names):
abbrev = get_lang_abbreviation(code, name)
for code in languages:
lines.append(
f"static_assert(sizeof(i18n_strings::STRINGS_{abbrev}) "
f"/ sizeof(i18n_strings::STRINGS_{abbrev}[0]) =="
f"static_assert(sizeof(i18n_strings::OFFSETS_{code}) "
f"/ sizeof(i18n_strings::OFFSETS_{code}[0]) =="
)
lines.append(" static_cast<size_t>(StrId::_COUNT),")
lines.append(f' "STRINGS_{abbrev} size mismatch");')
lines.append(f' "OFFSETS_{code} size mismatch");')
_write_file(output_path, lines)
_write_file(output_path, lines, verbose)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _append_string_entry(
lines: List[str], text: str, comment: str = ""
def _print_language_table(
language_codes: List[str],
language_names: List[str],
inherited_sets: List[Set[str]],
string_keys: List[str],
unused_keys: Set[str],
data_sizes: List[int],
) -> None:
"""Print a per-language summary table."""
total = len(string_keys)
headers = ("Language", "Code", "Own", "Fallback", "Unused", "Data (B)")
rows = []
for code, name, inherited, size in zip(
language_codes, language_names, inherited_sets, data_sizes
):
own = total - len(inherited)
fallback = len(inherited)
# strings this language translated but the code never calls
unused = len(unused_keys - inherited)
rows.append((name, code, str(own), str(fallback), str(unused), str(size)))
# EN first, then alphabetically by ISO code
rows.sort(key=lambda r: (0 if r[1] == "EN" else 1, r[1]))
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
col_widths[i] = max(col_widths[i], len(cell))
fmt = " ".join(f"{{:<{w}}}" for w in col_widths)
sep = " ".join("-" * w for w in col_widths)
def _safe_print(line: str) -> None:
print(
line.encode(sys.stdout.encoding or "utf-8", errors="replace").decode(
sys.stdout.encoding or "utf-8", errors="replace"
)
)
_safe_print(fmt.format(*headers))
_safe_print(sep)
for row in rows:
_safe_print(fmt.format(*row))
used = total - len(unused_keys)
total_size = sum(data_sizes)
n_lang = len(rows)
n_keys = len(string_keys)
# Current layout: uint16_t offset table (2 B per string per language)
offset_table_size = n_lang * n_keys * 2
current_total = total_size + offset_table_size
# Previous layout: const char* pointer array (4 B per string per language)
old_pointer_table_size = n_lang * n_keys * 4
old_total = total_size + old_pointer_table_size
saved = old_total - current_total
print(
f"\n Total: {total} | Used in code: {used} | Never used: {len(unused_keys)}"
)
print(
f" Flash (now): {total_size:>7,} B strings + {offset_table_size:>6,} B offset tables (uint16_t)"
f" = {current_total:>7,} B"
)
print(
f" Flash (before): {total_size:>7,} B strings + {old_pointer_table_size:>6,} B pointer tables (ptr32)"
f" = {old_total:>7,} B"
)
print(f" Saved by offset tables: {saved:,} B")
def _append_string_data_entry(lines: List[str], text: str) -> None:
"""
Escape *text*, append a \\0 null separator, and format as indented C++
string literal lines for inclusion in a flat char data array blob.
"""
segments = escape_cpp_string(text)
# Append the null entry separator to the last segment
if segments and segments[-1] != "":
segments[-1] += "\\0"
elif segments:
segments[-1] = "\\0"
else:
segments = ["\\0"]
lines.extend(format_cpp_string_literal(segments))
def _append_string_entry(lines: List[str], text: str, comment: str = "") -> None:
"""Escape *text*, format as indented C++ lines, append comma (and optional comment)."""
segments = escape_cpp_string(text)
formatted = format_cpp_string_literal(segments)
@@ -576,21 +771,30 @@ def _append_string_entry(
lines.extend(formatted)
def _write_file(path: str, lines: List[str]) -> None:
def _write_file(path: str, lines: List[str], verbose: bool = False) -> None:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(lines))
f.write("\n")
print(f"Generated: {path}")
if verbose:
print(f"Generated: {path}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main(translations_dir=None, output_dir=None) -> None:
def main(
translations_dir: Optional[str] = None,
output_dir: Optional[str] = None,
src_dirs: Optional[List[str]] = None,
strip_unused: bool = False,
verbose: bool = False,
) -> None:
# Default paths (relative to project root)
default_translations_dir = "lib/I18n/translations"
default_output_dir = "lib/I18n/"
default_src_dirs = ["src", "lib"]
if translations_dir is None or output_dir is None:
if len(sys.argv) == 3:
@@ -601,6 +805,8 @@ def main(translations_dir=None, output_dir=None) -> None:
translations_dir = default_translations_dir
output_dir = default_output_dir
if src_dirs is None:
src_dirs = default_src_dirs
if not os.path.isdir(translations_dir):
print(f"Error: Translations directory not found: {translations_dir}")
@@ -610,26 +816,91 @@ def main(translations_dir=None, output_dir=None) -> None:
print(f"Error: Output directory not found: {output_dir}")
sys.exit(1)
print(f"Reading translations from: {translations_dir}")
print(f"Output directory: {output_dir}")
print()
if verbose:
print(f"Reading translations from: {translations_dir}")
print(f"Output directory: {output_dir}")
print()
try:
languages, language_names, string_keys, translations = load_translations(
translations_dir
languages, language_names, string_keys, translations, inherited_sets = (
load_translations(translations_dir, verbose)
)
# --- Unused-string detection ---
scan_dirs = [d for d in src_dirs if os.path.isdir(d)]
if scan_dirs:
used_keys = find_used_string_keys(scan_dirs)
unused_set = set(report_unused_keys(string_keys, used_keys))
else:
used_keys = set(string_keys)
unused_set = set()
# --- Missing-string detection (used in code but absent from English) ---
missing_keys = sorted(used_keys - set(string_keys))
if missing_keys:
print(
f"\n CRITICAL: {len(missing_keys)} string(s) used in source but missing from english.yaml:"
)
for key in missing_keys:
print(f" - {key}")
print()
sys.exit(1)
# Compute per-language data blob sizes:
# sum of UTF-8 byte length + 1 (null terminator) per string
data_sizes = [
sum(len(translations[k][i].encode("utf-8")) + 1 for k in string_keys)
for i in range(len(languages))
]
_print_language_table(
languages,
language_names,
inherited_sets,
string_keys,
unused_set,
data_sizes,
)
print()
if verbose and unused_set:
print(f" Unused keys ({len(unused_set)}):")
for key in sorted(unused_set):
print(f" - {key}")
print()
if unused_set and strip_unused:
string_keys = [k for k in string_keys if k not in unused_set]
translations = {
k: v for k, v in translations.items() if k not in unused_set
}
inherited_sets = [s - unused_set for s in inherited_sets]
print(f" Stripping {len(unused_set)} unused string(s) from output.")
out = Path(output_dir)
generate_keys_header(languages, language_names, string_keys, str(out / "I18nKeys.h"))
generate_strings_header(languages, language_names, str(out / "I18nStrings.h"))
generate_keys_header(
languages, language_names, string_keys, str(out / "I18nKeys.h"), verbose
)
generate_strings_header(
languages, language_names, str(out / "I18nStrings.h"), verbose
)
generate_strings_cpp(
languages, language_names, string_keys, translations, str(out / "I18nStrings.cpp")
languages,
language_names,
string_keys,
translations,
str(out / "I18nStrings.cpp"),
verbose,
)
print()
print("Code generation complete!")
print("Code generation complete!")
print(f" Languages: {len(languages)}")
print(f" String keys: {len(string_keys)}")
if unused_set and not strip_unused:
print(
f" Unused keys: {len(unused_set)} (pass --strip-unused to remove them)"
)
except Exception as e:
print(f"\nError: {e}")
@@ -637,11 +908,52 @@ def main(translations_dir=None, output_dir=None) -> None:
if __name__ == "__main__":
main()
import argparse
parser = argparse.ArgumentParser(
description="Generate I18n C++ files from per-language YAML translations."
)
parser.add_argument(
"translations_dir",
nargs="?",
default=None,
help="Path to the translations directory (default: lib/I18n/translations)",
)
parser.add_argument(
"output_dir",
nargs="?",
default=None,
help="Path to the output directory (default: lib/I18n/)",
)
parser.add_argument(
"--src-dirs",
nargs="+",
metavar="DIR",
default=None,
help="Source directories to scan for STR_* usage (default: src lib)",
)
parser.add_argument(
"--strip-unused",
action="store_true",
help="Remove unused STR_* keys from the generated output",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Print per-key INFO/WARNING messages and file generation details",
)
args = parser.parse_args()
main(
args.translations_dir,
args.output_dir,
args.src_dirs,
args.strip_unused,
args.verbose,
)
else:
try:
Import("env")
print("Running i18n generation script from PlatformIO...")
main()
main(strip_unused=True)
except NameError:
pass
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Generate a test EPUB for <br> section-break rendering.
Tests that a bare <br> element between paragraphs produces a visible blank-line
gap (section separator), while a <br> inside a paragraph only produces a line
break with no extra spacing.
Cases covered:
1. Standalone <br> between paragraphs (section break — must show gap).
2. <br class="..."> with a CSS class (calibre-style section break).
3. Multiple consecutive <br> elements (each adds one line of spacing).
4. Inline <br> inside a <p> (line break only — no extra gap).
5. <br> at start of chapter (no gap before first paragraph).
6. <br> following a heading.
Visual verification instructions are embedded as the first paragraph of each
chapter so a human tester can confirm the expected result on device.
"""
import os
import zipfile
from pathlib import Path
OUTPUT_DIR = Path(__file__).parent.parent / "test" / "epubs"
OUTPUT_PATH = OUTPUT_DIR / "test_br_section_break.epub"
FILLER = (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod "
"tempor incididunt ut labore et dolore magna aliqua."
)
CSS = """\
body { margin: 0; padding: 0; }
p { margin-top: 1pt; margin-bottom: 0; text-indent: 1em; text-align: justify; }
h1 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; }
h2 { text-align: center; margin-top: 0.5em; margin-bottom: 0.5em; }
.section-br { display: block; }
"""
def xhtml(title, body):
return f"""\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{title}</title>
<link rel="stylesheet" type="text/css" href="styles/test.css"/>
</head>
<body>
{body}
</body>
</html>"""
# ---------------------------------------------------------------------------
# Chapter 1 — standalone <br> between paragraphs
# ---------------------------------------------------------------------------
ch1 = xhtml("Ch1: Standalone br", f"""
<h1>Ch 1: Standalone &lt;br&gt; Section Break</h1>
<p>PASS: A visible blank-line gap should appear between the two sections below.</p>
<p>{FILLER}</p>
<br/>
<p>{FILLER}</p>
<p>PASS: The gap above should be roughly one line tall (same as a blank line).</p>
""")
# ---------------------------------------------------------------------------
# Chapter 2 — <br class="..."> CSS-classed section break (calibre style)
# ---------------------------------------------------------------------------
ch2 = xhtml("Ch2: Classed br", f"""
<h1>Ch 2: &lt;br class="section-br"/&gt;</h1>
<p>PASS: A blank-line gap should appear between the two sections below, identical
to Ch 1, even though the &lt;br&gt; carries a CSS class.</p>
<p>{FILLER}</p>
<br class="section-br"/>
<p>{FILLER}</p>
""")
# ---------------------------------------------------------------------------
# Chapter 3 — multiple consecutive <br> elements
# ---------------------------------------------------------------------------
ch3 = xhtml("Ch3: Multiple br", f"""
<h1>Ch 3: Multiple Consecutive &lt;br&gt; Elements</h1>
<p>PASS: Two blank lines should appear between the sections (one per &lt;br&gt;).</p>
<p>{FILLER}</p>
<br/>
<br/>
<p>{FILLER}</p>
<p>PASS: Three blank lines should appear below.</p>
<p>{FILLER}</p>
<br/>
<br/>
<br/>
<p>{FILLER}</p>
""")
# ---------------------------------------------------------------------------
# Chapter 4 — inline <br> inside a paragraph (line break, NOT a gap)
# ---------------------------------------------------------------------------
ch4 = xhtml("Ch4: Inline br", """
<h1>Ch 4: Inline &lt;br&gt; Inside a Paragraph</h1>
<p>PASS: The two lines below should be adjacent with NO extra gap between them.
The &lt;br&gt; is inside the paragraph and must only break the line.</p>
<p>First line of the paragraph.<br/>Second line of the paragraph — directly below, no gap.</p>
<p>PASS: Above should look like two closely-spaced lines, not like two paragraphs
separated by a blank line.</p>
""")
# ---------------------------------------------------------------------------
# Chapter 5 — <br> following a heading
# ---------------------------------------------------------------------------
ch5 = xhtml("Ch5: br after heading", f"""
<h1>Ch 5: &lt;br&gt; After a Heading</h1>
<br/>
<p>PASS: There should be a blank-line gap between the heading above and this paragraph.</p>
<p>{FILLER}</p>
<h2>Section heading</h2>
<br/>
<p>PASS: There should be a blank-line gap between the section heading and this paragraph.</p>
""")
# ---------------------------------------------------------------------------
# Chapter 6 — <br> at very start of chapter (no spurious leading gap)
# ---------------------------------------------------------------------------
ch6 = xhtml("Ch6: br at chapter start", f"""<br/>
<h1>Ch 6: &lt;br&gt; at Chapter Start</h1>
<p>PASS: This heading should appear near the top of the page with no large blank
area above it despite the &lt;br&gt; being the very first element.</p>
<p>{FILLER}</p>
""")
CHAPTERS = [
("ch1", "chapter1.xhtml", "Chapter 1: Standalone br", ch1),
("ch2", "chapter2.xhtml", "Chapter 2: Classed br", ch2),
("ch3", "chapter3.xhtml", "Chapter 3: Multiple br", ch3),
("ch4", "chapter4.xhtml", "Chapter 4: Inline br", ch4),
("ch5", "chapter5.xhtml", "Chapter 5: br after heading", ch5),
("ch6", "chapter6.xhtml", "Chapter 6: br at start", ch6),
]
def build_epub(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as epub:
# mimetype must be first and uncompressed
epub.writestr("mimetype", "application/epub+zip",
compress_type=zipfile.ZIP_STORED)
epub.writestr("META-INF/container.xml", """\
<?xml version="1.0" encoding="UTF-8"?>
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
<rootfiles>
<rootfile full-path="OEBPS/content.opf"
media-type="application/oebps-package+xml"/>
</rootfiles>
</container>""")
epub.writestr("OEBPS/styles/test.css", CSS)
manifest_items = []
spine_items = []
nav_items = []
for (chid, chfile, chtitle, chcontent) in CHAPTERS:
epub.writestr(f"OEBPS/{chfile}", chcontent)
manifest_items.append(
f' <item id="{chid}" href="{chfile}" media-type="application/xhtml+xml"/>')
spine_items.append(f' <itemref idref="{chid}"/>')
nav_items.append(f' <li><a href="{chfile}">{chtitle}</a></li>')
manifest_items.append(
' <item id="nav" href="nav.xhtml" '
'media-type="application/xhtml+xml" properties="nav"/>')
content_opf = f"""\
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">test-epub-br-section-break</dc:identifier>
<dc:title>Test: br Section Break</dc:title>
<dc:language>en</dc:language>
</metadata>
<manifest>
{chr(10).join(manifest_items)}
</manifest>
<spine>
{chr(10).join(spine_items)}
</spine>
</package>"""
epub.writestr("OEBPS/content.opf", content_opf)
nav_xhtml = f"""\
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Table of Contents</title></head>
<body>
<nav epub:type="toc">
<ol>
{chr(10).join(nav_items)}
</ol>
</nav>
</body>
</html>"""
epub.writestr("OEBPS/nav.xhtml", nav_xhtml)
print(f"Generated: {path}")
if __name__ == "__main__":
build_epub(OUTPUT_PATH)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
Generate lib/Weather/WeatherIcons48.h from SVG sources.
By default, this script loads SVG files from assets/weather-icons/svg.
Use --fetch to download missing files from erikflowers/weather-icons.
"""
import argparse
import io
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from PIL import Image
from svg_utils import fit_inside_canvas, parse_svg_intrinsic_size
try:
import cairosvg # type: ignore
except ImportError:
cairosvg = None
SIZE = 64
# Higher threshold slightly thickens dark icon strokes after antialiasing.
THRESHOLD = 160
# Keep zero margin so rendered glyphs can use the full 64x64 canvas.
CONTENT_MARGIN = 0
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SVG_DIR = PROJECT_ROOT / "assets" / "weather-icons" / "svg"
DEFAULT_OUT = PROJECT_ROOT / "lib" / "Weather" / "WeatherIconsLarge.h"
UPSTREAM_BASE = "https://raw.githubusercontent.com/erikflowers/weather-icons/master/svg"
RESVG_ZIP_URL = (
"https://github.com/linebender/resvg/releases/latest/download/resvg-win64.zip"
)
RESVG_EXE = PROJECT_ROOT / ".cache" / "resvg" / "resvg.exe"
ICON_SOURCES = {
"WI_LARGE_CLEAR_DAY": "wi-day-sunny.svg",
"WI_LARGE_CLEAR_NIGHT": "wi-night-clear.svg",
"WI_LARGE_PARTLY_CLOUDY_DAY": "wi-day-cloudy.svg",
"WI_LARGE_PARTLY_CLOUDY_NIGHT": "wi-night-alt-cloudy.svg",
"WI_LARGE_OVERCAST": "wi-cloudy.svg",
"WI_LARGE_FOG": "wi-fog.svg",
"WI_LARGE_DRIZZLE": "wi-sprinkle.svg",
"WI_LARGE_RAIN": "wi-rain.svg",
"WI_LARGE_SNOW": "wi-snow.svg",
"WI_LARGE_THUNDERSTORM": "wi-thunderstorm.svg",
}
def ensure_resvg_binary():
if shutil.which("resvg"):
return Path(shutil.which("resvg"))
if RESVG_EXE.exists():
return RESVG_EXE
RESVG_EXE.parent.mkdir(parents=True, exist_ok=True)
archive_path = RESVG_EXE.parent / "resvg.zip"
with urllib.request.urlopen(RESVG_ZIP_URL) as response:
archive_path.write_bytes(response.read())
with zipfile.ZipFile(archive_path, "r") as zf:
zf.extractall(RESVG_EXE.parent)
found = list(RESVG_EXE.parent.rglob("resvg.exe"))
if not found:
raise RuntimeError("resvg.exe not found after extraction")
if found[0] != RESVG_EXE:
RESVG_EXE.write_bytes(found[0].read_bytes())
return RESVG_EXE
def render_svg_with_resvg(svg_data, render_w, render_h):
exe = ensure_resvg_binary()
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
in_svg = tmp_path / "icon.svg"
out_png = tmp_path / "icon.png"
in_svg.write_bytes(svg_data)
cmd = [
str(exe),
"--width",
str(render_w),
"--height",
str(render_h),
str(in_svg),
str(out_png),
]
subprocess.run(cmd, check=True, capture_output=True)
return out_png.read_bytes()
def render_svg_contain(svg_data, width, height):
src_w, src_h = parse_svg_intrinsic_size(svg_data)
render_w, render_h = fit_inside_canvas(
src_w or width, src_h or height, width, height
)
# Render larger first so trimming and re-fit preserve detail quality.
oversample = 4
render_w *= oversample
render_h *= oversample
if cairosvg is not None:
png_bytes = cairosvg.svg2png(
bytestring=svg_data, output_width=render_w, output_height=render_h
)
else:
png_bytes = render_svg_with_resvg(svg_data, render_w, render_h)
icon = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
# Trim transparent/empty margins so symbols use available icon area better.
alpha_bbox = icon.split()[3].getbbox()
if alpha_bbox is not None:
icon = icon.crop(alpha_bbox)
max_w = max(1, width - 2 * CONTENT_MARGIN)
max_h = max(1, height - 2 * CONTENT_MARGIN)
icon.thumbnail((max_w, max_h), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255))
off_x = (width - icon.width) // 2
off_y = (height - icon.height) // 2
canvas.paste(icon, (off_x, off_y), icon)
# Flatten alpha on white and convert to monochrome-friendly grayscale.
flat = Image.new("RGBA", canvas.size, (255, 255, 255, 255))
flat.paste(canvas, mask=canvas.split()[3])
return flat.convert("L")
def image_to_packed_bits(img):
width, height = img.size
pixels = img.tobytes()
packed = []
for y in range(height):
for x in range(0, width, 8):
out = 0
for b in range(8):
px = x + b
lum = pixels[y * width + px] if px < width else 255
# 1-bit means white/clear (not drawn), 0-bit means black/drawn.
bit = 1 if lum >= THRESHOLD else 0
out |= bit << (7 - b)
packed.append(out)
return packed
def format_array(name, data):
lines = []
per_line = 16
for i in range(0, len(data), per_line):
chunk = data[i : i + per_line]
lines.append(" " + ", ".join(f"0x{v:02X}" for v in chunk) + ",")
body = "\n".join(lines)
return f"static const uint8_t {name}[] = {{\n{body}\n}};\n"
def ensure_svg(path, fetch):
if path.exists():
return
if not fetch:
raise FileNotFoundError(f"Missing SVG: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
url = f"{UPSTREAM_BASE}/{path.name}"
with urllib.request.urlopen(url) as response:
data = response.read()
path.write_bytes(data)
def generate(svg_dir, output_path, fetch):
arrays = []
for symbol, filename in ICON_SOURCES.items():
svg_path = svg_dir / filename
ensure_svg(svg_path, fetch)
svg_data = svg_path.read_bytes()
img = render_svg_contain(svg_data, SIZE, SIZE)
packed = image_to_packed_bits(img)
arrays.append(format_array(symbol, packed))
header = [
"#pragma once",
"#include <cstdint>",
"",
"// Generated from erikflowers/weather-icons SVGs.",
f"// {SIZE}x{SIZE}, 1-bit, MSB-first, row-major.",
"// Regenerate with: python scripts/generate_weather_icons.py --fetch",
"// clang-format off",
f"constexpr int WEATHER_ICON_SIZE = {SIZE}; // Large icons for weather",
"",
]
header.extend(arrays)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text("\n".join(header) + "\n", encoding="utf-8")
def main():
parser = argparse.ArgumentParser(
description="Generate WeatherIconsLarge.h from SVG files"
)
parser.add_argument(
"--svg-dir",
type=Path,
default=DEFAULT_SVG_DIR,
help="Directory containing source SVG files",
)
parser.add_argument(
"--output", type=Path, default=DEFAULT_OUT, help="Output header path"
)
parser.add_argument(
"--fetch", action="store_true", help="Fetch missing SVG files from upstream"
)
args = parser.parse_args()
generate(args.svg_dir, args.output, args.fetch)
rel_out = os.path.relpath(args.output, PROJECT_ROOT)
print(f"Wrote {rel_out}")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
from defusedxml import ElementTree as ET
def parse_svg_intrinsic_size(svg_data):
try:
root = ET.fromstring(svg_data)
except ET.ParseError:
return None, None
viewbox = root.get("viewBox") or root.get("viewbox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) == 4:
try:
vb_w = float(parts[2])
vb_h = float(parts[3])
if vb_w > 0 and vb_h > 0:
return vb_w, vb_h
except ValueError:
pass
def parse_len(value):
if not value:
return None
cleaned = "".join(ch for ch in value if ch.isdigit() or ch in ".-")
if not cleaned:
return None
try:
parsed = float(cleaned)
return parsed if parsed > 0 else None
except ValueError:
return None
w = parse_len(root.get("width"))
h = parse_len(root.get("height"))
return w, h
def fit_inside_canvas(src_w, src_h, dst_w, dst_h):
if src_w <= 0 or src_h <= 0 or dst_w <= 0 or dst_h <= 0:
return dst_w, dst_h
src_ratio = src_w / src_h
dst_ratio = dst_w / dst_h
if src_ratio >= dst_ratio:
fit_w = dst_w
fit_h = max(1, round(fit_w / src_ratio))
else:
fit_h = dst_h
fit_w = max(1, round(fit_h * src_ratio))
return fit_w, fit_h
+35
View File
@@ -24,6 +24,7 @@ class CrossPointSettings {
COVER = 3,
BLANK = 4,
COVER_CUSTOM = 5,
OVERLAY = 6,
SLEEP_SCREEN_MODE_COUNT
};
enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT };
@@ -137,12 +138,35 @@ class CrossPointSettings {
// Image rendering in EPUB reader
enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT };
// Timezone options (POSIX TZ rules for DST support)
enum TIMEZONE {
TZ_UTC = 0,
TZ_CET = 1,
TZ_EET = 2,
TZ_MSK = 3,
TZ_UTC_PLUS4 = 4,
TZ_IST = 5,
TZ_UTC_PLUS7 = 6,
TZ_UTC_PLUS8 = 7,
TZ_UTC_PLUS9 = 8,
TZ_AEST = 9,
TZ_NZST = 10,
TZ_UTC_MINUS3 = 11,
TZ_EST = 12,
TZ_CST = 13,
TZ_MST = 14,
TZ_PST = 15,
TIMEZONE_COUNT
};
// Sleep screen settings
uint8_t sleepScreen = DARK;
// Sleep screen cover mode settings
uint8_t sleepScreenCoverMode = FIT;
// Sleep screen cover filter
uint8_t sleepScreenCoverFilter = NO_FILTER;
// Apply information overlay with reading progress on sleep cover
uint8_t sleepCoverOverlay = 0;
// Status bar settings (statusBar retained for migration only)
uint8_t statusBar = FULL;
uint8_t statusBarChapterPageCount = 1;
@@ -199,6 +223,17 @@ class CrossPointSettings {
uint8_t showHiddenFiles = 0;
// Image rendering mode in EPUB reader
uint8_t imageRendering = IMAGES_DISPLAY;
// Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled)
uint8_t syntheticTocFallback = 1;
// Show clock in the reader status bar
uint8_t statusBarClock = 0;
// Clock format: 0 = 24h (14:00), 1 = 12h (2:00pm)
uint8_t clockFormat12h = 0;
// Timezone selection (applies POSIX TZ rules for DST)
uint8_t timeZone = TZ_UTC;
// Use clock and keep the LP timer running during deep sleep (GPIO13 HIGH)
// so time can be accurately restored on wake. Increases sleep current by ~3-4 mA.
uint8_t useClock = 0;
~CrossPointSettings() = default;
+42
View File
@@ -5,6 +5,7 @@
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <cctype>
#include <cstring>
#include <string>
@@ -248,6 +249,7 @@ bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore& store, const char* js
bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) {
JsonDocument doc;
doc["lastConnectedSsid"] = store.getLastConnectedSsid();
doc["lastKnownMacAddress"] = store.getLastKnownMacAddress();
JsonArray arr = doc["credentials"].to<JsonArray>();
for (const auto& cred : store.getCredentials()) {
@@ -272,6 +274,33 @@ bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool
store.lastConnectedSsid = doc["lastConnectedSsid"] | std::string("");
const auto isValidDashedMac = [](const std::string& value) -> bool {
if (value.empty()) {
return true;
}
if (value.size() != 17) {
return false;
}
for (size_t i = 0; i < value.size(); i++) {
if (i == 2 || i == 5 || i == 8 || i == 11 || i == 14) {
if (value[i] != '-') {
return false;
}
} else if (!std::isxdigit(static_cast<unsigned char>(value[i]))) {
return false;
}
}
return true;
};
store.lastKnownMacAddress = doc["lastKnownMacAddress"] | std::string("");
if (!isValidDashedMac(store.lastKnownMacAddress)) {
store.lastKnownMacAddress.clear();
if (needsResave) {
*needsResave = true;
}
}
store.credentials.clear();
JsonArray arr = doc["credentials"].as<JsonArray>();
for (JsonObject obj : arr) {
@@ -301,7 +330,10 @@ bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char*
obj["path"] = book.path;
obj["title"] = book.title;
obj["author"] = book.author;
obj["series"] = book.series;
obj["coverBmpPath"] = book.coverBmpPath;
obj["embeddedStyleOverride"] = book.embeddedStyleOverride;
obj["imageRenderingOverride"] = book.imageRenderingOverride;
}
String json;
@@ -319,13 +351,23 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json)
store.recentBooks.clear();
JsonArray arr = doc["books"].as<JsonArray>();
auto clampInt8 = [](int value, int minValue, int maxValue, int8_t fallback) -> int8_t {
if (value < minValue || value > maxValue) {
return fallback;
}
return static_cast<int8_t>(value);
};
for (JsonObject obj : arr) {
if (store.getCount() >= 10) break;
RecentBook book;
book.path = obj["path"] | std::string("");
book.title = obj["title"] | std::string("");
book.author = obj["author"] | std::string("");
book.series = obj["series"] | std::string("");
book.coverBmpPath = obj["coverBmpPath"] | std::string("");
book.embeddedStyleOverride = clampInt8(obj["embeddedStyleOverride"] | -1, -1, 1, -1);
book.imageRenderingOverride = clampInt8(obj["imageRenderingOverride"] | -1, -1, 2, -1);
store.recentBooks.push_back(book);
}
+84 -25
View File
@@ -21,16 +21,22 @@ constexpr int MAX_RECENT_BOOKS = 10;
RecentBooksStore RecentBooksStore::instance;
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
const std::string& series, const std::string& coverBmpPath) {
int8_t embeddedStyleOverride = -1;
int8_t imageRenderingOverride = -1;
// Remove existing entry if present
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it != recentBooks.end()) {
embeddedStyleOverride = it->embeddedStyleOverride;
imageRenderingOverride = it->imageRenderingOverride;
recentBooks.erase(it);
}
// Add to front
recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath});
recentBooks.insert(recentBooks.begin(),
{path, title, author, series, coverBmpPath, embeddedStyleOverride, imageRenderingOverride});
// Trim to max size
if (recentBooks.size() > MAX_RECENT_BOOKS) {
@@ -40,19 +46,51 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
saveToFile();
}
void RecentBooksStore::removeBook(const std::string& path) {
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it != recentBooks.end()) {
recentBooks.erase(it);
saveToFile();
}
}
void RecentBooksStore::updateBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
const std::string& series, const std::string& coverBmpPath) {
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it != recentBooks.end()) {
RecentBook& book = *it;
book.title = title;
book.author = author;
book.series = series;
book.coverBmpPath = coverBmpPath;
saveToFile();
}
}
RecentBook RecentBooksStore::getBookByPath(const std::string& path) const {
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it != recentBooks.end()) {
return *it;
}
return RecentBook{};
}
bool RecentBooksStore::setReaderOverrides(const std::string& path, const int8_t embeddedStyleOverride,
const int8_t imageRenderingOverride) {
auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
if (it == recentBooks.end()) {
return false;
}
it->embeddedStyleOverride = embeddedStyleOverride;
it->imageRenderingOverride = imageRenderingOverride;
return saveToFile();
}
bool RecentBooksStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
return JsonSettingsIO::saveRecentBooks(*this, RECENT_BOOKS_FILE_JSON);
@@ -73,17 +111,18 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
if (FsHelpers::hasEpubExtension(lastBookFileName)) {
Epub epub(path, "/.crosspoint");
epub.load(false, true);
return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()};
std::string series = epub.getSeries();
if (!series.empty() && !epub.getSeriesIndex().empty()) series += " #" + epub.getSeriesIndex();
return RecentBook{path, epub.getTitle(), epub.getAuthor(), series, epub.getThumbBmpPath()};
} else if (FsHelpers::hasXtcExtension(lastBookFileName)) {
// Handle XTC file
Xtc xtc(path, "/.crosspoint");
if (xtc.load()) {
return RecentBook{path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()};
return RecentBook{path, xtc.getTitle(), xtc.getAuthor(), "", xtc.getThumbBmpPath()};
}
} else if (FsHelpers::hasTxtExtension(lastBookFileName) || FsHelpers::hasMarkdownExtension(lastBookFileName)) {
return RecentBook{path, lastBookFileName, "", ""};
return RecentBook{path, lastBookFileName, "", "", ""};
}
return RecentBook{path, "", "", ""};
return RecentBook{path, "", "", "", ""};
}
bool RecentBooksStore::loadFromFile() {
@@ -120,38 +159,57 @@ bool RecentBooksStore::loadFromBinaryFile() {
// Old version, just read paths
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
std::vector<RecentBook> tmpRecentBooks;
tmpRecentBooks.reserve(count);
for (uint8_t i = 0; i < count; i++) {
std::string path;
serialization::readString(inputFile, path);
if (!serialization::readString(inputFile, path)) {
LOG_ERR("RBS", "Corrupt recent.bin: string too long at entry %u", i);
inputFile.close();
return false;
}
// load book to get missing data
RecentBook book = getDataFromBook(path);
if (book.title.empty() && book.author.empty() && version == 2) {
// Fall back to loading what we can from the store
std::string title, author;
serialization::readString(inputFile, title);
serialization::readString(inputFile, author);
recentBooks.push_back({path, title, author, ""});
if (version == 2) {
// v2 always stores title and author after path; consume them regardless
// of whether live metadata was found, to keep the stream aligned.
std::string storedTitle, storedAuthor;
if (!serialization::readString(inputFile, storedTitle) || !serialization::readString(inputFile, storedAuthor)) {
LOG_ERR("RBS", "Corrupt recent.bin: string too long at entry %u", i);
inputFile.close();
return false;
}
// Prefer live metadata; fall back to stored when live is unavailable.
const std::string& title = !book.title.empty() ? book.title : storedTitle;
const std::string& author = !book.title.empty() ? book.author : storedAuthor;
if (!title.empty()) {
tmpRecentBooks.push_back({path, title, author, "", ""});
}
} else {
recentBooks.push_back(book);
// v1: no stored title/author bytes
if (!book.title.empty()) {
tmpRecentBooks.push_back(book);
}
}
}
recentBooks = std::move(tmpRecentBooks);
} else if (version == 3) {
uint8_t count;
serialization::readPod(inputFile, count);
recentBooks.clear();
recentBooks.reserve(count);
std::vector<RecentBook> tmpRecentBooks;
tmpRecentBooks.reserve(count);
uint8_t omitted = 0;
for (uint8_t i = 0; i < count; i++) {
std::string path, title, author, coverBmpPath;
serialization::readString(inputFile, path);
serialization::readString(inputFile, title);
serialization::readString(inputFile, author);
serialization::readString(inputFile, coverBmpPath);
if (!serialization::readString(inputFile, path) || !serialization::readString(inputFile, title) ||
!serialization::readString(inputFile, author) || !serialization::readString(inputFile, coverBmpPath)) {
LOG_ERR("RBS", "Corrupt recent.bin: string too long at entry %u", i);
inputFile.close();
return false;
}
// Omit books with missing title (e.g. saved before metadata was available)
if (title.empty()) {
@@ -159,8 +217,9 @@ bool RecentBooksStore::loadFromBinaryFile() {
continue;
}
recentBooks.push_back({path, title, author, coverBmpPath});
tmpRecentBooks.push_back({path, title, author, "", coverBmpPath});
}
recentBooks = std::move(tmpRecentBooks);
if (omitted > 0) {
inputFile.close();
+13 -2
View File
@@ -1,4 +1,5 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
@@ -6,7 +7,12 @@ struct RecentBook {
std::string path;
std::string title;
std::string author;
std::string series;
std::string coverBmpPath;
// -1 = use global setting, otherwise explicit per-book override.
int8_t embeddedStyleOverride = -1;
// -1 = use global setting, otherwise CrossPointSettings::IMAGE_RENDERING value.
int8_t imageRenderingOverride = -1;
bool operator==(const RecentBook& other) const { return path == other.path; }
};
@@ -31,11 +37,14 @@ class RecentBooksStore {
static RecentBooksStore& getInstance() { return instance; }
// Add a book to the recent list (moves to front if already exists)
void addBook(const std::string& path, const std::string& title, const std::string& author,
void addBook(const std::string& path, const std::string& title, const std::string& author, const std::string& series,
const std::string& coverBmpPath);
void updateBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath);
const std::string& series, const std::string& coverBmpPath);
// Remove a book from the recent list by path
void removeBook(const std::string& path);
// Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
@@ -47,6 +56,8 @@ class RecentBooksStore {
bool loadFromFile();
RecentBook getDataFromBook(std::string path) const;
RecentBook getBookByPath(const std::string& path) const;
bool setReaderOverrides(const std::string& path, int8_t embeddedStyleOverride, int8_t imageRenderingOverride);
private:
bool loadFromBinaryFile();
+18 -1
View File
@@ -16,13 +16,17 @@ inline const std::vector<SettingInfo>& getSettingsList() {
// --- Display ---
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
StrId::STR_COVER_CUSTOM},
StrId::STR_COVER_CUSTOM, StrId::STR_PAGE_OVERLAY},
"sleepScreen", StrId::STR_CAT_DISPLAY),
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
SettingInfo::Enum(StrId::STR_SLEEP_COVER_FILTER, &CrossPointSettings::sleepScreenCoverFilter,
{StrId::STR_NONE_OPT, StrId::STR_FILTER_CONTRAST, StrId::STR_INVERTED},
"sleepScreenCoverFilter", StrId::STR_CAT_DISPLAY),
SettingInfo::Enum(
StrId::STR_SLEEP_COVER_OVERLAY, &CrossPointSettings::sleepCoverOverlay,
{StrId::STR_OVERLAY_OFF, StrId::STR_OVERLAY_WHITE, StrId::STR_OVERLAY_GRAY, StrId::STR_OVERLAY_BLACK},
"sleepCoverOverlay", StrId::STR_CAT_DISPLAY),
SettingInfo::Enum(StrId::STR_HIDE_BATTERY, &CrossPointSettings::hideBatteryPercentage,
{StrId::STR_NEVER, StrId::STR_IN_READER, StrId::STR_ALWAYS}, "hideBatteryPercentage",
StrId::STR_CAT_DISPLAY),
@@ -65,6 +69,8 @@ inline const std::vector<SettingInfo>& getSettingsList() {
SettingInfo::Enum(StrId::STR_IMAGES, &CrossPointSettings::imageRendering,
{StrId::STR_IMAGES_DISPLAY, StrId::STR_IMAGES_PLACEHOLDER, StrId::STR_IMAGES_SUPPRESS},
"imageRendering", StrId::STR_CAT_READER),
SettingInfo::Toggle(StrId::STR_CREATE_FALLBACK_FOR_INVALID_TOC, &CrossPointSettings::syntheticTocFallback,
"syntheticTocFallback", StrId::STR_CAT_READER),
// --- Controls ---
SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout,
{StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV}, "sideButtonLayout", StrId::STR_CAT_CONTROLS),
@@ -80,6 +86,15 @@ inline const std::vector<SettingInfo>& getSettingsList() {
"sleepTimeout", StrId::STR_CAT_SYSTEM),
SettingInfo::Toggle(StrId::STR_SHOW_HIDDEN_FILES, &CrossPointSettings::showHiddenFiles, "showHiddenFiles",
StrId::STR_CAT_SYSTEM),
SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat12h, {StrId::STR_24H, StrId::STR_12H},
"clockFormat12h", StrId::STR_CAT_SYSTEM),
SettingInfo::Enum(StrId::STR_TIMEZONE, &CrossPointSettings::timeZone,
{StrId::STR_TZ_UTC, StrId::STR_TZ_CET, StrId::STR_TZ_EET, StrId::STR_TZ_MSK,
StrId::STR_TZ_UTC_PLUS4, StrId::STR_TZ_IST, StrId::STR_TZ_UTC_PLUS7, StrId::STR_TZ_UTC_PLUS8,
StrId::STR_TZ_UTC_PLUS9, StrId::STR_TZ_AEST, StrId::STR_TZ_NZST, StrId::STR_TZ_UTC_MINUS3,
StrId::STR_TZ_EST, StrId::STR_TZ_CST, StrId::STR_TZ_MST, StrId::STR_TZ_PST},
"timeZone", StrId::STR_CAT_SYSTEM),
SettingInfo::Toggle(StrId::STR_USE_CLOCK, &CrossPointSettings::useClock, "useClock", StrId::STR_CAT_SYSTEM),
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
SettingInfo::DynamicString(
@@ -136,6 +151,8 @@ inline const std::vector<SettingInfo>& getSettingsList() {
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Toggle(StrId::STR_BATTERY, &CrossPointSettings::statusBarBattery, "statusBarBattery",
StrId::STR_CUSTOMISE_STATUS_BAR),
SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock",
StrId::STR_CUSTOMISE_STATUS_BAR),
};
return list;
}

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