Compare commits
15
Commits
1.1.1
..
xsn/x3_fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0a7088fcc | ||
|
|
e79f86fa24 | ||
|
|
fd933ba368 | ||
|
|
c14dc4c109 | ||
|
|
cd4303a8fb | ||
|
|
961a704aaa | ||
|
|
7061d054ef | ||
|
|
39bf8a5609 | ||
|
|
a78897478f | ||
|
|
4f0a39ab03 | ||
|
|
d97a436a48 | ||
|
|
6114b80e5d | ||
|
|
d4eb089e49 | ||
|
|
9a1548189c | ||
|
|
cbea838f91 |
+1
-1
@@ -3,10 +3,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
lib/EpdFont/fontsrc
|
lib/EpdFont/fontsrc
|
||||||
lib/I18n/I18nStrings.cpp
|
|
||||||
*.generated.h
|
*.generated.h
|
||||||
.vs
|
.vs
|
||||||
build
|
build
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
/compile_commands.json
|
/compile_commands.json
|
||||||
/.cache
|
/.cache
|
||||||
|
/notes.md
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) do
|
|||||||
|
|
||||||
### Web (latest firmware)
|
### Web (latest firmware)
|
||||||
|
|
||||||
1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device
|
1. Connect your Xteink X4 to your computer via USB-C
|
||||||
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
|
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
|
||||||
|
|
||||||
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ usability over "swiss-army-knife" functionality.
|
|||||||
* **Library Management:** E.g. Simple, intuitive ways to organize and navigate a collection of books.
|
* **Library Management:** E.g. Simple, intuitive ways to organize and navigate a collection of books.
|
||||||
* **Local Transfer:** E.g. Simple, "pull" based book loading via a basic web-server or public and widely-used standards.
|
* **Local Transfer:** E.g. Simple, "pull" based book loading via a basic web-server or public and widely-used standards.
|
||||||
* **Language Support:** E.g. Support for multiple languages both in the reader and in the interfaces.
|
* **Language Support:** E.g. Support for multiple languages both in the reader and in the interfaces.
|
||||||
* **Reference Tools:** E.g. Local dictionary lookup. Providing quick, offline definitions to enhance comprehension
|
|
||||||
without breaking focus.
|
|
||||||
|
|
||||||
### Out-of-Scope
|
### Out-of-Scope
|
||||||
|
|
||||||
@@ -36,8 +34,8 @@ usability over "swiss-army-knife" functionality.
|
|||||||
* **Active Connectivity:** No RSS readers, News aggregators, or Web browsers. Background Wi-Fi tasks drain the battery
|
* **Active Connectivity:** No RSS readers, News aggregators, or Web browsers. Background Wi-Fi tasks drain the battery
|
||||||
and complicate the single-core CPU's execution.
|
and complicate the single-core CPU's execution.
|
||||||
* **Media Playback:** No Audio players or Audio-books.
|
* **Media Playback:** No Audio players or Audio-books.
|
||||||
* **Complex Annotation:** No typed out notes. These features are better suited for devices with better input
|
* **Complex Reader Features:** No highlighting, notes, or dictionary lookup. These features are better suited for
|
||||||
capabilities and more powerful chips.
|
devices with better input capabilities and more powerful chips.
|
||||||
|
|
||||||
## 3. Idea Evaluation
|
## 3. Idea Evaluation
|
||||||
|
|
||||||
|
|||||||
+1
-10
@@ -1,18 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Check if clang-format is availible
|
|
||||||
command -v clang-format >/dev/null 2>&1 || {
|
|
||||||
printf "'clang-format' not found in current environment\n"
|
|
||||||
printf "install 'clang', 'clang-tools', or 'clang-format' depending on your distro/os and tooling requirements\n"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
GIT_LS_FILES_FLAGS=""
|
GIT_LS_FILES_FLAGS=""
|
||||||
if [[ "$1" == "-g" ]]; then
|
if [[ "$1" == "-g" ]]; then
|
||||||
GIT_LS_FILES_FLAGS="--modified"
|
GIT_LS_FILES_FLAGS="--modified"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# --- Main Logic ---
|
# --- Main Logic ---
|
||||||
|
|
||||||
# Format all files (or only modified files if -g is passed)
|
# Format all files (or only modified files if -g is passed)
|
||||||
@@ -26,5 +18,4 @@ git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
|
|||||||
| grep -E '\.(c|cpp|h|hpp)$' \
|
| grep -E '\.(c|cpp|h|hpp)$' \
|
||||||
| grep -v -E '^lib/EpdFont/builtinFonts/' \
|
| grep -v -E '^lib/EpdFont/builtinFonts/' \
|
||||||
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
|
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
|
||||||
| grep -v -E '^lib/uzlib/' \
|
|
||||||
| xargs -r clang-format -style=file -i
|
| xargs -r clang-format -style=file -i
|
||||||
|
|||||||
@@ -45,9 +45,22 @@ byte arrays, and emits headers under
|
|||||||
`SerializedHyphenationPatterns` descriptor so the reader can keep the automaton
|
`SerializedHyphenationPatterns` descriptor so the reader can keep the automaton
|
||||||
in flash.
|
in flash.
|
||||||
|
|
||||||
A convenient script `update_hyphenation.sh` is used to update all languages.
|
To refresh the firmware assets after updating the `.bin` files, run:
|
||||||
To use it, run:
|
|
||||||
|
|
||||||
```sh
|
```
|
||||||
./scripts/update_hypenation.sh
|
./scripts/generate_hyphenation_trie.py \
|
||||||
|
--input lib/Epub/Epub/hyphenation/tries/en.bin \
|
||||||
|
--output lib/Epub/Epub/hyphenation/generated/hyph-en.trie.h
|
||||||
|
|
||||||
|
./scripts/generate_hyphenation_trie.py \
|
||||||
|
--input lib/Epub/Epub/hyphenation/tries/fr.bin \
|
||||||
|
--output lib/Epub/Epub/hyphenation/generated/hyph-fr.trie.h
|
||||||
|
|
||||||
|
./scripts/generate_hyphenation_trie.py \
|
||||||
|
--input lib/Epub/Epub/hyphenation/tries/de.bin \
|
||||||
|
--output lib/Epub/Epub/hyphenation/generated/hyph-de.trie.h
|
||||||
|
|
||||||
|
./scripts/generate_hyphenation_trie.py \
|
||||||
|
--input lib/Epub/Epub/hyphenation/tries/ru.bin \
|
||||||
|
--output lib/Epub/Epub/hyphenation/generated/hyph-ru.trie.h
|
||||||
```
|
```
|
||||||
|
|||||||
-237
@@ -1,237 +0,0 @@
|
|||||||
# Internationalization (I18N)
|
|
||||||
|
|
||||||
This guide explains the multi-language support system in CrossPoint Reader.
|
|
||||||
|
|
||||||
## Supported Languages
|
|
||||||
|
|
||||||
- English
|
|
||||||
- French
|
|
||||||
- German
|
|
||||||
- Portuguese
|
|
||||||
- Spanish
|
|
||||||
- Swedish
|
|
||||||
- Czech
|
|
||||||
- Russian
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## For Developers
|
|
||||||
|
|
||||||
### Translation System Architecture
|
|
||||||
|
|
||||||
The I18N system uses **per-language YAML files** to maintain translations and a Python script to generate C++ code:
|
|
||||||
|
|
||||||
```
|
|
||||||
lib/I18n/
|
|
||||||
├── translations/ # One YAML file per language
|
|
||||||
│ ├── english.yaml
|
|
||||||
│ ├── spanish.yaml
|
|
||||||
│ ├── french.yaml
|
|
||||||
│ └── ...
|
|
||||||
├── I18n.h
|
|
||||||
├── I18n.cpp
|
|
||||||
├── I18nKeys.h # Enums (auto-generated)
|
|
||||||
├── I18nStrings.h # String array declarations (auto-generated)
|
|
||||||
└── I18nStrings.cpp # String array definitions (auto-generated)
|
|
||||||
|
|
||||||
scripts/
|
|
||||||
└── gen_i18n.py # Code generator script
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key principle:** All translations are managed in the YAML files under `lib/I18n/translations/`. The Python script generates the necessary C++ code automatically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### YAML File Format
|
|
||||||
|
|
||||||
Each language has its own file in `lib/I18n/translations/` (e.g. `spanish.yaml`).
|
|
||||||
|
|
||||||
A file looks like this:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
_language_name: "Español"
|
|
||||||
_language_code: "SPANISH"
|
|
||||||
_order: "1"
|
|
||||||
|
|
||||||
STR_CROSSPOINT: "CrossPoint"
|
|
||||||
STR_BOOTING: "BOOTING"
|
|
||||||
STR_BROWSE_FILES: "Buscar archivos"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Metadata keys** (prefixed with `_`):
|
|
||||||
- `_language_name` — Native display name shown to the user (e.g. "Français")
|
|
||||||
- `_language_code` — C++ enum name (e.g. "FRENCH"). Must be a valid C++ identifier.
|
|
||||||
- `_order` — Controls the position in the Language enum (English is always 0)
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
- Use UTF-8 encoding
|
|
||||||
- Every line must follow the format: `KEY: "value"`
|
|
||||||
- Keys must be valid C++ identifiers (uppercase, strats with STR_)
|
|
||||||
- Keys must be unique within a file
|
|
||||||
- String values must be quoted
|
|
||||||
- Use `\n` for newlines, `\\` for literal backslashes, `\"` for literal quotes inside values
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Adding New Strings
|
|
||||||
|
|
||||||
To add a new translatable string:
|
|
||||||
|
|
||||||
#### 1. Edit the English YAML file
|
|
||||||
|
|
||||||
Add the key to `lib/I18n/translations/english.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
STR_MY_NEW_STRING: "My New String"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then add translations in each language file. If a key is missing from a
|
|
||||||
language file, the generator will automatically use the English text as a
|
|
||||||
fallback (and print a warning).
|
|
||||||
|
|
||||||
#### 2. Run the generator script
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
|
|
||||||
```
|
|
||||||
|
|
||||||
This automatically:
|
|
||||||
- Fills missing translations from English
|
|
||||||
- Updates the `StrId` enum in `I18nKeys.h`
|
|
||||||
- Regenerates all language arrays in `I18nStrings.cpp`
|
|
||||||
|
|
||||||
#### 3. Use in code
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
#include <I18n.h>
|
|
||||||
|
|
||||||
// Using the tr() macro (recommended)
|
|
||||||
renderer.drawText(font, x, y, tr(STR_MY_NEW_STRING));
|
|
||||||
|
|
||||||
// Using I18N.get() directly
|
|
||||||
const char* text = I18N.get(StrId::STR_MY_NEW_STRING);
|
|
||||||
```
|
|
||||||
|
|
||||||
**That's it!** No manual array synchronization needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Adding a New Language
|
|
||||||
|
|
||||||
To add support for a new language (e.g., Italian):
|
|
||||||
|
|
||||||
#### 1. Create a new YAML file
|
|
||||||
|
|
||||||
Create `lib/I18n/translations/italian.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
_language_name: "Italiano"
|
|
||||||
_language_code: "ITALIAN"
|
|
||||||
_order: "7"
|
|
||||||
|
|
||||||
STR_CROSSPOINT: "CrossPoint"
|
|
||||||
STR_BOOTING: "AVVIO"
|
|
||||||
```
|
|
||||||
|
|
||||||
You only need to include the strings you have translations for. Missing
|
|
||||||
keys will fall back to English automatically.
|
|
||||||
|
|
||||||
#### 2. Run the generator
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
|
|
||||||
```
|
|
||||||
|
|
||||||
This automatically updates all necessary code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modifying Existing Translations
|
|
||||||
|
|
||||||
Simply edit the relevant YAML file and regenerate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### UTF-8 Encoding
|
|
||||||
|
|
||||||
The YAML files use UTF-8 encoding. Special characters are automatically converted to C++ UTF-8 hex sequences by the generator.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### I18N API Reference
|
|
||||||
|
|
||||||
```cpp
|
|
||||||
// === Convenience Macros (Recommended) ===
|
|
||||||
|
|
||||||
// tr(id) - Get translated string without StrId:: prefix
|
|
||||||
const char* text = tr(STR_SETTINGS_TITLE);
|
|
||||||
renderer.drawText(font, x, y, tr(STR_BROWSE_FILES));
|
|
||||||
Serial.printf("Status: %s\n", tr(STR_CONNECTED));
|
|
||||||
|
|
||||||
// I18N - Shorthand for I18n::getInstance()
|
|
||||||
I18N.setLanguage(Language::SPANISH);
|
|
||||||
Language lang = I18N.getLanguage();
|
|
||||||
|
|
||||||
// === Full API ===
|
|
||||||
|
|
||||||
// Get the singleton instance
|
|
||||||
I18n& instance = I18n::getInstance();
|
|
||||||
|
|
||||||
// Get translated string (three equivalent ways)
|
|
||||||
const char* text = tr(STR_SETTINGS_TITLE); // Macro (recommended)
|
|
||||||
const char* text = I18N.get(StrId::STR_SETTINGS_TITLE); // Direct call
|
|
||||||
const char* text = I18N[StrId::STR_SETTINGS_TITLE]; // Operator overload
|
|
||||||
|
|
||||||
// Set language
|
|
||||||
I18N.setLanguage(Language::SPANISH);
|
|
||||||
|
|
||||||
// Get current language
|
|
||||||
Language lang = I18N.getLanguage();
|
|
||||||
|
|
||||||
// Save language setting to file
|
|
||||||
I18N.saveSettings();
|
|
||||||
|
|
||||||
// Load language setting from file
|
|
||||||
I18N.loadSettings();
|
|
||||||
|
|
||||||
// Get character set for font subsetting (static method)
|
|
||||||
const char* chars = I18n::getCharacterSet(Language::FRENCH);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Storage
|
|
||||||
|
|
||||||
Language settings are stored in:
|
|
||||||
```
|
|
||||||
/.crosspoint/language.bin
|
|
||||||
```
|
|
||||||
|
|
||||||
This file contains:
|
|
||||||
- Version byte
|
|
||||||
- Current language selection (1 byte)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Translation Workflow
|
|
||||||
|
|
||||||
### For Developers (Adding Features)
|
|
||||||
|
|
||||||
1. Add new strings to `lib/I18n/translations/english.yaml`
|
|
||||||
2. Run `python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/`
|
|
||||||
3. Use the new `StrId` in your code
|
|
||||||
4. Request translations from translators
|
|
||||||
|
|
||||||
### For Translators
|
|
||||||
|
|
||||||
1. Open the YAML file for your language in `lib/I18n/translations/`
|
|
||||||
2. Add or update translations using the format `STR_KEY: "translated text"`
|
|
||||||
3. Keep translations concise (E-ink space constraints)
|
|
||||||
4. Make sure the file is in UTF-8 encoding
|
|
||||||
5. Run `python3 scripts/gen_i18n.py lib/I18n/translations lib/I18n/` to verify
|
|
||||||
6. Test on device or submit for review
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 135 KiB |
@@ -1,36 +0,0 @@
|
|||||||
# Translators
|
|
||||||
|
|
||||||
Below is a list of users and languages CrossPoint may support in the future.
|
|
||||||
Note because a language is below does not mean there is official support for the language at this time.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
If you'd like to add your name to this list, please open a PR adding yourself and your Github link. Thank you!
|
|
||||||
|
|
||||||
## French
|
|
||||||
- [Spigaw](https://github.com/Spigaw)
|
|
||||||
- [CaptainFrito](https://github.com/CaptainFrito)
|
|
||||||
|
|
||||||
## German
|
|
||||||
- [DavidOrtmann](https://github.com/DavidOrtmann)
|
|
||||||
|
|
||||||
## Czech
|
|
||||||
- [brbla](https://github.com/brbla)
|
|
||||||
|
|
||||||
## Portuguese (Brazil)
|
|
||||||
- [yagofarias](https://github.com/yagofarias)
|
|
||||||
|
|
||||||
## Italian
|
|
||||||
- [fragolinux](https://github.com/fragolinux)
|
|
||||||
|
|
||||||
## Russian
|
|
||||||
- [madebyKir](https://github.com/madebyKir)
|
|
||||||
- [mrtnvgr](https://github.com/mrtnvgr)
|
|
||||||
|
|
||||||
## Spanish
|
|
||||||
- [yeyeto2788](https://github.com/yeyeto2788)
|
|
||||||
- [Skrzakk](https://github.com/Skrzakk)
|
|
||||||
- [pablohc](https://github.com/pablohc)
|
|
||||||
|
|
||||||
## Swedish
|
|
||||||
- [dawiik](https://github.com/dawiik)
|
|
||||||
+22
-32
@@ -1,14 +1,14 @@
|
|||||||
# Web Server Guide
|
# Web Server Guide
|
||||||
|
|
||||||
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload files from your computer or phone.
|
This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload EPUB files from your computer or phone.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
CrossPoint Reader includes a built-in web server that allows you to:
|
CrossPoint Reader includes a built-in web server that allows you to:
|
||||||
|
|
||||||
- Upload files wirelessly from any device on the same WiFi network
|
- Upload EPUB files wirelessly from any device on the same WiFi network
|
||||||
- Browse and manage files on your device's SD card
|
- Browse and manage files on your device's SD card
|
||||||
- Create folders to organize your library
|
- Create folders to organize your ebooks
|
||||||
- Delete files and folders
|
- Delete files and folders
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -129,31 +129,34 @@ Click **File Manager** to access file management features.
|
|||||||
#### Browsing Files
|
#### Browsing Files
|
||||||
|
|
||||||
- The file manager displays all files and folders on your SD card
|
- The file manager displays all files and folders on your SD card
|
||||||
- **Folders** are highlighted in yellow and indicated with a 📁 icon
|
- **Folders** are highlighted in yellow with a 📁 icon
|
||||||
- **EPUB Files** are highlighted in green and indicated with a 📗 icon
|
- **EPUB files** are highlighted in green with a 📗 icon
|
||||||
- **All Other Files** are not highlighted and indicated with a 📄 icon
|
|
||||||
- Click on a folder name to navigate into it
|
- Click on a folder name to navigate into it
|
||||||
- Use the breadcrumb navigation at the top to go back to parent folders
|
- Use the breadcrumb navigation at the top to go back to parent folders
|
||||||
|
|
||||||
<img src="./images/wifi/webserver_files.png" width="600">
|
<img src="./images/wifi/webserver_files.png" width="600">
|
||||||
|
|
||||||
#### Uploading Files
|
#### Uploading EPUB Files
|
||||||
|
|
||||||
1. Click the **📤 Upload** button in the top-right corner
|
1. Click the **+ Add** button in the top-right corner
|
||||||
2. Click **Choose File** and select a file from your device
|
2. Select **Upload eBook** from the dropdown menu
|
||||||
3. Click **Upload**
|
3. Click **Choose File** and select an `.epub` file from your device
|
||||||
4. A progress bar will show the upload status
|
4. Click **Upload**
|
||||||
5. The page will automatically refresh when the upload is complete
|
5. A progress bar will show the upload status
|
||||||
|
6. The page will automatically refresh when the upload is complete
|
||||||
|
|
||||||
|
**Note:** Only `.epub` files are accepted. Other file types will be rejected.
|
||||||
|
|
||||||
<img src="./images/wifi/webserver_upload.png" width="600">
|
<img src="./images/wifi/webserver_upload.png" width="600">
|
||||||
|
|
||||||
#### Creating Folders
|
#### Creating Folders
|
||||||
|
|
||||||
1. Click the **📁 New Folder** button in the top-right corner
|
1. Click the **+ Add** button in the top-right corner
|
||||||
2. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
2. Select **New Folder** from the dropdown menu
|
||||||
3. Click **Create Folder**
|
3. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
||||||
|
4. Click **Create Folder**
|
||||||
|
|
||||||
This is useful for organizing your library by genre, author, series or file type.
|
This is useful for organizing your ebooks by genre, author, or series.
|
||||||
|
|
||||||
#### Deleting Files and Folders
|
#### Deleting Files and Folders
|
||||||
|
|
||||||
@@ -165,25 +168,11 @@ This is useful for organizing your library by genre, author, series or file type
|
|||||||
|
|
||||||
**Note:** Folders must be empty before they can be deleted.
|
**Note:** Folders must be empty before they can be deleted.
|
||||||
|
|
||||||
#### Moving Files
|
|
||||||
|
|
||||||
1. Click the **📂** (folder) icon next to any file
|
|
||||||
2. Enter a folder name or select one from the dropdown
|
|
||||||
3. Click **Move** to relocate the file
|
|
||||||
|
|
||||||
**Note:** Typing in a nonexistent folder name will result in the following error: "Failed to move: Destination not found"
|
|
||||||
|
|
||||||
#### Renaming Files
|
|
||||||
|
|
||||||
1. Click the **✏️** (pencil) icon next to any file
|
|
||||||
2. Enter a file name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
|
|
||||||
3. Click **Rename** to permanently rename the file
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Command Line File Management
|
## Command Line File Management
|
||||||
|
|
||||||
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode. Detailed documentation can be found [here](./webserver-endpoints.md).
|
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode a detailed documentation can be found [here](./webserver-endpoints.md).
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
@@ -200,6 +189,7 @@ For power users, you can manage files directly from your terminal using `curl` w
|
|||||||
- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
|
- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
|
||||||
- **Web Server Port:** 80 (HTTP)
|
- **Web Server Port:** 80 (HTTP)
|
||||||
- **Maximum Upload Size:** Limited by available SD card space
|
- **Maximum Upload Size:** Limited by available SD card space
|
||||||
|
- **Supported File Format:** `.epub` only
|
||||||
- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
|
- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -208,7 +198,7 @@ For power users, you can manage files directly from your terminal using `curl` w
|
|||||||
|
|
||||||
1. **Organize with folders** - Create folders before uploading to keep your library organized
|
1. **Organize with folders** - Create folders before uploading to keep your library organized
|
||||||
2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
|
2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
|
||||||
3. **Upload multiple files** - You can select and upload multiple files at once; the manager will queue them and refresh when the batch is finished
|
3. **Upload multiple files** - You can upload files one at a time; the page refreshes after each upload
|
||||||
4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
|
4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
|
||||||
5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
|
5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
|
||||||
6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
|
6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
|
||||||
|
|||||||
+13
-29
@@ -17,11 +17,6 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
|||||||
|
|
||||||
int cursorX = startX;
|
int cursorX = startX;
|
||||||
const int cursorY = startY;
|
const int cursorY = startY;
|
||||||
int lastBaseX = startX;
|
|
||||||
int lastBaseAdvance = 0;
|
|
||||||
int lastBaseTop = 0;
|
|
||||||
bool hasBaseGlyph = false;
|
|
||||||
constexpr int MIN_COMBINING_GAP_PX = 1;
|
|
||||||
uint32_t cp;
|
uint32_t cp;
|
||||||
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
|
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
|
||||||
const EpdGlyph* glyph = getGlyph(cp);
|
const EpdGlyph* glyph = getGlyph(cp);
|
||||||
@@ -35,30 +30,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool isCombining = utf8IsCombiningMark(cp);
|
*minX = std::min(*minX, cursorX + glyph->left);
|
||||||
int raiseBy = 0;
|
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
|
||||||
if (isCombining && hasBaseGlyph) {
|
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
|
||||||
const int currentGap = glyph->top - glyph->height - lastBaseTop;
|
*maxY = std::max(*maxY, cursorY + glyph->top);
|
||||||
if (currentGap < MIN_COMBINING_GAP_PX) {
|
cursorX += glyph->advanceX;
|
||||||
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
|
|
||||||
const int glyphBaseY = cursorY - raiseBy;
|
|
||||||
|
|
||||||
*minX = std::min(*minX, glyphBaseX + glyph->left);
|
|
||||||
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
|
|
||||||
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
|
|
||||||
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
|
|
||||||
|
|
||||||
if (!isCombining) {
|
|
||||||
lastBaseX = cursorX;
|
|
||||||
lastBaseAdvance = glyph->advanceX;
|
|
||||||
lastBaseTop = glyph->top;
|
|
||||||
hasBaseGlyph = true;
|
|
||||||
cursorX += glyph->advanceX;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +47,14 @@ void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {
|
|||||||
*h = maxY - minY;
|
*h = maxY - minY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool EpdFont::hasPrintableChars(const char* string) const {
|
||||||
|
int w = 0, h = 0;
|
||||||
|
|
||||||
|
getTextDimensions(string, &w, &h);
|
||||||
|
|
||||||
|
return w > 0 || h > 0;
|
||||||
|
}
|
||||||
|
|
||||||
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
|
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
|
||||||
const EpdUnicodeInterval* intervals = data->intervals;
|
const EpdUnicodeInterval* intervals = data->intervals;
|
||||||
const int count = data->intervalCount;
|
const int count = data->intervalCount;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class EpdFont {
|
|||||||
explicit EpdFont(const EpdFontData* data) : data(data) {}
|
explicit EpdFont(const EpdFontData* data) : data(data) {}
|
||||||
~EpdFont() = default;
|
~EpdFont() = default;
|
||||||
void getTextDimensions(const char* string, int* w, int* h) const;
|
void getTextDimensions(const char* string, int* w, int* h) const;
|
||||||
|
bool hasPrintableChars(const char* string) const;
|
||||||
|
|
||||||
const EpdGlyph* getGlyph(uint32_t cp) const;
|
const EpdGlyph* getGlyph(uint32_t cp) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,18 +12,9 @@ typedef struct {
|
|||||||
int16_t left; ///< X dist from cursor pos to UL corner
|
int16_t left; ///< X dist from cursor pos to UL corner
|
||||||
int16_t top; ///< Y dist from cursor pos to UL corner
|
int16_t top; ///< Y dist from cursor pos to UL corner
|
||||||
uint16_t dataLength; ///< Size of the font data.
|
uint16_t dataLength; ///< Size of the font data.
|
||||||
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap (or within-group offset for compressed fonts)
|
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap
|
||||||
} EpdGlyph;
|
} EpdGlyph;
|
||||||
|
|
||||||
/// Compressed font group: a DEFLATE-compressed block of glyph bitmaps
|
|
||||||
typedef struct {
|
|
||||||
uint32_t compressedOffset; ///< Byte offset into compressed data array
|
|
||||||
uint32_t compressedSize; ///< Compressed DEFLATE stream size
|
|
||||||
uint32_t uncompressedSize; ///< Decompressed size
|
|
||||||
uint16_t glyphCount; ///< Number of glyphs in this group
|
|
||||||
uint16_t firstGlyphIndex; ///< First glyph index in the global glyph array
|
|
||||||
} EpdFontGroup;
|
|
||||||
|
|
||||||
/// Glyph interval structure
|
/// Glyph interval structure
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t first; ///< The first unicode code point of the interval
|
uint32_t first; ///< The first unicode code point of the interval
|
||||||
@@ -41,6 +32,4 @@ typedef struct {
|
|||||||
int ascender; ///< Maximal height of a glyph above the base line
|
int ascender; ///< Maximal height of a glyph above the base line
|
||||||
int descender; ///< Maximal height of a glyph below the base line
|
int descender; ///< Maximal height of a glyph below the base line
|
||||||
bool is2Bit;
|
bool is2Bit;
|
||||||
const EpdFontGroup* groups; ///< NULL for uncompressed fonts
|
|
||||||
uint16_t groupCount; ///< 0 for uncompressed fonts
|
|
||||||
} EpdFontData;
|
} EpdFontData;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const
|
|||||||
getFont(style)->getTextDimensions(string, w, h);
|
getFont(style)->getTextDimensions(string, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool EpdFontFamily::hasPrintableChars(const char* string, const Style style) const {
|
||||||
|
return getFont(style)->hasPrintableChars(string);
|
||||||
|
}
|
||||||
|
|
||||||
const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; }
|
const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; }
|
||||||
|
|
||||||
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
|
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class EpdFontFamily {
|
|||||||
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
|
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
|
||||||
~EpdFontFamily() = default;
|
~EpdFontFamily() = default;
|
||||||
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
|
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
|
||||||
|
bool hasPrintableChars(const char* string, Style style = REGULAR) const;
|
||||||
const EpdFontData* getData(Style style = REGULAR) const;
|
const EpdFontData* getData(Style style = REGULAR) const;
|
||||||
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
#include "FontDecompressor.h"
|
|
||||||
|
|
||||||
#include <Logging.h>
|
|
||||||
#include <uzlib.h>
|
|
||||||
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
bool FontDecompressor::init() {
|
|
||||||
clearCache();
|
|
||||||
memset(&decomp, 0, sizeof(decomp));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void FontDecompressor::freeAllEntries() {
|
|
||||||
for (auto& entry : cache) {
|
|
||||||
if (entry.data) {
|
|
||||||
free(entry.data);
|
|
||||||
entry.data = nullptr;
|
|
||||||
}
|
|
||||||
entry.valid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void FontDecompressor::deinit() { freeAllEntries(); }
|
|
||||||
|
|
||||||
void FontDecompressor::clearCache() {
|
|
||||||
freeAllEntries();
|
|
||||||
accessCounter = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t FontDecompressor::getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex) {
|
|
||||||
for (uint16_t i = 0; i < fontData->groupCount; i++) {
|
|
||||||
uint16_t first = fontData->groups[i].firstGlyphIndex;
|
|
||||||
if (glyphIndex >= first && glyphIndex < first + fontData->groups[i].glyphCount) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fontData->groupCount; // sentinel = not found
|
|
||||||
}
|
|
||||||
|
|
||||||
FontDecompressor::CacheEntry* FontDecompressor::findInCache(const EpdFontData* fontData, uint16_t groupIndex) {
|
|
||||||
for (auto& entry : cache) {
|
|
||||||
if (entry.valid && entry.font == fontData && entry.groupIndex == groupIndex) {
|
|
||||||
return &entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
FontDecompressor::CacheEntry* FontDecompressor::findEvictionCandidate() {
|
|
||||||
// Find an invalid slot first
|
|
||||||
for (auto& entry : cache) {
|
|
||||||
if (!entry.valid) {
|
|
||||||
return &entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise evict LRU
|
|
||||||
CacheEntry* lru = &cache[0];
|
|
||||||
for (auto& entry : cache) {
|
|
||||||
if (entry.lastUsed < lru->lastUsed) {
|
|
||||||
lru = &entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lru;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool FontDecompressor::decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry) {
|
|
||||||
const EpdFontGroup& group = fontData->groups[groupIndex];
|
|
||||||
|
|
||||||
// Free old buffer if reusing a slot
|
|
||||||
if (entry->data) {
|
|
||||||
free(entry->data);
|
|
||||||
entry->data = nullptr;
|
|
||||||
}
|
|
||||||
entry->valid = false;
|
|
||||||
|
|
||||||
// Allocate output buffer
|
|
||||||
auto* outBuf = static_cast<uint8_t*>(malloc(group.uncompressedSize));
|
|
||||||
if (!outBuf) {
|
|
||||||
LOG_ERR("FDC", "Failed to allocate %u bytes for group %u", group.uncompressedSize, groupIndex);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decompress using uzlib
|
|
||||||
const uint8_t* inputBuf = &fontData->bitmap[group.compressedOffset];
|
|
||||||
|
|
||||||
uzlib_uncompress_init(&decomp, NULL, 0);
|
|
||||||
decomp.source = inputBuf;
|
|
||||||
decomp.source_limit = inputBuf + group.compressedSize;
|
|
||||||
decomp.dest_start = outBuf;
|
|
||||||
decomp.dest = outBuf;
|
|
||||||
decomp.dest_limit = outBuf + group.uncompressedSize;
|
|
||||||
|
|
||||||
int res = uzlib_uncompress(&decomp);
|
|
||||||
|
|
||||||
if (res < 0 || decomp.dest != decomp.dest_limit) {
|
|
||||||
LOG_ERR("FDC", "Decompression failed for group %u (status %d)", groupIndex, res);
|
|
||||||
free(outBuf);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry->font = fontData;
|
|
||||||
entry->groupIndex = groupIndex;
|
|
||||||
entry->data = outBuf;
|
|
||||||
entry->dataSize = group.uncompressedSize;
|
|
||||||
entry->valid = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint8_t* FontDecompressor::getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex) {
|
|
||||||
if (!fontData->groups || fontData->groupCount == 0) {
|
|
||||||
return &fontData->bitmap[glyph->dataOffset];
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t groupIndex = getGroupIndex(fontData, glyphIndex);
|
|
||||||
if (groupIndex >= fontData->groupCount) {
|
|
||||||
LOG_ERR("FDC", "Glyph %u not found in any group", glyphIndex);
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache
|
|
||||||
CacheEntry* entry = findInCache(fontData, groupIndex);
|
|
||||||
if (entry) {
|
|
||||||
entry->lastUsed = ++accessCounter;
|
|
||||||
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
|
|
||||||
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
|
|
||||||
glyph->dataLength, groupIndex, entry->dataSize);
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
return &entry->data[glyph->dataOffset];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss - decompress
|
|
||||||
entry = findEvictionCandidate();
|
|
||||||
if (!decompressGroup(fontData, groupIndex, entry)) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry->lastUsed = ++accessCounter;
|
|
||||||
if (glyph->dataOffset + glyph->dataLength > entry->dataSize) {
|
|
||||||
LOG_ERR("FDC", "dataOffset %u + dataLength %u out of bounds for group %u (size %u)", glyph->dataOffset,
|
|
||||||
glyph->dataLength, groupIndex, entry->dataSize);
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
return &entry->data[glyph->dataOffset];
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <uzlib.h>
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
#include "EpdFontData.h"
|
|
||||||
|
|
||||||
class FontDecompressor {
|
|
||||||
public:
|
|
||||||
bool init();
|
|
||||||
void deinit();
|
|
||||||
|
|
||||||
// Returns pointer to decompressed bitmap data for the given glyph.
|
|
||||||
// Valid until LRU eviction (safe for the duration of one glyph render).
|
|
||||||
const uint8_t* getBitmap(const EpdFontData* fontData, const EpdGlyph* glyph, uint16_t glyphIndex);
|
|
||||||
|
|
||||||
// Evict all cached decompressed groups (call between pages for within-page-only caching).
|
|
||||||
void clearCache();
|
|
||||||
|
|
||||||
private:
|
|
||||||
static constexpr uint8_t CACHE_SLOTS = 4;
|
|
||||||
|
|
||||||
struct CacheEntry {
|
|
||||||
const EpdFontData* font = nullptr;
|
|
||||||
uint16_t groupIndex = 0;
|
|
||||||
uint8_t* data = nullptr;
|
|
||||||
uint32_t dataSize = 0;
|
|
||||||
uint32_t lastUsed = 0;
|
|
||||||
bool valid = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct uzlib_uncomp decomp = {};
|
|
||||||
CacheEntry cache[CACHE_SLOTS] = {};
|
|
||||||
uint32_t accessCounter = 0;
|
|
||||||
|
|
||||||
void freeAllEntries();
|
|
||||||
uint16_t getGroupIndex(const EpdFontData* fontData, uint16_t glyphIndex);
|
|
||||||
CacheEntry* findInCache(const EpdFontData* fontData, uint16_t groupIndex);
|
|
||||||
CacheEntry* findEvictionCandidate();
|
|
||||||
bool decompressGroup(const EpdFontData* fontData, uint16_t groupIndex, CacheEntry* entry);
|
|
||||||
};
|
|
||||||
+3896
-2156
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4897
-2508
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6046
-2679
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+7774
-3195
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3952
-2303
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+5074
-2688
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+6332
-2937
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+7685
-3281
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1619,6 +1619,4 @@ static const EpdFontData notosans_8_regular = {
|
|||||||
18,
|
18,
|
||||||
-5,
|
-5,
|
||||||
false,
|
false,
|
||||||
nullptr,
|
|
||||||
0,
|
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1522,6 +1522,4 @@ static const EpdFontData ubuntu_10_bold = {
|
|||||||
20,
|
20,
|
||||||
-4,
|
-4,
|
||||||
false,
|
false,
|
||||||
nullptr,
|
|
||||||
0,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1436,6 +1436,4 @@ static const EpdFontData ubuntu_10_regular = {
|
|||||||
20,
|
20,
|
||||||
-4,
|
-4,
|
||||||
false,
|
false,
|
||||||
nullptr,
|
|
||||||
0,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1832,6 +1832,4 @@ static const EpdFontData ubuntu_12_bold = {
|
|||||||
24,
|
24,
|
||||||
-5,
|
-5,
|
||||||
false,
|
false,
|
||||||
nullptr,
|
|
||||||
0,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1724,6 +1724,4 @@ static const EpdFontData ubuntu_12_regular = {
|
|||||||
24,
|
24,
|
||||||
-5,
|
-5,
|
||||||
false,
|
false,
|
||||||
nullptr,
|
|
||||||
0,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ for size in ${BOOKERLY_FONT_SIZES[@]}; do
|
|||||||
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||||
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
|
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
|
||||||
output_path="../builtinFonts/${font_name}.h"
|
output_path="../builtinFonts/${font_name}.h"
|
||||||
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
|
python fontconvert.py $font_name $size $font_path --2bit > $output_path
|
||||||
echo "Generated $output_path"
|
echo "Generated $output_path"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -24,7 +24,7 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
|
|||||||
font_name="notosans_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
font_name="notosans_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||||
font_path="../builtinFonts/source/NotoSans/NotoSans-${style}.ttf"
|
font_path="../builtinFonts/source/NotoSans/NotoSans-${style}.ttf"
|
||||||
output_path="../builtinFonts/${font_name}.h"
|
output_path="../builtinFonts/${font_name}.h"
|
||||||
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
|
python fontconvert.py $font_name $size $font_path --2bit > $output_path
|
||||||
echo "Generated $output_path"
|
echo "Generated $output_path"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -34,7 +34,7 @@ for size in ${OPENDYSLEXIC_FONT_SIZES[@]}; do
|
|||||||
font_name="opendyslexic_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
font_name="opendyslexic_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
|
||||||
font_path="../builtinFonts/source/OpenDyslexic/OpenDyslexic-${style}.otf"
|
font_path="../builtinFonts/source/OpenDyslexic/OpenDyslexic-${style}.otf"
|
||||||
output_path="../builtinFonts/${font_name}.h"
|
output_path="../builtinFonts/${font_name}.h"
|
||||||
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
|
python fontconvert.py $font_name $size $font_path --2bit > $output_path
|
||||||
echo "Generated $output_path"
|
echo "Generated $output_path"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -53,7 +53,3 @@ for size in ${UI_FONT_SIZES[@]}; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
python fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf > ../builtinFonts/notosans_8_regular.h
|
python fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf > ../builtinFonts/notosans_8_regular.h
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Running compression verification..."
|
|
||||||
python verify_compression.py ../builtinFonts/
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ parser.add_argument("size", type=int, help="font size to use.")
|
|||||||
parser.add_argument("fontstack", action="store", nargs='+', help="list of font files, ordered by descending priority.")
|
parser.add_argument("fontstack", action="store", nargs='+', help="list of font files, ordered by descending priority.")
|
||||||
parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate 2-bit greyscale bitmap instead of 1-bit black and white.")
|
parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate 2-bit greyscale bitmap instead of 1-bit black and white.")
|
||||||
parser.add_argument("--additional-intervals", dest="additional_intervals", action="append", help="Additional code point intervals to export as min,max. This argument can be repeated.")
|
parser.add_argument("--additional-intervals", dest="additional_intervals", action="append", help="Additional code point intervals to export as min,max. This argument can be repeated.")
|
||||||
parser.add_argument("--compress", dest="compress", action="store_true", help="Compress glyph bitmaps using DEFLATE with group-based compression.")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
|
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
|
||||||
@@ -271,115 +270,21 @@ for index, glyph in enumerate(all_glyphs):
|
|||||||
glyph_data.extend([b for b in packed])
|
glyph_data.extend([b for b in packed])
|
||||||
glyph_props.append(props)
|
glyph_props.append(props)
|
||||||
|
|
||||||
compress = args.compress
|
|
||||||
|
|
||||||
# Build groups for compression
|
|
||||||
if compress:
|
|
||||||
# Script-based grouping: glyphs that co-occur in typical text rendering
|
|
||||||
# are grouped together for efficient LRU caching on the embedded target.
|
|
||||||
# Since glyphs are in codepoint order, glyphs in the same Unicode block
|
|
||||||
# are contiguous in the array and form natural groups.
|
|
||||||
SCRIPT_GROUP_RANGES = [
|
|
||||||
(0x0000, 0x007F), # ASCII
|
|
||||||
(0x0080, 0x00FF), # Latin-1 Supplement
|
|
||||||
(0x0100, 0x017F), # Latin Extended-A
|
|
||||||
(0x0300, 0x036F), # Combining Diacritical Marks
|
|
||||||
(0x0400, 0x04FF), # Cyrillic
|
|
||||||
(0x2000, 0x206F), # General Punctuation
|
|
||||||
(0x2070, 0x209F), # Superscripts & Subscripts
|
|
||||||
(0x20A0, 0x20CF), # Currency Symbols
|
|
||||||
(0x2190, 0x21FF), # Arrows
|
|
||||||
(0x2200, 0x22FF), # Math Operators
|
|
||||||
(0xFFFD, 0xFFFD), # Replacement Character
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_script_group(code_point):
|
|
||||||
for i, (start, end) in enumerate(SCRIPT_GROUP_RANGES):
|
|
||||||
if start <= code_point <= end:
|
|
||||||
return i
|
|
||||||
return -1
|
|
||||||
|
|
||||||
groups = [] # list of (first_glyph_index, glyph_count)
|
|
||||||
current_group_id = None
|
|
||||||
group_start = 0
|
|
||||||
group_count = 0
|
|
||||||
|
|
||||||
for i, (props, packed) in enumerate(all_glyphs):
|
|
||||||
sg = get_script_group(props.code_point)
|
|
||||||
if sg != current_group_id:
|
|
||||||
if group_count > 0:
|
|
||||||
groups.append((group_start, group_count))
|
|
||||||
current_group_id = sg
|
|
||||||
group_start = i
|
|
||||||
group_count = 1
|
|
||||||
else:
|
|
||||||
group_count += 1
|
|
||||||
|
|
||||||
if group_count > 0:
|
|
||||||
groups.append((group_start, group_count))
|
|
||||||
|
|
||||||
# Compress each group
|
|
||||||
compressed_groups = [] # list of (compressed_bytes, uncompressed_size, glyph_count, first_glyph_index)
|
|
||||||
compressed_bitmap_data = []
|
|
||||||
compressed_offset = 0
|
|
||||||
|
|
||||||
# Also build modified glyph props with within-group offsets
|
|
||||||
modified_glyph_props = list(glyph_props)
|
|
||||||
|
|
||||||
for first_idx, count in groups:
|
|
||||||
# Concatenate bitmap data for this group
|
|
||||||
group_data = b''
|
|
||||||
for gi in range(first_idx, first_idx + count):
|
|
||||||
props, packed = all_glyphs[gi]
|
|
||||||
# Update glyph's dataOffset to be within-group offset
|
|
||||||
within_group_offset = len(group_data)
|
|
||||||
old_props = modified_glyph_props[gi]
|
|
||||||
modified_glyph_props[gi] = GlyphProps(
|
|
||||||
width=old_props.width,
|
|
||||||
height=old_props.height,
|
|
||||||
advance_x=old_props.advance_x,
|
|
||||||
left=old_props.left,
|
|
||||||
top=old_props.top,
|
|
||||||
data_length=old_props.data_length,
|
|
||||||
data_offset=within_group_offset,
|
|
||||||
code_point=old_props.code_point,
|
|
||||||
)
|
|
||||||
group_data += packed
|
|
||||||
|
|
||||||
# Compress with raw DEFLATE (no zlib/gzip header)
|
|
||||||
compressor = zlib.compressobj(level=9, wbits=-15)
|
|
||||||
compressed = compressor.compress(group_data) + compressor.flush()
|
|
||||||
|
|
||||||
compressed_groups.append((compressed, len(group_data), count, first_idx))
|
|
||||||
compressed_bitmap_data.extend(compressed)
|
|
||||||
compressed_offset += len(compressed)
|
|
||||||
|
|
||||||
glyph_props = modified_glyph_props
|
|
||||||
total_compressed = len(compressed_bitmap_data)
|
|
||||||
total_uncompressed = len(glyph_data)
|
|
||||||
print(f"// Compression: {total_uncompressed} -> {total_compressed} bytes ({100*total_compressed/total_uncompressed:.1f}%), {len(groups)} groups", file=sys.stderr)
|
|
||||||
|
|
||||||
print(f"""/**
|
print(f"""/**
|
||||||
* generated by fontconvert.py
|
* generated by fontconvert.py
|
||||||
* name: {font_name}
|
* name: {font_name}
|
||||||
* size: {size}
|
* size: {size}
|
||||||
* mode: {'2-bit' if is2Bit else '1-bit'}{' compressed: true' if compress else ''}
|
* mode: {'2-bit' if is2Bit else '1-bit'}
|
||||||
* Command used: {' '.join(sys.argv)}
|
* Command used: {' '.join(sys.argv)}
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
""")
|
""")
|
||||||
|
|
||||||
if compress:
|
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
|
||||||
print(f"static const uint8_t {font_name}Bitmaps[{len(compressed_bitmap_data)}] = {{")
|
for c in chunks(glyph_data, 16):
|
||||||
for c in chunks(compressed_bitmap_data, 16):
|
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
||||||
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
print ("};\n");
|
||||||
print ("};\n");
|
|
||||||
else:
|
|
||||||
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
|
|
||||||
for c in chunks(glyph_data, 16):
|
|
||||||
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
|
||||||
print ("};\n");
|
|
||||||
|
|
||||||
print(f"static const EpdGlyph {font_name}Glyphs[] = {{")
|
print(f"static const EpdGlyph {font_name}Glyphs[] = {{")
|
||||||
for i, g in enumerate(glyph_props):
|
for i, g in enumerate(glyph_props):
|
||||||
@@ -393,14 +298,6 @@ for i_start, i_end in intervals:
|
|||||||
offset += i_end - i_start + 1
|
offset += i_end - i_start + 1
|
||||||
print ("};\n");
|
print ("};\n");
|
||||||
|
|
||||||
if compress:
|
|
||||||
print(f"static const EpdFontGroup {font_name}Groups[] = {{")
|
|
||||||
compressed_offset = 0
|
|
||||||
for compressed, uncompressed_size, count, first_idx in compressed_groups:
|
|
||||||
print(f" {{ {compressed_offset}, {len(compressed)}, {uncompressed_size}, {count}, {first_idx} }},")
|
|
||||||
compressed_offset += len(compressed)
|
|
||||||
print("};\n")
|
|
||||||
|
|
||||||
print(f"static const EpdFontData {font_name} = {{")
|
print(f"static const EpdFontData {font_name} = {{")
|
||||||
print(f" {font_name}Bitmaps,")
|
print(f" {font_name}Bitmaps,")
|
||||||
print(f" {font_name}Glyphs,")
|
print(f" {font_name}Glyphs,")
|
||||||
@@ -410,10 +307,4 @@ print(f" {norm_ceil(face.size.height)},")
|
|||||||
print(f" {norm_ceil(face.size.ascender)},")
|
print(f" {norm_ceil(face.size.ascender)},")
|
||||||
print(f" {norm_floor(face.size.descender)},")
|
print(f" {norm_floor(face.size.descender)},")
|
||||||
print(f" {'true' if is2Bit else 'false'},")
|
print(f" {'true' if is2Bit else 'false'},")
|
||||||
if compress:
|
|
||||||
print(f" {font_name}Groups,")
|
|
||||||
print(f" {len(compressed_groups)},")
|
|
||||||
else:
|
|
||||||
print(f" nullptr,")
|
|
||||||
print(f" 0,")
|
|
||||||
print("};")
|
print("};")
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Round-trip verification for compressed font headers.
|
|
||||||
|
|
||||||
Parses each generated .h file in the given directory, identifies compressed fonts
|
|
||||||
(those with a Groups array), decompresses each group, and verifies that
|
|
||||||
decompression succeeds and all glyph offsets/lengths fall within bounds.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import zlib
|
|
||||||
|
|
||||||
|
|
||||||
def parse_hex_array(text):
|
|
||||||
"""Extract bytes from a C hex array string like '{ 0xAB, 0xCD, ... }'"""
|
|
||||||
hex_vals = re.findall(r'0x([0-9A-Fa-f]{2})', text)
|
|
||||||
return bytes(int(h, 16) for h in hex_vals)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_groups(text):
|
|
||||||
"""Parse EpdFontGroup array entries: { compressedOffset, compressedSize, uncompressedSize, glyphCount, firstGlyphIndex }"""
|
|
||||||
groups = []
|
|
||||||
for match in re.finditer(r'\{\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\}', text):
|
|
||||||
groups.append({
|
|
||||||
'compressedOffset': int(match.group(1)),
|
|
||||||
'compressedSize': int(match.group(2)),
|
|
||||||
'uncompressedSize': int(match.group(3)),
|
|
||||||
'glyphCount': int(match.group(4)),
|
|
||||||
'firstGlyphIndex': int(match.group(5)),
|
|
||||||
})
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def parse_glyphs(text):
|
|
||||||
"""Parse EpdGlyph array entries: { width, height, advanceX, left, top, dataLength, dataOffset }"""
|
|
||||||
glyphs = []
|
|
||||||
for match in re.finditer(r'\{\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\}', text):
|
|
||||||
glyphs.append({
|
|
||||||
'width': int(match.group(1)),
|
|
||||||
'height': int(match.group(2)),
|
|
||||||
'advanceX': int(match.group(3)),
|
|
||||||
'left': int(match.group(4)),
|
|
||||||
'top': int(match.group(5)),
|
|
||||||
'dataLength': int(match.group(6)),
|
|
||||||
'dataOffset': int(match.group(7)),
|
|
||||||
})
|
|
||||||
return glyphs
|
|
||||||
|
|
||||||
|
|
||||||
def verify_font_file(filepath):
|
|
||||||
"""Verify a single font header file. Returns (font_name, success, message)."""
|
|
||||||
with open(filepath, 'r') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Check if this is a compressed font (has Groups array)
|
|
||||||
groups_match = re.search(r'static const EpdFontGroup (\w+)Groups\[\]', content)
|
|
||||||
if not groups_match:
|
|
||||||
return (os.path.basename(filepath), None, "uncompressed, skipping")
|
|
||||||
|
|
||||||
font_name = groups_match.group(1)
|
|
||||||
|
|
||||||
# Extract bitmap data
|
|
||||||
bitmap_match = re.search(
|
|
||||||
r'static const uint8_t ' + re.escape(font_name) + r'Bitmaps\[\d+\]\s*=\s*\{([^}]+)\}',
|
|
||||||
content, re.DOTALL
|
|
||||||
)
|
|
||||||
if not bitmap_match:
|
|
||||||
return (font_name, False, "could not find Bitmaps array")
|
|
||||||
|
|
||||||
compressed_data = parse_hex_array(bitmap_match.group(1))
|
|
||||||
|
|
||||||
# Extract groups
|
|
||||||
groups_array_match = re.search(
|
|
||||||
r'static const EpdFontGroup ' + re.escape(font_name) + r'Groups\[\]\s*=\s*\{(.+?)\};',
|
|
||||||
content, re.DOTALL
|
|
||||||
)
|
|
||||||
if not groups_array_match:
|
|
||||||
return (font_name, False, "could not find Groups array")
|
|
||||||
|
|
||||||
groups = parse_groups(groups_array_match.group(1))
|
|
||||||
if not groups:
|
|
||||||
return (font_name, False, "Groups array parsed to 0 entries; check format")
|
|
||||||
|
|
||||||
# Extract glyphs
|
|
||||||
glyphs_match = re.search(
|
|
||||||
r'static const EpdGlyph ' + re.escape(font_name) + r'Glyphs\[\]\s*=\s*\{(.+?)\};',
|
|
||||||
content, re.DOTALL
|
|
||||||
)
|
|
||||||
if not glyphs_match:
|
|
||||||
return (font_name, False, "could not find Glyphs array")
|
|
||||||
|
|
||||||
glyphs = parse_glyphs(glyphs_match.group(1))
|
|
||||||
|
|
||||||
# Verify each group
|
|
||||||
for gi, group in enumerate(groups):
|
|
||||||
# Extract compressed chunk
|
|
||||||
chunk = compressed_data[group['compressedOffset']:group['compressedOffset'] + group['compressedSize']]
|
|
||||||
if len(chunk) != group['compressedSize']:
|
|
||||||
return (font_name, False, f"group {gi}: compressed data truncated (expected {group['compressedSize']}, got {len(chunk)})")
|
|
||||||
|
|
||||||
# Decompress with raw DEFLATE
|
|
||||||
try:
|
|
||||||
decompressed = zlib.decompress(chunk, -15)
|
|
||||||
except zlib.error as e:
|
|
||||||
return (font_name, False, f"group {gi}: decompression failed: {e}")
|
|
||||||
|
|
||||||
if len(decompressed) != group['uncompressedSize']:
|
|
||||||
return (font_name, False, f"group {gi}: size mismatch (expected {group['uncompressedSize']}, got {len(decompressed)})")
|
|
||||||
|
|
||||||
# Verify each glyph's data within the group
|
|
||||||
first = group['firstGlyphIndex']
|
|
||||||
for j in range(group['glyphCount']):
|
|
||||||
glyph_idx = first + j
|
|
||||||
if glyph_idx >= len(glyphs):
|
|
||||||
return (font_name, False, f"group {gi}: glyph index {glyph_idx} out of range")
|
|
||||||
|
|
||||||
glyph = glyphs[glyph_idx]
|
|
||||||
offset = glyph['dataOffset']
|
|
||||||
length = glyph['dataLength']
|
|
||||||
|
|
||||||
if offset + length > len(decompressed):
|
|
||||||
return (font_name, False, f"group {gi}, glyph {glyph_idx}: data extends beyond decompressed buffer "
|
|
||||||
f"(offset={offset}, length={length}, decompressed_size={len(decompressed)})")
|
|
||||||
|
|
||||||
return (font_name, True, f"{len(groups)} groups, {len(glyphs)} glyphs OK")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print(f"Usage: {sys.argv[0]} <font_headers_directory>", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
font_dir = sys.argv[1]
|
|
||||||
if not os.path.isdir(font_dir):
|
|
||||||
print(f"Error: {font_dir} is not a directory", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
files = sorted(f for f in os.listdir(font_dir) if f.endswith('.h') and f != 'all.h')
|
|
||||||
passed = 0
|
|
||||||
failed = 0
|
|
||||||
skipped = 0
|
|
||||||
|
|
||||||
for filename in files:
|
|
||||||
filepath = os.path.join(font_dir, filename)
|
|
||||||
_font_name, success, message = verify_font_file(filepath)
|
|
||||||
|
|
||||||
if success is None:
|
|
||||||
skipped += 1
|
|
||||||
elif success:
|
|
||||||
passed += 1
|
|
||||||
print(f" PASS: {filename} ({message})")
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
print(f" FAIL: {filename} - {message}")
|
|
||||||
|
|
||||||
print(f"\nResults: {passed} passed, {failed} failed, {skipped} skipped (uncompressed)")
|
|
||||||
|
|
||||||
if failed > 0:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
+57
-191
@@ -4,7 +4,6 @@
|
|||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <JpegToBmpConverter.h>
|
#include <JpegToBmpConverter.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <PngToBmpConverter.h>
|
|
||||||
#include <ZipFile.h>
|
#include <ZipFile.h>
|
||||||
|
|
||||||
#include "Epub/parsers/ContainerParser.h"
|
#include "Epub/parsers/ContainerParser.h"
|
||||||
@@ -77,54 +76,6 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|||||||
bookMetadata.author = opfParser.author;
|
bookMetadata.author = opfParser.author;
|
||||||
bookMetadata.language = opfParser.language;
|
bookMetadata.language = opfParser.language;
|
||||||
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
bookMetadata.coverItemHref = opfParser.coverItemHref;
|
||||||
|
|
||||||
// Guide-based cover fallback: if no cover found via metadata/properties,
|
|
||||||
// try extracting the image reference from the guide's cover page XHTML
|
|
||||||
if (bookMetadata.coverItemHref.empty() && !opfParser.guideCoverPageHref.empty()) {
|
|
||||||
LOG_DBG("EBP", "No cover from metadata, trying guide cover page: %s", opfParser.guideCoverPageHref.c_str());
|
|
||||||
size_t coverPageSize;
|
|
||||||
uint8_t* coverPageData = readItemContentsToBytes(opfParser.guideCoverPageHref, &coverPageSize, true);
|
|
||||||
if (coverPageData) {
|
|
||||||
const std::string coverPageHtml(reinterpret_cast<char*>(coverPageData), coverPageSize);
|
|
||||||
free(coverPageData);
|
|
||||||
|
|
||||||
// Determine base path of the cover page for resolving relative image references
|
|
||||||
std::string coverPageBase;
|
|
||||||
const auto lastSlash = opfParser.guideCoverPageHref.rfind('/');
|
|
||||||
if (lastSlash != std::string::npos) {
|
|
||||||
coverPageBase = opfParser.guideCoverPageHref.substr(0, lastSlash + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search for image references: xlink:href="..." (SVG) and src="..." (img)
|
|
||||||
std::string imageRef;
|
|
||||||
for (const char* pattern : {"xlink:href=\"", "src=\""}) {
|
|
||||||
auto pos = coverPageHtml.find(pattern);
|
|
||||||
while (pos != std::string::npos) {
|
|
||||||
pos += strlen(pattern);
|
|
||||||
const auto endPos = coverPageHtml.find('"', pos);
|
|
||||||
if (endPos != std::string::npos) {
|
|
||||||
const auto ref = coverPageHtml.substr(pos, endPos - pos);
|
|
||||||
// Check if it's an image file
|
|
||||||
if (ref.length() >= 4) {
|
|
||||||
const auto ext = ref.substr(ref.length() - 4);
|
|
||||||
if (ext == ".png" || ext == ".jpg" || ext == "jpeg" || ext == ".gif") {
|
|
||||||
imageRef = ref;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos = coverPageHtml.find(pattern, pos);
|
|
||||||
}
|
|
||||||
if (!imageRef.empty()) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imageRef.empty()) {
|
|
||||||
bookMetadata.coverItemHref = FsHelpers::normalisePath(coverPageBase + imageRef);
|
|
||||||
LOG_DBG("EBP", "Found cover image from guide: %s", bookMetadata.coverItemHref.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
bookMetadata.textReferenceHref = opfParser.textReferenceHref;
|
||||||
|
|
||||||
if (!opfParser.tocNcxPath.empty()) {
|
if (!opfParser.tocNcxPath.empty()) {
|
||||||
@@ -257,80 +208,68 @@ bool Epub::parseTocNavFile() const {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Epub::parseCssFiles() const {
|
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
|
||||||
// Maximum CSS file size we'll attempt to parse (uncompressed)
|
|
||||||
// Larger files risk memory exhaustion on ESP32
|
|
||||||
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
|
|
||||||
// Minimum heap required before attempting CSS parsing
|
|
||||||
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
|
|
||||||
|
|
||||||
|
bool Epub::loadCssRulesFromCache() const {
|
||||||
|
FsFile cssCacheFile;
|
||||||
|
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
|
||||||
|
if (cssParser->loadFromCache(cssCacheFile)) {
|
||||||
|
cssCacheFile.close();
|
||||||
|
LOG_DBG("EBP", "Loaded CSS rules from cache");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cssCacheFile.close();
|
||||||
|
LOG_DBG("EBP", "CSS cache invalid, reparsing");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Epub::parseCssFiles() const {
|
||||||
if (cssFiles.empty()) {
|
if (cssFiles.empty()) {
|
||||||
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
|
// Try to load from CSS cache first
|
||||||
|
if (!loadCssRulesFromCache()) {
|
||||||
|
// Cache miss - parse CSS files
|
||||||
|
for (const auto& cssPath : cssFiles) {
|
||||||
|
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||||
|
|
||||||
// See if we have a cached version of the CSS rules
|
// Extract CSS file to temp location
|
||||||
if (cssParser->hasCache()) {
|
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
||||||
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
|
FsFile tempCssFile;
|
||||||
return;
|
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
||||||
}
|
LOG_ERR("EBP", "Could not create temp CSS file");
|
||||||
|
|
||||||
// No cache yet - parse CSS files
|
|
||||||
for (const auto& cssPath : cssFiles) {
|
|
||||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
|
||||||
|
|
||||||
// Check heap before parsing - CSS parsing allocates heavily
|
|
||||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
|
||||||
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
|
|
||||||
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
|
|
||||||
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check CSS file size before decompressing - skip files that are too large
|
|
||||||
size_t cssFileSize = 0;
|
|
||||||
if (getItemSize(cssPath, &cssFileSize)) {
|
|
||||||
if (cssFileSize > MAX_CSS_FILE_SIZE) {
|
|
||||||
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
|
|
||||||
cssPath.c_str());
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
||||||
|
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
|
||||||
|
tempCssFile.close();
|
||||||
|
Storage.remove(tmpCssPath.c_str());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tempCssFile.close();
|
||||||
|
|
||||||
// Extract CSS file to temp location
|
// Parse the CSS file
|
||||||
const auto tmpCssPath = getCachePath() + "/.tmp.css";
|
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
||||||
FsFile tempCssFile;
|
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
||||||
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
|
Storage.remove(tmpCssPath.c_str());
|
||||||
LOG_ERR("EBP", "Could not create temp CSS file");
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
cssParser->loadFromStream(tempCssFile);
|
||||||
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
|
|
||||||
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
|
|
||||||
tempCssFile.close();
|
tempCssFile.close();
|
||||||
Storage.remove(tmpCssPath.c_str());
|
Storage.remove(tmpCssPath.c_str());
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
tempCssFile.close();
|
|
||||||
|
|
||||||
// Parse the CSS file
|
// Save to cache for next time
|
||||||
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
|
FsFile cssCacheFile;
|
||||||
LOG_ERR("EBP", "Could not open temp CSS file for reading");
|
if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
|
||||||
Storage.remove(tmpCssPath.c_str());
|
cssParser->saveToCache(cssCacheFile);
|
||||||
continue;
|
cssCacheFile.close();
|
||||||
}
|
}
|
||||||
cssParser->loadFromStream(tempCssFile);
|
|
||||||
tempCssFile.close();
|
|
||||||
Storage.remove(tmpCssPath.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to cache for next time
|
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||||
if (!cssParser->saveToCache()) {
|
|
||||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
|
||||||
}
|
}
|
||||||
cssParser->clear();
|
|
||||||
|
|
||||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// load in the meta data for the epub file
|
// load in the meta data for the epub file
|
||||||
@@ -340,24 +279,18 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
|||||||
// Initialize spine/TOC cache
|
// Initialize spine/TOC cache
|
||||||
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
bookMetadataCache.reset(new BookMetadataCache(cachePath));
|
||||||
// Always create CssParser - needed for inline style parsing even without CSS files
|
// Always create CssParser - needed for inline style parsing even without CSS files
|
||||||
cssParser.reset(new CssParser(cachePath));
|
cssParser.reset(new CssParser());
|
||||||
|
|
||||||
// Try to load existing cache first
|
// Try to load existing cache first
|
||||||
if (bookMetadataCache->load()) {
|
if (bookMetadataCache->load()) {
|
||||||
if (!skipLoadingCss) {
|
if (!skipLoadingCss && !loadCssRulesFromCache()) {
|
||||||
// Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file)
|
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
|
||||||
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
|
// to get CSS file list
|
||||||
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
|
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
||||||
cssParser->deleteCache();
|
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
||||||
|
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
||||||
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
|
|
||||||
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
|
|
||||||
// continue anyway - book will work without CSS and we'll still load any inline style CSS
|
|
||||||
}
|
|
||||||
parseCssFiles();
|
|
||||||
// Invalidate section caches so they are rebuilt with the new CSS
|
|
||||||
Storage.removeDir((cachePath + "/sections").c_str());
|
|
||||||
}
|
}
|
||||||
|
parseCssFiles();
|
||||||
}
|
}
|
||||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||||
return true;
|
return true;
|
||||||
@@ -458,7 +391,6 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
|
|||||||
if (!skipLoadingCss) {
|
if (!skipLoadingCss) {
|
||||||
// Parse CSS files after cache reload
|
// Parse CSS files after cache reload
|
||||||
parseCssFiles();
|
parseCssFiles();
|
||||||
Storage.removeDir((cachePath + "/sections").c_str());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
|
||||||
@@ -571,44 +503,12 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
|||||||
LOG_ERR("EBP", "Failed to generate BMP from cover image");
|
LOG_ERR("EBP", "Failed to generate BMP from cover image");
|
||||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
Storage.remove(getCoverBmpPath(cropped).c_str());
|
||||||
}
|
}
|
||||||
LOG_DBG("EBP", "Generated BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no");
|
||||||
return success;
|
return success;
|
||||||
|
} else {
|
||||||
|
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
|
|
||||||
LOG_DBG("EBP", "Generating BMP from PNG cover image (%s mode)", cropped ? "cropped" : "fit");
|
|
||||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
|
||||||
|
|
||||||
FsFile coverPng;
|
|
||||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
|
||||||
coverPng.close();
|
|
||||||
|
|
||||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
FsFile coverBmp;
|
|
||||||
if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
|
|
||||||
coverPng.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const bool success = PngToBmpConverter::pngFileToBmpStream(coverPng, coverBmp, cropped);
|
|
||||||
coverPng.close();
|
|
||||||
coverBmp.close();
|
|
||||||
Storage.remove(coverPngTempPath.c_str());
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
LOG_ERR("EBP", "Failed to generate BMP from PNG cover image");
|
|
||||||
Storage.remove(getCoverBmpPath(cropped).c_str());
|
|
||||||
}
|
|
||||||
LOG_DBG("EBP", "Generated BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,40 +566,6 @@ bool Epub::generateThumbBmp(int height) const {
|
|||||||
}
|
}
|
||||||
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
|
||||||
return success;
|
return success;
|
||||||
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".png") {
|
|
||||||
LOG_DBG("EBP", "Generating thumb BMP from PNG cover image");
|
|
||||||
const auto coverPngTempPath = getCachePath() + "/.cover.png";
|
|
||||||
|
|
||||||
FsFile coverPng;
|
|
||||||
if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
readItemContentsToStream(coverImageHref, coverPng, 1024);
|
|
||||||
coverPng.close();
|
|
||||||
|
|
||||||
if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
FsFile thumbBmp;
|
|
||||||
if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
|
||||||
coverPng.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
|
||||||
int THUMB_TARGET_HEIGHT = height;
|
|
||||||
const bool success =
|
|
||||||
PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, THUMB_TARGET_WIDTH, THUMB_TARGET_HEIGHT);
|
|
||||||
coverPng.close();
|
|
||||||
thumbBmp.close();
|
|
||||||
Storage.remove(coverPngTempPath.c_str());
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
LOG_ERR("EBP", "Failed to generate thumb BMP from PNG cover image");
|
|
||||||
Storage.remove(getThumbBmpPath(height).c_str());
|
|
||||||
}
|
|
||||||
LOG_DBG("EBP", "Generated thumb BMP from PNG cover image, success: %s", success ? "yes" : "no");
|
|
||||||
return success;
|
|
||||||
} else {
|
} else {
|
||||||
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -35,6 +35,8 @@ class Epub {
|
|||||||
bool parseTocNcxFile() const;
|
bool parseTocNcxFile() const;
|
||||||
bool parseTocNavFile() const;
|
bool parseTocNavFile() const;
|
||||||
void parseCssFiles() const;
|
void parseCssFiles() const;
|
||||||
|
std::string getCssRulesCache() const;
|
||||||
|
bool loadCssRulesFromCache() const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||||
@@ -71,5 +73,5 @@ class Epub {
|
|||||||
|
|
||||||
size_t getBookSize() const;
|
size_t getBookSize() const;
|
||||||
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||||
CssParser* getCssParser() const { return cssParser.get(); }
|
const CssParser* getCssParser() const { return cssParser.get(); }
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-29
@@ -25,29 +25,6 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
|
|||||||
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
|
||||||
}
|
}
|
||||||
|
|
||||||
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
|
|
||||||
// Images don't use fontId or text rendering
|
|
||||||
imageBlock->render(renderer, xPos + xOffset, yPos + yOffset);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool PageImage::serialize(FsFile& file) {
|
|
||||||
serialization::writePod(file, xPos);
|
|
||||||
serialization::writePod(file, yPos);
|
|
||||||
|
|
||||||
// serialize ImageBlock
|
|
||||||
return imageBlock->serialize(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
|
|
||||||
int16_t xPos;
|
|
||||||
int16_t yPos;
|
|
||||||
serialization::readPod(file, xPos);
|
|
||||||
serialization::readPod(file, yPos);
|
|
||||||
|
|
||||||
auto ib = ImageBlock::deserialize(file);
|
|
||||||
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
|
|
||||||
}
|
|
||||||
|
|
||||||
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
|
||||||
for (auto& element : elements) {
|
for (auto& element : elements) {
|
||||||
element->render(renderer, fontId, xOffset, yOffset);
|
element->render(renderer, fontId, xOffset, yOffset);
|
||||||
@@ -59,9 +36,8 @@ bool Page::serialize(FsFile& file) const {
|
|||||||
serialization::writePod(file, count);
|
serialization::writePod(file, count);
|
||||||
|
|
||||||
for (const auto& el : elements) {
|
for (const auto& el : elements) {
|
||||||
// Use getTag() method to determine type
|
// Only PageLine exists currently
|
||||||
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
|
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
|
||||||
|
|
||||||
if (!el->serialize(file)) {
|
if (!el->serialize(file)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -83,9 +59,6 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
|
|||||||
if (tag == TAG_PageLine) {
|
if (tag == TAG_PageLine) {
|
||||||
auto pl = PageLine::deserialize(file);
|
auto pl = PageLine::deserialize(file);
|
||||||
page->elements.push_back(std::move(pl));
|
page->elements.push_back(std::move(pl));
|
||||||
} else if (tag == TAG_PageImage) {
|
|
||||||
auto pi = PageImage::deserialize(file);
|
|
||||||
page->elements.push_back(std::move(pi));
|
|
||||||
} else {
|
} else {
|
||||||
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "blocks/ImageBlock.h"
|
|
||||||
#include "blocks/TextBlock.h"
|
#include "blocks/TextBlock.h"
|
||||||
|
|
||||||
enum PageElementTag : uint8_t {
|
enum PageElementTag : uint8_t {
|
||||||
TAG_PageLine = 1,
|
TAG_PageLine = 1,
|
||||||
TAG_PageImage = 2, // New tag
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// represents something that has been added to a page
|
// represents something that has been added to a page
|
||||||
@@ -22,7 +19,6 @@ class PageElement {
|
|||||||
virtual ~PageElement() = default;
|
virtual ~PageElement() = default;
|
||||||
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
|
||||||
virtual bool serialize(FsFile& file) = 0;
|
virtual bool serialize(FsFile& file) = 0;
|
||||||
virtual PageElementTag getTag() const = 0; // Add type identification
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// a line from a block element
|
// a line from a block element
|
||||||
@@ -34,24 +30,9 @@ class PageLine final : public PageElement {
|
|||||||
: PageElement(xPos, yPos), block(std::move(block)) {}
|
: PageElement(xPos, yPos), block(std::move(block)) {}
|
||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||||
bool serialize(FsFile& file) override;
|
bool serialize(FsFile& file) override;
|
||||||
PageElementTag getTag() const override { return TAG_PageLine; }
|
|
||||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||||
};
|
};
|
||||||
|
|
||||||
// New PageImage class
|
|
||||||
class PageImage final : public PageElement {
|
|
||||||
std::shared_ptr<ImageBlock> imageBlock;
|
|
||||||
|
|
||||||
public:
|
|
||||||
PageImage(std::shared_ptr<ImageBlock> block, const int16_t xPos, const int16_t yPos)
|
|
||||||
: PageElement(xPos, yPos), imageBlock(std::move(block)) {}
|
|
||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
|
||||||
bool serialize(FsFile& file) override;
|
|
||||||
PageElementTag getTag() const override { return TAG_PageImage; }
|
|
||||||
static std::unique_ptr<PageImage> deserialize(FsFile& file);
|
|
||||||
const ImageBlock& getImageBlock() const { return *imageBlock; }
|
|
||||||
};
|
|
||||||
|
|
||||||
class Page {
|
class Page {
|
||||||
public:
|
public:
|
||||||
// the list of block index and line numbers on this page
|
// the list of block index and line numbers on this page
|
||||||
@@ -59,38 +40,4 @@ class Page {
|
|||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
|
||||||
bool serialize(FsFile& file) const;
|
bool serialize(FsFile& file) const;
|
||||||
static std::unique_ptr<Page> deserialize(FsFile& file);
|
static std::unique_ptr<Page> deserialize(FsFile& file);
|
||||||
|
|
||||||
// Check if page contains any images (used to force full refresh)
|
|
||||||
bool hasImages() const {
|
|
||||||
return std::any_of(elements.begin(), elements.end(),
|
|
||||||
[](const std::shared_ptr<PageElement>& el) { return el->getTag() == TAG_PageImage; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get bounding box of all images on the page (union of image rects)
|
|
||||||
// Returns false if no images. Coordinates are relative to page origin.
|
|
||||||
bool getImageBoundingBox(int16_t& outX, int16_t& outY, int16_t& outW, int16_t& outH) const {
|
|
||||||
bool found = false;
|
|
||||||
int16_t minX = INT16_MAX, minY = INT16_MAX, maxX = INT16_MIN, maxY = INT16_MIN;
|
|
||||||
for (const auto& el : elements) {
|
|
||||||
if (el->getTag() == TAG_PageImage) {
|
|
||||||
const auto& img = static_cast<const PageImage&>(*el);
|
|
||||||
int16_t x = img.xPos;
|
|
||||||
int16_t y = img.yPos;
|
|
||||||
int16_t right = x + img.getImageBlock().getWidth();
|
|
||||||
int16_t bottom = y + img.getImageBlock().getHeight();
|
|
||||||
minX = std::min(minX, x);
|
|
||||||
minY = std::min(minY, y);
|
|
||||||
maxX = std::max(maxX, right);
|
|
||||||
maxY = std::max(maxY, bottom);
|
|
||||||
found = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) {
|
|
||||||
outX = minX;
|
|
||||||
outY = minY;
|
|
||||||
outW = maxX - minX;
|
|
||||||
outH = maxY - minY;
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,17 +29,15 @@ void stripSoftHyphensInPlace(std::string& word) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the advance width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
|
// Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
|
||||||
// Uses advance width (sum of glyph advances) rather than bounding box width so that italic glyph overhangs
|
|
||||||
// don't inflate inter-word spacing.
|
|
||||||
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
|
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
|
||||||
const EpdFontFamily::Style style, const bool appendHyphen = false) {
|
const EpdFontFamily::Style style, const bool appendHyphen = false) {
|
||||||
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
|
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
|
||||||
return renderer.getSpaceWidth(fontId, style);
|
return renderer.getSpaceWidth(fontId);
|
||||||
}
|
}
|
||||||
const bool hasSoftHyphen = containsSoftHyphen(word);
|
const bool hasSoftHyphen = containsSoftHyphen(word);
|
||||||
if (!hasSoftHyphen && !appendHyphen) {
|
if (!hasSoftHyphen && !appendHyphen) {
|
||||||
return renderer.getTextAdvanceX(fontId, word.c_str(), style);
|
return renderer.getTextWidth(fontId, word.c_str(), style);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string sanitized = word;
|
std::string sanitized = word;
|
||||||
@@ -49,7 +47,7 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
|
|||||||
if (appendHyphen) {
|
if (appendHyphen) {
|
||||||
sanitized.push_back('-');
|
sanitized.push_back('-');
|
||||||
}
|
}
|
||||||
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
|
return renderer.getTextWidth(fontId, sanitized.c_str(), style);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -378,35 +376,20 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
|||||||
words.insert(insertWordIt, remainder);
|
words.insert(insertWordIt, remainder);
|
||||||
wordStyles.insert(insertStyleIt, style);
|
wordStyles.insert(insertStyleIt, style);
|
||||||
|
|
||||||
// Continuation flag handling after splitting a word into prefix + remainder.
|
// The remainder inherits whatever continuation status the original word had with the word after it.
|
||||||
//
|
// Find the continues entry for the original word and insert the remainder's entry after it.
|
||||||
// The prefix keeps the original word's continuation flag so that no-break-space groups
|
|
||||||
// stay linked. The remainder always gets continues=false because it starts on the next
|
|
||||||
// line and is not attached to the prefix.
|
|
||||||
//
|
|
||||||
// Example: "200 Quadratkilometer" produces tokens:
|
|
||||||
// [0] "200" continues=false
|
|
||||||
// [1] " " continues=true
|
|
||||||
// [2] "Quadratkilometer" continues=true <-- the word being split
|
|
||||||
//
|
|
||||||
// After splitting "Quadratkilometer" at "Quadrat-" / "kilometer":
|
|
||||||
// [0] "200" continues=false
|
|
||||||
// [1] " " continues=true
|
|
||||||
// [2] "Quadrat-" continues=true (KEPT — still attached to the no-break group)
|
|
||||||
// [3] "kilometer" continues=false (NEW — starts fresh on the next line)
|
|
||||||
//
|
|
||||||
// This lets the backtracking loop keep the entire prefix group ("200 Quadrat-") on one
|
|
||||||
// line, while "kilometer" moves to the next line.
|
|
||||||
auto continuesIt = wordContinues.begin();
|
auto continuesIt = wordContinues.begin();
|
||||||
std::advance(continuesIt, wordIndex);
|
std::advance(continuesIt, wordIndex);
|
||||||
// *continuesIt is intentionally left unchanged — the prefix keeps its original attachment.
|
const bool originalContinuedToNext = *continuesIt;
|
||||||
|
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
|
||||||
|
*continuesIt = false;
|
||||||
const auto insertContinuesIt = std::next(continuesIt);
|
const auto insertContinuesIt = std::next(continuesIt);
|
||||||
wordContinues.insert(insertContinuesIt, false);
|
wordContinues.insert(insertContinuesIt, originalContinuedToNext);
|
||||||
|
|
||||||
// Keep the indexed vector in sync if provided.
|
// Keep the indexed vector in sync if provided
|
||||||
if (continuesVec) {
|
if (continuesVec) {
|
||||||
// (*continuesVec)[wordIndex] stays unchanged — prefix keeps its attachment.
|
(*continuesVec)[wordIndex] = false;
|
||||||
continuesVec->insert(continuesVec->begin() + wordIndex + 1, false);
|
continuesVec->insert(continuesVec->begin() + wordIndex + 1, originalContinuedToNext);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
#include <Serialization.h>
|
||||||
|
|
||||||
#include "Epub/css/CssParser.h"
|
|
||||||
#include "Page.h"
|
#include "Page.h"
|
||||||
#include "hyphenation/Hyphenator.h"
|
#include "hyphenation/Hyphenator.h"
|
||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 13;
|
constexpr uint8_t SECTION_FILE_VERSION = 12;
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
|
||||||
sizeof(uint32_t);
|
sizeof(uint32_t);
|
||||||
@@ -182,26 +181,11 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
viewportHeight, hyphenationEnabled, embeddedStyle);
|
viewportHeight, hyphenationEnabled, embeddedStyle);
|
||||||
std::vector<uint32_t> lut = {};
|
std::vector<uint32_t> lut = {};
|
||||||
|
|
||||||
// Derive the content base directory and image cache path prefix for the parser
|
|
||||||
size_t lastSlash = localPath.find_last_of('/');
|
|
||||||
std::string contentBase = (lastSlash != std::string::npos) ? localPath.substr(0, lastSlash + 1) : "";
|
|
||||||
std::string imageBasePath = epub->getCachePath() + "/img_" + std::to_string(spineIndex) + "_";
|
|
||||||
|
|
||||||
CssParser* cssParser = nullptr;
|
|
||||||
if (embeddedStyle) {
|
|
||||||
cssParser = epub->getCssParser();
|
|
||||||
if (cssParser) {
|
|
||||||
if (!cssParser->loadFromCache()) {
|
|
||||||
LOG_ERR("SCT", "Failed to load CSS from cache");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ChapterHtmlSlimParser visitor(
|
ChapterHtmlSlimParser visitor(
|
||||||
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
|
||||||
viewportHeight, hyphenationEnabled,
|
viewportHeight, hyphenationEnabled,
|
||||||
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
|
||||||
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
|
embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr);
|
||||||
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
Hyphenator::setPreferredLanguage(epub->getLanguage());
|
||||||
success = visitor.parseAndBuildPages();
|
success = visitor.parseAndBuildPages();
|
||||||
|
|
||||||
@@ -210,9 +194,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
LOG_ERR("SCT", "Failed to parse XML and build pages");
|
||||||
file.close();
|
file.close();
|
||||||
Storage.remove(filePath.c_str());
|
Storage.remove(filePath.c_str());
|
||||||
if (cssParser) {
|
|
||||||
cssParser->clear();
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,9 +220,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
|
|||||||
serialization::writePod(file, pageCount);
|
serialization::writePod(file, pageCount);
|
||||||
serialization::writePod(file, lutOffset);
|
serialization::writePod(file, lutOffset);
|
||||||
file.close();
|
file.close();
|
||||||
if (cssParser) {
|
|
||||||
cssParser->clear();
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
|
|||||||
class Block {
|
class Block {
|
||||||
public:
|
public:
|
||||||
virtual ~Block() = default;
|
virtual ~Block() = default;
|
||||||
|
virtual void layout(GfxRenderer& renderer) = 0;
|
||||||
virtual BlockType getType() = 0;
|
virtual BlockType getType() = 0;
|
||||||
virtual bool isEmpty() = 0;
|
virtual bool isEmpty() = 0;
|
||||||
virtual void finish() {}
|
virtual void finish() {}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
#include "ImageBlock.h"
|
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
#include <Logging.h>
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
#include <Serialization.h>
|
|
||||||
|
|
||||||
#include "../converters/DitherUtils.h"
|
|
||||||
#include "../converters/ImageDecoderFactory.h"
|
|
||||||
|
|
||||||
// Cache file format:
|
|
||||||
// - uint16_t width
|
|
||||||
// - uint16_t height
|
|
||||||
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
|
|
||||||
|
|
||||||
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
|
|
||||||
: imagePath(imagePath), width(width), height(height) {}
|
|
||||||
|
|
||||||
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
std::string getCachePath(const std::string& imagePath) {
|
|
||||||
// Replace extension with .pxc (pixel cache)
|
|
||||||
size_t dotPos = imagePath.rfind('.');
|
|
||||||
if (dotPos != std::string::npos) {
|
|
||||||
return imagePath.substr(0, dotPos) + ".pxc";
|
|
||||||
}
|
|
||||||
return imagePath + ".pxc";
|
|
||||||
}
|
|
||||||
|
|
||||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
|
||||||
int expectedHeight) {
|
|
||||||
FsFile cacheFile;
|
|
||||||
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t cachedWidth, cachedHeight;
|
|
||||||
if (cacheFile.read(&cachedWidth, 2) != 2 || cacheFile.read(&cachedHeight, 2) != 2) {
|
|
||||||
cacheFile.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify dimensions are close (allow 1 pixel tolerance for rounding differences)
|
|
||||||
int widthDiff = abs(cachedWidth - expectedWidth);
|
|
||||||
int heightDiff = abs(cachedHeight - expectedHeight);
|
|
||||||
if (widthDiff > 1 || heightDiff > 1) {
|
|
||||||
LOG_ERR("IMG", "Cache dimension mismatch: %dx%d vs %dx%d", cachedWidth, cachedHeight, expectedWidth,
|
|
||||||
expectedHeight);
|
|
||||||
cacheFile.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use cached dimensions for rendering (they're the actual decoded size)
|
|
||||||
expectedWidth = cachedWidth;
|
|
||||||
expectedHeight = cachedHeight;
|
|
||||||
|
|
||||||
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
|
|
||||||
|
|
||||||
// Read and render row by row to minimize memory usage
|
|
||||||
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
|
||||||
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
|
|
||||||
if (!rowBuffer) {
|
|
||||||
LOG_ERR("IMG", "Failed to allocate row buffer");
|
|
||||||
cacheFile.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int row = 0; row < cachedHeight; row++) {
|
|
||||||
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
|
|
||||||
LOG_ERR("IMG", "Cache read error at row %d", row);
|
|
||||||
free(rowBuffer);
|
|
||||||
cacheFile.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int destY = y + row;
|
|
||||||
for (int col = 0; col < cachedWidth; col++) {
|
|
||||||
int byteIdx = col / 4;
|
|
||||||
int bitShift = 6 - (col % 4) * 2; // MSB first within byte
|
|
||||||
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
|
||||||
|
|
||||||
drawPixelWithRenderMode(renderer, x + col, destY, pixelValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
free(rowBuffer);
|
|
||||||
cacheFile.close();
|
|
||||||
LOG_DBG("IMG", "Cache render complete");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
|
||||||
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
|
|
||||||
|
|
||||||
const int screenWidth = renderer.getScreenWidth();
|
|
||||||
const int screenHeight = renderer.getScreenHeight();
|
|
||||||
|
|
||||||
// Bounds check render position using logical screen dimensions
|
|
||||||
if (x < 0 || y < 0 || x + width > screenWidth || y + height > screenHeight) {
|
|
||||||
LOG_ERR("IMG", "Invalid render position: (%d,%d) size (%dx%d) screen (%dx%d)", x, y, width, height, screenWidth,
|
|
||||||
screenHeight);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to render from cache first
|
|
||||||
std::string cachePath = getCachePath(imagePath);
|
|
||||||
if (renderFromCache(renderer, cachePath, x, y, width, height)) {
|
|
||||||
return; // Successfully rendered from cache
|
|
||||||
}
|
|
||||||
|
|
||||||
// No cache - need to decode the image
|
|
||||||
// Check if image file exists
|
|
||||||
FsFile file;
|
|
||||||
if (!Storage.openFileForRead("IMG", imagePath, file)) {
|
|
||||||
LOG_ERR("IMG", "Image file not found: %s", imagePath.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
size_t fileSize = file.size();
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
if (fileSize == 0) {
|
|
||||||
LOG_ERR("IMG", "Image file is empty: %s", imagePath.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("IMG", "Decoding and caching: %s", imagePath.c_str());
|
|
||||||
|
|
||||||
RenderConfig config;
|
|
||||||
config.x = x;
|
|
||||||
config.y = y;
|
|
||||||
config.maxWidth = width;
|
|
||||||
config.maxHeight = height;
|
|
||||||
config.useGrayscale = true;
|
|
||||||
config.useDithering = true;
|
|
||||||
config.performanceMode = false;
|
|
||||||
config.useExactDimensions = true; // Use pre-calculated dimensions to avoid rounding mismatches
|
|
||||||
config.cachePath = cachePath; // Enable caching during decode
|
|
||||||
|
|
||||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(imagePath);
|
|
||||||
if (!decoder) {
|
|
||||||
LOG_ERR("IMG", "No decoder found for image: %s", imagePath.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("IMG", "Using %s decoder", decoder->getFormatName());
|
|
||||||
|
|
||||||
bool success = decoder->decodeToFramebuffer(imagePath, renderer, config);
|
|
||||||
if (!success) {
|
|
||||||
LOG_ERR("IMG", "Failed to decode image: %s", imagePath.c_str());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("IMG", "Decode successful");
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ImageBlock::serialize(FsFile& file) {
|
|
||||||
serialization::writeString(file, imagePath);
|
|
||||||
serialization::writePod(file, width);
|
|
||||||
serialization::writePod(file, height);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::unique_ptr<ImageBlock> ImageBlock::deserialize(FsFile& file) {
|
|
||||||
std::string path;
|
|
||||||
serialization::readString(file, path);
|
|
||||||
int16_t w, h;
|
|
||||||
serialization::readPod(file, w);
|
|
||||||
serialization::readPod(file, h);
|
|
||||||
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <SdFat.h>
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "Block.h"
|
|
||||||
|
|
||||||
class ImageBlock final : public Block {
|
|
||||||
public:
|
|
||||||
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
|
||||||
~ImageBlock() override = default;
|
|
||||||
|
|
||||||
const std::string& getImagePath() const { return imagePath; }
|
|
||||||
int16_t getWidth() const { return width; }
|
|
||||||
int16_t getHeight() const { return height; }
|
|
||||||
|
|
||||||
bool imageExists() const;
|
|
||||||
|
|
||||||
BlockType getType() override { return IMAGE_BLOCK; }
|
|
||||||
bool isEmpty() override { return false; }
|
|
||||||
|
|
||||||
void render(GfxRenderer& renderer, const int x, const int y);
|
|
||||||
bool serialize(FsFile& file);
|
|
||||||
static std::unique_ptr<ImageBlock> deserialize(FsFile& file);
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::string imagePath;
|
|
||||||
int16_t width;
|
|
||||||
int16_t height;
|
|
||||||
};
|
|
||||||
@@ -33,7 +33,7 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
if (w.size() >= 3 && static_cast<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
|
||||||
static_cast<uint8_t>(w[2]) == 0x83) {
|
static_cast<uint8_t>(w[2]) == 0x83) {
|
||||||
const char* visiblePtr = w.c_str() + 3;
|
const char* visiblePtr = w.c_str() + 3;
|
||||||
const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
|
const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str());
|
||||||
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
|
||||||
startX = wordX + prefixWidth;
|
startX = wordX + prefixWidth;
|
||||||
underlineWidth = visibleWidth;
|
underlineWidth = visibleWidth;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class TextBlock final : public Block {
|
|||||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||||
bool isEmpty() override { return words.empty(); }
|
bool isEmpty() override { return words.empty(); }
|
||||||
|
void layout(GfxRenderer& renderer) override {};
|
||||||
// given a renderer works out where to break the words into lines
|
// given a renderer works out where to break the words into lines
|
||||||
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
|
||||||
BlockType getType() override { return TEXT_BLOCK; }
|
BlockType getType() override { return TEXT_BLOCK; }
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
// 4x4 Bayer matrix for ordered dithering
|
|
||||||
inline const uint8_t bayer4x4[4][4] = {
|
|
||||||
{0, 8, 2, 10},
|
|
||||||
{12, 4, 14, 6},
|
|
||||||
{3, 11, 1, 9},
|
|
||||||
{15, 7, 13, 5},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply Bayer dithering and quantize to 4 levels (0-3)
|
|
||||||
// Stateless - works correctly with any pixel processing order
|
|
||||||
inline uint8_t applyBayerDither4Level(uint8_t gray, int x, int y) {
|
|
||||||
int bayer = bayer4x4[y & 3][x & 3];
|
|
||||||
int dither = (bayer - 8) * 5; // Scale to +/-40 (half of quantization step 85)
|
|
||||||
|
|
||||||
int adjusted = gray + dither;
|
|
||||||
if (adjusted < 0) adjusted = 0;
|
|
||||||
if (adjusted > 255) adjusted = 255;
|
|
||||||
|
|
||||||
if (adjusted < 64) return 0;
|
|
||||||
if (adjusted < 128) return 1;
|
|
||||||
if (adjusted < 192) return 2;
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw a pixel respecting the current render mode for grayscale support
|
|
||||||
inline void drawPixelWithRenderMode(GfxRenderer& renderer, int x, int y, uint8_t pixelValue) {
|
|
||||||
GfxRenderer::RenderMode renderMode = renderer.getRenderMode();
|
|
||||||
if (renderMode == GfxRenderer::BW && pixelValue < 3) {
|
|
||||||
renderer.drawPixel(x, y, true);
|
|
||||||
} else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (pixelValue == 1 || pixelValue == 2)) {
|
|
||||||
renderer.drawPixel(x, y, false);
|
|
||||||
} else if (renderMode == GfxRenderer::GRAYSCALE_LSB && pixelValue == 1) {
|
|
||||||
renderer.drawPixel(x, y, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#include "ImageDecoderFactory.h"
|
|
||||||
|
|
||||||
#include <Logging.h>
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "JpegToFramebufferConverter.h"
|
|
||||||
#include "PngToFramebufferConverter.h"
|
|
||||||
|
|
||||||
std::unique_ptr<JpegToFramebufferConverter> ImageDecoderFactory::jpegDecoder = nullptr;
|
|
||||||
std::unique_ptr<PngToFramebufferConverter> ImageDecoderFactory::pngDecoder = nullptr;
|
|
||||||
|
|
||||||
ImageToFramebufferDecoder* ImageDecoderFactory::getDecoder(const std::string& imagePath) {
|
|
||||||
std::string ext = imagePath;
|
|
||||||
size_t dotPos = ext.rfind('.');
|
|
||||||
if (dotPos != std::string::npos) {
|
|
||||||
ext = ext.substr(dotPos);
|
|
||||||
for (auto& c : ext) {
|
|
||||||
c = tolower(c);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ext = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (JpegToFramebufferConverter::supportsFormat(ext)) {
|
|
||||||
if (!jpegDecoder) {
|
|
||||||
jpegDecoder.reset(new JpegToFramebufferConverter());
|
|
||||||
}
|
|
||||||
return jpegDecoder.get();
|
|
||||||
} else if (PngToFramebufferConverter::supportsFormat(ext)) {
|
|
||||||
if (!pngDecoder) {
|
|
||||||
pngDecoder.reset(new PngToFramebufferConverter());
|
|
||||||
}
|
|
||||||
return pngDecoder.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_ERR("DEC", "No decoder found for image: %s", imagePath.c_str());
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool ImageDecoderFactory::isFormatSupported(const std::string& imagePath) { return getDecoder(imagePath) != nullptr; }
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <cstdint>
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "ImageToFramebufferDecoder.h"
|
|
||||||
|
|
||||||
class JpegToFramebufferConverter;
|
|
||||||
class PngToFramebufferConverter;
|
|
||||||
|
|
||||||
class ImageDecoderFactory {
|
|
||||||
public:
|
|
||||||
// Returns non-owning pointer - factory owns the decoder lifetime
|
|
||||||
static ImageToFramebufferDecoder* getDecoder(const std::string& imagePath);
|
|
||||||
static bool isFormatSupported(const std::string& imagePath);
|
|
||||||
|
|
||||||
private:
|
|
||||||
static std::unique_ptr<JpegToFramebufferConverter> jpegDecoder;
|
|
||||||
static std::unique_ptr<PngToFramebufferConverter> pngDecoder;
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#include "ImageToFramebufferDecoder.h"
|
|
||||||
|
|
||||||
#include <Logging.h>
|
|
||||||
|
|
||||||
bool ImageToFramebufferDecoder::validateImageDimensions(int width, int height, const std::string& format) {
|
|
||||||
if (width * height > MAX_SOURCE_PIXELS) {
|
|
||||||
LOG_ERR("IMG", "Image too large (%dx%d = %d pixels %s), max supported: %d pixels", width, height, width * height,
|
|
||||||
format.c_str(), MAX_SOURCE_PIXELS);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ImageToFramebufferDecoder::warnUnsupportedFeature(const std::string& feature, const std::string& imagePath) {
|
|
||||||
LOG_ERR("IMG", "Warning: Unsupported feature '%s' in image '%s'. Image may not display correctly.", feature.c_str(),
|
|
||||||
imagePath.c_str());
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <SdFat.h>
|
|
||||||
|
|
||||||
#include <memory>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
class GfxRenderer;
|
|
||||||
|
|
||||||
struct ImageDimensions {
|
|
||||||
int16_t width;
|
|
||||||
int16_t height;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct RenderConfig {
|
|
||||||
int x, y;
|
|
||||||
int maxWidth, maxHeight;
|
|
||||||
bool useGrayscale = true;
|
|
||||||
bool useDithering = true;
|
|
||||||
bool performanceMode = false;
|
|
||||||
bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation)
|
|
||||||
std::string cachePath; // If non-empty, decoder will write pixel cache to this path
|
|
||||||
};
|
|
||||||
|
|
||||||
class ImageToFramebufferDecoder {
|
|
||||||
public:
|
|
||||||
virtual ~ImageToFramebufferDecoder() = default;
|
|
||||||
|
|
||||||
virtual bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) = 0;
|
|
||||||
|
|
||||||
virtual bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const = 0;
|
|
||||||
|
|
||||||
virtual const char* getFormatName() const = 0;
|
|
||||||
|
|
||||||
protected:
|
|
||||||
// Size validation helpers
|
|
||||||
static constexpr int MAX_SOURCE_PIXELS = 3145728; // 2048 * 1536
|
|
||||||
|
|
||||||
bool validateImageDimensions(int width, int height, const std::string& format);
|
|
||||||
void warnUnsupportedFeature(const std::string& feature, const std::string& imagePath);
|
|
||||||
};
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
#include "JpegToFramebufferConverter.h"
|
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
#include <Logging.h>
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
#include <SdFat.h>
|
|
||||||
#include <picojpeg.h>
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include "DitherUtils.h"
|
|
||||||
#include "PixelCache.h"
|
|
||||||
|
|
||||||
struct JpegContext {
|
|
||||||
FsFile& file;
|
|
||||||
uint8_t buffer[512];
|
|
||||||
size_t bufferPos;
|
|
||||||
size_t bufferFilled;
|
|
||||||
JpegContext(FsFile& f) : file(f), bufferPos(0), bufferFilled(0) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
bool JpegToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
|
||||||
FsFile file;
|
|
||||||
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
|
||||||
LOG_ERR("JPG", "Failed to open file for dimensions: %s", imagePath.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
JpegContext context(file);
|
|
||||||
pjpeg_image_info_t imageInfo;
|
|
||||||
|
|
||||||
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
if (status != 0) {
|
|
||||||
LOG_ERR("JPG", "Failed to init JPEG for dimensions: %d", status);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.width = imageInfo.m_width;
|
|
||||||
out.height = imageInfo.m_height;
|
|
||||||
LOG_DBG("JPG", "Image dimensions: %dx%d", out.width, out.height);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
|
||||||
const RenderConfig& config) {
|
|
||||||
LOG_DBG("JPG", "Decoding JPEG: %s", imagePath.c_str());
|
|
||||||
|
|
||||||
FsFile file;
|
|
||||||
if (!Storage.openFileForRead("JPG", imagePath, file)) {
|
|
||||||
LOG_ERR("JPG", "Failed to open file: %s", imagePath.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
JpegContext context(file);
|
|
||||||
pjpeg_image_info_t imageInfo;
|
|
||||||
|
|
||||||
int status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
|
|
||||||
if (status != 0) {
|
|
||||||
LOG_ERR("JPG", "picojpeg init failed: %d", status);
|
|
||||||
file.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateImageDimensions(imageInfo.m_width, imageInfo.m_height, "JPEG")) {
|
|
||||||
file.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate output dimensions
|
|
||||||
int destWidth, destHeight;
|
|
||||||
float scale;
|
|
||||||
|
|
||||||
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
|
||||||
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
|
||||||
destWidth = config.maxWidth;
|
|
||||||
destHeight = config.maxHeight;
|
|
||||||
scale = (float)destWidth / imageInfo.m_width;
|
|
||||||
} else {
|
|
||||||
// Calculate scale factor to fit within maxWidth/maxHeight
|
|
||||||
float scaleX = (config.maxWidth > 0 && imageInfo.m_width > config.maxWidth)
|
|
||||||
? (float)config.maxWidth / imageInfo.m_width
|
|
||||||
: 1.0f;
|
|
||||||
float scaleY = (config.maxHeight > 0 && imageInfo.m_height > config.maxHeight)
|
|
||||||
? (float)config.maxHeight / imageInfo.m_height
|
|
||||||
: 1.0f;
|
|
||||||
scale = (scaleX < scaleY) ? scaleX : scaleY;
|
|
||||||
if (scale > 1.0f) scale = 1.0f;
|
|
||||||
|
|
||||||
destWidth = (int)(imageInfo.m_width * scale);
|
|
||||||
destHeight = (int)(imageInfo.m_height * scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("JPG", "JPEG %dx%d -> %dx%d (scale %.2f), scan type: %d, MCU: %dx%d", imageInfo.m_width, imageInfo.m_height,
|
|
||||||
destWidth, destHeight, scale, imageInfo.m_scanType, imageInfo.m_MCUWidth, imageInfo.m_MCUHeight);
|
|
||||||
|
|
||||||
if (!imageInfo.m_pMCUBufR || !imageInfo.m_pMCUBufG || !imageInfo.m_pMCUBufB) {
|
|
||||||
LOG_ERR("JPG", "Null buffer pointers in imageInfo");
|
|
||||||
file.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int screenWidth = renderer.getScreenWidth();
|
|
||||||
const int screenHeight = renderer.getScreenHeight();
|
|
||||||
|
|
||||||
// Allocate pixel cache if cachePath is provided
|
|
||||||
PixelCache cache;
|
|
||||||
bool caching = !config.cachePath.empty();
|
|
||||||
if (caching) {
|
|
||||||
if (!cache.allocate(destWidth, destHeight, config.x, config.y)) {
|
|
||||||
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
|
|
||||||
caching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int mcuX = 0;
|
|
||||||
int mcuY = 0;
|
|
||||||
|
|
||||||
while (mcuY < imageInfo.m_MCUSPerCol) {
|
|
||||||
status = pjpeg_decode_mcu();
|
|
||||||
if (status == PJPG_NO_MORE_BLOCKS) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (status != 0) {
|
|
||||||
LOG_ERR("JPG", "MCU decode failed: %d", status);
|
|
||||||
file.close();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source position in image coordinates
|
|
||||||
int srcStartX = mcuX * imageInfo.m_MCUWidth;
|
|
||||||
int srcStartY = mcuY * imageInfo.m_MCUHeight;
|
|
||||||
|
|
||||||
switch (imageInfo.m_scanType) {
|
|
||||||
case PJPG_GRAYSCALE:
|
|
||||||
for (int row = 0; row < 8; row++) {
|
|
||||||
int srcY = srcStartY + row;
|
|
||||||
int destY = config.y + (int)(srcY * scale);
|
|
||||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
|
||||||
for (int col = 0; col < 8; col++) {
|
|
||||||
int srcX = srcStartX + col;
|
|
||||||
int destX = config.x + (int)(srcX * scale);
|
|
||||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
|
||||||
uint8_t gray = imageInfo.m_pMCUBufR[row * 8 + col];
|
|
||||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
|
||||||
if (caching) cache.setPixel(destX, destY, dithered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PJPG_YH1V1:
|
|
||||||
for (int row = 0; row < 8; row++) {
|
|
||||||
int srcY = srcStartY + row;
|
|
||||||
int destY = config.y + (int)(srcY * scale);
|
|
||||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
|
||||||
for (int col = 0; col < 8; col++) {
|
|
||||||
int srcX = srcStartX + col;
|
|
||||||
int destX = config.x + (int)(srcX * scale);
|
|
||||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
|
||||||
uint8_t r = imageInfo.m_pMCUBufR[row * 8 + col];
|
|
||||||
uint8_t g = imageInfo.m_pMCUBufG[row * 8 + col];
|
|
||||||
uint8_t b = imageInfo.m_pMCUBufB[row * 8 + col];
|
|
||||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
|
||||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
|
||||||
if (caching) cache.setPixel(destX, destY, dithered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PJPG_YH2V1:
|
|
||||||
for (int row = 0; row < 8; row++) {
|
|
||||||
int srcY = srcStartY + row;
|
|
||||||
int destY = config.y + (int)(srcY * scale);
|
|
||||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
|
||||||
for (int col = 0; col < 16; col++) {
|
|
||||||
int srcX = srcStartX + col;
|
|
||||||
int destX = config.x + (int)(srcX * scale);
|
|
||||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
|
||||||
int blockIndex = (col < 8) ? 0 : 1;
|
|
||||||
int pixelIndex = row * 8 + (col % 8);
|
|
||||||
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 64 + pixelIndex];
|
|
||||||
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 64 + pixelIndex];
|
|
||||||
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 64 + pixelIndex];
|
|
||||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
|
||||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
|
||||||
if (caching) cache.setPixel(destX, destY, dithered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PJPG_YH1V2:
|
|
||||||
for (int row = 0; row < 16; row++) {
|
|
||||||
int srcY = srcStartY + row;
|
|
||||||
int destY = config.y + (int)(srcY * scale);
|
|
||||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
|
||||||
for (int col = 0; col < 8; col++) {
|
|
||||||
int srcX = srcStartX + col;
|
|
||||||
int destX = config.x + (int)(srcX * scale);
|
|
||||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
|
||||||
int blockIndex = (row < 8) ? 0 : 1;
|
|
||||||
int pixelIndex = (row % 8) * 8 + col;
|
|
||||||
uint8_t r = imageInfo.m_pMCUBufR[blockIndex * 128 + pixelIndex];
|
|
||||||
uint8_t g = imageInfo.m_pMCUBufG[blockIndex * 128 + pixelIndex];
|
|
||||||
uint8_t b = imageInfo.m_pMCUBufB[blockIndex * 128 + pixelIndex];
|
|
||||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
|
||||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
|
||||||
if (caching) cache.setPixel(destX, destY, dithered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PJPG_YH2V2:
|
|
||||||
for (int row = 0; row < 16; row++) {
|
|
||||||
int srcY = srcStartY + row;
|
|
||||||
int destY = config.y + (int)(srcY * scale);
|
|
||||||
if (destY >= screenHeight || destY >= config.y + destHeight) continue;
|
|
||||||
for (int col = 0; col < 16; col++) {
|
|
||||||
int srcX = srcStartX + col;
|
|
||||||
int destX = config.x + (int)(srcX * scale);
|
|
||||||
if (destX >= screenWidth || destX >= config.x + destWidth) continue;
|
|
||||||
int blockX = (col < 8) ? 0 : 1;
|
|
||||||
int blockY = (row < 8) ? 0 : 1;
|
|
||||||
int blockIndex = blockY * 2 + blockX;
|
|
||||||
int pixelIndex = (row % 8) * 8 + (col % 8);
|
|
||||||
int blockOffset = blockIndex * 64;
|
|
||||||
uint8_t r = imageInfo.m_pMCUBufR[blockOffset + pixelIndex];
|
|
||||||
uint8_t g = imageInfo.m_pMCUBufG[blockOffset + pixelIndex];
|
|
||||||
uint8_t b = imageInfo.m_pMCUBufB[blockOffset + pixelIndex];
|
|
||||||
uint8_t gray = (uint8_t)((r * 77 + g * 150 + b * 29) >> 8);
|
|
||||||
uint8_t dithered = config.useDithering ? applyBayerDither4Level(gray, destX, destY) : gray / 85;
|
|
||||||
if (dithered > 3) dithered = 3;
|
|
||||||
drawPixelWithRenderMode(renderer, destX, destY, dithered);
|
|
||||||
if (caching) cache.setPixel(destX, destY, dithered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
mcuX++;
|
|
||||||
if (mcuX >= imageInfo.m_MCUSPerRow) {
|
|
||||||
mcuX = 0;
|
|
||||||
mcuY++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("JPG", "Decoding complete");
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
// Write cache file if caching was enabled
|
|
||||||
if (caching) {
|
|
||||||
cache.writeToFile(config.cachePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsigned char JpegToFramebufferConverter::jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
|
||||||
unsigned char* pBytes_actually_read, void* pCallback_data) {
|
|
||||||
JpegContext* context = reinterpret_cast<JpegContext*>(pCallback_data);
|
|
||||||
|
|
||||||
if (context->bufferPos >= context->bufferFilled) {
|
|
||||||
int readCount = context->file.read(context->buffer, sizeof(context->buffer));
|
|
||||||
if (readCount <= 0) {
|
|
||||||
*pBytes_actually_read = 0;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
context->bufferFilled = readCount;
|
|
||||||
context->bufferPos = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsigned int bytesAvailable = context->bufferFilled - context->bufferPos;
|
|
||||||
unsigned int bytesToCopy = (bytesAvailable < buf_size) ? bytesAvailable : buf_size;
|
|
||||||
|
|
||||||
memcpy(pBuf, &context->buffer[context->bufferPos], bytesToCopy);
|
|
||||||
context->bufferPos += bytesToCopy;
|
|
||||||
*pBytes_actually_read = bytesToCopy;
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool JpegToFramebufferConverter::supportsFormat(const std::string& extension) {
|
|
||||||
std::string ext = extension;
|
|
||||||
for (auto& c : ext) {
|
|
||||||
c = tolower(c);
|
|
||||||
}
|
|
||||||
return (ext == ".jpg" || ext == ".jpeg");
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "ImageToFramebufferDecoder.h"
|
|
||||||
|
|
||||||
class JpegToFramebufferConverter final : public ImageToFramebufferDecoder {
|
|
||||||
public:
|
|
||||||
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
|
||||||
|
|
||||||
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
|
||||||
|
|
||||||
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
|
||||||
return getDimensionsStatic(imagePath, dims);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool supportsFormat(const std::string& extension);
|
|
||||||
const char* getFormatName() const override { return "JPEG"; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
|
||||||
unsigned char* pBytes_actually_read, void* pCallback_data);
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <Logging.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
#include <cstring>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
|
|
||||||
// Packs 4 pixels per byte, MSB first.
|
|
||||||
struct PixelCache {
|
|
||||||
uint8_t* buffer;
|
|
||||||
int width;
|
|
||||||
int height;
|
|
||||||
int bytesPerRow;
|
|
||||||
int originX; // config.x - to convert screen coords to cache coords
|
|
||||||
int originY; // config.y
|
|
||||||
|
|
||||||
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
|
|
||||||
PixelCache(const PixelCache&) = delete;
|
|
||||||
PixelCache& operator=(const PixelCache&) = delete;
|
|
||||||
|
|
||||||
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
|
|
||||||
|
|
||||||
bool allocate(int w, int h, int ox, int oy) {
|
|
||||||
width = w;
|
|
||||||
height = h;
|
|
||||||
originX = ox;
|
|
||||||
originY = oy;
|
|
||||||
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
|
||||||
size_t bufferSize = (size_t)bytesPerRow * h;
|
|
||||||
if (bufferSize > MAX_CACHE_BYTES) {
|
|
||||||
LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
buffer = (uint8_t*)malloc(bufferSize);
|
|
||||||
if (buffer) {
|
|
||||||
memset(buffer, 0, bufferSize);
|
|
||||||
LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h);
|
|
||||||
}
|
|
||||||
return buffer != nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setPixel(int screenX, int screenY, uint8_t value) {
|
|
||||||
if (!buffer) return;
|
|
||||||
int localX = screenX - originX;
|
|
||||||
int localY = screenY - originY;
|
|
||||||
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
|
|
||||||
|
|
||||||
int byteIdx = localY * bytesPerRow + localX / 4;
|
|
||||||
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
|
|
||||||
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool writeToFile(const std::string& cachePath) {
|
|
||||||
if (!buffer) return false;
|
|
||||||
|
|
||||||
FsFile cacheFile;
|
|
||||||
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
|
|
||||||
LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t w = width;
|
|
||||||
uint16_t h = height;
|
|
||||||
cacheFile.write(&w, 2);
|
|
||||||
cacheFile.write(&h, 2);
|
|
||||||
cacheFile.write(buffer, bytesPerRow * height);
|
|
||||||
cacheFile.close();
|
|
||||||
|
|
||||||
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
~PixelCache() {
|
|
||||||
if (buffer) {
|
|
||||||
free(buffer);
|
|
||||||
buffer = nullptr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
#include "PngToFramebufferConverter.h"
|
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
#include <Logging.h>
|
|
||||||
#include <PNGdec.h>
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
#include <SdFat.h>
|
|
||||||
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <new>
|
|
||||||
|
|
||||||
#include "DitherUtils.h"
|
|
||||||
#include "PixelCache.h"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Context struct passed through PNGdec callbacks to avoid global mutable state.
|
|
||||||
// The draw callback receives this via pDraw->pUser (set by png.decode()).
|
|
||||||
// The file I/O callbacks receive the FsFile* via pFile->fHandle (set by pngOpen()).
|
|
||||||
struct PngContext {
|
|
||||||
GfxRenderer* renderer;
|
|
||||||
const RenderConfig* config;
|
|
||||||
int screenWidth;
|
|
||||||
int screenHeight;
|
|
||||||
|
|
||||||
// Scaling state
|
|
||||||
float scale;
|
|
||||||
int srcWidth;
|
|
||||||
int srcHeight;
|
|
||||||
int dstWidth;
|
|
||||||
int dstHeight;
|
|
||||||
int lastDstY; // Track last rendered destination Y to avoid duplicates
|
|
||||||
|
|
||||||
PixelCache cache;
|
|
||||||
bool caching;
|
|
||||||
|
|
||||||
uint8_t* grayLineBuffer;
|
|
||||||
|
|
||||||
PngContext()
|
|
||||||
: renderer(nullptr),
|
|
||||||
config(nullptr),
|
|
||||||
screenWidth(0),
|
|
||||||
screenHeight(0),
|
|
||||||
scale(1.0f),
|
|
||||||
srcWidth(0),
|
|
||||||
srcHeight(0),
|
|
||||||
dstWidth(0),
|
|
||||||
dstHeight(0),
|
|
||||||
lastDstY(-1),
|
|
||||||
caching(false),
|
|
||||||
grayLineBuffer(nullptr) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// File I/O callbacks use pFile->fHandle to access the FsFile*,
|
|
||||||
// avoiding the need for global file state.
|
|
||||||
void* pngOpenWithHandle(const char* filename, int32_t* size) {
|
|
||||||
FsFile* f = new FsFile();
|
|
||||||
if (!Storage.openFileForRead("PNG", std::string(filename), *f)) {
|
|
||||||
delete f;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
*size = f->size();
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
void pngCloseWithHandle(void* handle) {
|
|
||||||
FsFile* f = reinterpret_cast<FsFile*>(handle);
|
|
||||||
if (f) {
|
|
||||||
f->close();
|
|
||||||
delete f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int32_t pngReadWithHandle(PNGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
|
||||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
|
||||||
if (!f) return 0;
|
|
||||||
return f->read(pBuf, len);
|
|
||||||
}
|
|
||||||
|
|
||||||
int32_t pngSeekWithHandle(PNGFILE* pFile, int32_t pos) {
|
|
||||||
FsFile* f = reinterpret_cast<FsFile*>(pFile->fHandle);
|
|
||||||
if (!f) return -1;
|
|
||||||
return f->seek(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The PNG decoder (PNGdec) is ~42 KB due to internal zlib decompression buffers.
|
|
||||||
// We heap-allocate it on demand rather than using a static instance, so this memory
|
|
||||||
// is only consumed while actually decoding/querying PNG images. This is critical on
|
|
||||||
// the ESP32-C3 where total RAM is ~320 KB.
|
|
||||||
constexpr size_t PNG_DECODER_APPROX_SIZE = 44 * 1024; // ~42 KB + overhead
|
|
||||||
constexpr size_t MIN_FREE_HEAP_FOR_PNG = PNG_DECODER_APPROX_SIZE + 16 * 1024; // decoder + 16 KB headroom
|
|
||||||
|
|
||||||
// PNGdec keeps TWO scanlines in its internal ucPixels buffer (current + previous)
|
|
||||||
// and each scanline includes a leading filter byte.
|
|
||||||
// Required storage is therefore approximately: 2 * (pitch + 1) + alignment slack.
|
|
||||||
// If PNG_MAX_BUFFERED_PIXELS is smaller than this requirement for a given image,
|
|
||||||
// PNGdec can overrun its internal buffer before our draw callback executes.
|
|
||||||
int bytesPerPixelFromType(int pixelType) {
|
|
||||||
switch (pixelType) {
|
|
||||||
case PNG_PIXEL_TRUECOLOR:
|
|
||||||
return 3;
|
|
||||||
case PNG_PIXEL_GRAY_ALPHA:
|
|
||||||
return 2;
|
|
||||||
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
|
||||||
return 4;
|
|
||||||
case PNG_PIXEL_GRAYSCALE:
|
|
||||||
case PNG_PIXEL_INDEXED:
|
|
||||||
default:
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int requiredPngInternalBufferBytes(int srcWidth, int pixelType) {
|
|
||||||
// +1 filter byte per scanline, *2 for current+previous lines, +32 for alignment margin.
|
|
||||||
int pitch = srcWidth * bytesPerPixelFromType(pixelType);
|
|
||||||
return ((pitch + 1) * 2) + 32;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert entire source line to grayscale with alpha blending to white background.
|
|
||||||
// For indexed PNGs with tRNS chunk, alpha values are stored at palette[768] onwards.
|
|
||||||
// Processing the whole line at once improves cache locality and reduces per-pixel overhead.
|
|
||||||
void convertLineToGray(uint8_t* pPixels, uint8_t* grayLine, int width, int pixelType, uint8_t* palette, int hasAlpha) {
|
|
||||||
switch (pixelType) {
|
|
||||||
case PNG_PIXEL_GRAYSCALE:
|
|
||||||
memcpy(grayLine, pPixels, width);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PNG_PIXEL_TRUECOLOR:
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
uint8_t* p = &pPixels[x * 3];
|
|
||||||
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PNG_PIXEL_INDEXED:
|
|
||||||
if (palette) {
|
|
||||||
if (hasAlpha) {
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
uint8_t idx = pPixels[x];
|
|
||||||
uint8_t* p = &palette[idx * 3];
|
|
||||||
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
|
||||||
uint8_t alpha = palette[768 + idx];
|
|
||||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
uint8_t* p = &palette[pPixels[x] * 3];
|
|
||||||
grayLine[x] = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
memcpy(grayLine, pPixels, width);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PNG_PIXEL_GRAY_ALPHA:
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
uint8_t gray = pPixels[x * 2];
|
|
||||||
uint8_t alpha = pPixels[x * 2 + 1];
|
|
||||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case PNG_PIXEL_TRUECOLOR_ALPHA:
|
|
||||||
for (int x = 0; x < width; x++) {
|
|
||||||
uint8_t* p = &pPixels[x * 4];
|
|
||||||
uint8_t gray = (uint8_t)((p[0] * 77 + p[1] * 150 + p[2] * 29) >> 8);
|
|
||||||
uint8_t alpha = p[3];
|
|
||||||
grayLine[x] = (uint8_t)((gray * alpha + 255 * (255 - alpha)) / 255);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
memset(grayLine, 128, width);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int pngDrawCallback(PNGDRAW* pDraw) {
|
|
||||||
PngContext* ctx = reinterpret_cast<PngContext*>(pDraw->pUser);
|
|
||||||
if (!ctx || !ctx->config || !ctx->renderer || !ctx->grayLineBuffer) return 0;
|
|
||||||
|
|
||||||
int srcY = pDraw->y;
|
|
||||||
int srcWidth = ctx->srcWidth;
|
|
||||||
|
|
||||||
// Calculate destination Y with scaling
|
|
||||||
int dstY = (int)(srcY * ctx->scale);
|
|
||||||
|
|
||||||
// Skip if we already rendered this destination row (multiple source rows map to same dest)
|
|
||||||
if (dstY == ctx->lastDstY) return 1;
|
|
||||||
ctx->lastDstY = dstY;
|
|
||||||
|
|
||||||
// Check bounds
|
|
||||||
if (dstY >= ctx->dstHeight) return 1;
|
|
||||||
|
|
||||||
int outY = ctx->config->y + dstY;
|
|
||||||
if (outY >= ctx->screenHeight) return 1;
|
|
||||||
|
|
||||||
// Convert entire source line to grayscale (improves cache locality)
|
|
||||||
convertLineToGray(pDraw->pPixels, ctx->grayLineBuffer, srcWidth, pDraw->iPixelType, pDraw->pPalette,
|
|
||||||
pDraw->iHasAlpha);
|
|
||||||
|
|
||||||
// Render scaled row using Bresenham-style integer stepping (no floating-point division)
|
|
||||||
int dstWidth = ctx->dstWidth;
|
|
||||||
int outXBase = ctx->config->x;
|
|
||||||
int screenWidth = ctx->screenWidth;
|
|
||||||
bool useDithering = ctx->config->useDithering;
|
|
||||||
bool caching = ctx->caching;
|
|
||||||
|
|
||||||
int srcX = 0;
|
|
||||||
int error = 0;
|
|
||||||
|
|
||||||
for (int dstX = 0; dstX < dstWidth; dstX++) {
|
|
||||||
int outX = outXBase + dstX;
|
|
||||||
if (outX < screenWidth) {
|
|
||||||
uint8_t gray = ctx->grayLineBuffer[srcX];
|
|
||||||
|
|
||||||
uint8_t ditheredGray;
|
|
||||||
if (useDithering) {
|
|
||||||
ditheredGray = applyBayerDither4Level(gray, outX, outY);
|
|
||||||
} else {
|
|
||||||
ditheredGray = gray / 85;
|
|
||||||
if (ditheredGray > 3) ditheredGray = 3;
|
|
||||||
}
|
|
||||||
drawPixelWithRenderMode(*ctx->renderer, outX, outY, ditheredGray);
|
|
||||||
if (caching) ctx->cache.setPixel(outX, outY, ditheredGray);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bresenham-style stepping: advance srcX based on ratio srcWidth/dstWidth
|
|
||||||
error += srcWidth;
|
|
||||||
while (error >= dstWidth) {
|
|
||||||
error -= dstWidth;
|
|
||||||
srcX++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool PngToFramebufferConverter::getDimensionsStatic(const std::string& imagePath, ImageDimensions& out) {
|
|
||||||
size_t freeHeap = ESP.getFreeHeap();
|
|
||||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
|
||||||
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
PNG* png = new (std::nothrow) PNG();
|
|
||||||
if (!png) {
|
|
||||||
LOG_ERR("PNG", "Failed to allocate PNG decoder for dimensions");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
|
||||||
nullptr);
|
|
||||||
|
|
||||||
if (rc != 0) {
|
|
||||||
LOG_ERR("PNG", "Failed to open PNG for dimensions: %d", rc);
|
|
||||||
delete png;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.width = png->getWidth();
|
|
||||||
out.height = png->getHeight();
|
|
||||||
|
|
||||||
png->close();
|
|
||||||
delete png;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer,
|
|
||||||
const RenderConfig& config) {
|
|
||||||
LOG_DBG("PNG", "Decoding PNG: %s", imagePath.c_str());
|
|
||||||
|
|
||||||
size_t freeHeap = ESP.getFreeHeap();
|
|
||||||
if (freeHeap < MIN_FREE_HEAP_FOR_PNG) {
|
|
||||||
LOG_ERR("PNG", "Not enough heap for PNG decoder (%u free, need %u)", freeHeap, MIN_FREE_HEAP_FOR_PNG);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heap-allocate PNG decoder (~42 KB) - freed at end of function
|
|
||||||
PNG* png = new (std::nothrow) PNG();
|
|
||||||
if (!png) {
|
|
||||||
LOG_ERR("PNG", "Failed to allocate PNG decoder");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
PngContext ctx;
|
|
||||||
ctx.renderer = &renderer;
|
|
||||||
ctx.config = &config;
|
|
||||||
ctx.screenWidth = renderer.getScreenWidth();
|
|
||||||
ctx.screenHeight = renderer.getScreenHeight();
|
|
||||||
|
|
||||||
int rc = png->open(imagePath.c_str(), pngOpenWithHandle, pngCloseWithHandle, pngReadWithHandle, pngSeekWithHandle,
|
|
||||||
pngDrawCallback);
|
|
||||||
if (rc != PNG_SUCCESS) {
|
|
||||||
LOG_ERR("PNG", "Failed to open PNG: %d", rc);
|
|
||||||
delete png;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateImageDimensions(png->getWidth(), png->getHeight(), "PNG")) {
|
|
||||||
png->close();
|
|
||||||
delete png;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate output dimensions
|
|
||||||
ctx.srcWidth = png->getWidth();
|
|
||||||
ctx.srcHeight = png->getHeight();
|
|
||||||
|
|
||||||
if (config.useExactDimensions && config.maxWidth > 0 && config.maxHeight > 0) {
|
|
||||||
// Use exact dimensions as specified (avoids rounding mismatches with pre-calculated sizes)
|
|
||||||
ctx.dstWidth = config.maxWidth;
|
|
||||||
ctx.dstHeight = config.maxHeight;
|
|
||||||
ctx.scale = (float)ctx.dstWidth / ctx.srcWidth;
|
|
||||||
} else {
|
|
||||||
// Calculate scale factor to fit within maxWidth/maxHeight
|
|
||||||
float scaleX = (float)config.maxWidth / ctx.srcWidth;
|
|
||||||
float scaleY = (float)config.maxHeight / ctx.srcHeight;
|
|
||||||
ctx.scale = (scaleX < scaleY) ? scaleX : scaleY;
|
|
||||||
if (ctx.scale > 1.0f) ctx.scale = 1.0f; // Don't upscale
|
|
||||||
|
|
||||||
ctx.dstWidth = (int)(ctx.srcWidth * ctx.scale);
|
|
||||||
ctx.dstHeight = (int)(ctx.srcHeight * ctx.scale);
|
|
||||||
}
|
|
||||||
ctx.lastDstY = -1; // Reset row tracking
|
|
||||||
|
|
||||||
LOG_DBG("PNG", "PNG %dx%d -> %dx%d (scale %.2f), bpp: %d", ctx.srcWidth, ctx.srcHeight, ctx.dstWidth, ctx.dstHeight,
|
|
||||||
ctx.scale, png->getBpp());
|
|
||||||
|
|
||||||
const int pixelType = png->getPixelType();
|
|
||||||
const int requiredInternal = requiredPngInternalBufferBytes(ctx.srcWidth, pixelType);
|
|
||||||
if (requiredInternal > PNG_MAX_BUFFERED_PIXELS) {
|
|
||||||
LOG_ERR("PNG",
|
|
||||||
"PNG row buffer too small: need %d bytes for width=%d type=%d, configured PNG_MAX_BUFFERED_PIXELS=%d",
|
|
||||||
requiredInternal, ctx.srcWidth, pixelType, PNG_MAX_BUFFERED_PIXELS);
|
|
||||||
LOG_ERR("PNG", "Aborting decode to avoid PNGdec internal buffer overflow");
|
|
||||||
png->close();
|
|
||||||
delete png;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (png->getBpp() != 8) {
|
|
||||||
warnUnsupportedFeature("bit depth (" + std::to_string(png->getBpp()) + "bpp)", imagePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allocate grayscale line buffer on demand (~3.2 KB) - freed after decode
|
|
||||||
const size_t grayBufSize = PNG_MAX_BUFFERED_PIXELS / 2;
|
|
||||||
ctx.grayLineBuffer = static_cast<uint8_t*>(malloc(grayBufSize));
|
|
||||||
if (!ctx.grayLineBuffer) {
|
|
||||||
LOG_ERR("PNG", "Failed to allocate gray line buffer");
|
|
||||||
png->close();
|
|
||||||
delete png;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allocate cache buffer using SCALED dimensions
|
|
||||||
ctx.caching = !config.cachePath.empty();
|
|
||||||
if (ctx.caching) {
|
|
||||||
if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
|
|
||||||
LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching");
|
|
||||||
ctx.caching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsigned long decodeStart = millis();
|
|
||||||
rc = png->decode(&ctx, 0);
|
|
||||||
unsigned long decodeTime = millis() - decodeStart;
|
|
||||||
|
|
||||||
free(ctx.grayLineBuffer);
|
|
||||||
ctx.grayLineBuffer = nullptr;
|
|
||||||
|
|
||||||
if (rc != PNG_SUCCESS) {
|
|
||||||
LOG_ERR("PNG", "Decode failed: %d", rc);
|
|
||||||
png->close();
|
|
||||||
delete png;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
png->close();
|
|
||||||
delete png;
|
|
||||||
LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime);
|
|
||||||
|
|
||||||
// Write cache file if caching was enabled and buffer was allocated
|
|
||||||
if (ctx.caching) {
|
|
||||||
ctx.cache.writeToFile(config.cachePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool PngToFramebufferConverter::supportsFormat(const std::string& extension) {
|
|
||||||
std::string ext = extension;
|
|
||||||
for (auto& c : ext) {
|
|
||||||
c = tolower(c);
|
|
||||||
}
|
|
||||||
return (ext == ".png");
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "ImageToFramebufferDecoder.h"
|
|
||||||
|
|
||||||
class PngToFramebufferConverter final : public ImageToFramebufferDecoder {
|
|
||||||
public:
|
|
||||||
static bool getDimensionsStatic(const std::string& imagePath, ImageDimensions& out);
|
|
||||||
|
|
||||||
bool decodeToFramebuffer(const std::string& imagePath, GfxRenderer& renderer, const RenderConfig& config) override;
|
|
||||||
|
|
||||||
bool getDimensions(const std::string& imagePath, ImageDimensions& dims) const override {
|
|
||||||
return getDimensionsStatic(imagePath, dims);
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool supportsFormat(const std::string& extension);
|
|
||||||
const char* getFormatName() const override { return "PNG"; }
|
|
||||||
};
|
|
||||||
+278
-431
@@ -1,57 +1,144 @@
|
|||||||
#include "CssParser.h"
|
#include "CssParser.h"
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <string_view>
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Stack-allocated string buffer to avoid heap reallocations during parsing
|
|
||||||
// Provides string-like interface with fixed capacity
|
|
||||||
struct StackBuffer {
|
|
||||||
static constexpr size_t CAPACITY = 1024;
|
|
||||||
char data[CAPACITY];
|
|
||||||
size_t len = 0;
|
|
||||||
|
|
||||||
void push_back(char c) {
|
|
||||||
if (len < CAPACITY - 1) {
|
|
||||||
data[len++] = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void clear() { len = 0; }
|
|
||||||
bool empty() const { return len == 0; }
|
|
||||||
size_t size() const { return len; }
|
|
||||||
|
|
||||||
// Get string view of current content (zero-copy)
|
|
||||||
std::string_view view() const { return std::string_view(data, len); }
|
|
||||||
|
|
||||||
// Convert to string for passing to functions (single allocation)
|
|
||||||
std::string str() const { return std::string(data, len); }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Buffer size for reading CSS files
|
// Buffer size for reading CSS files
|
||||||
constexpr size_t READ_BUFFER_SIZE = 512;
|
constexpr size_t READ_BUFFER_SIZE = 512;
|
||||||
|
|
||||||
// Maximum number of CSS rules to store in the selector map
|
// Maximum CSS file size we'll process (prevent memory issues)
|
||||||
// Prevents unbounded memory growth from pathological CSS files
|
constexpr size_t MAX_CSS_SIZE = 64 * 1024;
|
||||||
constexpr size_t MAX_RULES = 1500;
|
|
||||||
|
|
||||||
// Minimum free heap required to apply CSS during rendering
|
|
||||||
// If below this threshold, we skip CSS to avoid display artifacts.
|
|
||||||
constexpr size_t MIN_FREE_HEAP_FOR_CSS = 48 * 1024;
|
|
||||||
|
|
||||||
// Maximum length for a single selector string
|
|
||||||
// Prevents parsing of extremely long or malformed selectors
|
|
||||||
constexpr size_t MAX_SELECTOR_LENGTH = 256;
|
|
||||||
|
|
||||||
// Check if character is CSS whitespace
|
// Check if character is CSS whitespace
|
||||||
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
|
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
|
||||||
|
|
||||||
|
// Read entire file into string (with size limit)
|
||||||
|
std::string readFileContent(FsFile& file) {
|
||||||
|
std::string content;
|
||||||
|
content.reserve(std::min(static_cast<size_t>(file.size()), MAX_CSS_SIZE));
|
||||||
|
|
||||||
|
char buffer[READ_BUFFER_SIZE];
|
||||||
|
while (file.available() && content.size() < MAX_CSS_SIZE) {
|
||||||
|
const int bytesRead = file.read(buffer, sizeof(buffer));
|
||||||
|
if (bytesRead <= 0) break;
|
||||||
|
content.append(buffer, bytesRead);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove CSS comments (/* ... */) from content
|
||||||
|
std::string stripComments(const std::string& css) {
|
||||||
|
std::string result;
|
||||||
|
result.reserve(css.size());
|
||||||
|
|
||||||
|
size_t pos = 0;
|
||||||
|
while (pos < css.size()) {
|
||||||
|
// Look for start of comment
|
||||||
|
if (pos + 1 < css.size() && css[pos] == '/' && css[pos + 1] == '*') {
|
||||||
|
// Find end of comment
|
||||||
|
const size_t endPos = css.find("*/", pos + 2);
|
||||||
|
if (endPos == std::string::npos) {
|
||||||
|
// Unterminated comment - skip rest of file
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos = endPos + 2;
|
||||||
|
} else {
|
||||||
|
result.push_back(css[pos]);
|
||||||
|
++pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip @-rules (like @media, @import, @font-face)
|
||||||
|
// Returns position after the @-rule
|
||||||
|
size_t skipAtRule(const std::string& css, const size_t start) {
|
||||||
|
// Find the end - either semicolon (simple @-rule) or matching brace
|
||||||
|
size_t pos = start + 1; // Skip the '@'
|
||||||
|
|
||||||
|
// Skip identifier
|
||||||
|
while (pos < css.size() && (std::isalnum(css[pos]) || css[pos] == '-')) {
|
||||||
|
++pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for { or ;
|
||||||
|
int braceDepth = 0;
|
||||||
|
while (pos < css.size()) {
|
||||||
|
const char c = css[pos];
|
||||||
|
if (c == '{') {
|
||||||
|
++braceDepth;
|
||||||
|
} else if (c == '}') {
|
||||||
|
--braceDepth;
|
||||||
|
if (braceDepth == 0) {
|
||||||
|
return pos + 1;
|
||||||
|
}
|
||||||
|
} else if (c == ';' && braceDepth == 0) {
|
||||||
|
return pos + 1;
|
||||||
|
}
|
||||||
|
++pos;
|
||||||
|
}
|
||||||
|
return css.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract next rule from CSS content
|
||||||
|
// Returns true if a rule was found, with selector and body filled
|
||||||
|
bool extractNextRule(const std::string& css, size_t& pos, std::string& selector, std::string& body) {
|
||||||
|
selector.clear();
|
||||||
|
body.clear();
|
||||||
|
|
||||||
|
// Skip whitespace and @-rules until we find a regular rule
|
||||||
|
while (pos < css.size()) {
|
||||||
|
// Skip whitespace
|
||||||
|
while (pos < css.size() && isCssWhitespace(css[pos])) {
|
||||||
|
++pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos >= css.size()) return false;
|
||||||
|
|
||||||
|
// Handle @-rules iteratively (avoids recursion/stack overflow)
|
||||||
|
if (css[pos] == '@') {
|
||||||
|
pos = skipAtRule(css, pos);
|
||||||
|
continue; // Try again after skipping the @-rule
|
||||||
|
}
|
||||||
|
|
||||||
|
break; // Found start of a regular rule
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos >= css.size()) return false;
|
||||||
|
|
||||||
|
// Find opening brace
|
||||||
|
const size_t bracePos = css.find('{', pos);
|
||||||
|
if (bracePos == std::string::npos) return false;
|
||||||
|
|
||||||
|
// Extract selector (everything before the brace)
|
||||||
|
selector = css.substr(pos, bracePos - pos);
|
||||||
|
|
||||||
|
// Find matching closing brace
|
||||||
|
int depth = 1;
|
||||||
|
const size_t bodyStart = bracePos + 1;
|
||||||
|
size_t bodyEnd = bodyStart;
|
||||||
|
|
||||||
|
while (bodyEnd < css.size() && depth > 0) {
|
||||||
|
if (css[bodyEnd] == '{')
|
||||||
|
++depth;
|
||||||
|
else if (css[bodyEnd] == '}')
|
||||||
|
--depth;
|
||||||
|
++bodyEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract body (between braces)
|
||||||
|
if (bodyEnd > bodyStart) {
|
||||||
|
body = css.substr(bodyStart, bodyEnd - bodyStart - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = bodyEnd;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
} // anonymous namespace
|
} // anonymous namespace
|
||||||
|
|
||||||
// String utilities implementation
|
// String utilities implementation
|
||||||
@@ -74,34 +161,12 @@ std::string CssParser::normalized(const std::string& s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing space
|
// Remove trailing space
|
||||||
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
|
if (!result.empty() && result.back() == ' ') {
|
||||||
result.pop_back();
|
result.pop_back();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CssParser::normalizedInto(const std::string& s, std::string& out) {
|
|
||||||
out.clear();
|
|
||||||
out.reserve(s.size());
|
|
||||||
|
|
||||||
bool inSpace = true; // Start true to skip leading space
|
|
||||||
for (const char c : s) {
|
|
||||||
if (isCssWhitespace(c)) {
|
|
||||||
if (!inSpace) {
|
|
||||||
out.push_back(' ');
|
|
||||||
inSpace = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
|
|
||||||
inSpace = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!out.empty() && out.back() == ' ') {
|
|
||||||
out.pop_back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
|
std::vector<std::string> CssParser::splitOnChar(const std::string& s, const char delimiter) {
|
||||||
std::vector<std::string> parts;
|
std::vector<std::string> parts;
|
||||||
size_t start = 0;
|
size_t start = 0;
|
||||||
@@ -189,18 +254,10 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CssLength CssParser::interpretLength(const std::string& val) {
|
CssLength CssParser::interpretLength(const std::string& val) {
|
||||||
CssLength result;
|
|
||||||
tryInterpretLength(val, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
|
|
||||||
const std::string v = normalized(val);
|
const std::string v = normalized(val);
|
||||||
if (v.empty()) {
|
if (v.empty()) return CssLength{};
|
||||||
out = CssLength{};
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Find where the number ends
|
||||||
size_t unitStart = v.size();
|
size_t unitStart = v.size();
|
||||||
for (size_t i = 0; i < v.size(); ++i) {
|
for (size_t i = 0; i < v.size(); ++i) {
|
||||||
const char c = v[i];
|
const char c = v[i];
|
||||||
@@ -213,13 +270,12 @@ bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
|
|||||||
const std::string numPart = v.substr(0, unitStart);
|
const std::string numPart = v.substr(0, unitStart);
|
||||||
const std::string unitPart = v.substr(unitStart);
|
const std::string unitPart = v.substr(unitStart);
|
||||||
|
|
||||||
|
// Parse numeric value
|
||||||
char* endPtr = nullptr;
|
char* endPtr = nullptr;
|
||||||
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||||
if (endPtr == numPart.c_str()) {
|
if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed
|
||||||
out = CssLength{};
|
|
||||||
return false; // No number parsed (e.g. auto, inherit, initial)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Determine unit type (preserve for deferred resolution)
|
||||||
auto unit = CssUnit::Pixels;
|
auto unit = CssUnit::Pixels;
|
||||||
if (unitPart == "em") {
|
if (unitPart == "em") {
|
||||||
unit = CssUnit::Em;
|
unit = CssUnit::Em;
|
||||||
@@ -230,112 +286,133 @@ bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
|
|||||||
} else if (unitPart == "%") {
|
} else if (unitPart == "%") {
|
||||||
unit = CssUnit::Percent;
|
unit = CssUnit::Percent;
|
||||||
}
|
}
|
||||||
|
// px and unitless default to Pixels
|
||||||
|
|
||||||
out = CssLength{numericValue, unit};
|
return CssLength{numericValue, unit};
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
int8_t CssParser::interpretSpacing(const std::string& val) {
|
||||||
|
const std::string v = normalized(val);
|
||||||
|
if (v.empty()) return 0;
|
||||||
|
|
||||||
|
// For spacing, we convert to "lines" (discrete units for e-ink)
|
||||||
|
// 1em ≈ 1 line, percentages based on ~30 lines per page
|
||||||
|
|
||||||
|
float multiplier = 0.0f;
|
||||||
|
size_t unitStart = v.size();
|
||||||
|
|
||||||
|
for (size_t i = 0; i < v.size(); ++i) {
|
||||||
|
const char c = v[i];
|
||||||
|
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
|
||||||
|
unitStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string numPart = v.substr(0, unitStart);
|
||||||
|
const std::string unitPart = v.substr(unitStart);
|
||||||
|
|
||||||
|
if (unitPart == "em" || unitPart == "rem") {
|
||||||
|
multiplier = 1.0f; // 1em = 1 line
|
||||||
|
} else if (unitPart == "%") {
|
||||||
|
multiplier = 0.3f; // ~30 lines per page, so 10% = 3 lines
|
||||||
|
} else {
|
||||||
|
return 0; // Unsupported unit for spacing
|
||||||
|
}
|
||||||
|
|
||||||
|
char* endPtr = nullptr;
|
||||||
|
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
|
||||||
|
|
||||||
|
if (endPtr == numPart.c_str()) return 0;
|
||||||
|
|
||||||
|
int lines = static_cast<int>(numericValue * multiplier);
|
||||||
|
|
||||||
|
// Clamp to reasonable range (0-2 lines)
|
||||||
|
if (lines < 0) lines = 0;
|
||||||
|
if (lines > 2) lines = 2;
|
||||||
|
|
||||||
|
return static_cast<int8_t>(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declaration parsing
|
// Declaration parsing
|
||||||
|
|
||||||
void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
|
|
||||||
std::string& propValueBuf) {
|
|
||||||
const size_t colonPos = decl.find(':');
|
|
||||||
if (colonPos == std::string::npos || colonPos == 0) return;
|
|
||||||
|
|
||||||
normalizedInto(decl.substr(0, colonPos), propNameBuf);
|
|
||||||
normalizedInto(decl.substr(colonPos + 1), propValueBuf);
|
|
||||||
|
|
||||||
if (propNameBuf.empty() || propValueBuf.empty()) return;
|
|
||||||
|
|
||||||
if (propNameBuf == "text-align") {
|
|
||||||
style.textAlign = interpretAlignment(propValueBuf);
|
|
||||||
style.defined.textAlign = 1;
|
|
||||||
} else if (propNameBuf == "font-style") {
|
|
||||||
style.fontStyle = interpretFontStyle(propValueBuf);
|
|
||||||
style.defined.fontStyle = 1;
|
|
||||||
} else if (propNameBuf == "font-weight") {
|
|
||||||
style.fontWeight = interpretFontWeight(propValueBuf);
|
|
||||||
style.defined.fontWeight = 1;
|
|
||||||
} else if (propNameBuf == "text-decoration" || propNameBuf == "text-decoration-line") {
|
|
||||||
style.textDecoration = interpretDecoration(propValueBuf);
|
|
||||||
style.defined.textDecoration = 1;
|
|
||||||
} else if (propNameBuf == "text-indent") {
|
|
||||||
style.textIndent = interpretLength(propValueBuf);
|
|
||||||
style.defined.textIndent = 1;
|
|
||||||
} else if (propNameBuf == "margin-top") {
|
|
||||||
style.marginTop = interpretLength(propValueBuf);
|
|
||||||
style.defined.marginTop = 1;
|
|
||||||
} else if (propNameBuf == "margin-bottom") {
|
|
||||||
style.marginBottom = interpretLength(propValueBuf);
|
|
||||||
style.defined.marginBottom = 1;
|
|
||||||
} else if (propNameBuf == "margin-left") {
|
|
||||||
style.marginLeft = interpretLength(propValueBuf);
|
|
||||||
style.defined.marginLeft = 1;
|
|
||||||
} else if (propNameBuf == "margin-right") {
|
|
||||||
style.marginRight = interpretLength(propValueBuf);
|
|
||||||
style.defined.marginRight = 1;
|
|
||||||
} else if (propNameBuf == "margin") {
|
|
||||||
const auto values = splitWhitespace(propValueBuf);
|
|
||||||
if (!values.empty()) {
|
|
||||||
style.marginTop = interpretLength(values[0]);
|
|
||||||
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
|
|
||||||
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
|
|
||||||
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
|
|
||||||
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
|
||||||
}
|
|
||||||
} else if (propNameBuf == "padding-top") {
|
|
||||||
style.paddingTop = interpretLength(propValueBuf);
|
|
||||||
style.defined.paddingTop = 1;
|
|
||||||
} else if (propNameBuf == "padding-bottom") {
|
|
||||||
style.paddingBottom = interpretLength(propValueBuf);
|
|
||||||
style.defined.paddingBottom = 1;
|
|
||||||
} else if (propNameBuf == "padding-left") {
|
|
||||||
style.paddingLeft = interpretLength(propValueBuf);
|
|
||||||
style.defined.paddingLeft = 1;
|
|
||||||
} else if (propNameBuf == "padding-right") {
|
|
||||||
style.paddingRight = interpretLength(propValueBuf);
|
|
||||||
style.defined.paddingRight = 1;
|
|
||||||
} else if (propNameBuf == "padding") {
|
|
||||||
const auto values = splitWhitespace(propValueBuf);
|
|
||||||
if (!values.empty()) {
|
|
||||||
style.paddingTop = interpretLength(values[0]);
|
|
||||||
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
|
|
||||||
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
|
|
||||||
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
|
|
||||||
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom = style.defined.paddingLeft =
|
|
||||||
1;
|
|
||||||
}
|
|
||||||
} else if (propNameBuf == "height") {
|
|
||||||
CssLength len;
|
|
||||||
if (tryInterpretLength(propValueBuf, len)) {
|
|
||||||
style.imageHeight = len;
|
|
||||||
style.defined.imageHeight = 1;
|
|
||||||
}
|
|
||||||
} else if (propNameBuf == "width") {
|
|
||||||
CssLength len;
|
|
||||||
if (tryInterpretLength(propValueBuf, len)) {
|
|
||||||
style.imageWidth = len;
|
|
||||||
style.defined.imageWidth = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
||||||
CssStyle style;
|
CssStyle style;
|
||||||
std::string propNameBuf;
|
|
||||||
std::string propValueBuf;
|
|
||||||
|
|
||||||
size_t start = 0;
|
// Split declarations by semicolon
|
||||||
for (size_t i = 0; i <= declBlock.size(); ++i) {
|
const auto declarations = splitOnChar(declBlock, ';');
|
||||||
if (i == declBlock.size() || declBlock[i] == ';') {
|
|
||||||
if (i > start) {
|
for (const auto& decl : declarations) {
|
||||||
const size_t len = i - start;
|
// Find colon separator
|
||||||
std::string decl = declBlock.substr(start, len);
|
const size_t colonPos = decl.find(':');
|
||||||
if (!decl.empty()) {
|
if (colonPos == std::string::npos || colonPos == 0) continue;
|
||||||
parseDeclarationIntoStyle(decl, style, propNameBuf, propValueBuf);
|
|
||||||
}
|
std::string propName = normalized(decl.substr(0, colonPos));
|
||||||
|
std::string propValue = normalized(decl.substr(colonPos + 1));
|
||||||
|
|
||||||
|
if (propName.empty() || propValue.empty()) continue;
|
||||||
|
|
||||||
|
// Match property and set value
|
||||||
|
if (propName == "text-align") {
|
||||||
|
style.textAlign = interpretAlignment(propValue);
|
||||||
|
style.defined.textAlign = 1;
|
||||||
|
} else if (propName == "font-style") {
|
||||||
|
style.fontStyle = interpretFontStyle(propValue);
|
||||||
|
style.defined.fontStyle = 1;
|
||||||
|
} else if (propName == "font-weight") {
|
||||||
|
style.fontWeight = interpretFontWeight(propValue);
|
||||||
|
style.defined.fontWeight = 1;
|
||||||
|
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
|
||||||
|
style.textDecoration = interpretDecoration(propValue);
|
||||||
|
style.defined.textDecoration = 1;
|
||||||
|
} else if (propName == "text-indent") {
|
||||||
|
style.textIndent = interpretLength(propValue);
|
||||||
|
style.defined.textIndent = 1;
|
||||||
|
} else if (propName == "margin-top") {
|
||||||
|
style.marginTop = interpretLength(propValue);
|
||||||
|
style.defined.marginTop = 1;
|
||||||
|
} else if (propName == "margin-bottom") {
|
||||||
|
style.marginBottom = interpretLength(propValue);
|
||||||
|
style.defined.marginBottom = 1;
|
||||||
|
} else if (propName == "margin-left") {
|
||||||
|
style.marginLeft = interpretLength(propValue);
|
||||||
|
style.defined.marginLeft = 1;
|
||||||
|
} else if (propName == "margin-right") {
|
||||||
|
style.marginRight = interpretLength(propValue);
|
||||||
|
style.defined.marginRight = 1;
|
||||||
|
} else if (propName == "margin") {
|
||||||
|
// Shorthand: 1-4 values for top, right, bottom, left
|
||||||
|
const auto values = splitWhitespace(propValue);
|
||||||
|
if (!values.empty()) {
|
||||||
|
style.marginTop = interpretLength(values[0]);
|
||||||
|
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
|
||||||
|
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
|
||||||
|
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
|
||||||
|
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
} else if (propName == "padding-top") {
|
||||||
|
style.paddingTop = interpretLength(propValue);
|
||||||
|
style.defined.paddingTop = 1;
|
||||||
|
} else if (propName == "padding-bottom") {
|
||||||
|
style.paddingBottom = interpretLength(propValue);
|
||||||
|
style.defined.paddingBottom = 1;
|
||||||
|
} else if (propName == "padding-left") {
|
||||||
|
style.paddingLeft = interpretLength(propValue);
|
||||||
|
style.defined.paddingLeft = 1;
|
||||||
|
} else if (propName == "padding-right") {
|
||||||
|
style.paddingRight = interpretLength(propValue);
|
||||||
|
style.defined.paddingRight = 1;
|
||||||
|
} else if (propName == "padding") {
|
||||||
|
// Shorthand: 1-4 values for top, right, bottom, left
|
||||||
|
const auto values = splitWhitespace(propValue);
|
||||||
|
if (!values.empty()) {
|
||||||
|
style.paddingTop = interpretLength(values[0]);
|
||||||
|
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
|
||||||
|
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
|
||||||
|
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
|
||||||
|
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
|
||||||
|
style.defined.paddingLeft = 1;
|
||||||
}
|
}
|
||||||
start = i + 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,83 +421,20 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
|||||||
|
|
||||||
// Rule processing
|
// Rule processing
|
||||||
|
|
||||||
void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style) {
|
void CssParser::processRuleBlock(const std::string& selectorGroup, const std::string& declarations) {
|
||||||
// Check if we've reached the rule limit before processing
|
const CssStyle style = parseDeclarations(declarations);
|
||||||
if (rulesBySelector_.size() >= MAX_RULES) {
|
|
||||||
LOG_DBG("CSS", "Reached max rules limit (%zu), stopping CSS parsing", MAX_RULES);
|
// Only store if any properties were set
|
||||||
return;
|
if (!style.defined.anySet()) return;
|
||||||
}
|
|
||||||
|
|
||||||
// Handle comma-separated selectors
|
// Handle comma-separated selectors
|
||||||
const auto selectors = splitOnChar(selectorGroup, ',');
|
const auto selectors = splitOnChar(selectorGroup, ',');
|
||||||
|
|
||||||
for (const auto& sel : selectors) {
|
for (const auto& sel : selectors) {
|
||||||
// Validate selector length before processing
|
|
||||||
if (sel.size() > MAX_SELECTOR_LENGTH) {
|
|
||||||
LOG_DBG("CSS", "Selector too long (%zu > %zu), skipping", sel.size(), MAX_SELECTOR_LENGTH);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize the selector
|
// Normalize the selector
|
||||||
std::string key = normalized(sel);
|
std::string key = normalized(sel);
|
||||||
if (key.empty()) continue;
|
if (key.empty()) continue;
|
||||||
|
|
||||||
// TODO: Consider adding support for sibling css selectors in the future
|
|
||||||
// Ensure no + in selector as we don't support adjacent CSS selectors for now
|
|
||||||
if (key.find('+') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Consider adding support for direct nested css selectors in the future
|
|
||||||
// Ensure no > in selector as we don't support nested CSS selectors for now
|
|
||||||
if (key.find('>') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Consider adding support for attribute css selectors in the future
|
|
||||||
// Ensure no [ in selector as we don't support attribute CSS selectors for now
|
|
||||||
if (key.find('[') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Consider adding support for pseudo selectors in the future
|
|
||||||
// Ensure no : in selector as we don't support pseudo CSS selectors for now
|
|
||||||
if (key.find(':') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Consider adding support for ID css selectors in the future
|
|
||||||
// Ensure no # in selector as we don't support ID CSS selectors for now
|
|
||||||
if (key.find('#') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Consider adding support for general sibling combinator selectors in the future
|
|
||||||
// Ensure no ~ in selector as we don't support general sibling combinator CSS selectors for now
|
|
||||||
if (key.find('~') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Consider adding support for wildcard css selectors in the future
|
|
||||||
// Ensure no * in selector as we don't support wildcard CSS selectors for now
|
|
||||||
if (key.find('*') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Add support for more complex selectors in the future
|
|
||||||
// At the moment, we only ever check for `tag`, `tag.class1` or `.class1`
|
|
||||||
// If the selector has whitespace in it, then it's either a CSS selector for a descendant element (e.g. `tag1 tag2`)
|
|
||||||
// or some other slightly more advanced CSS selector which we don't support yet
|
|
||||||
if (key.find(' ') != std::string_view::npos) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if this would exceed the rule limit
|
|
||||||
if (rulesBySelector_.size() >= MAX_RULES) {
|
|
||||||
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store or merge with existing
|
// Store or merge with existing
|
||||||
auto it = rulesBySelector_.find(key);
|
auto it = rulesBySelector_.find(key);
|
||||||
if (it != rulesBySelector_.end()) {
|
if (it != rulesBySelector_.end()) {
|
||||||
@@ -439,158 +453,30 @@ bool CssParser::loadFromStream(FsFile& source) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t totalRead = 0;
|
// Read file content
|
||||||
|
const std::string content = readFileContent(source);
|
||||||
// Use stack-allocated buffers for parsing to avoid heap reallocations
|
if (content.empty()) {
|
||||||
StackBuffer selector;
|
return true; // Empty file is valid
|
||||||
StackBuffer declBuffer;
|
|
||||||
// Keep these as std::string since they're passed by reference to parseDeclarationIntoStyle
|
|
||||||
std::string propNameBuf;
|
|
||||||
std::string propValueBuf;
|
|
||||||
|
|
||||||
bool inComment = false;
|
|
||||||
bool maybeSlash = false;
|
|
||||||
bool prevStar = false;
|
|
||||||
|
|
||||||
bool inAtRule = false;
|
|
||||||
int atDepth = 0;
|
|
||||||
|
|
||||||
int bodyDepth = 0;
|
|
||||||
bool skippingRule = false;
|
|
||||||
CssStyle currentStyle;
|
|
||||||
|
|
||||||
auto handleChar = [&](const char c) {
|
|
||||||
if (inAtRule) {
|
|
||||||
if (c == '{') {
|
|
||||||
++atDepth;
|
|
||||||
} else if (c == '}') {
|
|
||||||
if (atDepth > 0) --atDepth;
|
|
||||||
if (atDepth == 0) inAtRule = false;
|
|
||||||
} else if (c == ';' && atDepth == 0) {
|
|
||||||
inAtRule = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bodyDepth == 0) {
|
|
||||||
if (selector.empty() && isCssWhitespace(c)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (c == '@' && selector.empty()) {
|
|
||||||
inAtRule = true;
|
|
||||||
atDepth = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (c == '{') {
|
|
||||||
bodyDepth = 1;
|
|
||||||
currentStyle = CssStyle{};
|
|
||||||
declBuffer.clear();
|
|
||||||
if (selector.size() > MAX_SELECTOR_LENGTH * 4) {
|
|
||||||
skippingRule = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selector.push_back(c);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// bodyDepth > 0
|
|
||||||
if (c == '{') {
|
|
||||||
++bodyDepth;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (c == '}') {
|
|
||||||
--bodyDepth;
|
|
||||||
if (bodyDepth == 0) {
|
|
||||||
if (!skippingRule && !declBuffer.empty()) {
|
|
||||||
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
|
|
||||||
}
|
|
||||||
if (!skippingRule) {
|
|
||||||
processRuleBlockWithStyle(selector.str(), currentStyle);
|
|
||||||
}
|
|
||||||
selector.clear();
|
|
||||||
declBuffer.clear();
|
|
||||||
skippingRule = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (bodyDepth > 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!skippingRule) {
|
|
||||||
if (c == ';') {
|
|
||||||
if (!declBuffer.empty()) {
|
|
||||||
parseDeclarationIntoStyle(declBuffer.str(), currentStyle, propNameBuf, propValueBuf);
|
|
||||||
declBuffer.clear();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
declBuffer.push_back(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
char buffer[READ_BUFFER_SIZE];
|
|
||||||
while (source.available()) {
|
|
||||||
int bytesRead = source.read(buffer, sizeof(buffer));
|
|
||||||
if (bytesRead <= 0) break;
|
|
||||||
|
|
||||||
totalRead += static_cast<size_t>(bytesRead);
|
|
||||||
|
|
||||||
for (int i = 0; i < bytesRead; ++i) {
|
|
||||||
const char c = buffer[i];
|
|
||||||
|
|
||||||
if (inComment) {
|
|
||||||
if (prevStar && c == '/') {
|
|
||||||
inComment = false;
|
|
||||||
prevStar = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
prevStar = c == '*';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maybeSlash) {
|
|
||||||
if (c == '*') {
|
|
||||||
inComment = true;
|
|
||||||
maybeSlash = false;
|
|
||||||
prevStar = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
handleChar('/');
|
|
||||||
maybeSlash = false;
|
|
||||||
// fall through to process current char
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '/') {
|
|
||||||
maybeSlash = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChar(c);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maybeSlash) {
|
// Remove comments
|
||||||
handleChar('/');
|
const std::string cleaned = stripComments(content);
|
||||||
|
|
||||||
|
// Parse rules
|
||||||
|
size_t pos = 0;
|
||||||
|
std::string selector, body;
|
||||||
|
|
||||||
|
while (extractNextRule(cleaned, pos, selector, body)) {
|
||||||
|
processRuleBlock(selector, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("CSS", "Parsed %zu rules from %zu bytes", rulesBySelector_.size(), totalRead);
|
LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Style resolution
|
// Style resolution
|
||||||
|
|
||||||
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
|
CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string& classAttr) const {
|
||||||
static bool lowHeapWarningLogged = false;
|
|
||||||
if (ESP.getFreeHeap() < MIN_FREE_HEAP_FOR_CSS) {
|
|
||||||
if (!lowHeapWarningLogged) {
|
|
||||||
lowHeapWarningLogged = true;
|
|
||||||
LOG_DBG("CSS", "Warning: low heap (%u bytes) below MIN_FREE_HEAP_FOR_CSS (%u), returning empty style",
|
|
||||||
ESP.getFreeHeap(), static_cast<unsigned>(MIN_FREE_HEAP_FOR_CSS));
|
|
||||||
}
|
|
||||||
return CssStyle{};
|
|
||||||
}
|
|
||||||
CssStyle result;
|
CssStyle result;
|
||||||
const std::string tag = normalized(tagName);
|
const std::string tag = normalized(tagName);
|
||||||
|
|
||||||
@@ -600,7 +486,6 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
|||||||
result.applyOver(tagIt->second);
|
result.applyOver(tagIt->second);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support combinations of classes (e.g. style on .class1.class2)
|
|
||||||
// 2. Apply class styles (medium priority)
|
// 2. Apply class styles (medium priority)
|
||||||
if (!classAttr.empty()) {
|
if (!classAttr.empty()) {
|
||||||
const auto classes = splitWhitespace(classAttr);
|
const auto classes = splitWhitespace(classAttr);
|
||||||
@@ -614,7 +499,6 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
|
|
||||||
// 3. Apply element.class styles (higher priority)
|
// 3. Apply element.class styles (higher priority)
|
||||||
for (const auto& cls : classes) {
|
for (const auto& cls : classes) {
|
||||||
std::string combinedKey = tag + "." + normalized(cls);
|
std::string combinedKey = tag + "." + normalized(cls);
|
||||||
@@ -635,27 +519,16 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
|
|||||||
|
|
||||||
// Cache serialization
|
// Cache serialization
|
||||||
|
|
||||||
// Cache file name (version is CssParser::CSS_CACHE_VERSION)
|
// Cache format version - increment when format changes
|
||||||
constexpr char rulesCache[] = "/css_rules.cache";
|
constexpr uint8_t CSS_CACHE_VERSION = 2;
|
||||||
|
|
||||||
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
|
bool CssParser::saveToCache(FsFile& file) const {
|
||||||
|
if (!file) {
|
||||||
void CssParser::deleteCache() const {
|
|
||||||
if (hasCache()) Storage.remove((cachePath + rulesCache).c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
bool CssParser::saveToCache() const {
|
|
||||||
if (cachePath.empty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
FsFile file;
|
|
||||||
if (!Storage.openFileForWrite("CSS", cachePath + rulesCache, file)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write version
|
// Write version
|
||||||
file.write(CssParser::CSS_CACHE_VERSION);
|
file.write(CSS_CACHE_VERSION);
|
||||||
|
|
||||||
// Write rule count
|
// Write rule count
|
||||||
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
|
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
|
||||||
@@ -690,8 +563,6 @@ bool CssParser::saveToCache() const {
|
|||||||
writeLength(style.paddingBottom);
|
writeLength(style.paddingBottom);
|
||||||
writeLength(style.paddingLeft);
|
writeLength(style.paddingLeft);
|
||||||
writeLength(style.paddingRight);
|
writeLength(style.paddingRight);
|
||||||
writeLength(style.imageHeight);
|
|
||||||
writeLength(style.imageWidth);
|
|
||||||
|
|
||||||
// Write defined flags as uint16_t
|
// Write defined flags as uint16_t
|
||||||
uint16_t definedBits = 0;
|
uint16_t definedBits = 0;
|
||||||
@@ -708,23 +579,15 @@ bool CssParser::saveToCache() const {
|
|||||||
if (style.defined.paddingBottom) definedBits |= 1 << 10;
|
if (style.defined.paddingBottom) definedBits |= 1 << 10;
|
||||||
if (style.defined.paddingLeft) definedBits |= 1 << 11;
|
if (style.defined.paddingLeft) definedBits |= 1 << 11;
|
||||||
if (style.defined.paddingRight) definedBits |= 1 << 12;
|
if (style.defined.paddingRight) definedBits |= 1 << 12;
|
||||||
if (style.defined.imageHeight) definedBits |= 1 << 13;
|
|
||||||
if (style.defined.imageWidth) definedBits |= 1 << 14;
|
|
||||||
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
|
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
|
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
|
||||||
file.close();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool CssParser::loadFromCache() {
|
bool CssParser::loadFromCache(FsFile& file) {
|
||||||
if (cachePath.empty()) {
|
if (!file) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
FsFile file;
|
|
||||||
if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,18 +596,14 @@ bool CssParser::loadFromCache() {
|
|||||||
|
|
||||||
// Read and verify version
|
// Read and verify version
|
||||||
uint8_t version = 0;
|
uint8_t version = 0;
|
||||||
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
|
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
|
||||||
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
|
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
|
||||||
CssParser::CSS_CACHE_VERSION);
|
|
||||||
file.close();
|
|
||||||
Storage.remove((cachePath + rulesCache).c_str());
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read rule count
|
// Read rule count
|
||||||
uint16_t ruleCount = 0;
|
uint16_t ruleCount = 0;
|
||||||
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
|
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,7 +613,6 @@ bool CssParser::loadFromCache() {
|
|||||||
uint16_t selectorLen = 0;
|
uint16_t selectorLen = 0;
|
||||||
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
|
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +620,6 @@ bool CssParser::loadFromCache() {
|
|||||||
selector.resize(selectorLen);
|
selector.resize(selectorLen);
|
||||||
if (file.read(&selector[0], selectorLen) != selectorLen) {
|
if (file.read(&selector[0], selectorLen) != selectorLen) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,28 +629,24 @@ bool CssParser::loadFromCache() {
|
|||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
if (file.read(&enumVal, 1) != 1) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
style.textAlign = static_cast<CssTextAlign>(enumVal);
|
style.textAlign = static_cast<CssTextAlign>(enumVal);
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
if (file.read(&enumVal, 1) != 1) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
style.fontStyle = static_cast<CssFontStyle>(enumVal);
|
style.fontStyle = static_cast<CssFontStyle>(enumVal);
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
if (file.read(&enumVal, 1) != 1) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
style.fontWeight = static_cast<CssFontWeight>(enumVal);
|
style.fontWeight = static_cast<CssFontWeight>(enumVal);
|
||||||
|
|
||||||
if (file.read(&enumVal, 1) != 1) {
|
if (file.read(&enumVal, 1) != 1) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
|
||||||
@@ -813,10 +666,8 @@ bool CssParser::loadFromCache() {
|
|||||||
|
|
||||||
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
|
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
|
||||||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
|
||||||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) ||
|
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
|
||||||
!readLength(style.imageHeight) || !readLength(style.imageWidth)) {
|
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,7 +675,6 @@ bool CssParser::loadFromCache() {
|
|||||||
uint16_t definedBits = 0;
|
uint16_t definedBits = 0;
|
||||||
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
|
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
|
||||||
rulesBySelector_.clear();
|
rulesBySelector_.clear();
|
||||||
file.close();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
style.defined.textAlign = (definedBits & 1 << 0) != 0;
|
style.defined.textAlign = (definedBits & 1 << 0) != 0;
|
||||||
@@ -840,13 +690,10 @@ bool CssParser::loadFromCache() {
|
|||||||
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
|
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
|
||||||
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
|
style.defined.paddingLeft = (definedBits & 1 << 11) != 0;
|
||||||
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
|
style.defined.paddingRight = (definedBits & 1 << 12) != 0;
|
||||||
style.defined.imageHeight = (definedBits & 1 << 13) != 0;
|
|
||||||
style.defined.imageWidth = (definedBits & 1 << 14) != 0;
|
|
||||||
|
|
||||||
rulesBySelector_[selector] = style;
|
rulesBySelector_[selector] = style;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
|
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
|
||||||
file.close();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <utility>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "CssStyle.h"
|
#include "CssStyle.h"
|
||||||
@@ -30,10 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
class CssParser {
|
class CssParser {
|
||||||
public:
|
public:
|
||||||
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
|
CssParser() = default;
|
||||||
static constexpr uint8_t CSS_CACHE_VERSION = 3;
|
|
||||||
|
|
||||||
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
|
|
||||||
~CssParser() = default;
|
~CssParser() = default;
|
||||||
|
|
||||||
// Non-copyable
|
// Non-copyable
|
||||||
@@ -80,40 +76,28 @@ class CssParser {
|
|||||||
*/
|
*/
|
||||||
void clear() { rulesBySelector_.clear(); }
|
void clear() { rulesBySelector_.clear(); }
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if CSS rules cache file exists
|
|
||||||
*/
|
|
||||||
bool hasCache() const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete CSS rules cache file exists
|
|
||||||
*/
|
|
||||||
void deleteCache() const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save parsed CSS rules to a cache file.
|
* Save parsed CSS rules to a cache file.
|
||||||
|
* @param file Open file handle to write to
|
||||||
* @return true if cache was written successfully
|
* @return true if cache was written successfully
|
||||||
*/
|
*/
|
||||||
bool saveToCache() const;
|
bool saveToCache(FsFile& file) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load CSS rules from a cache file.
|
* Load CSS rules from a cache file.
|
||||||
* Clears any existing rules before loading.
|
* Clears any existing rules before loading.
|
||||||
|
* @param file Open file handle to read from
|
||||||
* @return true if cache was loaded successfully
|
* @return true if cache was loaded successfully
|
||||||
*/
|
*/
|
||||||
bool loadFromCache();
|
bool loadFromCache(FsFile& file);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Storage: maps normalized selector -> style properties
|
// Storage: maps normalized selector -> style properties
|
||||||
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
std::unordered_map<std::string, CssStyle> rulesBySelector_;
|
||||||
|
|
||||||
std::string cachePath;
|
|
||||||
|
|
||||||
// Internal parsing helpers
|
// Internal parsing helpers
|
||||||
void processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style);
|
void processRuleBlock(const std::string& selectorGroup, const std::string& declarations);
|
||||||
static CssStyle parseDeclarations(const std::string& declBlock);
|
static CssStyle parseDeclarations(const std::string& declBlock);
|
||||||
static void parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
|
|
||||||
std::string& propValueBuf);
|
|
||||||
|
|
||||||
// Individual property value parsers
|
// Individual property value parsers
|
||||||
static CssTextAlign interpretAlignment(const std::string& val);
|
static CssTextAlign interpretAlignment(const std::string& val);
|
||||||
@@ -121,12 +105,10 @@ class CssParser {
|
|||||||
static CssFontWeight interpretFontWeight(const std::string& val);
|
static CssFontWeight interpretFontWeight(const std::string& val);
|
||||||
static CssTextDecoration interpretDecoration(const std::string& val);
|
static CssTextDecoration interpretDecoration(const std::string& val);
|
||||||
static CssLength interpretLength(const std::string& val);
|
static CssLength interpretLength(const std::string& val);
|
||||||
/** Returns true only when a numeric length was parsed (e.g. 2em, 50%). False for auto/inherit/initial. */
|
static int8_t interpretSpacing(const std::string& val);
|
||||||
static bool tryInterpretLength(const std::string& val, CssLength& out);
|
|
||||||
|
|
||||||
// String utilities
|
// String utilities
|
||||||
static std::string normalized(const std::string& s);
|
static std::string normalized(const std::string& s);
|
||||||
static void normalizedInto(const std::string& s, std::string& out);
|
|
||||||
static std::vector<std::string> splitOnChar(const std::string& s, char delimiter);
|
static std::vector<std::string> splitOnChar(const std::string& s, char delimiter);
|
||||||
static std::vector<std::string> splitWhitespace(const std::string& s);
|
static std::vector<std::string> splitWhitespace(const std::string& s);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ struct CssPropertyFlags {
|
|||||||
uint16_t paddingBottom : 1;
|
uint16_t paddingBottom : 1;
|
||||||
uint16_t paddingLeft : 1;
|
uint16_t paddingLeft : 1;
|
||||||
uint16_t paddingRight : 1;
|
uint16_t paddingRight : 1;
|
||||||
uint16_t imageHeight : 1;
|
|
||||||
uint16_t imageWidth : 1;
|
|
||||||
|
|
||||||
CssPropertyFlags()
|
CssPropertyFlags()
|
||||||
: textAlign(0),
|
: textAlign(0),
|
||||||
@@ -85,21 +83,17 @@ struct CssPropertyFlags {
|
|||||||
paddingTop(0),
|
paddingTop(0),
|
||||||
paddingBottom(0),
|
paddingBottom(0),
|
||||||
paddingLeft(0),
|
paddingLeft(0),
|
||||||
paddingRight(0),
|
paddingRight(0) {}
|
||||||
imageHeight(0),
|
|
||||||
imageWidth(0) {}
|
|
||||||
|
|
||||||
[[nodiscard]] bool anySet() const {
|
[[nodiscard]] bool anySet() const {
|
||||||
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
|
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
|
||||||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight ||
|
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
|
||||||
imageWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
|
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
|
||||||
marginTop = marginBottom = marginLeft = marginRight = 0;
|
marginTop = marginBottom = marginLeft = marginRight = 0;
|
||||||
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
|
||||||
imageHeight = imageWidth = 0;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,8 +115,6 @@ struct CssStyle {
|
|||||||
CssLength paddingBottom; // Padding after
|
CssLength paddingBottom; // Padding after
|
||||||
CssLength paddingLeft; // Padding left
|
CssLength paddingLeft; // Padding left
|
||||||
CssLength paddingRight; // Padding right
|
CssLength paddingRight; // Padding right
|
||||||
CssLength imageHeight; // Height for img (e.g. 2em) – width derived from aspect ratio when only height set
|
|
||||||
CssLength imageWidth; // Width for img when both or only width set
|
|
||||||
|
|
||||||
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
||||||
|
|
||||||
@@ -181,14 +173,6 @@ struct CssStyle {
|
|||||||
paddingRight = base.paddingRight;
|
paddingRight = base.paddingRight;
|
||||||
defined.paddingRight = 1;
|
defined.paddingRight = 1;
|
||||||
}
|
}
|
||||||
if (base.hasImageHeight()) {
|
|
||||||
imageHeight = base.imageHeight;
|
|
||||||
defined.imageHeight = 1;
|
|
||||||
}
|
|
||||||
if (base.hasImageWidth()) {
|
|
||||||
imageWidth = base.imageWidth;
|
|
||||||
defined.imageWidth = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
|
[[nodiscard]] bool hasTextAlign() const { return defined.textAlign; }
|
||||||
@@ -204,8 +188,6 @@ struct CssStyle {
|
|||||||
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
|
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
|
||||||
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
|
||||||
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
|
||||||
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
|
|
||||||
[[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; }
|
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
textAlign = CssTextAlign::Left;
|
textAlign = CssTextAlign::Left;
|
||||||
@@ -215,7 +197,6 @@ struct CssStyle {
|
|||||||
textIndent = CssLength{};
|
textIndent = CssLength{};
|
||||||
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
|
||||||
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
|
||||||
imageHeight = imageWidth = CssLength{};
|
|
||||||
defined.clearAll();
|
defined.clearAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -174,213 +174,6 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
|
|||||||
while (*ptr != 0) {
|
while (*ptr != 0) {
|
||||||
const unsigned char* current = ptr;
|
const unsigned char* current = ptr;
|
||||||
const uint32_t cp = utf8NextCodepoint(&ptr);
|
const uint32_t cp = utf8NextCodepoint(&ptr);
|
||||||
// If this is a combining diacritic (e.g., U+0301 = acute) and there's
|
|
||||||
// a previous base character that can be composed into a single
|
|
||||||
// precomposed Unicode scalar (Latin-1 / Latin-Extended), do that
|
|
||||||
// composition here. This provides lightweight NFC-like behavior for
|
|
||||||
// common Western European diacritics (acute, grave, circumflex, tilde,
|
|
||||||
// diaeresis, cedilla) without pulling in a full Unicode normalization
|
|
||||||
// library.
|
|
||||||
if (!cps.empty()) {
|
|
||||||
uint32_t prev = cps.back().value;
|
|
||||||
uint32_t composed = 0;
|
|
||||||
switch (cp) {
|
|
||||||
case 0x0300: // grave
|
|
||||||
switch (prev) {
|
|
||||||
case 0x0041:
|
|
||||||
composed = 0x00C0;
|
|
||||||
break; // A -> À
|
|
||||||
case 0x0061:
|
|
||||||
composed = 0x00E0;
|
|
||||||
break; // a -> à
|
|
||||||
case 0x0045:
|
|
||||||
composed = 0x00C8;
|
|
||||||
break; // E -> È
|
|
||||||
case 0x0065:
|
|
||||||
composed = 0x00E8;
|
|
||||||
break; // e -> è
|
|
||||||
case 0x0049:
|
|
||||||
composed = 0x00CC;
|
|
||||||
break; // I -> Ì
|
|
||||||
case 0x0069:
|
|
||||||
composed = 0x00EC;
|
|
||||||
break; // i -> ì
|
|
||||||
case 0x004F:
|
|
||||||
composed = 0x00D2;
|
|
||||||
break; // O -> Ò
|
|
||||||
case 0x006F:
|
|
||||||
composed = 0x00F2;
|
|
||||||
break; // o -> ò
|
|
||||||
case 0x0055:
|
|
||||||
composed = 0x00D9;
|
|
||||||
break; // U -> Ù
|
|
||||||
case 0x0075:
|
|
||||||
composed = 0x00F9;
|
|
||||||
break; // u -> ù
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 0x0301: // acute
|
|
||||||
switch (prev) {
|
|
||||||
case 0x0041:
|
|
||||||
composed = 0x00C1;
|
|
||||||
break; // A -> Á
|
|
||||||
case 0x0061:
|
|
||||||
composed = 0x00E1;
|
|
||||||
break; // a -> á
|
|
||||||
case 0x0045:
|
|
||||||
composed = 0x00C9;
|
|
||||||
break; // E -> É
|
|
||||||
case 0x0065:
|
|
||||||
composed = 0x00E9;
|
|
||||||
break; // e -> é
|
|
||||||
case 0x0049:
|
|
||||||
composed = 0x00CD;
|
|
||||||
break; // I -> Í
|
|
||||||
case 0x0069:
|
|
||||||
composed = 0x00ED;
|
|
||||||
break; // i -> í
|
|
||||||
case 0x004F:
|
|
||||||
composed = 0x00D3;
|
|
||||||
break; // O -> Ó
|
|
||||||
case 0x006F:
|
|
||||||
composed = 0x00F3;
|
|
||||||
break; // o -> ó
|
|
||||||
case 0x0055:
|
|
||||||
composed = 0x00DA;
|
|
||||||
break; // U -> Ú
|
|
||||||
case 0x0075:
|
|
||||||
composed = 0x00FA;
|
|
||||||
break; // u -> ú
|
|
||||||
case 0x0059:
|
|
||||||
composed = 0x00DD;
|
|
||||||
break; // Y -> Ý
|
|
||||||
case 0x0079:
|
|
||||||
composed = 0x00FD;
|
|
||||||
break; // y -> ý
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 0x0302: // circumflex
|
|
||||||
switch (prev) {
|
|
||||||
case 0x0041:
|
|
||||||
composed = 0x00C2;
|
|
||||||
break; // A -> Â
|
|
||||||
case 0x0061:
|
|
||||||
composed = 0x00E2;
|
|
||||||
break; // a -> â
|
|
||||||
case 0x0045:
|
|
||||||
composed = 0x00CA;
|
|
||||||
break; // E -> Ê
|
|
||||||
case 0x0065:
|
|
||||||
composed = 0x00EA;
|
|
||||||
break; // e -> ê
|
|
||||||
case 0x0049:
|
|
||||||
composed = 0x00CE;
|
|
||||||
break; // I -> Î
|
|
||||||
case 0x0069:
|
|
||||||
composed = 0x00EE;
|
|
||||||
break; // i -> î
|
|
||||||
case 0x004F:
|
|
||||||
composed = 0x00D4;
|
|
||||||
break; // O -> Ô
|
|
||||||
case 0x006F:
|
|
||||||
composed = 0x00F4;
|
|
||||||
break; // o -> ô
|
|
||||||
case 0x0055:
|
|
||||||
composed = 0x00DB;
|
|
||||||
break; // U -> Û
|
|
||||||
case 0x0075:
|
|
||||||
composed = 0x00FB;
|
|
||||||
break; // u -> û
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 0x0303: // tilde
|
|
||||||
switch (prev) {
|
|
||||||
case 0x0041:
|
|
||||||
composed = 0x00C3;
|
|
||||||
break; // A -> Ã
|
|
||||||
case 0x0061:
|
|
||||||
composed = 0x00E3;
|
|
||||||
break; // a -> ã
|
|
||||||
case 0x004E:
|
|
||||||
composed = 0x00D1;
|
|
||||||
break; // N -> Ñ
|
|
||||||
case 0x006E:
|
|
||||||
composed = 0x00F1;
|
|
||||||
break; // n -> ñ
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 0x0308: // diaeresis/umlaut
|
|
||||||
switch (prev) {
|
|
||||||
case 0x0041:
|
|
||||||
composed = 0x00C4;
|
|
||||||
break; // A -> Ä
|
|
||||||
case 0x0061:
|
|
||||||
composed = 0x00E4;
|
|
||||||
break; // a -> ä
|
|
||||||
case 0x0045:
|
|
||||||
composed = 0x00CB;
|
|
||||||
break; // E -> Ë
|
|
||||||
case 0x0065:
|
|
||||||
composed = 0x00EB;
|
|
||||||
break; // e -> ë
|
|
||||||
case 0x0049:
|
|
||||||
composed = 0x00CF;
|
|
||||||
break; // I -> Ï
|
|
||||||
case 0x0069:
|
|
||||||
composed = 0x00EF;
|
|
||||||
break; // i -> ï
|
|
||||||
case 0x004F:
|
|
||||||
composed = 0x00D6;
|
|
||||||
break; // O -> Ö
|
|
||||||
case 0x006F:
|
|
||||||
composed = 0x00F6;
|
|
||||||
break; // o -> ö
|
|
||||||
case 0x0055:
|
|
||||||
composed = 0x00DC;
|
|
||||||
break; // U -> Ü
|
|
||||||
case 0x0075:
|
|
||||||
composed = 0x00FC;
|
|
||||||
break; // u -> ü
|
|
||||||
case 0x0059:
|
|
||||||
composed = 0x0178;
|
|
||||||
break; // Y -> Ÿ
|
|
||||||
case 0x0079:
|
|
||||||
composed = 0x00FF;
|
|
||||||
break; // y -> ÿ
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 0x0327: // cedilla
|
|
||||||
switch (prev) {
|
|
||||||
case 0x0043:
|
|
||||||
composed = 0x00C7;
|
|
||||||
break; // C -> Ç
|
|
||||||
case 0x0063:
|
|
||||||
composed = 0x00E7;
|
|
||||||
break; // c -> ç
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (composed != 0) {
|
|
||||||
cps.back().value = composed;
|
|
||||||
continue; // skip pushing the combining mark itself
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cps.push_back({cp, static_cast<size_t>(current - base)});
|
cps.push_back({cp, static_cast<size_t>(current - base)});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
#include "Hyphenator.h"
|
#include "Hyphenator.h"
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "HyphenationCommon.h"
|
#include "HyphenationCommon.h"
|
||||||
#include "LanguageHyphenator.h"
|
|
||||||
#include "LanguageRegistry.h"
|
#include "LanguageRegistry.h"
|
||||||
|
|
||||||
const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr;
|
const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr;
|
||||||
@@ -34,19 +32,10 @@ size_t byteOffsetForIndex(const std::vector<CodepointInfo>& cps, const size_t in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Builds a vector of break information from explicit hyphen markers in the given codepoints.
|
// Builds a vector of break information from explicit hyphen markers in the given codepoints.
|
||||||
// Only hyphens that appear between two alphabetic characters are considered valid breaks.
|
|
||||||
//
|
|
||||||
// Example: "US-Satellitensystems" (cps: U, S, -, S, a, t, ...)
|
|
||||||
// -> finds '-' at index 2 with alphabetic neighbors 'S' and 'S'
|
|
||||||
// -> returns one BreakInfo at the byte offset of 'S' (the char after '-'),
|
|
||||||
// with requiresInsertedHyphen=false because '-' is already visible.
|
|
||||||
//
|
|
||||||
// Example: "Satel\u00ADliten" (soft-hyphen between 'l' and 'l')
|
|
||||||
// -> returns one BreakInfo with requiresInsertedHyphen=true (soft-hyphen
|
|
||||||
// is invisible and needs a visible '-' when the break is used).
|
|
||||||
std::vector<Hyphenator::BreakInfo> buildExplicitBreakInfos(const std::vector<CodepointInfo>& cps) {
|
std::vector<Hyphenator::BreakInfo> buildExplicitBreakInfos(const std::vector<CodepointInfo>& cps) {
|
||||||
std::vector<Hyphenator::BreakInfo> breaks;
|
std::vector<Hyphenator::BreakInfo> breaks;
|
||||||
|
|
||||||
|
// Scan every codepoint looking for explicit/soft hyphen markers that are surrounded by letters.
|
||||||
for (size_t i = 1; i + 1 < cps.size(); ++i) {
|
for (size_t i = 1; i + 1 < cps.size(); ++i) {
|
||||||
const uint32_t cp = cps[i].value;
|
const uint32_t cp = cps[i].value;
|
||||||
if (!isExplicitHyphen(cp) || !isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value)) {
|
if (!isExplicitHyphen(cp) || !isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value)) {
|
||||||
@@ -74,43 +63,6 @@ std::vector<Hyphenator::BreakInfo> Hyphenator::breakOffsets(const std::string& w
|
|||||||
// Explicit hyphen markers (soft or hard) take precedence over language breaks.
|
// Explicit hyphen markers (soft or hard) take precedence over language breaks.
|
||||||
auto explicitBreakInfos = buildExplicitBreakInfos(cps);
|
auto explicitBreakInfos = buildExplicitBreakInfos(cps);
|
||||||
if (!explicitBreakInfos.empty()) {
|
if (!explicitBreakInfos.empty()) {
|
||||||
// When a word contains explicit hyphens we also run Liang patterns on each alphabetic
|
|
||||||
// segment between them. Without this, "US-Satellitensystems" would only offer one split
|
|
||||||
// point (after "US-"), making it impossible to break mid-"Satellitensystems" even when
|
|
||||||
// "US-Satelliten-" would fit on the line.
|
|
||||||
//
|
|
||||||
// Example: "US-Satellitensystems"
|
|
||||||
// Segments: ["US", "Satellitensystems"]
|
|
||||||
// Explicit break: after "US-" -> @3 (no inserted hyphen)
|
|
||||||
// Pattern breaks on "Satellitensystems" -> @5 Sa|tel (+hyphen)
|
|
||||||
// @8 Satel|li (+hyphen)
|
|
||||||
// @10 Satelli|ten (+hyphen)
|
|
||||||
// @13 Satelliten|sys (+hyphen)
|
|
||||||
// @16 Satellitensys|tems (+hyphen)
|
|
||||||
// Result: 6 sorted break points; the line-breaker picks the widest prefix that fits.
|
|
||||||
if (hyphenator) {
|
|
||||||
size_t segStart = 0;
|
|
||||||
for (size_t i = 0; i <= cps.size(); ++i) {
|
|
||||||
const bool atEnd = (i == cps.size());
|
|
||||||
const bool atHyphen = !atEnd && isExplicitHyphen(cps[i].value);
|
|
||||||
if (atEnd || atHyphen) {
|
|
||||||
if (i > segStart) {
|
|
||||||
std::vector<CodepointInfo> segment(cps.begin() + segStart, cps.begin() + i);
|
|
||||||
auto segIndexes = hyphenator->breakIndexes(segment);
|
|
||||||
for (const size_t idx : segIndexes) {
|
|
||||||
const size_t cpIdx = segStart + idx;
|
|
||||||
if (cpIdx < cps.size()) {
|
|
||||||
explicitBreakInfos.push_back({cps[cpIdx].byteOffset, true});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
segStart = i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Merge explicit and pattern breaks into ascending byte-offset order.
|
|
||||||
std::sort(explicitBreakInfos.begin(), explicitBreakInfos.end(),
|
|
||||||
[](const BreakInfo& a, const BreakInfo& b) { return a.byteOffset < b.byteOffset; });
|
|
||||||
}
|
|
||||||
return explicitBreakInfos;
|
return explicitBreakInfos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,24 +9,11 @@ class LanguageHyphenator;
|
|||||||
class Hyphenator {
|
class Hyphenator {
|
||||||
public:
|
public:
|
||||||
struct BreakInfo {
|
struct BreakInfo {
|
||||||
size_t byteOffset; // Byte position inside the UTF-8 word where a break may occur.
|
size_t byteOffset;
|
||||||
bool requiresInsertedHyphen; // true = a visible '-' must be rendered at the break (pattern/fallback breaks).
|
bool requiresInsertedHyphen;
|
||||||
// false = the word already contains a hyphen at this position (explicit '-').
|
|
||||||
};
|
};
|
||||||
|
// Returns byte offsets where the word may be hyphenated. When includeFallback is true, all positions obeying the
|
||||||
// Returns byte offsets where the word may be hyphenated.
|
// minimum prefix/suffix constraints are returned even if no language-specific rule matches.
|
||||||
//
|
|
||||||
// Break sources (in priority order):
|
|
||||||
// 1. Explicit hyphens already present in the word (e.g. '-' or soft-hyphen U+00AD).
|
|
||||||
// When found, language patterns are additionally run on each alphabetic segment
|
|
||||||
// between hyphens so compound words can break within their parts.
|
|
||||||
// Example: "US-Satellitensystems" yields breaks after "US-" (no inserted hyphen)
|
|
||||||
// plus pattern breaks inside "Satellitensystems" (Sa|tel|li|ten|sys|tems).
|
|
||||||
// 2. Language-specific Liang patterns (e.g. German de_patterns).
|
|
||||||
// Example: "Quadratkilometer" -> Qua|drat|ki|lo|me|ter.
|
|
||||||
// 3. Fallback every-N-chars splitting (only when includeFallback is true AND no
|
|
||||||
// pattern breaks were found). Used as a last resort to prevent a single oversized
|
|
||||||
// word from overflowing the page width.
|
|
||||||
static std::vector<BreakInfo> breakOffsets(const std::string& word, bool includeFallback);
|
static std::vector<BreakInfo> breakOffsets(const std::string& word, bool includeFallback);
|
||||||
|
|
||||||
// Provide a publication-level language hint (e.g. "en", "en-US", "ru") used to select hyphenation rules.
|
// Provide a publication-level language hint (e.g. "en", "en-US", "ru") used to select hyphenation rules.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user