Merge branch 'master' into integrate-upstream
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||

|
||||
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, it’s recommended to capture detailed logs from the serial port.
|
||||
|
||||
First, make sure all required Python packages are installed:
|
||||
|
||||
```python
|
||||
python3 -m pip install pyserial colorama matplotlib
|
||||
```
|
||||
after that run the script:
|
||||
```sh
|
||||
# For Linux
|
||||
# This was tested on Debian and should work on most Linux systems.
|
||||
python3 scripts/debugging_monitor.py
|
||||
|
||||
# For macOS
|
||||
python3 scripts/debugging_monitor.py /dev/cu.usbmodem2101
|
||||
```
|
||||
Minor adjustments may be required for Windows.
|
||||
|
||||
## Internals
|
||||
|
||||
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
|
||||
- ...
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
@@ -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 (< → <), 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, " ") == 0) {
|
||||
result += ' ';
|
||||
i += 5;
|
||||
} else if (html.compare(i, 7, "–") == 0) {
|
||||
result += '-';
|
||||
i += 6;
|
||||
} else if (html.compare(i, 7, "—") == 0) {
|
||||
result += '-';
|
||||
i += 6;
|
||||
} else if (html.compare(i, 8, "…") == 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.5–3.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 (0–3). 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 0–3) and b1 (pixels 4–7) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
@@ -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,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 Wi‑Fi existente"
|
||||
STR_HOTSPOT_DESC: "Crie uma rede Wi‑Fi outras pessoas entrarem"
|
||||
STR_HOTSPOT_DESC: "Crie uma rede Wi‑Fi à 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 Wi‑Fi"
|
||||
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 Wi‑Fi"
|
||||
STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\""
|
||||
STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi‑Fi"
|
||||
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 Wi‑Fi"
|
||||
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)"
|
||||
@@ -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 Wi‑Fi"
|
||||
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 Wi‑Fi existente"
|
||||
STR_HOTSPOT_DESC: "Crie uma rede Wi‑Fi à 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 Wi‑Fi"
|
||||
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 Wi‑Fi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Introduza a palavra-passe do Wi‑Fi"
|
||||
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 Wi‑Fi"
|
||||
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 Wi‑Fi"
|
||||
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)"
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 16‑bit 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);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 +=
|
||||
"¤t=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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}%)"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <br> 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: <br class="section-br"/></h1>
|
||||
<p>PASS: A blank-line gap should appear between the two sections below, identical
|
||||
to Ch 1, even though the <br> 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 <br> Elements</h1>
|
||||
<p>PASS: Two blank lines should appear between the sections (one per <br>).</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 <br> Inside a Paragraph</h1>
|
||||
<p>PASS: The two lines below should be adjacent with NO extra gap between them.
|
||||
The <br> 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: <br> 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: <br> 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 <br> 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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||