Compare commits

..
Author SHA1 Message Date
Xuan Son Nguyen c0a7088fcc passes = 1 2026-02-16 21:48:09 +01:00
Xuan Son Nguyen e79f86fa24 more resync 2026-02-16 11:13:34 +01:00
Xuan Son Nguyen fd933ba368 move requestResync to HAL 2026-02-16 10:56:40 +01:00
Justin Mitchell c14dc4c109 Fix scanning text in calibre and customize buttons for x3 2026-02-15 21:14:52 -05:00
Justin Mitchell cd4303a8fb Updates to X3-specific display and power button handling
Centralize display initialization and power button verification for X3 devices
2026-02-15 19:58:37 -05:00
Justin Mitchell 961a704aaa Revert platformio.ini to use open-x4-sdk submodule
Accidentally committed local community-sdk path.
2026-02-15 18:32:46 -05:00
Justin Mitchell 7061d054ef improves wake from sleep on x3 2026-02-13 19:30:15 -05:00
Justin Mitchell 39bf8a5609 clang formatting 2026-02-13 19:28:47 -05:00
Justin Mitchell a78897478f Fixes maintainer requests 2026-02-13 19:04:58 -05:00
Justin Mitchell 4f0a39ab03 Merge upstream/master into x3-support 2026-02-13 15:56:38 -05:00
Justin Mitchell d97a436a48 fixes wake from sleep being randomly not reliable 2026-02-13 15:53:42 -05:00
Justin Mitchell 6114b80e5d adds x3 commands for refresh types 2026-02-13 15:27:13 -05:00
Justin Mitchell d4eb089e49 cleanup 2026-02-12 18:28:47 -05:00
Justin Mitchell 9a1548189c fixes battery reading on x3 2026-02-12 16:55:27 -05:00
Justin Mitchell cbea838f91 Initial support for the x3 2026-02-12 16:16:54 -05:00
253 changed files with 264960 additions and 158024 deletions
+1 -1
View File
@@ -3,10 +3,10 @@
.DS_Store
.vscode
lib/EpdFont/fontsrc
lib/I18n/I18nStrings.cpp
*.generated.h
.vs
build
**/__pycache__/
/compile_commands.json
/.cache
/notes.md
+1 -1
View File
@@ -51,7 +51,7 @@ For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) do
### 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"
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap
+2 -4
View File
@@ -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.
* **Local Transfer:** E.g. Simple, "pull" based book loading via a basic web-server or public and widely-used standards.
* **Language Support:** E.g. Support for multiple languages both in the reader and in the interfaces.
* **Reference Tools:** E.g. Local dictionary lookup. Providing quick, offline definitions to enhance comprehension
without breaking focus.
### Out-of-Scope
@@ -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
and complicate the single-core CPU's execution.
* **Media Playback:** No Audio players or Audio-books.
* **Complex Annotation:** No typed out notes. These features are better suited for devices with better input
capabilities and more powerful chips.
* **Complex Reader Features:** No highlighting, notes, or dictionary lookup. These features are better suited for
devices with better input capabilities and more powerful chips.
## 3. Idea Evaluation
+1 -10
View File
@@ -1,18 +1,10 @@
#!/usr/bin/env bash
# Check if clang-format is availible
command -v clang-format >/dev/null 2>&1 || {
printf "'clang-format' not found in current environment\n"
printf "install 'clang', 'clang-tools', or 'clang-format' depending on your distro/os and tooling requirements\n"
exit 1
}
#!/bin/bash
GIT_LS_FILES_FLAGS=""
if [[ "$1" == "-g" ]]; then
GIT_LS_FILES_FLAGS="--modified"
fi
# --- Main Logic ---
# 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 -v -E '^lib/EpdFont/builtinFonts/' \
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
| grep -v -E '^lib/uzlib/' \
| xargs -r clang-format -style=file -i
+17 -4
View File
@@ -45,9 +45,22 @@ byte arrays, and emits headers under
`SerializedHyphenationPatterns` descriptor so the reader can keep the automaton
in flash.
A convenient script `update_hyphenation.sh` is used to update all languages.
To use it, run:
To refresh the firmware assets after updating the `.bin` files, 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
View File
@@ -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

-36
View File
@@ -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
View File
@@ -1,14 +1,14 @@
# 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
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
- Create folders to organize your library
- Create folders to organize your ebooks
- Delete files and folders
## Prerequisites
@@ -129,31 +129,34 @@ Click **File Manager** to access file management features.
#### Browsing Files
- The file manager displays all files and folders on your SD card
- **Folders** are highlighted in yellow and indicated with a 📁 icon
- **EPUB Files** are highlighted in green and indicated with a 📗 icon
- **All Other Files** are not highlighted and indicated with a 📄 icon
- **Folders** are highlighted in yellow with a 📁 icon
- **EPUB files** are highlighted in green with a 📗 icon
- Click on a folder name to navigate into it
- Use the breadcrumb navigation at the top to go back to parent folders
<img src="./images/wifi/webserver_files.png" width="600">
#### Uploading Files
#### Uploading EPUB Files
1. Click the **📤 Upload** button in the top-right corner
2. Click **Choose File** and select a file from your device
3. Click **Upload**
4. A progress bar will show the upload status
5. The page will automatically refresh when the upload is complete
1. Click the **+ Add** button in the top-right corner
2. Select **Upload eBook** from the dropdown menu
3. Click **Choose File** and select an `.epub` file from your device
4. Click **Upload**
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">
#### Creating Folders
1. Click the **📁 New Folder** button in the top-right corner
2. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..)
3. Click **Create Folder**
1. Click the **+ Add** button in the top-right corner
2. Select **New Folder** from the dropdown menu
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
@@ -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.
#### 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
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
@@ -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)
- **Web Server Port:** 80 (HTTP)
- **Maximum Upload Size:** Limited by available SD card space
- **Supported File Format:** `.epub` only
- **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
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")
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
+13 -29
View File
@@ -17,11 +17,6 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
int cursorX = startX;
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;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
const EpdGlyph* glyph = getGlyph(cp);
@@ -35,30 +30,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
continue;
}
const bool isCombining = utf8IsCombiningMark(cp);
int raiseBy = 0;
if (isCombining && hasBaseGlyph) {
const int currentGap = glyph->top - glyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
const int glyphBaseY = cursorY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorX;
lastBaseAdvance = glyph->advanceX;
lastBaseTop = glyph->top;
hasBaseGlyph = true;
cursorX += glyph->advanceX;
}
*minX = std::min(*minX, cursorX + glyph->left);
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
*maxY = std::max(*maxY, cursorY + glyph->top);
cursorX += glyph->advanceX;
}
}
@@ -71,6 +47,14 @@ void EpdFont::getTextDimensions(const char* string, int* w, int* h) const {
*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 EpdUnicodeInterval* intervals = data->intervals;
const int count = data->intervalCount;
+1
View File
@@ -9,6 +9,7 @@ class EpdFont {
explicit EpdFont(const EpdFontData* data) : data(data) {}
~EpdFont() = default;
void getTextDimensions(const char* string, int* w, int* h) const;
bool hasPrintableChars(const char* string) const;
const EpdGlyph* getGlyph(uint32_t cp) const;
};
+1 -12
View File
@@ -12,18 +12,9 @@ typedef struct {
int16_t left; ///< X 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.
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap (or within-group offset for compressed fonts)
uint32_t dataOffset; ///< Pointer into EpdFont->bitmap
} 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
typedef struct {
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 descender; ///< Maximal height of a glyph below the base line
bool is2Bit;
const EpdFontGroup* groups; ///< NULL for uncompressed fonts
uint16_t groupCount; ///< 0 for uncompressed fonts
} EpdFontData;
+4
View File
@@ -22,6 +22,10 @@ void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const
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 EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
+1
View File
@@ -10,6 +10,7 @@ class EpdFontFamily {
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
~EpdFontFamily() = default;
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 EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
-147
View File
@@ -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];
}
-42
View File
@@ -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);
};
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
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
@@ -1619,6 +1619,4 @@ static const EpdFontData notosans_8_regular = {
18,
-5,
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,
-4,
false,
nullptr,
0,
};
@@ -1436,6 +1436,4 @@ static const EpdFontData ubuntu_10_regular = {
20,
-4,
false,
nullptr,
0,
};
@@ -1832,6 +1832,4 @@ static const EpdFontData ubuntu_12_bold = {
24,
-5,
false,
nullptr,
0,
};
@@ -1724,6 +1724,4 @@ static const EpdFontData ubuntu_12_regular = {
24,
-5,
false,
nullptr,
0,
};
+3 -7
View File
@@ -14,7 +14,7 @@ for size in ${BOOKERLY_FONT_SIZES[@]}; do
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
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"
done
done
@@ -24,7 +24,7 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
font_name="notosans_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/NotoSans/NotoSans-${style}.ttf"
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"
done
done
@@ -34,7 +34,7 @@ for size in ${OPENDYSLEXIC_FONT_SIZES[@]}; do
font_name="opendyslexic_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/OpenDyslexic/OpenDyslexic-${style}.otf"
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"
done
done
@@ -53,7 +53,3 @@ for size in ${UI_FONT_SIZES[@]}; do
done
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/
+5 -114
View File
@@ -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("--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("--compress", dest="compress", action="store_true", help="Compress glyph bitmaps using DEFLATE with group-based compression.")
args = parser.parse_args()
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_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"""/**
* generated by fontconvert.py
* name: {font_name}
* 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)}
*/
#pragma once
#include "EpdFontData.h"
""")
if compress:
print(f"static const uint8_t {font_name}Bitmaps[{len(compressed_bitmap_data)}] = {{")
for c in chunks(compressed_bitmap_data, 16):
print (" " + " ".join(f"0x{b:02X}," for b in c))
print ("};\n");
else:
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
for c in chunks(glyph_data, 16):
print (" " + " ".join(f"0x{b:02X}," for b in c))
print ("};\n");
print(f"static const 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[] = {{")
for i, g in enumerate(glyph_props):
@@ -393,14 +298,6 @@ for i_start, i_end in intervals:
offset += i_end - i_start + 1
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" {font_name}Bitmaps,")
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_floor(face.size.descender)},")
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("};")
-164
View File
@@ -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
View File
@@ -4,7 +4,6 @@
#include <HalStorage.h>
#include <JpegToBmpConverter.h>
#include <Logging.h>
#include <PngToBmpConverter.h>
#include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h"
@@ -77,54 +76,6 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
bookMetadata.author = opfParser.author;
bookMetadata.language = opfParser.language;
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;
if (!opfParser.tocNcxPath.empty()) {
@@ -257,80 +208,68 @@ bool Epub::parseTocNavFile() const {
return true;
}
void Epub::parseCssFiles() const {
// Maximum CSS file size we'll attempt to parse (uncompressed)
// Larger files risk memory exhaustion on ESP32
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
// Minimum heap required before attempting CSS parsing
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cache"; }
bool Epub::loadCssRulesFromCache() const {
FsFile cssCacheFile;
if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
if (cssParser->loadFromCache(cssCacheFile)) {
cssCacheFile.close();
LOG_DBG("EBP", "Loaded CSS rules from cache");
return true;
}
cssCacheFile.close();
LOG_DBG("EBP", "CSS cache invalid, reparsing");
}
return false;
}
void Epub::parseCssFiles() const {
if (cssFiles.empty()) {
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
}
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
if (cssParser->hasCache()) {
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
return;
}
// No cache yet - parse CSS files
for (const auto& cssPath : cssFiles) {
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
// Check heap before parsing - CSS parsing allocates heavily
const uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
continue;
}
// Check CSS file size before decompressing - skip files that are too large
size_t cssFileSize = 0;
if (getItemSize(cssPath, &cssFileSize)) {
if (cssFileSize > MAX_CSS_FILE_SIZE) {
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
cssPath.c_str());
// Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not create temp CSS file");
continue;
}
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
tempCssFile.close();
// Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not create temp CSS file");
continue;
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
// Parse the CSS file
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str());
continue;
}
cssParser->loadFromStream(tempCssFile);
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
tempCssFile.close();
// Parse the CSS file
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str());
continue;
// Save to cache for next time
FsFile cssCacheFile;
if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
cssParser->saveToCache(cssCacheFile);
cssCacheFile.close();
}
cssParser->loadFromStream(tempCssFile);
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
}
// Save to cache for next time
if (!cssParser->saveToCache()) {
LOG_ERR("EBP", "Failed to save CSS rules to cache");
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
}
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
@@ -340,24 +279,18 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Initialize spine/TOC cache
bookMetadataCache.reset(new BookMetadataCache(cachePath));
// Always create CssParser - needed for inline style parsing even without CSS files
cssParser.reset(new CssParser(cachePath));
cssParser.reset(new CssParser());
// Try to load existing cache first
if (bookMetadataCache->load()) {
if (!skipLoadingCss) {
// Rebuild CSS cache when missing or when cache version changed (loadFromCache removes stale file)
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
cssParser->deleteCache();
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
// continue anyway - book will work without CSS and we'll still load any inline style CSS
}
parseCssFiles();
// Invalidate section caches so they are rebuilt with the new CSS
Storage.removeDir((cachePath + "/sections").c_str());
if (!skipLoadingCss && !loadCssRulesFromCache()) {
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
// to get CSS file list
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
// continue anyway - book will work without CSS and we'll still load any inline style CSS
}
parseCssFiles();
}
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true;
@@ -458,7 +391,6 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
if (!skipLoadingCss) {
// Parse CSS files after cache reload
parseCssFiles();
Storage.removeDir((cachePath + "/sections").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");
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;
} 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;
}
@@ -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");
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 {
LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
}
+3 -1
View File
@@ -35,6 +35,8 @@ class Epub {
bool parseTocNcxFile() const;
bool parseTocNavFile() const;
void parseCssFiles() const;
std::string getCssRulesCache() const;
bool loadCssRulesFromCache() const;
public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
@@ -71,5 +73,5 @@ class Epub {
size_t getBookSize() const;
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
CssParser* getCssParser() const { return cssParser.get(); }
const CssParser* getCssParser() const { return cssParser.get(); }
};
+2 -29
View File
@@ -25,29 +25,6 @@ std::unique_ptr<PageLine> PageLine::deserialize(FsFile& file) {
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 {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
@@ -59,9 +36,8 @@ bool Page::serialize(FsFile& file) const {
serialization::writePod(file, count);
for (const auto& el : elements) {
// Use getTag() method to determine type
serialization::writePod(file, static_cast<uint8_t>(el->getTag()));
// Only PageLine exists currently
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
if (!el->serialize(file)) {
return false;
}
@@ -83,9 +59,6 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(file);
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 {
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
return nullptr;
-53
View File
@@ -1,16 +1,13 @@
#pragma once
#include <HalStorage.h>
#include <algorithm>
#include <utility>
#include <vector>
#include "blocks/ImageBlock.h"
#include "blocks/TextBlock.h"
enum PageElementTag : uint8_t {
TAG_PageLine = 1,
TAG_PageImage = 2, // New tag
};
// represents something that has been added to a page
@@ -22,7 +19,6 @@ class PageElement {
virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual bool serialize(FsFile& file) = 0;
virtual PageElementTag getTag() const = 0; // Add type identification
};
// a line from a block element
@@ -34,24 +30,9 @@ class PageLine final : public PageElement {
: PageElement(xPos, yPos), block(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_PageLine; }
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 {
public:
// 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;
bool serialize(FsFile& file) const;
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;
}
};
+13 -30
View File
@@ -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.
// Uses advance width (sum of glyph advances) rather than bounding box width so that italic glyph overhangs
// don't inflate inter-word spacing.
// Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
const EpdFontFamily::Style style, const bool appendHyphen = false) {
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
return renderer.getSpaceWidth(fontId, style);
return renderer.getSpaceWidth(fontId);
}
const bool hasSoftHyphen = containsSoftHyphen(word);
if (!hasSoftHyphen && !appendHyphen) {
return renderer.getTextAdvanceX(fontId, word.c_str(), style);
return renderer.getTextWidth(fontId, word.c_str(), style);
}
std::string sanitized = word;
@@ -49,7 +47,7 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s
if (appendHyphen) {
sanitized.push_back('-');
}
return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style);
return renderer.getTextWidth(fontId, sanitized.c_str(), style);
}
} // namespace
@@ -378,35 +376,20 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
words.insert(insertWordIt, remainder);
wordStyles.insert(insertStyleIt, style);
// Continuation flag handling after splitting a word into prefix + remainder.
//
// The prefix keeps the original word's continuation flag so that no-break-space groups
// stay linked. The remainder always gets continues=false because it starts on the next
// line and is not attached to the prefix.
//
// Example: "200&#xA0;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.
// 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.
auto continuesIt = wordContinues.begin();
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);
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) {
// (*continuesVec)[wordIndex] stays unchanged — prefix keeps its attachment.
continuesVec->insert(continuesVec->begin() + wordIndex + 1, false);
(*continuesVec)[wordIndex] = false;
continuesVec->insert(continuesVec->begin() + wordIndex + 1, originalContinuedToNext);
}
// Update cached widths to reflect the new prefix/remainder pairing.
+3 -25
View File
@@ -4,13 +4,12 @@
#include <Logging.h>
#include <Serialization.h>
#include "Epub/css/CssParser.h"
#include "Page.h"
#include "hyphenation/Hyphenator.h"
#include "parsers/ChapterHtmlSlimParser.h"
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) +
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) +
sizeof(uint32_t);
@@ -182,26 +181,11 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
viewportHeight, hyphenationEnabled, embeddedStyle);
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(
epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
embeddedStyle, contentBase, imageBasePath, popupFn, cssParser);
embeddedStyle, popupFn, embeddedStyle ? epub->getCssParser() : nullptr);
Hyphenator::setPreferredLanguage(epub->getLanguage());
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");
file.close();
Storage.remove(filePath.c_str());
if (cssParser) {
cssParser->clear();
}
return false;
}
@@ -239,9 +220,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
file.close();
if (cssParser) {
cssParser->clear();
}
return true;
}
+1 -1
View File
@@ -8,7 +8,7 @@ typedef enum { TEXT_BLOCK, IMAGE_BLOCK } BlockType;
class Block {
public:
virtual ~Block() = default;
virtual void layout(GfxRenderer& renderer) = 0;
virtual BlockType getType() = 0;
virtual bool isEmpty() = 0;
virtual void finish() {}
-174
View File
@@ -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));
}
-31
View File
@@ -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;
};
+1 -1
View File
@@ -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 &&
static_cast<uint8_t>(w[2]) == 0x83) {
const char* visiblePtr = w.c_str() + 3;
const int prefixWidth = renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle);
const int prefixWidth = renderer.getTextAdvanceX(fontId, std::string("\xe2\x80\x83").c_str());
const int visibleWidth = renderer.getTextWidth(fontId, visiblePtr, currentStyle);
startX = wordX + prefixWidth;
underlineWidth = visibleWidth;
+1
View File
@@ -28,6 +28,7 @@ class TextBlock final : public Block {
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
const BlockStyle& getBlockStyle() const { return blockStyle; }
bool isEmpty() override { return words.empty(); }
void layout(GfxRenderer& renderer) override {};
// given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; }
-40
View File
@@ -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);
};
-82
View File
@@ -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
View File
@@ -1,57 +1,144 @@
#include "CssParser.h"
#include <Arduino.h>
#include <Logging.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <string_view>
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
constexpr size_t READ_BUFFER_SIZE = 512;
// Maximum number of CSS rules to store in the selector map
// Prevents unbounded memory growth from pathological CSS files
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;
// Maximum CSS file size we'll process (prevent memory issues)
constexpr size_t MAX_CSS_SIZE = 64 * 1024;
// Check if character is CSS whitespace
bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; }
// Read entire file into string (with size limit)
std::string readFileContent(FsFile& file) {
std::string content;
content.reserve(std::min(static_cast<size_t>(file.size()), MAX_CSS_SIZE));
char buffer[READ_BUFFER_SIZE];
while (file.available() && content.size() < MAX_CSS_SIZE) {
const int bytesRead = file.read(buffer, sizeof(buffer));
if (bytesRead <= 0) break;
content.append(buffer, bytesRead);
}
return content;
}
// Remove CSS comments (/* ... */) from content
std::string stripComments(const std::string& css) {
std::string result;
result.reserve(css.size());
size_t pos = 0;
while (pos < css.size()) {
// Look for start of comment
if (pos + 1 < css.size() && css[pos] == '/' && css[pos + 1] == '*') {
// Find end of comment
const size_t endPos = css.find("*/", pos + 2);
if (endPos == std::string::npos) {
// Unterminated comment - skip rest of file
break;
}
pos = endPos + 2;
} else {
result.push_back(css[pos]);
++pos;
}
}
return result;
}
// Skip @-rules (like @media, @import, @font-face)
// Returns position after the @-rule
size_t skipAtRule(const std::string& css, const size_t start) {
// Find the end - either semicolon (simple @-rule) or matching brace
size_t pos = start + 1; // Skip the '@'
// Skip identifier
while (pos < css.size() && (std::isalnum(css[pos]) || css[pos] == '-')) {
++pos;
}
// Look for { or ;
int braceDepth = 0;
while (pos < css.size()) {
const char c = css[pos];
if (c == '{') {
++braceDepth;
} else if (c == '}') {
--braceDepth;
if (braceDepth == 0) {
return pos + 1;
}
} else if (c == ';' && braceDepth == 0) {
return pos + 1;
}
++pos;
}
return css.size();
}
// Extract next rule from CSS content
// Returns true if a rule was found, with selector and body filled
bool extractNextRule(const std::string& css, size_t& pos, std::string& selector, std::string& body) {
selector.clear();
body.clear();
// Skip whitespace and @-rules until we find a regular rule
while (pos < css.size()) {
// Skip whitespace
while (pos < css.size() && isCssWhitespace(css[pos])) {
++pos;
}
if (pos >= css.size()) return false;
// Handle @-rules iteratively (avoids recursion/stack overflow)
if (css[pos] == '@') {
pos = skipAtRule(css, pos);
continue; // Try again after skipping the @-rule
}
break; // Found start of a regular rule
}
if (pos >= css.size()) return false;
// Find opening brace
const size_t bracePos = css.find('{', pos);
if (bracePos == std::string::npos) return false;
// Extract selector (everything before the brace)
selector = css.substr(pos, bracePos - pos);
// Find matching closing brace
int depth = 1;
const size_t bodyStart = bracePos + 1;
size_t bodyEnd = bodyStart;
while (bodyEnd < css.size() && depth > 0) {
if (css[bodyEnd] == '{')
++depth;
else if (css[bodyEnd] == '}')
--depth;
++bodyEnd;
}
// Extract body (between braces)
if (bodyEnd > bodyStart) {
body = css.substr(bodyStart, bodyEnd - bodyStart - 1);
}
pos = bodyEnd;
return true;
}
} // anonymous namespace
// String utilities implementation
@@ -74,34 +161,12 @@ std::string CssParser::normalized(const std::string& s) {
}
// Remove trailing space
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
if (!result.empty() && result.back() == ' ') {
result.pop_back();
}
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> parts;
size_t start = 0;
@@ -189,18 +254,10 @@ CssTextDecoration CssParser::interpretDecoration(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);
if (v.empty()) {
out = CssLength{};
return false;
}
if (v.empty()) return CssLength{};
// Find where the number ends
size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) {
const char c = v[i];
@@ -213,13 +270,12 @@ bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
const std::string numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart);
// Parse numeric value
char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
if (endPtr == numPart.c_str()) {
out = CssLength{};
return false; // No number parsed (e.g. auto, inherit, initial)
}
if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed
// Determine unit type (preserve for deferred resolution)
auto unit = CssUnit::Pixels;
if (unitPart == "em") {
unit = CssUnit::Em;
@@ -230,112 +286,133 @@ bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
} else if (unitPart == "%") {
unit = CssUnit::Percent;
}
// px and unitless default to Pixels
out = CssLength{numericValue, unit};
return true;
return CssLength{numericValue, unit};
}
int8_t CssParser::interpretSpacing(const std::string& val) {
const std::string v = normalized(val);
if (v.empty()) return 0;
// For spacing, we convert to "lines" (discrete units for e-ink)
// 1em ≈ 1 line, percentages based on ~30 lines per page
float multiplier = 0.0f;
size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) {
const char c = v[i];
if (!std::isdigit(c) && c != '.' && c != '-' && c != '+') {
unitStart = i;
break;
}
}
const std::string numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart);
if (unitPart == "em" || unitPart == "rem") {
multiplier = 1.0f; // 1em = 1 line
} else if (unitPart == "%") {
multiplier = 0.3f; // ~30 lines per page, so 10% = 3 lines
} else {
return 0; // Unsupported unit for spacing
}
char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
if (endPtr == numPart.c_str()) return 0;
int lines = static_cast<int>(numericValue * multiplier);
// Clamp to reasonable range (0-2 lines)
if (lines < 0) lines = 0;
if (lines > 2) lines = 2;
return static_cast<int8_t>(lines);
}
// Declaration parsing
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 style;
std::string propNameBuf;
std::string propValueBuf;
size_t start = 0;
for (size_t i = 0; i <= declBlock.size(); ++i) {
if (i == declBlock.size() || declBlock[i] == ';') {
if (i > start) {
const size_t len = i - start;
std::string decl = declBlock.substr(start, len);
if (!decl.empty()) {
parseDeclarationIntoStyle(decl, style, propNameBuf, propValueBuf);
}
// Split declarations by semicolon
const auto declarations = splitOnChar(declBlock, ';');
for (const auto& decl : declarations) {
// Find colon separator
const size_t colonPos = decl.find(':');
if (colonPos == std::string::npos || colonPos == 0) continue;
std::string propName = normalized(decl.substr(0, colonPos));
std::string propValue = normalized(decl.substr(colonPos + 1));
if (propName.empty() || propValue.empty()) continue;
// Match property and set value
if (propName == "text-align") {
style.textAlign = interpretAlignment(propValue);
style.defined.textAlign = 1;
} else if (propName == "font-style") {
style.fontStyle = interpretFontStyle(propValue);
style.defined.fontStyle = 1;
} else if (propName == "font-weight") {
style.fontWeight = interpretFontWeight(propValue);
style.defined.fontWeight = 1;
} else if (propName == "text-decoration" || propName == "text-decoration-line") {
style.textDecoration = interpretDecoration(propValue);
style.defined.textDecoration = 1;
} else if (propName == "text-indent") {
style.textIndent = interpretLength(propValue);
style.defined.textIndent = 1;
} else if (propName == "margin-top") {
style.marginTop = interpretLength(propValue);
style.defined.marginTop = 1;
} else if (propName == "margin-bottom") {
style.marginBottom = interpretLength(propValue);
style.defined.marginBottom = 1;
} else if (propName == "margin-left") {
style.marginLeft = interpretLength(propValue);
style.defined.marginLeft = 1;
} else if (propName == "margin-right") {
style.marginRight = interpretLength(propValue);
style.defined.marginRight = 1;
} else if (propName == "margin") {
// Shorthand: 1-4 values for top, right, bottom, left
const auto values = splitWhitespace(propValue);
if (!values.empty()) {
style.marginTop = interpretLength(values[0]);
style.marginRight = values.size() >= 2 ? interpretLength(values[1]) : style.marginTop;
style.marginBottom = values.size() >= 3 ? interpretLength(values[2]) : style.marginTop;
style.marginLeft = values.size() >= 4 ? interpretLength(values[3]) : style.marginRight;
style.defined.marginTop = style.defined.marginRight = style.defined.marginBottom = style.defined.marginLeft = 1;
}
} else if (propName == "padding-top") {
style.paddingTop = interpretLength(propValue);
style.defined.paddingTop = 1;
} else if (propName == "padding-bottom") {
style.paddingBottom = interpretLength(propValue);
style.defined.paddingBottom = 1;
} else if (propName == "padding-left") {
style.paddingLeft = interpretLength(propValue);
style.defined.paddingLeft = 1;
} else if (propName == "padding-right") {
style.paddingRight = interpretLength(propValue);
style.defined.paddingRight = 1;
} else if (propName == "padding") {
// Shorthand: 1-4 values for top, right, bottom, left
const auto values = splitWhitespace(propValue);
if (!values.empty()) {
style.paddingTop = interpretLength(values[0]);
style.paddingRight = values.size() >= 2 ? interpretLength(values[1]) : style.paddingTop;
style.paddingBottom = values.size() >= 3 ? interpretLength(values[2]) : style.paddingTop;
style.paddingLeft = values.size() >= 4 ? interpretLength(values[3]) : style.paddingRight;
style.defined.paddingTop = style.defined.paddingRight = style.defined.paddingBottom =
style.defined.paddingLeft = 1;
}
start = i + 1;
}
}
@@ -344,83 +421,20 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
// Rule processing
void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, const CssStyle& style) {
// Check if we've reached the rule limit before processing
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit (%zu), stopping CSS parsing", MAX_RULES);
return;
}
void CssParser::processRuleBlock(const std::string& selectorGroup, const std::string& declarations) {
const CssStyle style = parseDeclarations(declarations);
// Only store if any properties were set
if (!style.defined.anySet()) return;
// Handle comma-separated selectors
const auto selectors = splitOnChar(selectorGroup, ',');
for (const auto& sel : selectors) {
// 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
std::string key = normalized(sel);
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
auto it = rulesBySelector_.find(key);
if (it != rulesBySelector_.end()) {
@@ -439,158 +453,30 @@ bool CssParser::loadFromStream(FsFile& source) {
return false;
}
size_t totalRead = 0;
// Use stack-allocated buffers for parsing to avoid heap reallocations
StackBuffer selector;
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);
}
// Read file content
const std::string content = readFileContent(source);
if (content.empty()) {
return true; // Empty file is valid
}
if (maybeSlash) {
handleChar('/');
// Remove comments
const std::string cleaned = stripComments(content);
// Parse rules
size_t pos = 0;
std::string selector, body;
while (extractNextRule(cleaned, pos, selector, body)) {
processRuleBlock(selector, body);
}
LOG_DBG("CSS", "Parsed %zu rules from %zu bytes", rulesBySelector_.size(), totalRead);
LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size());
return true;
}
// Style resolution
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;
const std::string tag = normalized(tagName);
@@ -600,7 +486,6 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
result.applyOver(tagIt->second);
}
// TODO: Support combinations of classes (e.g. style on .class1.class2)
// 2. Apply class styles (medium priority)
if (!classAttr.empty()) {
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)
for (const auto& cls : classes) {
std::string combinedKey = tag + "." + normalized(cls);
@@ -635,27 +519,16 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
// Cache serialization
// Cache file name (version is CssParser::CSS_CACHE_VERSION)
constexpr char rulesCache[] = "/css_rules.cache";
// Cache format version - increment when format changes
constexpr uint8_t CSS_CACHE_VERSION = 2;
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
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)) {
bool CssParser::saveToCache(FsFile& file) const {
if (!file) {
return false;
}
// Write version
file.write(CssParser::CSS_CACHE_VERSION);
file.write(CSS_CACHE_VERSION);
// Write rule count
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
@@ -690,8 +563,6 @@ bool CssParser::saveToCache() const {
writeLength(style.paddingBottom);
writeLength(style.paddingLeft);
writeLength(style.paddingRight);
writeLength(style.imageHeight);
writeLength(style.imageWidth);
// Write defined flags as uint16_t
uint16_t definedBits = 0;
@@ -708,23 +579,15 @@ bool CssParser::saveToCache() const {
if (style.defined.paddingBottom) definedBits |= 1 << 10;
if (style.defined.paddingLeft) definedBits |= 1 << 11;
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));
}
LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
file.close();
return true;
}
bool CssParser::loadFromCache() {
if (cachePath.empty()) {
return false;
}
FsFile file;
if (!Storage.openFileForRead("CSS", cachePath + rulesCache, file)) {
bool CssParser::loadFromCache(FsFile& file) {
if (!file) {
return false;
}
@@ -733,18 +596,14 @@ bool CssParser::loadFromCache() {
// Read and verify version
uint8_t version = 0;
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
CssParser::CSS_CACHE_VERSION);
file.close();
Storage.remove((cachePath + rulesCache).c_str());
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
return false;
}
// Read rule count
uint16_t ruleCount = 0;
if (file.read(&ruleCount, sizeof(ruleCount)) != sizeof(ruleCount)) {
file.close();
return false;
}
@@ -754,7 +613,6 @@ bool CssParser::loadFromCache() {
uint16_t selectorLen = 0;
if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) {
rulesBySelector_.clear();
file.close();
return false;
}
@@ -762,7 +620,6 @@ bool CssParser::loadFromCache() {
selector.resize(selectorLen);
if (file.read(&selector[0], selectorLen) != selectorLen) {
rulesBySelector_.clear();
file.close();
return false;
}
@@ -772,28 +629,24 @@ bool CssParser::loadFromCache() {
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.textAlign = static_cast<CssTextAlign>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.fontStyle = static_cast<CssFontStyle>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.fontWeight = static_cast<CssFontWeight>(enumVal);
if (file.read(&enumVal, 1) != 1) {
rulesBySelector_.clear();
file.close();
return false;
}
style.textDecoration = static_cast<CssTextDecoration>(enumVal);
@@ -813,10 +666,8 @@ bool CssParser::loadFromCache() {
if (!readLength(style.textIndent) || !readLength(style.marginTop) || !readLength(style.marginBottom) ||
!readLength(style.marginLeft) || !readLength(style.marginRight) || !readLength(style.paddingTop) ||
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight) ||
!readLength(style.imageHeight) || !readLength(style.imageWidth)) {
!readLength(style.paddingBottom) || !readLength(style.paddingLeft) || !readLength(style.paddingRight)) {
rulesBySelector_.clear();
file.close();
return false;
}
@@ -824,7 +675,6 @@ bool CssParser::loadFromCache() {
uint16_t definedBits = 0;
if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) {
rulesBySelector_.clear();
file.close();
return false;
}
style.defined.textAlign = (definedBits & 1 << 0) != 0;
@@ -840,13 +690,10 @@ bool CssParser::loadFromCache() {
style.defined.paddingBottom = (definedBits & 1 << 10) != 0;
style.defined.paddingLeft = (definedBits & 1 << 11) != 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;
}
LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
file.close();
return true;
}
+7 -25
View File
@@ -4,7 +4,6 @@
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include "CssStyle.h"
@@ -30,10 +29,7 @@
*/
class CssParser {
public:
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
static constexpr uint8_t CSS_CACHE_VERSION = 3;
explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {}
CssParser() = default;
~CssParser() = default;
// Non-copyable
@@ -80,40 +76,28 @@ class CssParser {
*/
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.
* @param file Open file handle to write to
* @return true if cache was written successfully
*/
bool saveToCache() const;
bool saveToCache(FsFile& file) const;
/**
* Load CSS rules from a cache file.
* Clears any existing rules before loading.
* @param file Open file handle to read from
* @return true if cache was loaded successfully
*/
bool loadFromCache();
bool loadFromCache(FsFile& file);
private:
// Storage: maps normalized selector -> style properties
std::unordered_map<std::string, CssStyle> rulesBySelector_;
std::string cachePath;
// 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 void parseDeclarationIntoStyle(const std::string& decl, CssStyle& style, std::string& propNameBuf,
std::string& propValueBuf);
// Individual property value parsers
static CssTextAlign interpretAlignment(const std::string& val);
@@ -121,12 +105,10 @@ class CssParser {
static CssFontWeight interpretFontWeight(const std::string& val);
static CssTextDecoration interpretDecoration(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 bool tryInterpretLength(const std::string& val, CssLength& out);
static int8_t interpretSpacing(const std::string& val);
// String utilities
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> splitWhitespace(const std::string& s);
};
+2 -21
View File
@@ -69,8 +69,6 @@ struct CssPropertyFlags {
uint16_t paddingBottom : 1;
uint16_t paddingLeft : 1;
uint16_t paddingRight : 1;
uint16_t imageHeight : 1;
uint16_t imageWidth : 1;
CssPropertyFlags()
: textAlign(0),
@@ -85,21 +83,17 @@ struct CssPropertyFlags {
paddingTop(0),
paddingBottom(0),
paddingLeft(0),
paddingRight(0),
imageHeight(0),
imageWidth(0) {}
paddingRight(0) {}
[[nodiscard]] bool anySet() const {
return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom ||
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight ||
imageWidth;
marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight;
}
void clearAll() {
textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0;
marginTop = marginBottom = marginLeft = marginRight = 0;
paddingTop = paddingBottom = paddingLeft = paddingRight = 0;
imageHeight = imageWidth = 0;
}
};
@@ -121,8 +115,6 @@ struct CssStyle {
CssLength paddingBottom; // Padding after
CssLength paddingLeft; // Padding left
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
@@ -181,14 +173,6 @@ struct CssStyle {
paddingRight = base.paddingRight;
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; }
@@ -204,8 +188,6 @@ struct CssStyle {
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
[[nodiscard]] bool hasPaddingLeft() const { return defined.paddingLeft; }
[[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; }
[[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; }
[[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; }
void reset() {
textAlign = CssTextAlign::Left;
@@ -215,7 +197,6 @@ struct CssStyle {
textIndent = CssLength{};
marginTop = marginBottom = marginLeft = marginRight = CssLength{};
paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{};
imageHeight = imageWidth = CssLength{};
defined.clearAll();
}
};
@@ -174,213 +174,6 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
while (*ptr != 0) {
const unsigned char* current = 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)});
}
+1 -49
View File
@@ -1,10 +1,8 @@
#include "Hyphenator.h"
#include <algorithm>
#include <vector>
#include "HyphenationCommon.h"
#include "LanguageHyphenator.h"
#include "LanguageRegistry.h"
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.
// 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> 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) {
const uint32_t cp = cps[i].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.
auto explicitBreakInfos = buildExplicitBreakInfos(cps);
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;
}
+4 -17
View File
@@ -9,24 +9,11 @@ class LanguageHyphenator;
class Hyphenator {
public:
struct BreakInfo {
size_t byteOffset; // Byte position inside the UTF-8 word where a break may occur.
bool requiresInsertedHyphen; // true = a visible '-' must be rendered at the break (pattern/fallback breaks).
// false = the word already contains a hyphen at this position (explicit '-').
size_t byteOffset;
bool requiresInsertedHyphen;
};
// Returns byte offsets where the word may be hyphenated.
//
// 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.
// Returns byte offsets where the word may be hyphenated. When includeFallback is true, all positions obeying the
// minimum prefix/suffix constraints are returned even if no language-specific rule matches.
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.

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