diff --git a/.gitmodules b/.gitmodules index b0d8e240..2edc234e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "open-x4-sdk"] path = open-x4-sdk - url = https://github.com/open-x4-epaper/community-sdk.git + url = https://github.com/jpirnay/community-sdk.git diff --git a/.skills/SKILL.md b/.skills/SKILL.md index 23e7772a..1e687b3e 100644 --- a/.skills/SKILL.md +++ b/.skills/SKILL.md @@ -848,7 +848,7 @@ rm -rf /path/to/sd/.crosspoint/epub_/sections/ **Current Versions** (as of docs/file-formats.md): - `book.bin`: **Version 5** (metadata structure) -- `section.bin`: **Version 12** (layout structure) +- `section.bin`: **Version 20** (layout structure, includes paragraph LUT) **Version Increment Rules**: 1. **ALWAYS increment version** BEFORE changing binary structure @@ -858,7 +858,7 @@ rm -rf /path/to/sd/.crosspoint/epub_/sections/ **Example** (incrementing section format version): ```cpp // lib/Epub/Epub/Section.cpp -static constexpr uint8_t SECTION_FILE_VERSION = 13; // Was 12, now 13 +static constexpr uint8_t SECTION_FILE_VERSION = 20; // Was 19, now 20 // Add new field to structure struct PageLine { diff --git a/README.md b/README.md index b9093ea5..d284d838 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,21 @@ -# CrossPoint Reader +# CrossPoint Reader ++ -Firmware for the **Xteink X4** e-paper display reader (unaffiliated with Xteink). -Built using **PlatformIO** and targeting the **ESP32-C3** microcontroller. +This firmware is based on the [crosspoint-reader](https://github.com/crosspoint-reader/crosspoint-reader) for the XTEINK X4, a great piece of software by Dave Allie and others -CrossPoint Reader is a purpose-built firmware designed to be a drop-in, fully open-source replacement for the official -Xteink firmware. It aims to match or improve upon the standard EPUB reading experience. +Unfortunately the official repository suffers from too many good ideas floating around and a lack of clear governance how to deal +with these contributions, so it's lacking fundamental fixes for a proper reading experience (rendering issues, sub-par sync +capabilities with KOReader, a popular multi-platform open-source epub reader) -![](./docs/images/cover.jpg) +Therefore this branch focuses on real fixes and real improvements while trying to keep up to pace with developments in the main branch. -## Motivation +# What's different -E-paper devices are fantastic for reading, but most commercially available readers are closed systems with limited -customisation. The **Xteink X4** is an affordable, e-paper device, however the official firmware remains closed. -CrossPoint exists partly as a fun side-project and partly to open up the ecosystem and truely unlock the device's -potential. - -CrossPoint Reader aims to: -* Provide a **fully open-source alternative** to the official firmware. -* Offer a **document reader** capable of handling EPUB content on constrained hardware. -* Support **customisable font, layout, and display** options. -* Run purely on the **Xteink X4 hardware**. - -This project is **not affiliated with Xteink**; it's built as a community project. - -## Features & Usage - -- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3) -- [x] Image support within EPUB -- [x] Saved reading position -- [x] File explorer with file picker - - [x] Basic EPUB picker from root directory - - [x] Support nested folders - - [ ] EPUB picker with cover art -- [x] Custom sleep screen - - [x] Cover sleep screen -- [x] Wifi book upload -- [x] Wifi OTA updates -- [x] KOReader Sync integration for cross-device reading progress -- [x] Configurable font, layout, and display options - - [ ] User provided fonts - - [ ] Full UTF support -- [x] Screen rotation - -Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages). - -See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint, including the -[KOReader Sync quick setup](./USER_GUIDE.md#365-koreader-sync-quick-setup). - -For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) document. - -## Installing - -### Web (latest firmware) - -1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device -2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware" - -To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap -back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug. - -### Web (specific firmware version) - -1. Connect your Xteink X4 to your computer via USB-C -2. Download the `firmware.bin` file from the release of your choice via the [releases page](https://github.com/crosspoint-reader/crosspoint-reader/releases) -3. Go to https://xteink.dve.al/ and flash the firmware file using the "OTA fast flash controls" section - -To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap -back to the other partition using the "Swap boot partition" button here https://xteink.dve.al/debug. - -### Manual - -See [Development](#development) below. - -## Development - -### Prerequisites - -* **PlatformIO Core** (`pio`) or **VS Code + PlatformIO IDE** -* Python 3.8+ -* USB-C cable for flashing the ESP32-C3 -* Xteink X4 - -### Checking out the code - -CrossPoint uses PlatformIO for building and flashing the firmware. To get started, clone the repository: - -``` -git clone --recursive https://github.com/crosspoint-reader/crosspoint-reader - -# Or, if you've already cloned without --recursive: -git submodule update --init --recursive -``` - -### Flashing your device - -Connect your Xteink X4 to your computer via USB-C and run the following command. - -```sh -pio run --target upload -``` -### Debugging - -After flashing the new features, it’s recommended to capture detailed logs from the serial port. - -First, make sure all required Python packages are installed: - -```python -python3 -m pip install pyserial colorama matplotlib -``` -after that run the script: -```sh -# For Linux -# This was tested on Debian and should work on most Linux systems. -python3 scripts/debugging_monitor.py - -# For macOS -python3 scripts/debugging_monitor.py /dev/cu.usbmodem2101 -``` -Minor adjustments may be required for Windows. - -## Internals - -CrossPoint Reader is pretty aggressive about caching data down to the SD card to minimise RAM usage. The ESP32-C3 only -has ~380KB of usable RAM, so we have to be careful. A lot of the decisions made in the design of the firmware were based -on this constraint. - -### Data caching - -The first time chapters of a book are loaded, they are cached to the SD card. Subsequent loads are served from the -cache. This cache directory exists at `.crosspoint` on the SD card. The structure is as follows: - - -``` -.crosspoint/ -├── epub_12471232/ # Each EPUB is cached to a subdirectory named `epub_` -│ ├── progress.bin # Stores reading progress (chapter, page, etc.) -│ ├── cover.bmp # Book cover image (once generated) -│ ├── book.bin # Book metadata (title, author, spine, table of contents, etc.) -│ └── sections/ # All chapter data is stored in the sections subdirectory -│ ├── 0.bin # Chapter data (screen count, all text layout info, etc.) -│ ├── 1.bin # files are named by their index in the spine -│ └── ... -│ -└── epub_189013891/ -``` - -Deleting the `.crosspoint` directory will clear the entire cache. - -Due the way it's currently implemented, the cache is not automatically cleared when a book is deleted and moving a book -file will use a new cache directory, resetting the reading progress. - -For more details on the internal file structures, see the [file formats document](./docs/file-formats.md). - -## Contributing - -Contributions are very welcome! - -If you are new to the codebase, start with the [contributing docs](./docs/contributing/README.md). - -If you're looking for a way to help out, take a look at the [ideas discussion board](https://github.com/crosspoint-reader/crosspoint-reader/discussions/categories/ideas). -If there's something there you'd like to work on, leave a comment so that we can avoid duplicated effort. - -Everyone here is a volunteer, so please be respectful and patient. For more details on our goverance and community -principles, please see [GOVERNANCE.md](GOVERNANCE.md). - -### To submit a contribution: - -1. Fork the repo -2. Create a branch (`feature/dithering-improvement`) -3. Make changes -4. Submit a PR - ---- - -CrossPoint Reader is **not affiliated with Xteink or any manufacturer of the X4 hardware**. - -Huge shoutout to [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader), which was a project I took a lot of inspiration from as I -was making CrossPoint. +- Proper KOReader Snychronisation (including https TLS OOM fix) +- Fixes for a lot of css rendering issues +- Additional sleep screens support (information overlay, transparent pictures over current reader screen) +- Clock-Support +- Weather information panel +- Multiple under-the-hood performance improvements +- Book information screen +- Reading ruler +- ... diff --git a/assets/weather-icons/svg/wi-cloudy.svg b/assets/weather-icons/svg/wi-cloudy.svg new file mode 100644 index 00000000..c3eaf41d --- /dev/null +++ b/assets/weather-icons/svg/wi-cloudy.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-day-cloudy.svg b/assets/weather-icons/svg/wi-day-cloudy.svg new file mode 100644 index 00000000..cf63543a --- /dev/null +++ b/assets/weather-icons/svg/wi-day-cloudy.svg @@ -0,0 +1,26 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-day-sunny.svg b/assets/weather-icons/svg/wi-day-sunny.svg new file mode 100644 index 00000000..eafcd77d --- /dev/null +++ b/assets/weather-icons/svg/wi-day-sunny.svg @@ -0,0 +1,27 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-fog.svg b/assets/weather-icons/svg/wi-fog.svg new file mode 100644 index 00000000..cf06f8f8 --- /dev/null +++ b/assets/weather-icons/svg/wi-fog.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-night-alt-cloudy.svg b/assets/weather-icons/svg/wi-night-alt-cloudy.svg new file mode 100644 index 00000000..fcd1496d --- /dev/null +++ b/assets/weather-icons/svg/wi-night-alt-cloudy.svg @@ -0,0 +1,19 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-night-clear.svg b/assets/weather-icons/svg/wi-night-clear.svg new file mode 100644 index 00000000..053b2de9 --- /dev/null +++ b/assets/weather-icons/svg/wi-night-clear.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-rain.svg b/assets/weather-icons/svg/wi-rain.svg new file mode 100644 index 00000000..7ecef3fe --- /dev/null +++ b/assets/weather-icons/svg/wi-rain.svg @@ -0,0 +1,23 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-snow.svg b/assets/weather-icons/svg/wi-snow.svg new file mode 100644 index 00000000..56afebf9 --- /dev/null +++ b/assets/weather-icons/svg/wi-snow.svg @@ -0,0 +1,27 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-sprinkle.svg b/assets/weather-icons/svg/wi-sprinkle.svg new file mode 100644 index 00000000..c26b2075 --- /dev/null +++ b/assets/weather-icons/svg/wi-sprinkle.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/assets/weather-icons/svg/wi-thunderstorm.svg b/assets/weather-icons/svg/wi-thunderstorm.svg new file mode 100644 index 00000000..52555ec7 --- /dev/null +++ b/assets/weather-icons/svg/wi-thunderstorm.svg @@ -0,0 +1,21 @@ + + + + + diff --git a/bin/clang-format-fix.ps1 b/bin/clang-format-fix.ps1 index 9eae1715..f5d73d1c 100644 --- a/bin/clang-format-fix.ps1 +++ b/bin/clang-format-fix.ps1 @@ -97,6 +97,7 @@ $exclude = @( 'lib\Epub\Epub\hyphenation\generated' 'lib\uzlib' '.pio' + '.venv' ) function Test-Excluded($fullPath) { @@ -108,16 +109,16 @@ function Test-Excluded($fullPath) { } if ($g) { - # Only git-modified *.cpp / *.h files + # Only git-modified *.cpp / *.c / *.h files # Covers both staged and unstaged changes $files = @(git -C $repoRoot diff --name-only HEAD) + @(git -C $repoRoot diff --name-only --cached) | Sort-Object -Unique | - Where-Object { $_ -match '\.(cpp|h)$' } | + Where-Object { $_ -match '\.(cpp|c|h)$' } | ForEach-Object { Get-Item (Join-Path $repoRoot $_) -ErrorAction SilentlyContinue } | Where-Object { $_ -and -not (Test-Excluded $_.FullName) } } else { - $files = Get-ChildItem -Path $repoRoot -Recurse -Include *.cpp, *.h -File | + $files = Get-ChildItem -Path $repoRoot -Recurse -Include *.cpp, *.c, *.h -File | Where-Object { -not (Test-Excluded $_.FullName) } } diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index f5478115..cab0db95 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -125,6 +125,23 @@ Notes: - rendering favors reusing precomputed layout data to keep page turns responsive on constrained hardware - progress/session state is persisted so the reader can reopen at the last position after reboot/sleep +## KOReader sync position mapping + +KOReader sync integration is implemented under `lib/KOReaderSync/` and is used by +`src/activities/reader/KOReaderSyncActivity.*`. + +Position translation currently follows a dual-path strategy: + +- CrossPoint -> KOReader: prefer element-level XPath extracted from the current + spine XHTML; fallback to chapter-level `DocFragment` path when needed. +- KOReader -> CrossPoint: prefer incoming XPath resolution; fallback to + percentage-based estimation if XPath is invalid or cannot be resolved. + +Detailed algorithm and constraints (including low-memory rationale for ESP32-C3) +are documented in: + +- [KOReader Sync XPath Mapping](koreader-sync-xpath-mapping.md) + ## State and persistence Two singletons are central: diff --git a/docs/contributing/koreader-sync-xpath-mapping.md b/docs/contributing/koreader-sync-xpath-mapping.md new file mode 100644 index 00000000..f437dd6b --- /dev/null +++ b/docs/contributing/koreader-sync-xpath-mapping.md @@ -0,0 +1,128 @@ +# KOReader Sync XPath Mapping + +This note documents how CrossPoint maps reading positions to and from KOReader sync payloads. + +Related architecture overview: [koreader-synchronization.md](koreader-synchronization.md) + +## Problem + +CrossPoint internally stores position as: + +- `spineIndex` (chapter index, 0-based) +- `pageNumber` + `totalPages` + +KOReader sync payload stores: + +- `progress` (XPath-like location) +- `percentage` (overall progress) + +A direct 1:1 mapping is not guaranteed because page layout differs between engines/devices. + +## DocFragment Index Convention + +KOReader uses **1-based** XPath predicates throughout, following standard XPath conventions. +The first EPUB spine item is `DocFragment[1]`, the second is `DocFragment[2]`, and so on. + +CrossPoint stores spine items as 0-based indices internally. The conversion is: + +- **Generating XPath (to KOReader):** `DocFragment[spineIndex + 1]` +- **Parsing XPath (from KOReader):** `spineIndex = DocFragment[N] - 1` + +Reference: [koreader/koreader#11585](https://github.com/koreader/koreader/issues/11585) confirms this +via a KOReader contributor mapping spine items to DocFragment numbers. + +## Current Strategy + +### CrossPoint -> KOReader + +Implemented in `ProgressMapper::toKOReader`. + +1. Compute overall `percentage` from chapter/page. +2. If a paragraph index is available from the section cache LUT (`CrossPointPosition::hasParagraphIndex`), + generate an XPath directly: `/body/DocFragment[spineIndex + 1]/body/p[paragraphIndex]`. +3. Otherwise, attempt byte-offset estimation via `ChapterXPathIndexer::findXPathForProgress`. +4. If XPath extraction fails, fallback to synthetic chapter path: + - `/body/DocFragment[spineIndex + 1]/body` + +### KOReader -> CrossPoint + +Implemented in `ProgressMapper::toCrossPoint`. + +1. Attempt to parse `DocFragment[N]` from incoming XPath; convert N to 0-based `spineIndex = N - 1`. +2. If valid, attempt XPath-to-offset mapping via `ChapterXPathIndexer::findProgressForXPath`. +3. Extract paragraph index from XPath via `ChapterXPathIndexer::tryExtractParagraphIndexFromXPath` + (e.g. `/body/DocFragment[7]/body/p[685]/text().96` → `paragraphIndex = 685`). +4. Convert resolved intra-spine progress to page estimate. +5. If XPath path is invalid/unresolvable, fallback to percentage-based chapter/page estimation. + +When a paragraph index is available, `EpubReaderActivity` refines the page estimate using +the section cache's per-page paragraph LUT (`Section::getPageForParagraphIndex`). This finds +the first page whose recorded paragraph index is >= the target, giving a more accurate +landing position than byte-offset-based estimation alone. + +## ChapterXPathIndexer Design + +The module reparses **one spine XHTML** on demand using Expat and builds temporary anchors: + +Source-of-truth note: XPath anchors are built from the original EPUB spine XHTML bytes (zip item contents), not from CrossPoint's distilled section render cache. This is intentional to preserve KOReader XPath compatibility. + +- anchor: `` +- `textOffset` counts non-whitespace bytes +- When multiple anchors exist for the same path, the one with the **smallest** textOffset is used + (start of element), not the latest periodic anchor. + +Forward lookup (CrossPoint → XPath): uses `upper_bound` to find the last anchor at or before the +target text offset, ensuring the returned XPath corresponds to the element the user is currently +inside rather than the next element. + +Matching for reverse lookup: + +1. exact path match — reported as `exact=yes` +2. index-insensitive path match (`div[2]` vs `div[3]` tolerated) — reported as `exact=no` +3. ancestor fallback — reported as `exact=no` + +If no match is found, caller must fallback to percentage. + +## Memory / Safety Constraints (ESP32-C3) + +The implementation intentionally avoids full DOM storage. + +- Parse one chapter only. +- Keep anchors in transient vectors only for duration of call. +- Free XML parser and chapter byte buffer on all success/failure paths. +- No persistent cache structures are introduced by this module. + +## Paragraph Index LUT + +The section cache stores a per-page paragraph index LUT built during page layout +(`ChapterHtmlSlimParser`). Each entry records the 1-based `

` sibling index +(direct children of ``, matching XPath convention) at the time each page was completed. + +This enables two lookups without reparsing: + +- **XPath → page** (`Section::getPageForParagraphIndex`): finds the first page where the + recorded paragraph index >= target. Used when applying remote KOReader progress. +- **Page → XPath** (`Section::getParagraphIndexForPage`): returns the paragraph index for + a given page. Used when uploading local progress to KOReader. + +The paragraph counter in `ChapterHtmlSlimParser` counts **all** `

` elements at body-child +level, including `display:none` elements. This matches `ChapterXPathIndexer` and crengine's +standard XPath same-name sibling counting. + +## Known Limitations + +- Page number on reverse mapping is still an estimate (renderer differences). + The paragraph LUT refines this but cannot guarantee exact page matching. +- XPath mapping intentionally uses original spine XHTML while pagination comes from distilled renderer output, so minor roundtrip page drift is expected. +- Image-only/low-text chapters may yield coarse anchors. +- Extremely malformed XHTML can force fallback behavior. + +## Operational Logging + +`ProgressMapper` logs mapping source in reverse direction: + +- `xpath` when XPath mapping path was used +- `percentage` when fallback path was used + +It also logs exactness (`exact=yes/no`) for XPath matches. Note that `exact=yes` is only set for +a full path match with correct indices; index-insensitive and ancestor matches always log `exact=no`. diff --git a/docs/contributing/koreader-synchronization.md b/docs/contributing/koreader-synchronization.md new file mode 100644 index 00000000..31aa74ad --- /dev/null +++ b/docs/contributing/koreader-synchronization.md @@ -0,0 +1,115 @@ +# KOReader Synchronization Architecture + +This document explains the intent and internal structure of the KOReader synchronization code in CrossPoint. + +Scope: +- Synchronization logic that maps between CrossPoint reading position and KOReader sync payloads. +- Module boundaries and responsibilities. +- Matching rules, fallback strategy, and expected behavior. + +For XPath-specific details and examples, see [koreader-sync-xpath-mapping.md](koreader-sync-xpath-mapping.md). + +## Goals + +The synchronization layer is designed to: +- Be robust on constrained devices (ESP32-C3 memory constraints). +- Be deterministic and debuggable when mapping positions. +- Keep transport/client logic separated from parsing/mapping logic. +- Prefer precise anchors when available, but degrade gracefully. + +## Data Model Mismatch + +CrossPoint stores position as chapter/page-centric state. +KOReader sync payload stores position as XPath-like anchor plus percentage. + +Because layout engines differ, page equality cannot be guaranteed across devices. +The synchronization strategy therefore combines: +- Structural anchor mapping (XPath). +- Percent-based fallback. +- Paragraph LUT refinement when available. + +## Module Responsibilities + +### Client / orchestration + +- [lib/KOReaderSync/KOReaderSyncClient.cpp](../../lib/KOReaderSync/KOReaderSyncClient.cpp) + - HTTP calls and payload exchange. + +- [lib/KOReaderSync/ProgressMapper.cpp](../../lib/KOReaderSync/ProgressMapper.cpp) + - High-level mapping from app state to KOReader payload and back. + - Chooses XPath path or percentage fallback. + +### XPath indexing facade + +- [lib/KOReaderSync/ChapterXPathIndexer.h](../../lib/KOReaderSync/ChapterXPathIndexer.h) +- [lib/KOReaderSync/ChapterXPathIndexer.cpp](../../lib/KOReaderSync/ChapterXPathIndexer.cpp) + - Public API consumed by ProgressMapper. + - Thin facade over forward/reverse mapper internals. + - Utility extraction helpers (DocFragment index, paragraph index). + +### Forward mapping engine + +- [lib/KOReaderSync/ChapterXPathForwardMapper.cpp](../../lib/KOReaderSync/ChapterXPathForwardMapper.cpp) + - Maps intra-spine progress to XPath. + - Emits /text()[N].M for body-level text-node locations. + +### Reverse mapping engine + +- [lib/KOReaderSync/ChapterXPathReverseMapper.cpp](../../lib/KOReaderSync/ChapterXPathReverseMapper.cpp) + - Maps XPath to intra-spine progress. + - Supports exact and tolerant matching tiers. + - Handles /text()[N].M codepoint offsets. + +### Shared parser/state/utilities + +- [lib/KOReaderSync/ChapterXPathIndexerInternal.cpp](../../lib/KOReaderSync/ChapterXPathIndexerInternal.cpp) +- [lib/KOReaderSync/ChapterXPathIndexerInternal.h](../../lib/KOReaderSync/ChapterXPathIndexerInternal.h) + - UTF-8 helpers, XPath normalization, parse runner, and chapter text-byte counting. + +- [lib/KOReaderSync/ChapterXPathIndexerState.h](../../lib/KOReaderSync/ChapterXPathIndexerState.h) + - Shared stack model and generic Expat callback adapters. + - Common parser code pattern used by both forward/reverse engines. + +## Core Logic + +### Forward (CrossPoint -> KOReader) + +1. Decompress one spine XHTML to a temporary file. +2. Count total visible text bytes. +3. Cache that total per spine (cache-path + spine index + href) so repeated + mappings for the same chapter can skip the expensive counting pass. +4. Convert intra-spine progress to target visible-byte offset. +5. Stream parse and stop at target. +6. Emit anchor: + - element XPath, or + - /text()[N].M when in body-level text-node context. + +### Reverse (KOReader -> CrossPoint) + +1. Decompress one spine XHTML to a temporary file. +2. Stream parse chapter while evaluating candidate matches. +3. Resolve best tier in this order: + - exact + - exact-no-index + - ancestor + - ancestor-no-index +4. Convert resolved byte offset to intra-spine progress. + +For text-node anchors /text()[N].M: +- N is treated as 1-based text node index. +- M is treated as 0-based codepoint offset. + +## Fallback Strategy + +When XPath mapping fails or is ambiguous: +- Fall back to percentage-driven chapter/page estimation. +- Use paragraph LUT refinement where available. + +This guarantees user progress continuity even for malformed or sparse content. + +## Constraints and Non-Goals + +- No full DOM materialization for entire books. +- Parse only one spine item on demand. +- Keep memory usage bounded and transient. +- Do not attempt pixel-perfect page parity with KOReader. diff --git a/docs/epub-toc-navigation.md b/docs/epub-toc-navigation.md new file mode 100644 index 00000000..2e623c65 --- /dev/null +++ b/docs/epub-toc-navigation.md @@ -0,0 +1,159 @@ +# EPUB TOC Anchor Navigation + +This document describes how the reader handles EPUB Table of Contents (TOC) entries that use fragment anchors to point into spine files, enabling navigation to sub-chapters within a single XHTML file. + +## Background: EPUB spine and TOC structure + +An EPUB's **spine** is an ordered list of XHTML files that define reading order. The **TOC** (table of contents) maps chapter names to positions in the spine, optionally with fragment anchors (e.g. `chapter1.xhtml#section-5`). + +Two layouts are relevant here: + +- **1:1** -- one TOC entry per spine item (most common, no anchors needed) +- **Multi-TOC-per-spine** -- multiple TOC entries point into a single spine file using fragment anchors (e.g. Moby Dick from Project Gutenberg packs 3-9 chapters per file) + +Spine items before the first TOC entry (cover pages) and after the last (appendices, copyright) have no TOC entry of their own. + +## BookMetadataCache and TOC-to-spine mapping + +`BookMetadataCache` builds the mapping between spine items and TOC entries at epub open time. Key details: + +- Each `SpineEntry` has a `tocIndex` field set during cache building. For spines with no matching TOC entry, `tocIndex` inherits the previous spine's value (`lastSpineTocIndex`). This means orphan spines (cover pages, appendices) are treated as continuations of the nearest preceding chapter. +- `getTocIndexForSpineIndex(i)` returns the stored `tocIndex` for spine `i` -- a file seek into BookMetadataCache, not computed on the fly. +- `getTocItem(i)` returns the TOC entry (title, spineIndex, anchor) for TOC index `i` -- also a file seek per call, not cached in memory. Code that queries TOC metadata in a loop should cache the results locally first. +- `getSpineIndexForTocIndex(i)` does the reverse lookup (TOC index to spine index). + +## Section cache file format + +The section cache (`.bin`) stores pre-rendered page data for a spine item. The file layout: + +``` +[header: version, render parameters, pageCount, lutOffset, anchorMapOffset] +[serialized pages...] +[page LUT: array of uint32_t file offsets, one per page] +[anchor map: uint16_t count, then (string, uint16_t) pairs] +``` + +The header size is defined by `HEADER_SIZE` (a constexpr computed via `sizeof` sum) and validated with a `static_assert`. Three functions read this header independently and must stay in sync: + +- `loadSectionFile` -- full section load, reads header + builds TOC boundaries from anchor map +- `getPageForAnchor` -- seeks directly to anchor map offset from header +- `writeSectionFileHeader` -- writes the header during cache creation + +When modifying the header layout, bump `SECTION_FILE_VERSION` to invalidate stale caches and update all read paths. + +## Anchor-to-page mapping + +### Recording anchors during parsing + +`ChapterHtmlSlimParser` records every HTML `id` attribute and its corresponding page number into `anchorData` (a flat `std::vector>`). Recording is deferred via `pendingAnchorId` until `startNewTextBlock()`, after the previous text block is flushed to pages via `makePages()`. This ensures `completedPageCount` reflects the correct page. + +For TOC anchors specifically, `startNewTextBlock` also forces a page break before recording, so chapters start on fresh pages rather than mid-page. The parser receives the set of TOC anchor strings via `tocAnchors` (a `std::vector`) from `Section::createSectionFile`. + +### On-disk format + +The anchor data is serialized at the end of the section cache file (`.bin`), after the page LUT. The header stores the anchor map offset. Format: + +``` +[uint16_t count] +[string anchor_1][uint16_t page_1] +[string anchor_2][uint16_t page_2] +... +``` + +This data serves two purposes: +- **Footnote navigation** (`getPageForAnchor`): on-demand linear scan for a single anchor +- **TOC boundary resolution** (`buildTocBoundariesFromFile`): scan matching only TOC anchors + +### Data structure choices + +All anchor storage uses flat vectors, not `std::map` or `std::set`. On the ESP32-C3, each `std::map`/`std::set` node requires its own heap allocation, causing fragmentation. Vectors use a single contiguous allocation. The entry counts are small enough (typically 1-10 TOC anchors per spine, dozens to hundreds of total anchors) that linear scans are faster than tree lookups at these sizes. + +## TOC boundaries in Section + +When a section is loaded or created, `Section` builds an in-memory `tocBoundaries` vector mapping each TOC entry in that spine to its start page. This is a small vector (1-3 entries typically) that enables O(1) lookups without file I/O. + +### Two build paths + +**From in-memory anchors** (`buildTocBoundaries`): Called after `createSectionFile` when the parser's anchor vector is still in memory. Iterates TOC entries and does linear scans against the anchor vector. + +**From disk** (`buildTocBoundariesFromFile`): Called from `loadSectionFile` when loading a cached section. Caches the small set of TOC anchor strings first (since `getTocItem()` does file I/O to `BookMetadataCache`), then streams through on-disk anchors matching only those, stopping early once all are resolved. Uses a reusable `std::string` buffer to avoid per-entry heap allocation. + +The two functions are kept separate because their iteration patterns differ fundamentally: in-memory iterates TOC entries with inner scans of anchors, while the disk path iterates disk entries with inner scans of the small TOC anchor set. + +### Early exit optimization + +If no TOC entries in the spine have anchors (`unresolvedCount == 0`), both functions return immediately without storing any boundaries. `getTocIndexForPage` falls back to `epub->getTocIndexForSpineIndex`, which gives the correct answer for the common 1:1 case. + +### Query methods + +- `getTocIndexForPage(page)` -- binary search on sorted `tocBoundaries` to find which chapter a page belongs to +- `getPageForTocIndex(tocIndex)` -- linear scan to find a chapter's start page +- `getPageRangeForTocIndex(tocIndex)` -- returns `[startPage, endPage)` range for a chapter within this spine + +All are in-memory, no file I/O. + +## Chapter navigation in EpubReaderActivity + +### Chapter skip (long-press) + +Navigates by TOC index, not spine index. Uses `getTocIndexForPage` to determine the current chapter, then increments or decrements. + +- **Same-spine skip**: Resolves the target page via `getPageForTocIndex` entirely in memory +- **Cross-spine skip**: Sets `pendingTocIndex` (a `std::optional`) which is resolved after the target section loads in `render()` +- **Forward past last TOC entry**: Jumps to end-of-book (spine index clamped in `render()`) +- **Backward before first TOC entry**: Jumps to the spine before the current chapter's first spine (clamped to 0 in `render()`) +- **No TOC entry for spine** (`curTocIndex < 0`): Falls back to spine-level skip + +### Chapter selector + +The chapter selection activity receives `currentTocIndex` (per-page, not per-spine) so it highlights the correct sub-chapter. Returns `ChapterResult` with both `spineIndex` and `std::optional tocIndex`. The reader resolves the page via `getPageForTocIndex` for same-spine navigation or defers via `pendingTocIndex` for cross-spine. + +### Footnote navigation + +Uses the existing `pendingAnchor` mechanism from the footnote anchor navigation commit (4d222567). `getPageForAnchor` does an on-demand linear scan of the on-disk anchor data. This is separate from TOC boundaries -- it reads all anchors (not just TOC ones) and is only called for footnote jumps. + +### Status bar + +Uses `getTocIndexForPage()` for the chapter title, so the status bar shows the correct sub-chapter name when reading a multi-TOC-per-spine file. + +## Orphan spine handling + +Spine items without a TOC entry inherit the previous spine's `tocIndex` in `BookMetadataCache`. This means: + +- Pre-TOC spines (cover pages) may have `tocIndex == -1` if they're before any chapter +- Post-TOC spines (appendices, copyright) inherit the last chapter's `tocIndex` + +The chapter skip logic guards against `curTocIndex < 0` and falls back to spine-level navigation. + +## Implementation pitfalls and edge cases + +### Anchor recording timing + +The `pendingAnchorId` deferred recording pattern is critical for correctness. Anchors must be recorded *after* `makePages()` flushes the previous text block (so `completedPageCount` reflects the right page) but the TOC page break must happen *before* recording (so the anchor lands on the new page). Both of these happen inside `startNewTextBlock()`. An earlier design used a `recordAnchor` lambda called at various points in `startElement()`, but this had wrong timing for headings and block elements -- `startNewTextBlock` would consume `pendingAnchorId` before `recordAnchor` could force the page break. Moving all page-break logic into `startNewTextBlock` fixed this. + +### pendingAnchorId overwrite on consecutive elements + +If two elements with `id` attributes appear before any `startNewTextBlock` call (e.g. nested divs), the second `id` overwrites `pendingAnchorId` and the first anchor is never recorded. This is a known limitation inherited from the footnote anchor navigation commit (4d222567) on master. In practice, TOC anchors are on chapter headings which trigger `startNewTextBlock`, so this doesn't affect TOC navigation. + +### wordsExtractedInBlock reset on empty block reuse + +When `startNewTextBlock` reuses an empty text block (the early-return path), `wordsExtractedInBlock` must be reset to 0. Without this, footnotes in the reused block could be assigned to wrong pages based on stale word counts from a prior block. + +### getTocItem() does file I/O + +`epub->getTocItem()` reads from `BookMetadataCache` via file seek on every call. This is why `buildTocBoundariesFromFile` caches the TOC anchor strings into a small vector before entering the disk scan loop -- otherwise the inner loop would do file I/O (BookMetadataCache) for every on-disk anchor entry. + +### Defensive sort on tocBoundaries + +`tocBoundaries` is sorted by `startPage` after building. In well-formed EPUBs, entries are already in order (TOC follows document order). The sort is a safety net for malformed EPUBs where TOC entries might be out of document order. With 1-3 entries it has no measurable cost. + +## Test epub + +`scripts/generate_spine_toc_edges_epub.py` generates `test/epubs/test_spine_toc_edges.epub`, a purpose-built epub that exercises spine/TOC relationship patterns. See the script header for the full list of edge cases covered. + +## Performance characteristics + +- **Per page turn**: All in-memory. `getTocIndexForPage` (binary search on 1-3 entries), `getTocItem` for title (one file seek to BookMetadataCache -- noted as a future optimization opportunity). +- **Section load**: One file open for the section cache. `buildTocBoundariesFromFile` scans the anchor map for a few TOC entries with early exit. +- **Footnote navigation**: One additional file open to scan the anchor map for a single anchor. +- **1:1 TOC-to-spine (common case)**: No overhead. `unresolvedCount == 0`, `tocBoundaries` stays empty, all queries fall back to spine-level methods. diff --git a/docs/file-formats.md b/docs/file-formats.md index 2fa0c60b..8571f7d4 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -104,7 +104,7 @@ if (parsedSize != fileSize) { ## `section.bin` -### Version 8 +### Version 20 ImHex Pattern: @@ -114,7 +114,7 @@ import std.string; import std.core; // === Configuration === -#define EXPECTED_VERSION 8 +#define EXPECTED_VERSION 20 #define MAX_STRING_LENGTH 65535 // === String Structure === @@ -175,36 +175,60 @@ struct Page { PageElement elements[elementCount] [[inline]]; }; +// === Anchor Map Entry === + +struct AnchorEntry { + String anchorId [[comment("HTML id attribute value")]]; + u16 pageNumber [[comment("Page where the anchor appears")]]; +}; + // === Section Bin Structure === struct SectionBin { // Header u8 version [[comment("Format version"), color("FFD93D")]]; - + // Version validation if (version != EXPECTED_VERSION) { std::error(std::format("Unsupported version: {} (expected {})", version, EXPECTED_VERSION)); } - + // Cache busting parameters s32 fontId; float lineCompression; bool extraParagraphSpacing; + u8 paragraphAlignment; u16 viewportWidth; - u16 vieportHeight; + u16 viewportHeight; u16 pageCount; - u32 lutOffset; - + bool hyphenationEnabled; + bool embeddedStyle; + u8 imageRendering; + u32 pageLutOffset [[comment("Offset to page offset LUT")]]; + u32 anchorMapOffset [[comment("Offset to anchor map")]]; + u32 paragraphLutOffset [[comment("Offset to per-page paragraph index LUT")]]; + Page page[pageCount]; - + + // === Page Offset LUT === // Validate LUT offset alignment u32 currentOffset = $; - if (currentOffset != lutOffset) { - std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset)); + if (currentOffset != pageLutOffset) { + std::warning(std::format("Page LUT offset mismatch: expected 0x{:X}, got 0x{:X}", pageLutOffset, currentOffset)); } - - // Lookup Tables - u32 lut[pageCount]; + + u32 pageOffsets[pageCount] [[comment("File offsets to serialized pages")]]; + + // === Anchor Map === + u16 anchorCount; + AnchorEntry anchors[anchorCount]; + + // === Paragraph Index LUT === + // One entry per page: the 1-based

sibling index (XPath convention) + // at the time each page was completed during parsing. + // Used to resolve KOReader XPath p[N] positions to page numbers. + u16 paragraphEntryCount; + u16 paragraphIndexPerPage[paragraphEntryCount] [[comment("1-based

index at page completion")]]; }; // === File Parsing === diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index cb0b1801..baf1b2e7 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -77,6 +78,9 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) { bookMetadata.author = opfParser.author; bookMetadata.language = opfParser.language; bookMetadata.coverItemHref = opfParser.coverItemHref; + bookMetadata.series = opfParser.series; + bookMetadata.seriesIndex = opfParser.seriesIndex; + bookMetadata.description = opfParser.description; // Guide-based cover fallback: if no cover found via metadata/properties, // try extracting the image reference from the guide's cover page XHTML @@ -333,6 +337,7 @@ void Epub::parseCssFiles() const { // load in the meta data for the epub file bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str()); + tocReliabilityState = -1; // Initialize spine/TOC cache bookMetadataCache.reset(new BookMetadataCache(cachePath)); @@ -516,6 +521,30 @@ const std::string& Epub::getLanguage() const { return bookMetadataCache->coreMetadata.language; } +const std::string& Epub::getSeries() const { + static std::string blank; + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return blank; + } + return bookMetadataCache->coreMetadata.series; +} + +const std::string& Epub::getSeriesIndex() const { + static std::string blank; + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return blank; + } + return bookMetadataCache->coreMetadata.seriesIndex; +} + +const std::string& Epub::getDescription() const { + static std::string blank; + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return blank; + } + return bookMetadataCache->coreMetadata.description; +} + std::string Epub::getCoverBmpPath(bool cropped) const { const auto coverFileName = std::string("cover") + (cropped ? "_crop" : ""); return cachePath + "/" + coverFileName + ".bmp"; @@ -767,6 +796,18 @@ BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const { return {}; } + if (syntheticTocFallbackEnabled && !hasReliableToc()) { + const int spineCount = bookMetadataCache->getSpineCount(); + if (tocIndex < 0 || tocIndex >= spineCount) { + LOG_DBG("EBP", "getTocItem synthetic index:%d is out of range", tocIndex); + return {}; + } + + const auto spine = bookMetadataCache->getSpineEntry(tocIndex); + return BookMetadataCache::TocEntry(tr(STR_SECTION_PREFIX) + std::to_string(tocIndex + 1), spine.href, "", 1, + static_cast(tocIndex)); + } + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { LOG_DBG("EBP", "getTocItem index:%d is out of range", tocIndex); return {}; @@ -780,6 +821,10 @@ int Epub::getTocItemsCount() const { return 0; } + if (syntheticTocFallbackEnabled && !hasReliableToc()) { + return bookMetadataCache->getSpineCount(); + } + return bookMetadataCache->getTocCount(); } @@ -790,6 +835,14 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const { return 0; } + if (syntheticTocFallbackEnabled && !hasReliableToc()) { + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getSpineCount()) { + LOG_ERR("EBP", "getSpineIndexForTocIndex synthetic tocIndex %d out of range", tocIndex); + return 0; + } + return tocIndex; + } + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { LOG_ERR("EBP", "getSpineIndexForTocIndex: tocIndex %d out of range", tocIndex); return 0; @@ -804,7 +857,66 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const { return spineIndex; } -int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; } +bool Epub::hasReliableToc() const { + if (tocReliabilityState != -1) { + return tocReliabilityState == 1; + } + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + tocReliabilityState = 0; + return false; + } + + const int spineCount = bookMetadataCache->getSpineCount(); + const int tocCount = bookMetadataCache->getTocCount(); + + if (spineCount <= 0 || tocCount <= 0) { + tocReliabilityState = 0; + return false; + } + + // If a larger book only exposes one TOC entry, treat TOC as unusable for chapter UX. + if (spineCount >= 8 && tocCount <= 1) { + tocReliabilityState = 0; + return false; + } + + std::vector spineReferenced(static_cast(spineCount), false); + int distinctSpinesReferenced = 0; + for (int i = 0; i < tocCount; i++) { + const auto toc = bookMetadataCache->getTocEntry(i); + if (toc.spineIndex >= 0 && toc.spineIndex < spineCount) { + const size_t idx = static_cast(toc.spineIndex); + if (!spineReferenced[idx]) { + spineReferenced[idx] = true; + distinctSpinesReferenced++; + } + } + } + + // Require at least 25% spine coverage from TOC references. + const bool reliable = (distinctSpinesReferenced * 4 >= spineCount); + tocReliabilityState = reliable ? 1 : 0; + return reliable; +} + +int Epub::getTocIndexForSpineIndex(const int spineIndex) const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + LOG_ERR("EBP", "getTocIndexForSpineIndex called but cache not loaded"); + return -1; + } + + if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) { + LOG_ERR("EBP", "getTocIndexForSpineIndex: spineIndex %d out of range", spineIndex); + return -1; + } + + if (syntheticTocFallbackEnabled && !hasReliableToc()) { + return spineIndex; + } + + return bookMetadataCache->getSpineEntry(spineIndex).tocIndex; +} size_t Epub::getBookSize() const { if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) { diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 9ffa8d37..77d52a04 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -29,6 +29,10 @@ class Epub { std::unique_ptr cssParser; // CSS files std::vector cssFiles; + // -1 unknown, 0 unreliable, 1 reliable + mutable int tocReliabilityState = -1; + // Library-level option: app code can override this per-book instance. + bool syntheticTocFallbackEnabled = false; bool findContentOpfFile(std::string* contentOpfFile) const; bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata); @@ -51,6 +55,9 @@ class Epub { const std::string& getTitle() const; const std::string& getAuthor() const; const std::string& getLanguage() const; + const std::string& getSeries() const; + const std::string& getSeriesIndex() const; + const std::string& getDescription() const; std::string getCoverBmpPath(bool cropped = false) const; bool generateCoverBmp(bool cropped = false) const; std::string getThumbBmpPath() const; @@ -66,6 +73,8 @@ class Epub { int getTocItemsCount() const; int getSpineIndexForTocIndex(int tocIndex) const; int getTocIndexForSpineIndex(int spineIndex) const; + bool hasReliableToc() const; + void setSyntheticTocFallbackEnabled(bool enabled) { syntheticTocFallbackEnabled = enabled; } size_t getCumulativeSpineItemSize(int spineIndex) const; int getSpineIndexForTextReference() const; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 3cdee0b0..e320a86f 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -9,7 +9,7 @@ #include "FsHelpers.h" namespace { -constexpr uint8_t BOOK_CACHE_VERSION = 5; +constexpr uint8_t BOOK_CACHE_VERSION = 6; constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; @@ -117,7 +117,8 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(uint32_t) + sizeof(spineCount) + sizeof(tocCount); const uint32_t metadataSize = metadata.title.size() + metadata.author.size() + metadata.language.size() + metadata.coverItemHref.size() + metadata.textReferenceHref.size() + - sizeof(uint32_t) * 5; + metadata.series.size() + metadata.seriesIndex.size() + metadata.description.size() + + sizeof(uint32_t) * 8; const uint32_t lutSize = sizeof(uint32_t) * spineCount + sizeof(uint32_t) * tocCount; const uint32_t lutOffset = headerASize + metadataSize; @@ -132,6 +133,9 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta serialization::writeString(bookFile, metadata.language); serialization::writeString(bookFile, metadata.coverItemHref); serialization::writeString(bookFile, metadata.textReferenceHref); + serialization::writeString(bookFile, metadata.series); + serialization::writeString(bookFile, metadata.seriesIndex); + serialization::writeString(bookFile, metadata.description); // Loop through spine entries, writing LUT positions spineFile.seek(0); @@ -386,6 +390,9 @@ bool BookMetadataCache::load() { serialization::readString(bookFile, coreMetadata.language); serialization::readString(bookFile, coreMetadata.coverItemHref); serialization::readString(bookFile, coreMetadata.textReferenceHref); + serialization::readString(bookFile, coreMetadata.series); + serialization::readString(bookFile, coreMetadata.seriesIndex); + serialization::readString(bookFile, coreMetadata.description); loaded = true; LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount); diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h index 9439b37f..330c29cd 100644 --- a/lib/Epub/Epub/BookMetadataCache.h +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -14,6 +14,9 @@ class BookMetadataCache { std::string language; std::string coverItemHref; std::string textReferenceHref; + std::string series; + std::string seriesIndex; + std::string description; }; struct SpineEntry { diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 043020d7..5219c57e 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -165,6 +165,20 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c const size_t totalWordCount = words.size(); + // Pre-compute inter-word gaps once so the O(n²) DP inner loop avoids repeated + // codepoint scanning and renderer calls for every (i,j) pair. + // interWordGaps[j] = the spacing between words[j-1] and words[j] (0 for j==0). + std::vector interWordGaps(totalWordCount, 0); + for (size_t j = 1; j < totalWordCount; ++j) { + if (!continuesVec[j]) { + interWordGaps[j] = + renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + } else { + interWordGaps[j] = + renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + } + } + // DP table to store the minimum badness (cost) of lines starting at index i std::vector dp(totalWordCount); // 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i' @@ -182,15 +196,7 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c const int effectivePageWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth; for (size_t j = i; j < totalWordCount; ++j) { - // Add space before word j, unless it's the first word on the line or a continuation - int gap = 0; - if (j > static_cast(i) && !continuesVec[j]) { - gap = - renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); - } else if (j > static_cast(i) && continuesVec[j]) { - // Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation) - gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); - } + const int gap = (j > static_cast(i)) ? interWordGaps[j] : 0; currlen += wordWidths[j] + gap; if (currlen > effectivePageWidth) { @@ -284,6 +290,21 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r ? blockStyle.textIndent : 0; + // Pre-compute inter-word gaps to avoid repeated codepoint scanning and renderer + // calls in the inner loop. When hyphenateWordAtIndex inserts a new word, we insert + // a placeholder gap (0) at that position to keep the vector in sync; the remainder + // is always the first word on the next line so its spacing is never used. + std::vector interWordGaps(wordWidths.size(), 0); + for (size_t j = 1; j < wordWidths.size(); ++j) { + if (!continuesVec[j]) { + interWordGaps[j] = + renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + } else { + interWordGaps[j] = + renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + } + } + std::vector lineBreakIndices; size_t currentIndex = 0; bool isFirstLine = true; @@ -298,15 +319,7 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r // Consume as many words as possible for current line, splitting when prefixes fit while (currentIndex < wordWidths.size()) { const bool isFirstWord = currentIndex == lineStart; - int spacing = 0; - if (!isFirstWord && !continuesVec[currentIndex]) { - spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]), - firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]); - } else if (!isFirstWord && continuesVec[currentIndex]) { - // Cross-boundary kerning for continuation words (e.g. nonbreaking spaces, attached punctuation) - spacing = renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]), - firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]); - } + const int spacing = isFirstWord ? 0 : interWordGaps[currentIndex]; const int candidateWidth = spacing + wordWidths[currentIndex]; // Word fits on current line @@ -322,6 +335,9 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) { + // Keep interWordGaps in sync: insert placeholder for the new remainder word. + // The remainder is always the first word on the next line so this slot is never read. + interWordGaps.insert(interWordGaps.begin() + currentIndex + 1, 0); // Prefix now fits; append it to this line and move to next line lineWidth += spacing + wordWidths[currentIndex]; ++currentIndex; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 9365df20..499e9437 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -4,16 +4,29 @@ #include #include +#include + #include "Epub/css/CssParser.h" #include "Page.h" #include "hyphenation/Hyphenator.h" #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 18; -constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + - sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + - sizeof(uint8_t) + sizeof(uint32_t) + sizeof(uint32_t); +constexpr uint8_t SECTION_FILE_VERSION = 20; +constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION + sizeof(int) + // fontId + sizeof(float) + // lineCompression + sizeof(bool) + // extraParagraphSpacing + sizeof(uint8_t) + // paragraphAlignment + sizeof(uint16_t) + // viewportWidth + sizeof(uint16_t) + // viewportHeight + sizeof(uint16_t) + // pageCount (stored as 16-bit in header) + sizeof(bool) + // hyphenationEnabled + sizeof(bool) + // embeddedStyle + sizeof(uint8_t) + // imageRendering + sizeof(uint32_t) + // page LUT offset + sizeof(uint32_t) + // anchor map offset + sizeof(uint32_t); // paragraph LUT offset } // namespace uint32_t Section::onPageComplete(std::unique_ptr page) { @@ -44,7 +57,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) + sizeof(viewportHeight) + sizeof(pageCount) + sizeof(hyphenationEnabled) + - sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + sizeof(uint32_t), + sizeof(embeddedStyle) + sizeof(imageRendering) + sizeof(uint32_t) + + sizeof(uint32_t) + sizeof(uint32_t), "Header size mismatch"); serialization::writePod(file, SECTION_FILE_VERSION); serialization::writePod(file, fontId); @@ -59,6 +73,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later) serialization::writePod(file, static_cast(0)); // Placeholder for LUT offset (patched later) serialization::writePod(file, static_cast(0)); // Placeholder for anchor map offset (patched later) + serialization::writePod(file, static_cast(0)); // Placeholder for paragraph LUT offset (patched later) } bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, @@ -74,9 +89,8 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con uint8_t version; serialization::readPod(file, version); if (version != SECTION_FILE_VERSION) { - file.close(); LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version); - clearCache(); + clearCache(); // closes file before removal return false; } @@ -103,21 +117,54 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight || hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle || imageRendering != fileImageRendering) { - file.close(); LOG_ERR("SCT", "Deserialization failed: Parameters do not match"); - clearCache(); + clearCache(); // closes file before removal return false; } } serialization::readPod(file, pageCount); - file.close(); - LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount); + + // Sanity check: same upper bound used by TextBlock::deserialize for word count + if (pageCount > 10000) { + LOG_ERR("SCT", "Deserialization failed: page count %u exceeds maximum", pageCount); + clearCache(); + return false; + } + + // Load LUT into memory (file is now positioned at the lutOffset field) + uint32_t lutOffset; + serialization::readPod(file, lutOffset); + lut.resize(pageCount); + if (!file.seek(lutOffset)) { + LOG_ERR("SCT", "Deserialization failed: seek to LUT offset %u failed", lutOffset); + clearCache(); + return false; + } + for (uint32_t& pos : lut) { + serialization::readPod(file, pos); + if (pos < HEADER_SIZE || pos >= lutOffset) { + LOG_ERR("SCT", "Deserialization failed: LUT entry %u out of range [%u, %u)", pos, HEADER_SIZE, lutOffset); + clearCache(); + return false; + } + } + // Build TOC boundaries by scanning anchor data from the still-open file, + // matching only the TOC anchors we need (avoids loading all anchors into memory). + buildTocBoundariesFromFile(file); + + // File is intentionally left open; subsequent loadPageFromSectionFile() calls + // seek within this handle instead of re-opening the file each time. + LOG_DBG("SCT", "Deserialization succeeded: %d pages, LUT cached", pageCount); return true; } -// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem) -bool Section::clearCache() const { +bool Section::clearCache() { + file.close(); // Must be closed before removal on FAT32 + lut.clear(); + pageCount = 0; + currentPage = 0; + if (!Storage.exists(filePath.c_str())) { LOG_DBG("SCT", "Cache does not exist, no action needed"); return true; @@ -135,7 +182,7 @@ bool Section::clearCache() const { bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle, - const uint8_t imageRendering, const std::function& popupFn) { + const uint8_t imageRendering, const std::function& progressFn) { const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; @@ -203,11 +250,24 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c } } + // Collect TOC anchors for this spine so the parser can insert page breaks at chapter boundaries + std::vector tocAnchors; + const int startTocIndex = epub->getTocIndexForSpineIndex(spineIndex); + if (startTocIndex >= 0) { + for (int i = startTocIndex; i < epub->getTocItemsCount(); i++) { + auto entry = epub->getTocItem(i); + if (entry.spineIndex != spineIndex) break; + if (!entry.anchor.empty()) { + tocAnchors.push_back(std::move(entry.anchor)); + } + } + } + ChapterHtmlSlimParser visitor( epub, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight, hyphenationEnabled, [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, - embeddedStyle, contentBase, imageBasePath, imageRendering, popupFn, cssParser); + embeddedStyle, contentBase, imageBasePath, imageRendering, std::move(tocAnchors), progressFn, cssParser); Hyphenator::setPreferredLanguage(epub->getLanguage()); success = visitor.parseAndBuildPages(); @@ -240,7 +300,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c return false; } - // Write anchor-to-page map for fragment navigation (e.g. footnote targets) + // Write anchor-to-page map for fragment navigation (TOC + footnote targets) const uint32_t anchorMapOffset = file.position(); const auto& anchors = visitor.getAnchors(); serialization::writePod(file, static_cast(anchors.size())); @@ -249,34 +309,210 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c serialization::writePod(file, page); } - // Patch header with final pageCount, lutOffset, and anchorMapOffset - file.seek(HEADER_SIZE - sizeof(uint32_t) * 2 - sizeof(pageCount)); + // Write per-page paragraph index LUT for XPath-to-page resolution + const uint32_t paragraphLutOffset = file.position(); + const auto& paragraphPerPage = visitor.getParagraphIndexPerPage(); + serialization::writePod(file, static_cast(paragraphPerPage.size())); + for (const uint16_t& pIdx : paragraphPerPage) { + serialization::writePod(file, pIdx); + } + + // Patch header with final pageCount, lutOffset, anchorMapOffset, and paragraphLutOffset + file.seek(HEADER_SIZE - sizeof(uint32_t) * 3 - sizeof(pageCount)); serialization::writePod(file, pageCount); serialization::writePod(file, lutOffset); serialization::writePod(file, anchorMapOffset); + serialization::writePod(file, paragraphLutOffset); file.close(); if (cssParser) { cssParser->clear(); } + + buildTocBoundaries(anchors); + + // Cache the LUT in memory and open the file for reading so that + // subsequent loadPageFromSectionFile() calls can seek directly without re-opening. + if (!Storage.openFileForRead("SCT", filePath, file)) { + LOG_ERR("SCT", "Failed to open section file for reading after creation"); + return false; + } + this->lut = std::move(lut); return true; } std::unique_ptr Section::loadPageFromSectionFile() { - if (!Storage.openFileForRead("SCT", filePath, file)) { + if (currentPage < 0 || currentPage >= static_cast(lut.size())) { + LOG_ERR("SCT", "loadPageFromSectionFile: page %d out of LUT range (%u entries)", currentPage, + static_cast(lut.size())); return nullptr; } - file.seek(HEADER_SIZE - sizeof(uint32_t) * 2); - uint32_t lutOffset; - serialization::readPod(file, lutOffset); - file.seek(lutOffset + sizeof(uint32_t) * currentPage); - uint32_t pagePos; - serialization::readPod(file, pagePos); - file.seek(pagePos); + if (!file) { + // Safety fallback: file was closed unexpectedly; reopen + LOG_ERR("SCT", "loadPageFromSectionFile: file not open, reopening"); + if (!Storage.openFileForRead("SCT", filePath, file)) { + return nullptr; + } + } - auto page = Page::deserialize(file); - file.close(); - return page; + if (!file.seek(lut[currentPage])) { + LOG_ERR("SCT", "loadPageFromSectionFile: seek to page %d offset %u failed", currentPage, lut[currentPage]); + return nullptr; + } + return Page::deserialize(file); + // File is intentionally NOT closed; stays open for the next page load +} + +// Resolve TOC anchor-to-page mappings from the parser's in-memory anchor vector. +// Called after createSectionFile when anchors are already in memory. +// See buildTocBoundariesFromFile for the on-disk variant; the two are kept separate +// because the anchor resolution has fundamentally different iteration patterns +// (scan in-memory vector vs. stream from file with early exit). +void Section::buildTocBoundaries(const std::vector>& anchors) { + const int startTocIndex = epub->getTocIndexForSpineIndex(spineIndex); + if (startTocIndex < 0) return; + + // Count TOC entries for this spine and how many have anchors to resolve + const int tocCount = epub->getTocItemsCount(); + uint16_t totalEntries = 0; + uint16_t unresolvedCount = 0; + for (int i = startTocIndex; i < tocCount; i++) { + const auto entry = epub->getTocItem(i); + if (entry.spineIndex != spineIndex) break; + totalEntries++; + if (!entry.anchor.empty()) unresolvedCount++; + } + + // If no TOC entries have anchors, all chapters start at page 0 and + // getTocIndexForPage falls back to epub->getTocIndexForSpineIndex, + // so there's nothing to resolve and no value in storing boundaries. + if (totalEntries == 0 || unresolvedCount == 0) return; + + tocBoundaries.reserve(totalEntries); + for (int i = startTocIndex; i < startTocIndex + totalEntries; i++) { + const auto entry = epub->getTocItem(i); + uint16_t page = 0; + if (!entry.anchor.empty()) { + for (const auto& [key, val] : anchors) { + if (key == entry.anchor) { + page = val; + break; + } + } + } + tocBoundaries.push_back({i, page}); + } + + // Defensive sort in case TOC entries are out of document order in a malformed epub + std::sort(tocBoundaries.begin(), tocBoundaries.end(), + [](const TocBoundary& a, const TocBoundary& b) { return a.startPage < b.startPage; }); +} + +// Resolve TOC anchor-to-page mappings by scanning the section cache's on-disk anchor data. +// Called from loadSectionFile when anchors are not in memory. Caches the small set of +// TOC anchor strings first (since getTocItem does file I/O to BookMetadataCache), then +// streams through on-disk anchors matching only those, stopping as soon as all are found. +// See buildTocBoundaries for the in-memory variant. +void Section::buildTocBoundariesFromFile(FsFile& f) { + const int startTocIndex = epub->getTocIndexForSpineIndex(spineIndex); + if (startTocIndex < 0) return; + + // Count TOC entries for this spine, then reserve and populate + const int tocCount = epub->getTocItemsCount(); + uint16_t totalEntries = 0; + uint16_t unresolvedCount = 0; + for (int i = startTocIndex; i < tocCount; i++) { + const auto entry = epub->getTocItem(i); + if (entry.spineIndex != spineIndex) break; + totalEntries++; + if (!entry.anchor.empty()) unresolvedCount++; + } + + // If no TOC entries have anchors, all chapters start at page 0 and + // getTocIndexForPage falls back to epub->getTocIndexForSpineIndex, + // so there's nothing to resolve and no value in storing boundaries. + if (totalEntries == 0 || unresolvedCount == 0) return; + + // Cache TOC anchor strings before scanning disk, since getTocItem() does file I/O + struct TocAnchorEntry { + int tocIndex; + std::string anchor; + }; + std::vector tocAnchorsToResolve; + tocAnchorsToResolve.reserve(unresolvedCount); + tocBoundaries.reserve(totalEntries); + for (int i = startTocIndex; i < startTocIndex + totalEntries; i++) { + const auto entry = epub->getTocItem(i); + tocBoundaries.push_back({i, 0}); + if (!entry.anchor.empty()) { + tocAnchorsToResolve.push_back({i, std::move(entry.anchor)}); + } + } + + // Single pass through on-disk anchors, matching against cached TOC anchors. + // Stop early once all TOC anchors are resolved. + // Header layout: ... | lutOffset (u32) | anchorMapOffset (u32) | paragraphLutOffset (u32) | + f.seek(HEADER_SIZE - sizeof(uint32_t) * 2); + uint32_t anchorMapOffset; + serialization::readPod(f, anchorMapOffset); + + if (anchorMapOffset != 0) { + f.seek(anchorMapOffset); + uint16_t count; + serialization::readPod(f, count); + std::string key; + for (uint16_t i = 0; i < count && unresolvedCount > 0; i++) { + uint16_t page; + serialization::readString(f, key); + serialization::readPod(f, page); + for (auto& tocAnchor : tocAnchorsToResolve) { + if (!tocAnchor.anchor.empty() && key == tocAnchor.anchor) { + tocBoundaries[tocAnchor.tocIndex - startTocIndex].startPage = page; + tocAnchor.anchor.clear(); // mark resolved + unresolvedCount--; + break; + } + } + } + } + + // Defensive sort in case TOC entries are out of document order in a malformed epub + std::sort(tocBoundaries.begin(), tocBoundaries.end(), + [](const TocBoundary& a, const TocBoundary& b) { return a.startPage < b.startPage; }); +} + +int Section::getTocIndexForPage(const int page) const { + if (tocBoundaries.empty()) { + return epub->getTocIndexForSpineIndex(spineIndex); + } + + // Find the first boundary AFTER page, then step back one + auto it = std::upper_bound(tocBoundaries.begin(), tocBoundaries.end(), static_cast(page), + [](uint16_t page, const TocBoundary& boundary) { return page < boundary.startPage; }); + if (it == tocBoundaries.begin()) { + return tocBoundaries[0].tocIndex; + } + return std::prev(it)->tocIndex; +} + +std::optional Section::getPageForTocIndex(const int tocIndex) const { + for (const auto& boundary : tocBoundaries) { + if (boundary.tocIndex == tocIndex) { + return boundary.startPage; + } + } + return std::nullopt; +} + +std::optional Section::getPageRangeForTocIndex(const int tocIndex) const { + for (size_t i = 0; i < tocBoundaries.size(); i++) { + if (tocBoundaries[i].tocIndex == tocIndex) { + const int startPage = tocBoundaries[i].startPage; + const int endPage = (i + 1 < tocBoundaries.size()) ? static_cast(tocBoundaries[i + 1].startPage) : pageCount; + return TocPageRange{startPage, endPage}; + } + } + return std::nullopt; } std::optional Section::getPageForAnchor(const std::string& anchor) const { @@ -286,7 +522,7 @@ std::optional Section::getPageForAnchor(const std::string& anchor) con } const uint32_t fileSize = f.size(); - f.seek(HEADER_SIZE - sizeof(uint32_t)); + f.seek(HEADER_SIZE - sizeof(uint32_t) * 2); uint32_t anchorMapOffset; serialization::readPod(f, anchorMapOffset); if (anchorMapOffset == 0 || anchorMapOffset >= fileSize) { @@ -311,3 +547,91 @@ std::optional Section::getPageForAnchor(const std::string& anchor) con f.close(); return std::nullopt; } + +std::optional Section::getPageForParagraphIndex(const uint16_t pIndex) const { + FsFile f; + if (!Storage.openFileForRead("SCT", filePath, f)) { + return std::nullopt; + } + + const uint32_t fileSize = f.size(); + + // Read paragraph LUT offset from end of header + f.seek(HEADER_SIZE - sizeof(uint32_t)); + uint32_t paragraphLutOffset; + serialization::readPod(f, paragraphLutOffset); + if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) { + f.close(); + return std::nullopt; + } + + f.seek(paragraphLutOffset); + uint16_t count; + serialization::readPod(f, count); + if (count == 0) { + f.close(); + return std::nullopt; + } + + // Validate that all entries fit within the file + const uint32_t lutEnd = paragraphLutOffset + sizeof(uint16_t) + count * sizeof(uint16_t); + if (lutEnd > fileSize) { + f.close(); + return std::nullopt; + } + + // Find the first page whose paragraph index >= pIndex. + // Each entry stores the

index at the time that page was completed. + uint16_t resultPage = count - 1; // default to last page + for (uint16_t i = 0; i < count; i++) { + uint16_t pagePIdx; + serialization::readPod(f, pagePIdx); + if (pagePIdx >= pIndex) { + resultPage = i; + break; + } + } + + f.close(); + return resultPage; +} + +std::optional Section::getParagraphIndexForPage(const uint16_t page) const { + FsFile f; + if (!Storage.openFileForRead("SCT", filePath, f)) { + return std::nullopt; + } + + const uint32_t fileSize = f.size(); + + f.seek(HEADER_SIZE - sizeof(uint32_t)); + uint32_t paragraphLutOffset; + serialization::readPod(f, paragraphLutOffset); + if (paragraphLutOffset == 0 || paragraphLutOffset >= fileSize) { + f.close(); + return std::nullopt; + } + + f.seek(paragraphLutOffset); + uint16_t count; + serialization::readPod(f, count); + if (count == 0 || page >= count) { + f.close(); + return std::nullopt; + } + + // Validate that the target entry fits within the file + const uint32_t entryEnd = paragraphLutOffset + sizeof(uint16_t) + (page + 1) * sizeof(uint16_t); + if (entryEnd > fileSize) { + f.close(); + return std::nullopt; + } + + // Seek to the entry for the requested page + f.seek(paragraphLutOffset + sizeof(uint16_t) + page * sizeof(uint16_t)); + uint16_t pIdx; + serialization::readPod(f, pIdx); + + f.close(); + return pIdx; +} diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 6f002c44..9cfba2e7 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "Epub.h" @@ -15,12 +16,22 @@ class Section { GfxRenderer& renderer; std::string filePath; FsFile file; + std::vector lut; // Cached page byte-offsets; loaded once, avoids per-page LUT seek void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering); uint32_t onPageComplete(std::unique_ptr page); + struct TocBoundary { + int tocIndex = 0; + uint16_t startPage = 0; + }; + std::vector tocBoundaries; + + void buildTocBoundaries(const std::vector>& anchors); + void buildTocBoundariesFromFile(FsFile& f); + public: uint16_t pageCount = 0; int currentPage = 0; @@ -34,12 +45,35 @@ class Section { bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, uint8_t imageRendering); - bool clearCache() const; + bool clearCache(); bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, bool embeddedStyle, - uint8_t imageRendering, const std::function& popupFn = nullptr); + uint8_t imageRendering, const std::function& progressFn = nullptr); std::unique_ptr loadPageFromSectionFile(); + // Given a page in this section, return the TOC index for that page. + int getTocIndexForPage(int page) const; + // Given a TOC index, return the start page in this section. + // Returns nullopt if the TOC index doesn't map to a boundary in this spine (e.g. belongs to a different spine). + std::optional getPageForTocIndex(int tocIndex) const; + + struct TocPageRange { + int startPage; // inclusive + int endPage; // exclusive + }; + // Returns the page range [start, end) within this spine that belongs to the given TOC index. + std::optional getPageRangeForTocIndex(int tocIndex) const; + // Look up the page number for an anchor id from the section cache file. std::optional getPageForAnchor(const std::string& anchor) const; + + // Look up the page number for a paragraph index (1-based, from XPath p[N]). + // Uses the per-page paragraph index LUT stored in the section cache. + // Returns nullopt if the paragraph LUT is not available (old cache format). + std::optional getPageForParagraphIndex(uint16_t pIndex) const; + + // Look up the paragraph index for a given page number. + // Returns the 1-based paragraph index of the last

element on or before the page. + // Returns nullopt if the paragraph LUT is not available (old cache format). + std::optional getParagraphIndexForPage(uint16_t page) const; }; diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index a5a616bf..db56f6b7 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -22,6 +22,10 @@ struct BlockStyle { int16_t textIndent = 0; bool textIndentDefined = false; // true if text-indent was explicitly set in CSS bool textAlignDefined = false; // true if text-align was explicitly set in CSS + // Set when this block was created by a
element. Used by startNewTextBlock to inject + // a full line-height gap when the
block stays empty (section-break use case). + // NOT propagated through getCombinedBlockStyle so it can't leak into sibling blocks. + bool fromBrElement = false; // Combined horizontal insets (margin + padding) [[nodiscard]] int16_t leftInset() const { return marginLeft + paddingLeft; } @@ -58,6 +62,9 @@ struct BlockStyle { combinedBlockStyle.alignment = alignment; combinedBlockStyle.textAlignDefined = textAlignDefined; } + // fromBrElement is never propagated — it is consumed by startNewTextBlock + // when the empty
block is merged with the following paragraph. + combinedBlockStyle.fromBrElement = false; return combinedBlockStyle; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 368a4c60..4284746c 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -7,6 +7,9 @@ #include #include +#include +#include + #include "../../Epub.h" #include "../Page.h" #include "../converters/ImageDecoderFactory.h" @@ -20,7 +23,7 @@ constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB constexpr size_t PARSE_BUFFER_SIZE = 1024; -const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; +const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote", "pre"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); const char* BOLD_TAGS[] = {"b", "strong"}; @@ -76,6 +79,28 @@ bool isTableStructuralTag(const char* name) { return strcmp(name, "table") == 0 || strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || strcmp(name, "th") == 0; } +// Calibre sometimes injects empty

...

+// spacers inside running prose. Keep them as paragraph boundaries, but ignore +// their inner text payload (usually NBSP) to avoid no-break-space glue artifacts. +bool isZeroHeightSpacerParagraph(const char* name, const std::string& styleAttr) { + if (strcmp(name, "p") != 0 || styleAttr.empty()) { + return false; + } + + std::string normalized; + normalized.reserve(styleAttr.size()); + for (const char ch : styleAttr) { + if (!isWhitespace(ch)) { + normalized.push_back(static_cast(std::tolower(static_cast(ch)))); + } + } + + const bool hasZeroHeight = normalized.find("height:0") != std::string::npos; + const bool hasZeroMargin = normalized.find("margin:0") != std::string::npos; + const bool hasZeroBorder = normalized.find("border:0") != std::string::npos; + return hasZeroHeight && hasZeroMargin && hasZeroBorder; +} + // Update effective bold/italic/underline based on block style and inline style stack void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { // Start with block-level styles @@ -133,18 +158,52 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { // Merge with existing block style to accumulate CSS styling from parent block elements. // This handles cases like

text

where the // div's margin should be preserved, even though it has no direct text content. - currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle)); + BlockStyle incoming = blockStyle; + const bool brGapPending = currentTextBlock->getBlockStyle().fromBrElement; + if (brGapPending) { + // The empty block was created by a
section separator. Inject a full line of + // blank space before the following paragraph so the scene/section break is visible. + // This only fires when the
block stayed empty (i.e. no inline text was added). + const int16_t lineHeight = static_cast(renderer.getLineHeight(fontId) * lineCompression + 0.5f); + incoming.marginTop = static_cast(incoming.marginTop + lineHeight); + } + + BlockStyle merged = currentTextBlock->getBlockStyle().getCombinedBlockStyle(incoming); + // Preserve only whether the current empty block still represents
separators. + // This lets consecutive
accumulate one line each without leaking the flag to real content blocks. + merged.fromBrElement = blockStyle.fromBrElement; + currentTextBlock->setBlockStyle(merged); if (!pendingAnchorId.empty()) { + if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) { + if (currentPage && !currentPage->elements.empty()) { + completePageFn(std::move(currentPage)); + completedPageCount++; + currentPage.reset(new Page()); + currentPageNextY = 0; + } + } anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); } + wordsExtractedInBlock = 0; return; } makePages(); } - // Record deferred anchor after previous block is flushed + // If the pending anchor is a TOC chapter boundary, force a page break after the previous + // block is flushed so the chapter starts on a fresh page. + if (!pendingAnchorId.empty() && + std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) { + if (currentPage && !currentPage->elements.empty()) { + completePageFn(std::move(currentPage)); + completedPageCount++; + currentPage.reset(new Page()); + currentPageNextY = 0; + } + } + // Record deferred anchor after previous block is flushed (and any TOC page break) if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); @@ -172,7 +231,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } else if (strcmp(atts[i], "style") == 0) { styleAttr = atts[i + 1]; } else if (strcmp(atts[i], "id") == 0) { - // Defer recording until startNewTextBlock, after previous block is flushed to pages + // Defer both anchor recording and TOC page breaks until startNewTextBlock, + // after the previous block is flushed to pages via makePages(). self->pendingAnchorId = atts[i + 1]; } } @@ -186,10 +246,26 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // before tag-specific branches emit any content or metadata. CssStyle cssStyle; if (self->cssParser) { - cssStyle = self->cssParser->resolveStyle(name, classAttr); + { + std::string cacheKey(name); + cacheKey += '|'; + cacheKey += classAttr; + auto it = self->cssStyleCache_.find(cacheKey); + if (it != self->cssStyleCache_.end()) { + cssStyle = it->second; + } else { + CssStyle resolved = self->cssParser->resolveStyle(name, classAttr); + if (resolved.defined.anySet()) + cssStyle = self->cssStyleCache_.emplace(cacheKey, resolved).first->second; + else + cssStyle = resolved; // transient fallback: skip cache so future calls can re-resolve + } + } if (!styleAttr.empty()) { - CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr); - cssStyle.applyOver(inlineStyle); + auto it = self->inlineStyleCache_.find(styleAttr); + if (it == self->inlineStyleCache_.end()) + it = self->inlineStyleCache_.emplace(styleAttr, CssParser::parseInlineStyle(styleAttr)).first; + cssStyle.applyOver(it->second); } } @@ -277,6 +353,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // imageRendering: 0=display, 1=placeholder (alt text only), 2=suppress entirely if (self->imageRendering == 2) { + // Suppressing an image should not leak accumulated wrapper block spacing + // (e.g. figure/h1 margins) into the next text paragraph. + if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { + BlockStyle resetStyle; + resetStyle.textAlignDefined = true; + const auto align = (self->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(self->paragraphAlignment); + resetStyle.alignment = align; + self->currentTextBlock->setBlockStyle(resetStyle); + LOG_DBG("EHP", "Image suppressed: pending empty block style reset"); + } self->skipUntilDepth = self->depth; self->depth += 1; return; @@ -284,11 +372,30 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // Skip image if CSS display:none if (self->cssParser) { - CssStyle imgDisplayStyle = self->cssParser->resolveStyle("img", classAttr); + std::string imgCacheKey("img|"); + imgCacheKey += classAttr; + auto imgIt = self->cssStyleCache_.find(imgCacheKey); + if (imgIt == self->cssStyleCache_.end()) + imgIt = self->cssStyleCache_.emplace(imgCacheKey, self->cssParser->resolveStyle("img", classAttr)).first; + CssStyle imgDisplayStyle = imgIt->second; if (!styleAttr.empty()) { - imgDisplayStyle.applyOver(CssParser::parseInlineStyle(styleAttr)); + auto it = self->inlineStyleCache_.find(styleAttr); + if (it == self->inlineStyleCache_.end()) + it = self->inlineStyleCache_.emplace(styleAttr, CssParser::parseInlineStyle(styleAttr)).first; + imgDisplayStyle.applyOver(it->second); } if (imgDisplayStyle.hasDisplay() && imgDisplayStyle.display == CssDisplay::None) { + // CSS-hidden images should behave like suppressed images for spacing. + if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { + BlockStyle resetStyle; + resetStyle.textAlignDefined = true; + const auto align = (self->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(self->paragraphAlignment); + resetStyle.alignment = align; + self->currentTextBlock->setBlockStyle(resetStyle); + LOG_DBG("EHP", "Image hidden via CSS display:none: pending empty block style reset"); + } self->skipUntilDepth = self->depth; self->depth += 1; return; @@ -331,10 +438,19 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* int displayWidth = 0; int displayHeight = 0; const float emSize = static_cast(self->renderer.getFontAscenderSize(self->fontId)); - CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{}; + std::string imgCacheKey("img|"); + imgCacheKey += classAttr; + auto imgStyleIt = self->cssParser ? self->cssStyleCache_.find(imgCacheKey) : self->cssStyleCache_.end(); + if (self->cssParser && imgStyleIt == self->cssStyleCache_.end()) + imgStyleIt = + self->cssStyleCache_.emplace(imgCacheKey, self->cssParser->resolveStyle("img", classAttr)).first; + CssStyle imgStyle = self->cssParser ? imgStyleIt->second : CssStyle{}; // Merge inline style (e.g. style="height: 2em") so it overrides stylesheet rules if (!styleAttr.empty()) { - imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr)); + auto it = self->inlineStyleCache_.find(styleAttr); + if (it == self->inlineStyleCache_.end()) + it = self->inlineStyleCache_.emplace(styleAttr, CssParser::parseInlineStyle(styleAttr)).first; + imgStyle.applyOver(it->second); } const bool hasCssHeight = imgStyle.hasImageHeight(); const bool hasCssWidth = imgStyle.hasImageWidth(); @@ -424,9 +540,31 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(parentBlockStyle); } + // If the current text block is still empty, it may carry accumulated parent + // block spacing (e.g. div/figure/h1 wrappers). Apply that spacing around the + // image itself so it doesn't leak into the next text paragraph. + BlockStyle pendingImageBlockStyle; + if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { + pendingImageBlockStyle = self->currentTextBlock->getBlockStyle(); + } + + const int imageSpacingTop = std::max(0, static_cast(pendingImageBlockStyle.marginTop)) + + std::max(0, static_cast(pendingImageBlockStyle.paddingTop)); + const int imageSpacingBottom = std::max(0, static_cast(pendingImageBlockStyle.marginBottom)) + + std::max(0, static_cast(pendingImageBlockStyle.paddingBottom)); + const int totalImageHeightWithSpacing = imageSpacingTop + displayHeight + imageSpacingBottom; + + LOG_DBG("EHP", + "Image layout prep: src=%s dims=%dx%d display=%dx%d y=%d spacing(top=%d,bottom=%d,total=%d)", + src.c_str(), dims.width, dims.height, displayWidth, displayHeight, self->currentPageNextY, + imageSpacingTop, imageSpacingBottom, totalImageHeightWithSpacing); + // Create page for image - only break if image won't fit remaining space if (self->currentPage && !self->currentPage->elements.empty() && - (self->currentPageNextY + displayHeight > self->viewportHeight)) { + (self->currentPageNextY + totalImageHeightWithSpacing > self->viewportHeight)) { + LOG_DBG("EHP", "Image page break: currentY=%d needed=%d viewportH=%d", self->currentPageNextY, + totalImageHeightWithSpacing, self->viewportHeight); + self->paragraphIndexPerPage.push_back(self->xpathParagraphIndex); self->completePageFn(std::move(self->currentPage)); self->completedPageCount++; self->currentPage.reset(new Page()); @@ -444,6 +582,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->currentPageNextY = 0; } + self->currentPageNextY += imageSpacingTop; + // Create ImageBlock and add to page auto imageBlock = std::make_shared(cachedImagePath, displayWidth, displayHeight); if (!imageBlock) { @@ -458,6 +598,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } self->currentPage->elements.push_back(pageImage); self->currentPageNextY += displayHeight; + self->currentPageNextY += imageSpacingBottom; + + LOG_DBG("EHP", "Image placed: x=%d y=%d w=%d h=%d nextY=%d", xPos, pageImage->yPos, displayWidth, + displayHeight, self->currentPageNextY); + + // Reset empty pending block style after consuming spacing around the image. + // This prevents figure/header wrapper margins from being applied again to the + // next paragraph block. + if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { + BlockStyle resetStyle; + resetStyle.textAlignDefined = true; + const auto align = (self->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(self->paragraphAlignment); + resetStyle.alignment = align; + self->currentTextBlock->setBlockStyle(resetStyle); + LOG_DBG("EHP", "Image spacing consumed; pending empty block style reset for following text"); + } self->depth += 1; return; @@ -491,6 +649,19 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } } + // Track body element depth for paragraph index counting + if (strcmp(name, "body") == 0 && self->xpathBodyDepth < 0) { + self->xpathBodyDepth = self->depth; + } + + // Count

sibling indices at body-child level. Must happen BEFORE the display:none + // check so that hidden

elements are still counted, matching ChapterXPathIndexer's + // counting (pure XML, no CSS). This ensures paragraph indices in the section cache LUT + // align with KOReader's crengine XPath indices. + if (self->xpathBodyDepth >= 0 && self->depth == self->xpathBodyDepth + 1 && strcmp(name, "p") == 0) { + self->xpathParagraphIndex++; + } + if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { // start skip self->skipUntilDepth = self->depth; @@ -554,10 +725,21 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } } + if (strcmp(name, "ul") == 0 || strcmp(name, "ol") == 0) { + self->listStack.push_back({self->depth, name[0] == 'o', 0}); + } + const float emSize = static_cast(self->renderer.getFontAscenderSize(self->fontId)); const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle( cssStyle, emSize, static_cast(self->paragraphAlignment), self->viewportWidth); + // Block/header boundaries must flush any buffered trailing word first. + // Otherwise tags like ..."item?"

can carry the final word into the next paragraph. + if (self->partWordBufferIndex > 0 && ((matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) || + (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) && strcmp(name, "br") != 0))) { + self->flushPartWordBuffer(); + } + if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) { self->currentCssStyle = cssStyle; auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center, self->viewportWidth); @@ -569,19 +751,64 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); self->updateEffectiveInlineStyle(); } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { + if (isZeroHeightSpacerParagraph(name, styleAttr)) { + // Preserve paragraph break semantics for this

, but skip its inner text payload. + self->currentCssStyle = cssStyle; + auto blockStyle = userAlignmentBlockStyle; + if (self->embeddedStyle && cssStyle.hasTextAlign()) { + blockStyle.alignment = cssStyle.textAlign; + blockStyle.textAlignDefined = true; + } + self->startNewTextBlock(blockStyle); + self->updateEffectiveInlineStyle(); + + self->skipTextUntilDepth = self->depth; + self->depth += 1; + return; + } + if (strcmp(name, "br") == 0) { if (self->partWordBufferIndex > 0) { // flush word preceding
to currentTextBlock before calling startNewTextBlock self->flushPartWordBuffer(); } - self->startNewTextBlock(self->currentTextBlock->getBlockStyle()); + // Tag the new block so startNewTextBlock can inject a full line-height gap if + // the block remains empty (i.e.
is a section separator between paragraphs). + // If the block gets text added before the next block opens it becomes non-empty, + // goes through makePages() normally, and the flag has no effect (inline
case). + // Build a neutral
style that keeps inline alignment/indent context but avoids + // carrying cumulative margins from previous empty blocks (which can force spurious page breaks). + const BlockStyle& currentStyle = self->currentTextBlock->getBlockStyle(); + BlockStyle brStyle; + brStyle.alignment = currentStyle.alignment; + brStyle.textAlignDefined = currentStyle.textAlignDefined; + brStyle.textIndent = currentStyle.textIndent; + brStyle.textIndentDefined = currentStyle.textIndentDefined; + brStyle.fromBrElement = true; + self->startNewTextBlock(brStyle); } else { self->currentCssStyle = cssStyle; - self->startNewTextBlock(userAlignmentBlockStyle); + auto blockStyle = userAlignmentBlockStyle; + if (self->embeddedStyle && cssStyle.hasTextAlign()) { + blockStyle.alignment = cssStyle.textAlign; + blockStyle.textAlignDefined = true; + } + self->startNewTextBlock(blockStyle); self->updateEffectiveInlineStyle(); if (strcmp(name, "li") == 0) { - self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + char marker[12]; + if (!self->listStack.empty() && self->listStack.back().isOrdered) { + self->listStack.back().counter += 1; + snprintf(marker, sizeof(marker), "%d.", self->listStack.back().counter); + } else { + strcpy(marker, "\xe2\x80\xa2"); + } + self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR); + } else if (strcmp(name, "pre") == 0) { + // Record depth so characterData can treat \n as a hard line break inside

.
+        // depth has not been incremented yet here; it will be after startElement returns.
+        self->preUntilDepth = std::min(self->preUntilDepth, self->depth);
       }
     }
   } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
@@ -694,6 +921,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
     return;
   }
 
+  // Ignore character data inside synthetic zero-height spacer 

tags. + if (self->skipTextUntilDepth < self->depth) { + return; + } + // Collect footnote link display text (for the number label) // Skip whitespace and brackets to normalize noterefs like "[1]" → "1" if (self->insideFootnoteLink) { @@ -708,7 +940,38 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char } for (int i = 0; i < len; i++) { + const unsigned char c = static_cast(s[i]); + + // Fast path for plain ASCII word characters (> 0x20 and < 0x80). + // This covers the vast majority of characters in Latin-script text. + // All multi-byte UTF-8 sequences start with a byte >= 0x80, so this + // path is safe to take without any further multi-byte checks. + if (c > 0x20 && c < 0x80) { + if (self->partWordBufferIndex >= MAX_WORD_SIZE) { + // Buffer is full — flush before appending. Pure ASCII means no + // partial multi-byte sequence can be at the boundary. + self->flushPartWordBuffer(); + } + self->partWordBuffer[self->partWordBufferIndex++] = s[i]; + continue; + } + if (isWhitespace(s[i])) { + // Inside

: treat \n as a hard line break.
+      if (s[i] == '\n' && self->preUntilDepth < self->depth) {
+        if (self->partWordBufferIndex > 0) {
+          self->flushPartWordBuffer();
+        }
+        // Blank line: the current block is empty, but we still need to emit a visible
+        // empty line.  Add a single space so the block is non-empty and makePages()
+        // will produce a line of the correct height instead of reusing the empty block.
+        if (self->currentTextBlock->isEmpty()) {
+          self->currentTextBlock->addWord(" ", EpdFontFamily::REGULAR);
+        }
+        self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
+        self->nextWordContinues = false;
+        continue;
+      }
       // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
       if (self->partWordBufferIndex > 0) {
         self->flushPartWordBuffer();
@@ -893,6 +1156,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
 
   self->depth -= 1;
 
+  // Pop list entries whose ul/ol is now out of scope
+  while (!self->listStack.empty() && self->listStack.back().depth >= self->depth) {
+    self->listStack.pop_back();
+  }
+
   // Closing a footnote link — create entry from collected text and href
   if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) {
     if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') {
@@ -913,6 +1181,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
     self->skipUntilDepth = INT_MAX;
   }
 
+  // Leaving zero-height spacer paragraph text-skip scope
+  if (self->skipTextUntilDepth == self->depth) {
+    self->skipTextUntilDepth = INT_MAX;
+  }
+
   if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
     self->nextWordContinues = false;
   }
@@ -943,6 +1216,11 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
     self->underlineUntilDepth = INT_MAX;
   }
 
+  // Leaving pre tag
+  if (self->preUntilDepth == self->depth) {
+    self->preUntilDepth = INT_MAX;
+  }
+
   // Pop from inline style stack if we pushed an entry at this depth
   // This handles all inline elements: b, i, u, span, etc.
   if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
@@ -962,11 +1240,17 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
     // Margins/padding are preserved so parent element spacing still accumulates correctly.
     if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
       auto style = self->currentTextBlock->getBlockStyle();
-      style.textAlignDefined = false;
-      style.alignment = (self->paragraphAlignment == static_cast(CssTextAlign::None))
-                            ? CssTextAlign::Justify
-                            : static_cast(self->paragraphAlignment);
-      self->currentTextBlock->setBlockStyle(style);
+      // Keep alignment only when closing the 
separator itself so subsequent text + // within the same block container stays aligned. Reset alignment when closing + // other block tags (e.g. div/p) to avoid leaking centered/right alignment globally. + const bool preserveForBrClose = style.fromBrElement && strcmp(name, "br") == 0; + if (!preserveForBrClose) { + style.textAlignDefined = false; + style.alignment = (self->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(self->paragraphAlignment); + self->currentTextBlock->setBlockStyle(style); + } } } } @@ -999,9 +1283,13 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - // Get file size to decide whether to show indexing popup. - if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) { - popupFn(); + const size_t totalFileSize = file.size(); + size_t bytesRead = 0; + int lastReportedProgress = -1; + + // Show initial progress popup for files above threshold. + if (progressFn && totalFileSize >= MIN_SIZE_FOR_POPUP) { + progressFn(0); } XML_SetUserData(parser, this); @@ -1023,6 +1311,16 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { } const size_t len = file.read(buf, PARSE_BUFFER_SIZE); + bytesRead += len; + + // Report progress in 5% increments to limit e-ink refreshes. + if (progressFn && totalFileSize >= MIN_SIZE_FOR_POPUP) { + const int progress = static_cast(bytesRead * 100 / totalFileSize); + if (progress / 5 > lastReportedProgress / 5) { + lastReportedProgress = progress; + progressFn(progress); + } + } if (len == 0 && file.available() > 0) { LOG_ERR("EHP", "File read error"); @@ -1047,7 +1345,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } } while (!done); - LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - chapterStartTime); + const uint32_t totalTimeMs = millis() - chapterStartTime; + LOG_DBG("EHP", "Time to parse and build pages: %lu ms", totalTimeMs); XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks @@ -1062,6 +1361,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); } + paragraphIndexPerPage.push_back(xpathParagraphIndex); completePageFn(std::move(currentPage)); completedPageCount++; currentPage.reset(); @@ -1080,6 +1380,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line) { } if (currentPageNextY + lineHeight > viewportHeight) { + paragraphIndexPerPage.push_back(xpathParagraphIndex); completePageFn(std::move(currentPage)); completedPageCount++; currentPage.reset(new Page()); @@ -1150,8 +1451,11 @@ void ChapterHtmlSlimParser::makePages() { currentPageNextY += blockStyle.paddingBottom; } - // Extra paragraph spacing if enabled (default behavior) - if (extraParagraphSpacing) { + // Extra paragraph spacing if enabled (default behavior). + // Suppressed between lines within a
 block so code/preformatted text is not
+  // double-spaced; the last line of the block is flushed after 
is closed and + // preUntilDepth has already been reset, so it still receives normal paragraph spacing. + if (extraParagraphSpacing && preUntilDepth == INT_MAX) { currentPageNextY += lineHeight / 2; } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 1cc0ea39..092f5737 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "../FootnoteEntry.h" @@ -26,12 +27,14 @@ class ChapterHtmlSlimParser { const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; - std::function popupFn; // Popup callback + std::function progressFn; // Progress callback (0-100) int depth = 0; int skipUntilDepth = INT_MAX; + int skipTextUntilDepth = INT_MAX; // skip character data inside synthetic zero-height spacer

int boldUntilDepth = INT_MAX; int italicUntilDepth = INT_MAX; int underlineUntilDepth = INT_MAX; + int preUntilDepth = INT_MAX; // set when inside a

 element; enables \n → line-break handling
   // buffer for building up words from characters, will auto break if longer than this
   // leave one char at end for null pointer
   char partWordBuffer[MAX_WORD_SIZE + 1] = {};
@@ -70,10 +73,26 @@ class ChapterHtmlSlimParser {
   int tableRowIndex = 0;
   int tableColIndex = 0;
 
+  struct ListEntry {
+    int depth;
+    bool isOrdered;
+    int counter;
+  };
+  std::vector listStack;
+
   // Anchor-to-page mapping: tracks which page each HTML id attribute lands on
   int completedPageCount = 0;
   std::vector> anchorData;
   std::string pendingAnchorId;  // deferred until after previous text block is flushed
+  std::vector tocAnchors;
+
+  // Paragraph index tracking for XPath-to-page lookup table.
+  // Counts 

sibling indices (1-based, matching XPath convention) during page building. + // Stored per page in the section cache so that XPath p[N] can be resolved to a page + // without reparsing, and current page can generate an XPath without reparsing. + uint16_t xpathParagraphIndex = 0; // current

sibling index (1-based) + int xpathBodyDepth = -1; // depth of the element (-1 = not yet seen) + std::vector paragraphIndexPerPage; //

index at each page completion // Footnote link tracking bool insideFootnoteLink = false; @@ -84,6 +103,11 @@ class ChapterHtmlSlimParser { std::vector> pendingFootnotes; // int wordsExtractedInBlock = 0; + // Per-chapter caches: resolveStyle and parseInlineStyle are called for every HTML element; + // caching by (tag|classAttr) and styleAttr avoids repeated string operations and hash lookups. + std::unordered_map cssStyleCache_; + std::unordered_map inlineStyleCache_; + void updateEffectiveInlineStyle(); void startNewTextBlock(const BlockStyle& blockStyle); void flushPartWordBuffer(); @@ -102,7 +126,9 @@ class ChapterHtmlSlimParser { const std::function)>& completePageFn, const bool embeddedStyle, const std::string& contentBase, const std::string& imageBasePath, const uint8_t imageRendering = 0, - const std::function& popupFn = nullptr, const CssParser* cssParser = nullptr) + std::vector tocAnchors = {}, + const std::function& progressFn = nullptr, + const CssParser* cssParser = nullptr) : epub(epub), filepath(filepath), @@ -115,15 +141,17 @@ class ChapterHtmlSlimParser { viewportHeight(viewportHeight), hyphenationEnabled(hyphenationEnabled), completePageFn(completePageFn), - popupFn(popupFn), + progressFn(progressFn), cssParser(cssParser), embeddedStyle(embeddedStyle), imageRendering(imageRendering), contentBase(contentBase), - imageBasePath(imageBasePath) {} + imageBasePath(imageBasePath), + tocAnchors(std::move(tocAnchors)) {} ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); void addLineToPage(std::shared_ptr line); const std::vector>& getAnchors() const { return anchorData; } + const std::vector& getParagraphIndexPerPage() const { return paragraphIndexPerPage; } }; diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 33518bc6..963d4fd0 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -10,6 +10,87 @@ namespace { constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; constexpr char MEDIA_TYPE_CSS[] = "text/css"; constexpr char itemCacheFile[] = "/.items.bin"; +constexpr size_t MAX_DESCRIPTION_LENGTH = 1024; + +// Strip HTML tags and collapse whitespace from a description string. +// Expat already decodes XML entities (< → <), so we see raw angle brackets. +std::string stripHtml(const std::string& html) { + std::string result; + result.reserve(html.size()); + bool inTag = false; + for (size_t i = 0; i < html.size(); ++i) { + const char c = html[i]; + if (c == '<') { + // Only treat as a tag if immediately followed (no space skip) by a tag-like character + const size_t j = i + 1; + if (j < html.size() && + (isalpha(static_cast(html[j])) || html[j] == '/' || html[j] == '!' || html[j] == '?')) { + inTag = true; + // Ensure words don't merge when a tag is removed + if (!result.empty() && result.back() != ' ') result += ' '; + } else { + result += c; + } + } else if (c == '>') { + if (inTag) { + inTag = false; + } else { + result += c; + } + } else if (!inTag) { + if (c == '&') { + // Decode common HTML entities not covered by Expat + if (html.compare(i, 6, " ") == 0) { + result += ' '; + i += 5; + } else if (html.compare(i, 7, "–") == 0) { + result += '-'; + i += 6; + } else if (html.compare(i, 7, "—") == 0) { + result += '-'; + i += 6; + } else if (html.compare(i, 8, "…") == 0) { + result += "..."; + i += 7; + } else + result += c; + } else if (c == '\n' || c == '\r' || c == '\t') { + if (!result.empty() && result.back() != ' ') result += ' '; + } else { + result += c; + } + } + } + // Collapse consecutive spaces and trim trailing whitespace + std::string out; + out.reserve(result.size()); + bool lastSpace = false; + for (char c : result) { + if (c == ' ') { + if (!lastSpace && !out.empty()) { + out += ' '; + lastSpace = true; + } + } else { + out += c; + lastSpace = false; + } + } + while (!out.empty() && out.back() == ' ') out.pop_back(); + return out; +} + +std::string trim(const std::string& in) { + size_t start = 0; + while (start < in.size() && (in[start] == ' ' || in[start] == '\n' || in[start] == '\r' || in[start] == '\t')) { + ++start; + } + size_t end = in.size(); + while (end > start && (in[end - 1] == ' ' || in[end - 1] == '\n' || in[end - 1] == '\r' || in[end - 1] == '\t')) { + --end; + } + return in.substr(start, end - start); +} } // namespace bool ContentOpfParser::setup() { @@ -117,6 +198,14 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } + if (self->state == IN_METADATA && strcmp(name, "dc:description") == 0) { + // Only capture the first dc:description element; subsequent ones are alternate/localized variants + if (self->description.empty()) { + self->state = IN_BOOK_DESCRIPTION; + } + return; + } + if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { self->state = IN_MANIFEST; if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { @@ -153,20 +242,55 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name } if (self->state == IN_METADATA && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) { - bool isCover = false; - std::string coverItemId; + const char* metaName = nullptr; + const char* metaContent = nullptr; + const char* metaProperty = nullptr; for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], "name") == 0 && strcmp(atts[i + 1], "cover") == 0) { - isCover = true; + if (strcmp(atts[i], "name") == 0) { + metaName = atts[i + 1]; } else if (strcmp(atts[i], "content") == 0) { - coverItemId = atts[i + 1]; + metaContent = atts[i + 1]; + } else if (strcmp(atts[i], "property") == 0) { + metaProperty = atts[i + 1]; } } - if (isCover) { - self->coverItemId = coverItemId; + if (metaName && metaContent) { + if (strcmp(metaName, "cover") == 0) { + self->coverItemId = metaContent; + } else if (strcmp(metaName, "calibre:series") == 0 && self->series.empty()) { + self->series = trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH}))); + } else if (strcmp(metaName, "calibre:series_index") == 0 && self->seriesIndex.empty()) { + self->seriesIndex = + trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH}))); + } } + + // EPUB 3 collection metadata: + // Series Name (character data) + // (attribute, some generators) + // 1 + if (metaProperty) { + if (strcmp(metaProperty, "belongs-to-collection") == 0 && self->series.empty()) { + if (metaContent) { + self->series = trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH}))); + } else { + self->state = IN_BOOK_SERIES; + return; + } + } + if (strcmp(metaProperty, "group-position") == 0 && self->seriesIndex.empty()) { + if (metaContent) { + self->seriesIndex = + trim(std::string(metaContent, std::min(strlen(metaContent), size_t{MAX_DESCRIPTION_LENGTH}))); + } else { + self->state = IN_BOOK_SERIES_INDEX; + return; + } + } + } + return; } @@ -338,6 +462,30 @@ void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s, self->language.append(s, len); return; } + + if (self->state == IN_BOOK_DESCRIPTION) { + if (self->description.size() < MAX_DESCRIPTION_LENGTH) { + const size_t remaining = MAX_DESCRIPTION_LENGTH - self->description.size(); + self->description.append(s, std::min(static_cast(len), remaining)); + } + return; + } + + if (self->state == IN_BOOK_SERIES) { + if (self->series.size() < MAX_DESCRIPTION_LENGTH) { + const size_t remaining = MAX_DESCRIPTION_LENGTH - self->series.size(); + self->series.append(s, std::min(static_cast(len), remaining)); + } + return; + } + + if (self->state == IN_BOOK_SERIES_INDEX) { + if (self->seriesIndex.size() < MAX_DESCRIPTION_LENGTH) { + const size_t remaining = MAX_DESCRIPTION_LENGTH - self->seriesIndex.size(); + self->seriesIndex.append(s, std::min(static_cast(len), remaining)); + } + return; + } } void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) { @@ -377,6 +525,24 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) return; } + if (self->state == IN_BOOK_DESCRIPTION && strcmp(name, "dc:description") == 0) { + self->description = stripHtml(self->description); + self->state = IN_METADATA; + return; + } + + if (self->state == IN_BOOK_SERIES && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) { + self->series = trim(self->series); + self->state = IN_METADATA; + return; + } + + if (self->state == IN_BOOK_SERIES_INDEX && (strcmp(name, "meta") == 0 || strcmp(name, "opf:meta") == 0)) { + self->seriesIndex = trim(self->seriesIndex); + self->state = IN_METADATA; + return; + } + if (self->state == IN_METADATA && (strcmp(name, "metadata") == 0 || strcmp(name, "opf:metadata") == 0)) { self->state = IN_PACKAGE; return; diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index 89fb3379..59f763f3 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -17,6 +17,9 @@ class ContentOpfParser final : public Print { IN_BOOK_TITLE, IN_BOOK_AUTHOR, IN_BOOK_LANGUAGE, + IN_BOOK_DESCRIPTION, + IN_BOOK_SERIES, + IN_BOOK_SERIES_INDEX, IN_MANIFEST, IN_SPINE, IN_GUIDE, @@ -60,6 +63,9 @@ class ContentOpfParser final : public Print { std::string title; std::string author; std::string language; + std::string description; + std::string series; + std::string seriesIndex; std::string tocNcxPath; std::string tocNavPath; // EPUB 3 nav document path std::string coverItemHref; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 50aa2db1..48e4732e 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -74,6 +74,512 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, enum class TextRotation { None, Rotated90CW }; +// ============================================================================= +// Fast-path glyph rendering helpers (1-bit BW fonts, TextRotation::None) +// ============================================================================= +// +// OVERVIEW +// -------- +// The legacy path called drawPixel() once per set glyph pixel. drawPixel() +// invokes rotateCoordinates() (a switch), does a bounds check, logs on OOB, +// then writes one bit. For a typical 10×14 UI glyph that is ~100 calls. +// +// This fast path eliminates drawPixel() entirely by writing directly to the +// framebuffer in up to 8-pixel chunks via writeRowBits(). +// +// FRAMEBUFFER LAYOUT +// ------------------ +// 1 bpp, MSB-first, DISPLAY_WIDTH (800) pixels per row stored in +// DISPLAY_WIDTH_BYTES (100) bytes. Bit 7 of byte 0 = leftmost pixel of +// row 0. "Physical row" phyY occupies bytes [phyY*100 .. phyY*100+99]. +// A set bit (1) is WHITE; a cleared bit (0) is BLACK. +// +// LANDSCAPE ORIENTATIONS (2.5–3.1× speedup vs legacy) +// ------------------------------------------------------- +// phyX and phyY are both linear functions of glyphX/glyphY in these modes, +// so each glyph row maps directly to a physical framebuffer row. +// +// LandscapeCounterClockwise: phyX = screenXBase+glyphX, phyY = screenYBase+glyphY +// LandscapeClockwise: phyX = W-1-screenXBase-glyphX, phyY = H-1-screenYBase-glyphY +// +// Strategy: outer loop over glyphY (one physical row per iteration), inner +// loop reads 8-pixel chunks of that glyph row with bitmapExtract() and writes +// them with writeRowBits(). Bitmap access is purely sequential — fastest. +// LandscapeClockwise iterates glyph chunks right-to-left and applies +// reverseBits8() to flip horizontal direction. +// +// PORTRAIT ORIENTATIONS (~2× speedup vs legacy) +// ----------------------------------------------- +// Portrait (90° CW panel rotation): +// phyX = screenYBase+glyphY, phyY = H-1-screenXBase-glyphX +// PortraitInverted (90° CCW panel rotation): +// phyX = W-1-screenYBase-glyphY, phyY = screenXBase+glyphX +// +// Here glyph COLUMNS map to physical rows. Naively iterating column-by-column +// reads the bitmap with stride glyphWidth — cache-unfriendly and one bit at a +// time. Instead we use an 8×8 bit-matrix transpose: +// +// For each 8-row × 8-column glyph block: +// 1. Read 8 consecutive glyph rows (sequential bitmap access) into the +// top 8 bytes of a uint64_t (one bitmapExtract per row). +// 2. Call transpose8x8() — an O(log 8) butterfly transform — to swap +// the role of rows and columns in 3 passes of XOR-masking. +// 3. The resulting uint64_t holds 8 column bytes: byte k contains the +// bits for glyph column glyphX+k, one per physical row, MSB-aligned. +// 4. Write each column byte with writeRowBits() to its physical row. +// +// For PortraitInverted the glyph rows are packed in reverse order (last row +// at MSB of the uint64_t) before transposing. This ensures the post-transpose +// column bytes are already correctly ordered (MSB = leftmost phyX) without any +// per-column bit-reversal step. +// +// PARAMETERS +// ---------- +// screenXBase = cursorX + glyph->left (logical X of glyph pixel [0,0]) +// screenYBase = cursorY - glyph->top (logical Y of glyph pixel [0,0]) + +// Reverse all 8 bits of a byte (bit 7 ↔ bit 0). +static inline uint8_t reverseBits8(uint8_t b) { + b = (b & 0xF0) >> 4 | (b & 0x0F) << 4; + b = (b & 0xCC) >> 2 | (b & 0x33) << 2; + b = (b & 0xAA) >> 1 | (b & 0x55) << 1; + return b; +} + +// Transpose an 8×8 bit matrix packed into a uint64_t. +// +// Input layout (row-major, row 0 at MSB): +// bit (63 - 8*r - c) = matrix[r][c] (r=row 0..7, c=col 0..7) +// +// After transposition: +// bit (63 - 8*c - r) = matrix[r][c] +// i.e. byte k = bits [63-8k .. 56-8k] holds column k, MSB = row 0. +// +// Uses the classic 3-pass butterfly (Warren, "Hacker's Delight" §7-3): +// pass 1 swaps adjacent bit-pairs across a stride of 7 (nibble level), +// pass 2 swaps across stride 14 (byte level), +// pass 3 swaps across stride 28 (half-word level). +static inline uint64_t transpose8x8(uint64_t x) { + uint64_t t; + t = (x ^ (x >> 7)) & 0x00AA00AA00AA00AAULL; + x ^= t ^ (t << 7); + t = (x ^ (x >> 14)) & 0x0000CCCC0000CCCCULL; + x ^= t ^ (t << 14); + t = (x ^ (x >> 28)) & 0x00000000F0F0F0F0ULL; + x ^= t ^ (t << 28); + return x; +} + +// Extract up to 8 bits from a 1-bit MSB-first packed bitmap starting at bit +// position 'bitPos'. Returns them MSB-aligned (bit 7 = first extracted bit); +// the lower (8-count) bits are zeroed. +// All 'count' bits must lie within the valid bitmap byte range. +static inline uint8_t bitmapExtract(const uint8_t* bitmap, const int bitPos, const int count) { + const int byteIdx = bitPos >> 3; + const int bitOff = bitPos & 7; + uint8_t result; + if (bitOff == 0) { + result = bitmap[byteIdx]; + } else if (count <= 8 - bitOff) { + result = bitmap[byteIdx] << bitOff; // all bits inside first byte + } else { + result = (uint8_t)(((uint16_t)bitmap[byteIdx] << 8 | bitmap[byteIdx + 1]) >> (8 - bitOff)); + } + if (count < 8) result &= static_cast(0xFF << (8 - count)); + return result; +} + +// --------------------------------------------------------------------------- +// Fast glyph render pipeline +// --------------------------------------------------------------------------- +// Both 1-bit (BW) and 2-bit (antialiased) paths share the same structure: +// +// gather → [reindex] → scatter +// +// The glyph bitmap is a row-major 2D tensor [glyphHeight][glyphWidth]. +// The framebuffer is a row-major 2D tensor [DISPLAY_HEIGHT][DISPLAY_WIDTH_BYTES] +// (1 bpp) with a fixed row stride of DISPLAY_WIDTH_BYTES bytes. +// +// Non-rotated (Landscape): glyph rows map 1-to-1 to framebuffer rows. +// Reindex is a no-op; the pipeline is a tight per-row gather+scatter loop. +// +// Rotated 90° (Portrait): glyph rows become framebuffer columns. +// A row↔column axis swap (reindex) is required before scattering. +// +// 1-bit pipeline +// gather : extractGlyphBlock reads an 8×8 glyph tile into a +// contiguous uint64_t block +// (≈ glyphTensor[tile].contiguous()) +// reindex : transpose8x8 swaps row↔column axes in the uint64_t; +// pure index transform, no data movement +// scatter : scatterBlockToFrameBuffer → writeRowBits +// writes each column-byte to its row +// +// 2-bit pipeline (why it differs) +// The glyph stores 4 gray levels (0–3). Rendering reduces these to a 1-bit +// draw/skip decision via a render-mode threshold. That reduction is +// information-lossy, so gather and threshold cannot be separated — there is +// no contiguous 2-bit block to transpose. The two steps are fused: +// +// gather+threshold : build2BitRowMask Landscape — samples along glyph X +// build2BitColMask Portrait — samples along glyph Y +// both return a 1-bit mask ready for writeRowBits +// scatter : writeRowBits same atom as the 1-bit path +// --------------------------------------------------------------------------- + +// Scatter atom: merges 8 MSB-aligned bits into the framebuffer row at physical bit offset phyBitPos. +// Shared by both pipelines (1-bit: via scatterBlockToFrameBuffer; 2-bit: called directly). +// bits — MSB-aligned; bit 7 = pixel at phyBitPos, lower (8-count) bits are zero. +// phyBitPos — physical X of the MSB pixel; may be negative for left-edge partial chunks. +// pixelState true → black (clear bits to 0), false → white (set bits to 1). +static inline void writeRowBits(uint8_t* const row, const int phyBitPos, const uint8_t bits, const bool pixelState) { + uint8_t effectiveBits = bits; + int byteIdx; + int shift; + if (phyBitPos < 0) { + // Chunk starts off-screen left: clip by shifting out the off-screen MSBs. + // bits is MSB-aligned, so (bits << neg) discards the neg off-screen pixels + // and leaves the on-screen pixels MSB-aligned starting at physical X=0. + const int neg = -phyBitPos; + if (neg >= 8) return; // entire chunk is off-screen left + effectiveBits = bits << neg; + byteIdx = 0; + shift = 0; + } else { + byteIdx = phyBitPos >> 3; + shift = phyBitPos & 7; + } + if (pixelState) { + row[byteIdx] &= ~(effectiveBits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) + row[byteIdx + 1] &= ~(uint8_t)(effectiveBits << (8 - shift)); + } else { + row[byteIdx] |= (effectiveBits >> shift); + if (shift > 0 && byteIdx + 1 < HalDisplay::DISPLAY_WIDTH_BYTES) + row[byteIdx + 1] |= (uint8_t)(effectiveBits << (8 - shift)); + } +} + +// 1-bit pipeline step 1 — gather: reads an up-to-8×8 tile from the glyph tensor +// ([glyphHeight][glyphWidth], 1 bpp, row stride = glyphWidth bits) into a contiguous uint64_t. +// Equivalent to glyphTensor[glyphY:+rowCount, glyphX:+colCount].contiguous(). +// Byte 7 = first source row (MSB-aligned). reverseRows implements a negative-stride gather along Y +// (reads rows bottom-to-top), needed for PortraitInverted. +// Full pipeline: extractGlyphBlock (gather) → transpose8x8 (reindex) → scatterBlockToFrameBuffer (scatter). +static inline uint64_t extractGlyphBlock(const uint8_t* const bitmap, const int stride, const int glyphX, + const int glyphY, const int rowCount, const int colCount, + const bool reverseRows) { + uint64_t pack = 0; + int bitStart = glyphY * stride + glyphX; + for (int n = 0; n < rowCount; n++, bitStart += stride) { + const int slot = reverseRows ? (rowCount - 1 - n) : n; + pack |= static_cast(bitmapExtract(bitmap, bitStart, colCount)) << (56 - 8 * slot); + } + return pack; +} + +// 1-bit pipeline step 3 — scatter: writes column-bytes of the transposed block into framebuffer rows. +// The framebuffer is a 2D tensor [DISPLAY_HEIGHT][DISPLAY_WIDTH_BYTES] with non-unit row stride; +// phyYStride=±1 selects the traversal direction along Y (positive = top-to-bottom, negative = inverted). +// Each column k maps to row (phyYBase + k*phyYStride) via writeRowBits. +static inline void scatterBlockToFrameBuffer(uint8_t* const frameBuffer, const uint64_t pack, const int colCount, + const int phyYBase, const int phyYStride, const int phyBitPos, + const bool pixelState) { + for (int k = 0; k < colCount; k++) { + const uint8_t cols_k = static_cast(pack >> (56 - 8 * k)); + if (cols_k == 0) continue; + const int phyY = phyYBase + k * phyYStride; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + writeRowBits(frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES, phyBitPos, cols_k, pixelState); + } +} + +static void renderGlyphFastBW(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, + const int glyphHeight, const int screenXBase, const int screenYBase, + const bool pixelState, const GfxRenderer::Orientation orientation) { + switch (orientation) { + case GfxRenderer::LandscapeCounterClockwise: { + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = screenYBase + glyphY; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowBitStart = glyphY * glyphWidth; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int count = std::min(8, glyphWidth - glyphX); + const uint8_t gbyte = bitmapExtract(bitmap, rowBitStart + glyphX, count); + if (gbyte == 0) continue; + const int phyBitPos = screenXBase + glyphX; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, gbyte, pixelState); + } + } + break; + } + + case GfxRenderer::LandscapeClockwise: { + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowBitStart = glyphY * glyphWidth; + for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { + const int chunkStart = std::max(0, chunkEnd - 7); + const int count = chunkEnd - chunkStart + 1; + const uint8_t gbyte_fwd = bitmapExtract(bitmap, rowBitStart + chunkStart, count); + const uint8_t gbyte = reverseBits8(gbyte_fwd >> (8 - count)); + if (gbyte == 0) continue; + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, gbyte, pixelState); + } + } + break; + } + + case GfxRenderer::Portrait: { + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int rowCount = std::min(8, glyphHeight - glyphY); + const int phyBitPos = screenYBase + glyphY; + if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int colCount = std::min(8, glyphWidth - glyphX); + const uint64_t pack = + transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, false)); + scatterBlockToFrameBuffer(frameBuffer, pack, colCount, HalDisplay::DISPLAY_HEIGHT - 1 - screenXBase - glyphX, + -1, phyBitPos, pixelState); + } + } + break; + } + + case GfxRenderer::PortraitInverted: { + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int rowCount = std::min(8, glyphHeight - glyphY); + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + rowCount - 1); + if (phyBitPos + rowCount <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int colCount = std::min(8, glyphWidth - glyphX); + const uint64_t pack = + transpose8x8(extractGlyphBlock(bitmap, glyphWidth, glyphX, glyphY, rowCount, colCount, true)); + scatterBlockToFrameBuffer(frameBuffer, pack, colCount, screenXBase + glyphX, 1, phyBitPos, pixelState); + } + } + break; + } + } +} + +// Read one pixel from a tightly-packed 2-bit-per-pixel glyph bitmap. +// The bitmap is a row-major tensor [glyphHeight][glyphWidth] with no row padding; +// its pixel-row stride equals glyphWidth. pixelPosition = row * glyphWidth + col. +// Returns the raw font value: 0=white, 1=light-gray, 2=dark-gray, 3=black. +static inline uint8_t get2BitPixel(const uint8_t* const bitmap, const int pixelPosition) { + return (bitmap[pixelPosition >> 2] >> ((3 - (pixelPosition & 3)) * 2)) & 0x3; +} + +// Convenience overload using explicit row/col/stride (tensor element access). +static inline uint8_t get2BitPixel(const uint8_t* const bitmap, const int stride, const int row, const int col) { + return get2BitPixel(bitmap, row * stride + col); +} + +template +static constexpr uint8_t drawMaskFor2BitMode() { + if constexpr (mode == GfxRenderer::BW) + return 0x0E; // draw raw {1,2,3} + else if constexpr (mode == GfxRenderer::GRAYSCALE_MSB) + return 0x06; // draw raw {1,2} + else + return 0x04; // GRAYSCALE_LSB: draw raw {2} +} + +// 2-bit pipeline — fused gather+threshold (X axis): the 2-bit analog of extractGlyphBlock, but +// gather and threshold are collapsed into one pass. The threshold (2-bit raw value → 1-bit on/off) +// is information-lossy, so no contiguous 2-bit intermediate block can be formed mid-pipeline. +// The resulting 1-bit mask feeds writeRowBits directly (scatter). build2BitColMask is the Y-axis counterpart. +template +static inline uint8_t build2BitRowMask(const uint8_t* const bitmap, const int rowStartPixel, const int glyphXStartOrEnd, + const int count, const bool reverseXInChunk) { + // drawMask uses raw 2-bit glyph values directly from font bitmaps: + // raw 0=white, 1=light gray, 2=dark gray, 3=black. + // Bit N set means: draw/update when raw==N. + // Compile-time constant lets the compiler reduce (drawMask >> raw) & 1 to a single comparison. + constexpr uint8_t drawMask = drawMaskFor2BitMode(); + + uint8_t mask = 0; + for (int i = 0; i < count; i++) { + const int logicalX = reverseXInChunk ? (glyphXStartOrEnd - i) : (glyphXStartOrEnd + i); + const uint8_t raw = get2BitPixel(bitmap, rowStartPixel + logicalX); + if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); + } + return mask; +} + +// Fast-path 2-bit mask builder for 8 byte-aligned pixels. +// +// The 2-bit glyph bitmap stores 4 pixels per byte, MSB-first: +// byte b = [p0.msb p0.lsb p1.msb p1.lsb p2.msb p2.lsb p3.msb p3.lsb] +// +// For each render mode the draw decision collapses to a two-bit boolean: +// BW (draw if raw ≠ 0): msb | lsb +// GRAYSCALE_MSB (draw if raw ∈ {1,2}): msb ^ lsb +// GRAYSCALE_LSB (draw if raw == 2): msb & ~lsb +// +// Derivation for one byte: +// msb_bits = b & 0xAA → bits 7,5,3,1 hold p0.msb … p3.msb; bits 6,4,2,0 = 0 +// lsb_bits = (b & 0x55) << 1 → same positions hold p0.lsb … p3.lsb +// draw_bits = msb_bits OP lsb_bits → bits 7,5,3,1 are the per-pixel draw flags +// +// compact4: squeezes those 4 draw flags from bit positions 7,5,3,1 +// into the top nibble (bits 7,6,5,4 → pixels 0,1,2,3). +// +// Two bytes b0 (pixels 0–3) and b1 (pixels 4–7) are combined: +// mask = compact4(draw(b0)) | (compact4(draw(b1)) >> 4) +// +// This avoids the 8-iteration per-pixel loop in build2BitRowMask and +// processes the full 8-pixel chunk in ~16 ALU ops instead of ~56. +// The caller is responsible for only calling this when pixelStart is +// 4-pixel (1-byte) aligned (pixelStart & 3 == 0) and count == 8. +template +static inline uint8_t build2BitRowMaskFromTwoBytes(const uint8_t b0, const uint8_t b1) { + const uint8_t msb0 = b0 & 0xAA; + const uint8_t lsb0 = (b0 & 0x55) << 1; + const uint8_t msb1 = b1 & 0xAA; + const uint8_t lsb1 = (b1 & 0x55) << 1; + + uint8_t draw0, draw1; + if constexpr (mode == GfxRenderer::BW) { + draw0 = msb0 | lsb0; + draw1 = msb1 | lsb1; + } else if constexpr (mode == GfxRenderer::GRAYSCALE_MSB) { + draw0 = msb0 ^ lsb0; + draw1 = msb1 ^ lsb1; + } else { // GRAYSCALE_LSB + draw0 = msb0 & ~lsb0; + draw1 = msb1 & ~lsb1; + } + + // Compact each nibble's draw flags from bit positions 7,5,3,1 → 7,6,5,4. + auto compact4 = [](const uint8_t d) -> uint8_t { + return (d & 0x80) | ((d & 0x20) << 1) | ((d & 0x08) << 2) | ((d & 0x02) << 3); + }; + return compact4(draw0) | (compact4(draw1) >> 4); +} + +// 2-bit pipeline — fused gather+threshold (Y axis): column-direction counterpart to build2BitRowMask. +// Samples count pixels down glyph column glyphX starting at row glyphYStart; reverseRows implements +// a negative-stride view along Y (reads bottom-to-top), needed for PortraitInverted. +template +static inline uint8_t build2BitColMask(const uint8_t* const bitmap, const int glyphWidth, const int glyphX, + const int glyphYStart, const int count, const bool reverseRows) { + constexpr uint8_t drawMask = drawMaskFor2BitMode(); + uint8_t mask = 0; + for (int i = 0; i < count; i++) { + const int row = reverseRows ? (glyphYStart + count - 1 - i) : (glyphYStart + i); + const uint8_t raw = get2BitPixel(bitmap, glyphWidth, row, glyphX); + if ((drawMask >> raw) & 0x01) mask |= static_cast(1u << (7 - i)); + } + return mask; +} + +// Shared body for Portrait and PortraitInverted 2-bit rendering. +// inverted=false → Portrait (phyY counts down, phyBitPos counts up). +// inverted=true → PortraitInverted (phyY counts up, phyBitPos counts down). +// Both template params are compile-time constants; all ternaries fold away. +template +static void renderGlyphFast2BitPortrait(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, + const int glyphHeight, const int screenXBase, const int screenYBase, + const bool writeState) { + for (int glyphX = 0; glyphX < glyphWidth; glyphX++) { + const int phyY = inverted ? (screenXBase + glyphX) : (HalDisplay::DISPLAY_HEIGHT - 1 - (screenXBase + glyphX)); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + for (int glyphY = 0; glyphY < glyphHeight; glyphY += 8) { + const int count = std::min(8, glyphHeight - glyphY); + const uint8_t mask = build2BitColMask(bitmap, glyphWidth, glyphX, glyphY, count, inverted); + if (mask == 0) continue; + const int phyBitPos = + inverted ? (HalDisplay::DISPLAY_WIDTH - 1 - screenYBase - (glyphY + count - 1)) : (screenYBase + glyphY); + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } +} + +template +static void renderGlyphFast2Bit(uint8_t* const frameBuffer, const uint8_t* const bitmap, const int glyphWidth, + const int glyphHeight, const int screenXBase, const int screenYBase, + const bool pixelState, const GfxRenderer::Orientation orientation) { + // Non-rotated text fast path for 2-bit glyphs. Writes compact masks directly to framebuffer rows. + // TextRotation::Rotated90CW keeps the legacy per-pixel fallback path for safety and readability. + const bool writeState = (mode == GfxRenderer::BW) ? pixelState : false; + + switch (orientation) { + case GfxRenderer::LandscapeCounterClockwise: { + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = screenYBase + glyphY; + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowStartPixel = glyphY * glyphWidth; + for (int glyphX = 0; glyphX < glyphWidth; glyphX += 8) { + const int count = std::min(8, glyphWidth - glyphX); + const int pixelStart = rowStartPixel + glyphX; + uint8_t mask; + if (count == 8 && (pixelStart & 3) == 0) { + const int srcByteIdx = pixelStart >> 2; + mask = build2BitRowMaskFromTwoBytes(bitmap[srcByteIdx], bitmap[srcByteIdx + 1]); + } else { + mask = build2BitRowMask(bitmap, rowStartPixel, glyphX, count, false); + } + if (mask == 0) continue; + const int phyBitPos = screenXBase + glyphX; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } + break; + } + + case GfxRenderer::LandscapeClockwise: { + // Row-outer/chunk-inner: framebuffer rows are written at stride -DISPLAY_WIDTH_BYTES + // (phyY decreases as glyphY increases). Keeping row-outer preserves sequential access + // within each row, which is more cache-friendly than the chunk-outer alternative. + for (int glyphY = 0; glyphY < glyphHeight; glyphY++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - (screenYBase + glyphY); + if (phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int rowStartPixel = glyphY * glyphWidth; + for (int chunkEnd = glyphWidth - 1; chunkEnd >= 0; chunkEnd -= 8) { + const int chunkStart = std::max(0, chunkEnd - 7); + const int count = chunkEnd - chunkStart + 1; + const int pixelStart = rowStartPixel + chunkStart; + uint8_t mask; + if (count == 8 && (pixelStart & 3) == 0) { + const int srcByteIdx = pixelStart >> 2; + mask = reverseBits8(build2BitRowMaskFromTwoBytes(bitmap[srcByteIdx], bitmap[srcByteIdx + 1])); + } else { + mask = build2BitRowMask(bitmap, rowStartPixel, chunkEnd, count, true); + } + if (mask == 0) continue; + const int phyBitPos = HalDisplay::DISPLAY_WIDTH - 1 - screenXBase - chunkEnd; + if (phyBitPos + count <= 0 || phyBitPos >= HalDisplay::DISPLAY_WIDTH) continue; + writeRowBits(row, phyBitPos, mask, writeState); + } + } + break; + } + + case GfxRenderer::Portrait: + renderGlyphFast2BitPortrait(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, screenYBase, + writeState); + break; + + case GfxRenderer::PortraitInverted: + renderGlyphFast2BitPortrait(frameBuffer, bitmap, glyphWidth, glyphHeight, screenXBase, screenYBase, + writeState); + break; + } +} + // Shared glyph rendering logic for normal and rotated text. // Coordinate mapping and cursor advance direction are selected at compile time via the template parameter. template @@ -108,6 +614,27 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } if (is2Bit) { + if constexpr (rotation == TextRotation::None) { + // Fast path for normal text orientation. Handles all device orientations via renderGlyphFast2Bit. + // Dispatch on renderMode at compile time so each specialization gets a constant drawMask. + switch (renderMode) { + case GfxRenderer::BW: + renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, + pixelState, renderer.getOrientation()); + break; + case GfxRenderer::GRAYSCALE_MSB: + renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, + outerBase, pixelState, renderer.getOrientation()); + break; + case GfxRenderer::GRAYSCALE_LSB: + renderGlyphFast2Bit(renderer.getFrameBuffer(), bitmap, width, height, innerBase, + outerBase, pixelState, renderer.getOrientation()); + break; + } + return; + } + + // Rotated text fallback: keep explicit per-pixel behavior. int pixelPosition = 0; for (int glyphY = 0; glyphY < height; glyphY++) { const int outerCoord = outerBase + glyphY; @@ -143,6 +670,15 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } } } else { + // Fast path: 1-bit BW mode, non-rotated text — byte-level framebuffer writes, no drawPixel() per pixel. + if constexpr (rotation == TextRotation::None) { + if (renderMode == GfxRenderer::BW) { + renderGlyphFastBW(renderer.getFrameBuffer(), bitmap, width, height, innerBase, outerBase, pixelState, + renderer.getOrientation()); + return; + } + } + // Fallback: rotated text or non-BW render mode — per-pixel drawPixel(). int pixelPosition = 0; for (int glyphY = 0; glyphY < height; glyphY++) { const int outerCoord = outerBase + glyphY; @@ -275,21 +811,129 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } } +#ifdef ENABLE_RENDERCHAR_BENCHMARK +// Legacy per-pixel rendering path — mirrors the old renderCharImpl 1-bit BW loop. +// Used only by the renderChar benchmark to establish the baseline. +void GfxRenderer::drawTextBWLegacy(const int fontId, const int x, const int y, const char* text) const { + if (text == nullptr || *text == '\0') return; + const auto fontIt = fontMap.find(fontId); + if (fontIt == fontMap.end()) return; + const auto& fontFamily = fontIt->second; + + int yPos = y + getFontAscenderSize(fontId); + int xPos = x; + uint32_t cp; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const EpdGlyph* glyph = fontFamily.getGlyph(cp, EpdFontFamily::REGULAR); + if (!glyph) glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, EpdFontFamily::REGULAR); + if (!glyph) continue; + const EpdFontData* fontData = fontFamily.getData(EpdFontFamily::REGULAR); + if (fontData->is2Bit) { + xPos += glyph->advanceX; + continue; + } + const uint8_t* bitmap = getGlyphBitmap(fontData, glyph); + if (bitmap != nullptr) { + const int screenYBase = yPos - glyph->top; + const int screenXBase = xPos + glyph->left; + int pixelPosition = 0; + for (int glyphY = 0; glyphY < glyph->height; glyphY++) { + for (int glyphX = 0; glyphX < glyph->width; glyphX++, pixelPosition++) { + const uint8_t bit = (bitmap[pixelPosition >> 3] >> (7 - (pixelPosition & 7))) & 1; + if (!bit) continue; + // Inline drawPixel without OOB logging — mirrors the old per-pixel path but clips silently, + // matching the fast path's behaviour so the benchmark measures rendering cost only. + int phyX, phyY; + rotateCoordinates(orientation, screenXBase + glyphX, screenYBase + glyphY, &phyX, &phyY); + if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); + const uint8_t bitPosition = 7 - (phyX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); // black pixel + } + } + } + xPos += glyph->advanceX; + } +} + +// Legacy per-pixel rendering path — mirrors the old renderCharImpl 2-bit BW loop. +// Used only by the renderChar benchmark to establish the baseline for antialiased fonts. +void GfxRenderer::drawText2BitLegacy(const int fontId, const int x, const int y, const char* text) const { + if (text == nullptr || *text == '\0') return; + const auto fontIt = fontMap.find(fontId); + if (fontIt == fontMap.end()) return; + const auto& fontFamily = fontIt->second; + + int yPos = y + getFontAscenderSize(fontId); + int xPos = x; + uint32_t cp; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const EpdGlyph* glyph = fontFamily.getGlyph(cp, EpdFontFamily::REGULAR); + if (!glyph) glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, EpdFontFamily::REGULAR); + if (!glyph) continue; + const EpdFontData* fontData = fontFamily.getData(EpdFontFamily::REGULAR); + if (!fontData->is2Bit) { + xPos += glyph->advanceX; + continue; + } + const uint8_t* bitmap = getGlyphBitmap(fontData, glyph); + if (bitmap != nullptr) { + const int screenYBase = yPos - glyph->top; + const int screenXBase = xPos + glyph->left; + int pixelPosition = 0; + for (int glyphY = 0; glyphY < glyph->height; glyphY++) { + for (int glyphX = 0; glyphX < glyph->width; glyphX++, pixelPosition++) { + // 2-bit: each pixel occupies 2 bits; MSB first within each byte + const uint8_t raw = (bitmap[pixelPosition >> 2] >> (6 - ((pixelPosition & 3) << 1))) & 3; + if (!raw) continue; + int phyX, phyY; + rotateCoordinates(orientation, screenXBase + glyphX, screenYBase + glyphY, &phyX, &phyY); + if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) continue; + const uint16_t byteIndex = phyY * HalDisplay::DISPLAY_WIDTH_BYTES + (phyX / 8); + const uint8_t bitPosition = 7 - (phyX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); // black pixel + } + } + } + xPos += glyph->advanceX; + } +} +#endif // ENABLE_RENDERCHAR_BENCHMARK + void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (fontCacheManager_ && fontCacheManager_->isScanning()) return; if (x1 == x2) { if (y2 < y1) { std::swap(y1, y2); } - for (int y = y1; y <= y2; y++) { - drawPixel(x1, y, state); + // In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span. + switch (orientation) { + case Portrait: + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - x1, y1, y2, state); + return; + case PortraitInverted: + fillPhysicalHSpan(x1, HalDisplay::DISPLAY_WIDTH - 1 - y2, HalDisplay::DISPLAY_WIDTH - 1 - y1, state); + return; + default: + for (int y = y1; y <= y2; y++) drawPixel(x1, y, state); + return; } } else if (y1 == y2) { if (x2 < x1) { std::swap(x1, x2); } - for (int x = x1; x <= x2; x++) { - drawPixel(x, y1, state); + // In Landscape a logical horizontal line maps to a physical horizontal span. + switch (orientation) { + case LandscapeCounterClockwise: + fillPhysicalHSpan(y1, x1, x2, state); + return; + case LandscapeClockwise: + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - y1, HalDisplay::DISPLAY_WIDTH - 1 - x2, + HalDisplay::DISPLAY_WIDTH - 1 - x1, state); + return; + default: + for (int x = x1; x <= x2; x++) drawPixel(x, y1, state); + return; } } else { // Bresenham's line algorithm — integer arithmetic only @@ -345,17 +989,40 @@ void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const const int lineWidth, const bool state) const { const int stroke = std::min(lineWidth, maxRadius); const int innerRadius = std::max(maxRadius - stroke, 0); - const int outerRadiusSq = maxRadius * maxRadius; + const int outerRadius = maxRadius; + + if (outerRadius <= 0) { + return; + } + + const int outerRadiusSq = outerRadius * outerRadius; const int innerRadiusSq = innerRadius * innerRadius; - for (int dy = 0; dy <= maxRadius; ++dy) { - for (int dx = 0; dx <= maxRadius; ++dx) { - const int distSq = dx * dx + dy * dy; - if (distSq > outerRadiusSq || distSq < innerRadiusSq) { - continue; - } - const int px = cx + xDir * dx; - const int py = cy + yDir * dy; - drawPixel(px, py, state); + + int xOuter = outerRadius; + int xInner = innerRadius; + + for (int dy = 0; dy <= outerRadius; ++dy) { + while (xOuter > 0 && (xOuter * xOuter + dy * dy) > outerRadiusSq) { + --xOuter; + } + // Keep the smallest x that still lies outside/at the inner radius, + // i.e. (x^2 + y^2) >= innerRadiusSq. + while (xInner > 0 && ((xInner - 1) * (xInner - 1) + dy * dy) >= innerRadiusSq) { + --xInner; + } + + if (xOuter < xInner) { + continue; + } + + const int x0 = cx + xDir * xInner; + const int x1 = cx + xDir * xOuter; + const int left = std::min(x0, x1); + const int width = std::abs(x1 - x0) + 1; + const int py = cy + yDir * dy; + + if (width > 0) { + fillRect(left, py, width, 1, state); } } }; @@ -418,9 +1085,85 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con } } +// Write a patterned horizontal span directly into the physical framebuffer with byte-level operations. +// patternByte is repeated across the full span; partial edge bytes are blended with existing content. +// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); 0 bits = dark pixel, 1 bits = white pixel. +void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, const int phyX_end, + const uint8_t patternByte) const { + const int cX0 = std::max(phyX_start, 0); + const int cX1 = std::min(phyX_end, (int)HalDisplay::DISPLAY_WIDTH - 1); + if (cX0 > cX1 || phyY < 0 || phyY >= (int)HalDisplay::DISPLAY_HEIGHT) return; + + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int startByte = cX0 >> 3; + const int endByte = cX1 >> 3; + const int leftBits = cX0 & 7; // first bit index within startByte + const int rightBits = cX1 & 7; // last bit index within endByte + + if (startByte == endByte) { + // Both endpoints in the same byte + const uint8_t fillMask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1)); + row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask); + return; + } + + // Left partial byte + if (leftBits != 0) { + const uint8_t fillMask = 0xFF >> leftBits; + row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask); + } + + // Full bytes in the middle + const int fullStart = (leftBits == 0) ? startByte : startByte + 1; + const int fullEnd = (rightBits == 7) ? endByte : endByte - 1; + if (fullStart <= fullEnd) { + memset(row + fullStart, patternByte, fullEnd - fullStart + 1); + } + + // Right partial byte + if (rightBits != 7) { + const uint8_t fillMask = ~(0xFF >> (rightBits + 1)); + row[endByte] = (row[endByte] & ~fillMask) | (patternByte & fillMask); + } +} + +// Thin wrapper: state=true → 0x00 (all dark), false → 0xFF (all white). +void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const { + fillPhysicalHSpanByte(phyY, phyX_start, phyX_end, state ? 0x00 : 0xFF); +} + void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { - for (int fillY = y; fillY < y + height; fillY++) { - drawLine(x, fillY, x + width - 1, fillY, state); + if (width <= 0 || height <= 0) return; + + // For each orientation, one logical dimension maps to a constant physical row, allowing the + // perpendicular dimension to be written as a byte-level span — eliminating per-pixel overhead. + switch (orientation) { + case Portrait: + // Logical column x → physical row (479-x); logical y range → physical x span + for (int lx = x; lx < x + width; lx++) { + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - lx, y, y + height - 1, state); + } + return; + case PortraitInverted: + // Logical column x → physical row x; logical y range → physical x span (mirrored) + for (int lx = x; lx < x + width; lx++) { + fillPhysicalHSpan(lx, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), HalDisplay::DISPLAY_WIDTH - 1 - y, + state); + } + return; + case LandscapeCounterClockwise: + // Logical row y → physical row y; logical x range → physical x span + for (int ly = y; ly < y + height; ly++) { + fillPhysicalHSpan(ly, x, x + width - 1, state); + } + return; + case LandscapeClockwise: + // Logical row y → physical row (479-y); logical x range → physical x span (mirrored) + for (int ly = y; ly < y + height; ly++) { + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - ly, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, state); + } + return; } } @@ -457,32 +1200,120 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons fillRect(x, y, width, height, true); } else if (color == Color::White) { fillRect(x, y, width, height, false); - } else if (color == Color::LightGray) { - for (int fillY = y; fillY < y + height; fillY++) { - for (int fillX = x; fillX < x + width; fillX++) { - drawPixelDither(fillX, fillY); - } - } } else if (color == Color::DarkGray) { - for (int fillY = y; fillY < y + height; fillY++) { - for (int fillX = x; fillX < x + width; fillX++) { - drawPixelDither(fillX, fillY); - } + // Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard). + // Byte patterns (phyY even / phyY odd): + // Portrait / PortraitInverted: 0xAA / 0x55 + // LandscapeCW / LandscapeCCW: 0x55 / 0xAA + switch (orientation) { + case Portrait: + for (int lx = x; lx < x + width; lx++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx; + const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55; + fillPhysicalHSpanByte(phyY, y, y + height - 1, pb); + } + return; + case PortraitInverted: + for (int lx = x; lx < x + width; lx++) { + const int phyY = lx; + const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), + HalDisplay::DISPLAY_WIDTH - 1 - y, pb); + } + return; + case LandscapeCounterClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = ly; + const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA; + fillPhysicalHSpanByte(phyY, x, x + width - 1, pb); + } + return; + case LandscapeClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly; + const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, pb); + } + return; + } + } else if (color == Color::LightGray) { + // Pattern: dark where phyX % 2 == 0 && phyY % 2 == 0 (1-in-4 pixels dark). + // Byte patterns (phyY even / phyY odd) — 0xFF rows write no dark pixels and are skipped: + // Portrait: 0xFF (skip) / 0x55 + // PortraitInverted: 0xAA / 0xFF (skip) + // LandscapeCCW: 0x55 / 0xFF (skip) + // LandscapeCW: 0xFF (skip) / 0xAA + switch (orientation) { + case Portrait: + for (int lx = x; lx < x + width; lx++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx; + if (phyY % 2 == 0) continue; // all-white row — no dark pixels to write + fillPhysicalHSpanByte(phyY, y, y + height - 1, 0x55); + } + return; + case PortraitInverted: + for (int lx = x; lx < x + width; lx++) { + const int phyY = lx; + if (phyY % 2 != 0) continue; // all-white row + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), + HalDisplay::DISPLAY_WIDTH - 1 - y, 0xAA); + } + return; + case LandscapeCounterClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = ly; + if (phyY % 2 != 0) continue; // all-white row + fillPhysicalHSpanByte(phyY, x, x + width - 1, 0x55); + } + return; + case LandscapeClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly; + if (phyY % 2 == 0) continue; // all-white row + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, 0xAA); + } + return; } } } template void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir) const { + if (maxRadius <= 0) return; + + if constexpr (color == Color::Clear) { + return; + } + const int radiusSq = maxRadius * maxRadius; + + // Avoid sqrt by scanning from outer radius inward while y grows. + int x = maxRadius; for (int dy = 0; dy <= maxRadius; ++dy) { - for (int dx = 0; dx <= maxRadius; ++dx) { - const int distSq = dx * dx + dy * dy; - const int px = cx + xDir * dx; - const int py = cy + yDir * dy; - if (distSq <= radiusSq) { - drawPixelDither(px, py); - } + while (x > 0 && (x * x + dy * dy) > radiusSq) { + --x; + } + if (x < 0) break; + + const int py = cy + yDir * dy; + if (py < 0 || py >= getScreenHeight()) continue; + + int x0 = cx; + int x1 = cx + xDir * x; + if (x0 > x1) std::swap(x0, x1); + const int width = x1 - x0 + 1; + + if (width <= 0) continue; + + if constexpr (color == Color::Black) { + fillRect(x0, py, width, 1, true); + } else if constexpr (color == Color::White) { + fillRect(x0, py, width, 1, false); + } else { + // LightGray / DarkGray: use existing dithered fill path. + fillRectDither(x0, py, width, 1, color); } } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 01556522..afe14511 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -55,6 +55,14 @@ class GfxRenderer { void drawPixelDither(int x, int y) const; template void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; + // Write a patterned horizontal span directly to the physical framebuffer using byte-level operations. + // phyY: physical row; phyX_start/phyX_end: inclusive physical column range. + // patternByte is repeated across the span; partial edge bytes are blended with existing content. + // Bit layout: MSB-first (bit 7 = phyX=0); 0 bits = dark pixel, 1 bits = white pixel. + void fillPhysicalHSpanByte(int phyY, int phyX_start, int phyX_end, uint8_t patternByte) const; + // Write a solid horizontal span directly to the physical framebuffer using byte-level operations. + // Thin wrapper around fillPhysicalHSpanByte: state=true → 0x00 (dark), false → 0xFF (white). + void fillPhysicalHSpan(int phyY, int phyX_start, int phyX_end, bool state) const; public: explicit GfxRenderer(HalDisplay& halDisplay) @@ -157,4 +165,10 @@ class GfxRenderer { // Low level functions uint8_t* getFrameBuffer() const; size_t getBufferSize() const; + +#ifdef ENABLE_RENDERCHAR_BENCHMARK + // Legacy per-pixel paths — used only by the renderChar benchmark to establish baselines. + void drawTextBWLegacy(int fontId, int x, int y, const char* text) const; + void drawText2BitLegacy(int fontId, int x, int y, const char* text) const; +#endif }; diff --git a/lib/I18n/I18n.cpp b/lib/I18n/I18n.cpp index 55cf8fc4..9ccb3cd4 100644 --- a/lib/I18n/I18n.cpp +++ b/lib/I18n/I18n.cpp @@ -4,13 +4,15 @@ #include #include +#include + #include "I18nStrings.h" using namespace i18n_strings; // Settings file path static constexpr const char* SETTINGS_FILE = "/.crosspoint/language.bin"; -static constexpr uint8_t SETTINGS_VERSION = 1; +static constexpr uint8_t SETTINGS_VERSION = 2; I18n& I18n::getInstance() { static I18n instance; @@ -24,8 +26,8 @@ const char* I18n::get(StrId id) const { } // Use generated helper function - no hardcoded switch needed! - const char* const* strings = getStringArray(_language); - return strings[index]; + const LangStrings lang = getLanguageStrings(_language); + return lang.data + lang.offsets[index]; } void I18n::setLanguage(Language lang) { @@ -44,6 +46,14 @@ const char* I18n::getLanguageName(Language lang) const { return LANGUAGE_NAMES[index]; } +const char* I18n::getLanguageCode(Language lang) const { + const auto index = static_cast(lang); + if (index >= static_cast(Language::_COUNT)) { + return LANGUAGE_CODES[0]; + } + return LANGUAGE_CODES[index]; +} + void I18n::saveSettings() { Storage.mkdir("/.crosspoint"); @@ -54,10 +64,11 @@ void I18n::saveSettings() { } serialization::writePod(file, SETTINGS_VERSION); - serialization::writePod(file, static_cast(_language)); + serialization::writeString(file, getLanguageCode(_language)); file.close(); - Serial.printf("[I18N] Settings saved: language=%d\n", static_cast(_language)); + Serial.printf("[I18N] Settings saved: language=%d code=%s\n", static_cast(_language), + getLanguageCode(_language)); } void I18n::loadSettings() { @@ -69,19 +80,48 @@ void I18n::loadSettings() { uint8_t version; serialization::readPod(file, version); - if (version != SETTINGS_VERSION) { - Serial.printf("[I18N] Settings version mismatch\n"); + + if (version == SETTINGS_VERSION) { + std::string code; + serialization::readString(file, code); + bool found = false; + + for (uint8_t i = 0; i < getLanguageCount(); i++) { + if (code == LANGUAGE_CODES[i]) { + _language = static_cast(i); + found = true; + break; + } + } + + if (found) { + Serial.printf("[I18N] Loaded language code: %s (%d)\n", code.c_str(), static_cast(_language)); + } else { + Serial.printf("[I18N] Unknown language code in settings: %s\n", code.c_str()); + } file.close(); return; } - uint8_t lang; - serialization::readPod(file, lang); - if (lang < static_cast(Language::_COUNT)) { - _language = static_cast(lang); - Serial.printf("[I18N] Loaded language: %d\n", static_cast(_language)); + // Legacy migration path: version 1 stored language enum index directly. + if (version == 1) { + uint8_t lang; + serialization::readPod(file, lang); + if (lang < static_cast(Language::_COUNT)) { + _language = static_cast(lang); + Serial.printf("[I18N] Migrating v1 language index: %d -> %s\n", static_cast(_language), + getLanguageCode(_language)); + file.close(); + saveSettings(); + return; + } + file.close(); + Serial.printf("[I18N] Invalid v1 language index: %d\n", static_cast(lang)); + return; } + Serial.printf("[I18N] Settings version mismatch: %d\n", static_cast(version)); + file.close(); } diff --git a/lib/I18n/I18n.h b/lib/I18n/I18n.h index 347b7fcc..74273b30 100644 --- a/lib/I18n/I18n.h +++ b/lib/I18n/I18n.h @@ -22,6 +22,7 @@ class I18n { Language getLanguage() const { return _language; } void setLanguage(Language lang); + const char* getLanguageCode(Language lang) const; const char* getLanguageName(Language lang) const; void saveSettings(); diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index df91c1a1..3e904089 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -70,6 +70,7 @@ STR_IMAGES: "Images" STR_IMAGES_DISPLAY: "Display" STR_IMAGES_PLACEHOLDER: "Placeholder" STR_IMAGES_SUPPRESS: "Suppress" +STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Create fallback for invalid TOC" STR_SHORT_PWR_BTN: "Short Power Button Click" STR_ORIENTATION: "Reading Orientation" STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)" @@ -82,6 +83,43 @@ STR_PARA_ALIGNMENT: "Reader Paragraph Alignment" STR_HYPHENATION: "Hyphenation" STR_TIME_TO_SLEEP: "Time to Sleep" STR_SHOW_HIDDEN_FILES: "Show Hidden Files" +STR_USE_CLOCK: "Use Clock" +STR_CLOCK_SETTINGS: "Clock Settings" +STR_CLOCK_SETTINGS_WARNING: "Uses more battery; clock may drift" +STR_CLOCK: "Clock" +STR_CLOCK_FORMAT: "Clock Format" +STR_TIMEZONE: "Timezone" +STR_24H: "24h" +STR_12H: "12h" +STR_TZ_UTC: "UTC (GMT/BST)" +STR_TZ_CET: "Central Europe (CET/CEST)" +STR_TZ_EET: "Eastern Europe (EET/EEST)" +STR_TZ_EST: "US Eastern (EST/EDT)" +STR_TZ_CST: "US Central (CST/CDT)" +STR_TZ_MST: "US Mountain (MST/MDT)" +STR_TZ_PST: "US Pacific (PST/PDT)" +STR_TZ_AEST: "Australia Eastern (AEST/AEDT)" +STR_TZ_NZST: "New Zealand (NZST/NZDT)" +STR_TZ_MSK: "Russia (MSK)" +STR_TZ_UTC_MINUS3: "South America (UTC-3)" +STR_TZ_UTC_PLUS4: "Gulf (UTC+4)" +STR_TZ_IST: "India (UTC+5:30)" +STR_TZ_UTC_PLUS7: "SE Asia (UTC+7)" +STR_TZ_UTC_PLUS8: "China/SE Asia (UTC+8)" +STR_TZ_UTC_PLUS9: "Japan/Korea (UTC+9)" +STR_SYNC_TIME: "Sync Time" +STR_DETECT_TIMEZONE: "Detect Timezone" +STR_SYNCING_CLOCK: "Syncing clock..." +STR_DETECTING_TIMEZONE: "Detecting timezone..." +STR_TIME_SYNCED: "Time synced" +STR_TIME_SYNC_FAILED: "Time sync failed" +STR_CLOCK_DRIFT: "Drift: %s" +STR_LAST_NTP_SYNC: "Last sync: %s" +STR_TIMEZONE_DETECTED: "Timezone detected" +STR_TIMEZONE_DETECT_FAILED: "Timezone detect failed" +STR_DST_ACTIVE: "DST: active" +STR_DST_INACTIVE: "DST: inactive" +STR_DST_UNKNOWN: "DST: unknown" STR_REFRESH_FREQ: "Refresh Frequency" STR_KOREADER_SYNC: "KOReader Sync" STR_CHECK_UPDATES: "Check for updates" @@ -102,14 +140,19 @@ STR_AUTHENTICATING: "Authenticating..." STR_AUTH_SUCCESS: "Successfully authenticated!" STR_KOREADER_AUTH: "KOReader Auth" STR_SYNC_READY: "KOReader sync is ready to use" -STR_AUTH_FAILED: "Authentication Failed" +STR_AUTH_FAILED: "Authentication failed" +STR_REGISTER: "Register" +STR_REGISTERING: "Registering..." +STR_REGISTER_SUCCESS: "Account created successfully!" +STR_REGISTER_FAILED: "Registration failed" +STR_USERNAME_TAKEN: "Username already taken" STR_DONE: "Done" STR_CLEAR_CACHE_WARNING_1: "This will clear all cached book data." STR_CLEAR_CACHE_WARNING_2: "All reading progress will be lost!" STR_CLEAR_CACHE_WARNING_3: "Books will need to be re-indexed" STR_CLEAR_CACHE_WARNING_4: "when opened again." STR_CLEARING_CACHE: "Clearing cache..." -STR_CACHE_CLEARED: "Cache Cleared" +STR_CACHE_CLEARED: "Cache cleared" STR_ITEMS_REMOVED: "items removed" STR_FAILED_LOWER: "failed" STR_CLEAR_CACHE_FAILED: "Failed to clear cache" @@ -230,6 +273,8 @@ STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" STR_OPDS_BROWSER: "OPDS Browser" STR_COVER_CUSTOM: "Cover + Custom" +STR_PAGE_OVERLAY: "Page overlay" +STR_RECENTS: "Recents" STR_MENU_RECENT_BOOKS: "Recent Books" STR_NO_RECENT_BOOKS: "No recent books" STR_CALIBRE_DESC: "Use Calibre wireless device transfers" @@ -254,6 +299,7 @@ STR_GO_HOME_BUTTON: "Go Home" STR_SYNC_PROGRESS: "Sync Progress" STR_DELETE_CACHE: "Delete Book Cache" STR_DELETE: "Delete" +STR_REMOVE: "Remove" STR_DISPLAY_QR: "Show page as QR" STR_CHAPTER_PREFIX: "Chapter: " STR_PAGES_SEPARATOR: " pages | " @@ -264,6 +310,8 @@ STR_SYNCING_TIME: "Syncing time..." STR_CALC_HASH: "Calculating document hash..." STR_HASH_FAILED: "Failed to calculate document hash" STR_FETCH_PROGRESS: "Fetching remote progress..." +STR_MAPPING_REMOTE: "Mapping remote position..." +STR_MAPPING_LOCAL: "Calculating local position..." STR_UPLOAD_PROGRESS: "Uploading progress..." STR_NO_CREDENTIALS_MSG: "No credentials configured" STR_KOREADER_SETUP_HINT: "Set up KOReader account in Settings" @@ -290,3 +338,109 @@ STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Take screenshot" STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" +STR_WEATHER: "Weather" +STR_WEATHER_LOCATION: "Location" +STR_WEATHER_NO_LOCATION: "No location set for weather" +STR_WEATHER_FETCH_FAILED: "Failed to fetch weather" +STR_WEATHER_SETTINGS: "Weather Settings" +STR_WEATHER_SETTINGS_SHORT: "Settings" +STR_WEATHER_REFRESH: "Refresh" +STR_WEATHER_FEELS_LIKE: "Feels like" +STR_WEATHER_HUMIDITY: "Humidity" +STR_WEATHER_WIND: "Wind" +STR_WEATHER_PRESSURE: "Pressure" +STR_WEATHER_LAST_UPDATED: "Last updated" +STR_WEATHER_PRECIP: "Precip." +STR_WEATHER_PRECIP_UNIT: "Precipitation Unit" +STR_WEATHER_WIND_UNIT: "Wind Speed Unit" +STR_WEATHER_TEMP_UNIT: "Temperature Unit" +STR_WEATHER_48H_FORECAST: "48-Hour Forecast" +STR_WEATHER_SEARCH_CITY: "Search City" +STR_WEATHER_LONGITUDE: "Longitude" +STR_WEATHER_LATITUDE: "Latitude" +STR_WEATHER_SEARCH_RESULTS: "Search Results" +STR_WEATHER_DAY_MON: "Mon" +STR_WEATHER_DAY_TUE: "Tue" +STR_WEATHER_DAY_WED: "Wed" +STR_WEATHER_DAY_THU: "Thu" +STR_WEATHER_DAY_FRI: "Fri" +STR_WEATHER_DAY_SAT: "Sat" +STR_WEATHER_DAY_SUN: "Sun" +STR_WEATHER_MONTH_JAN: "Jan" +STR_WEATHER_MONTH_FEB: "Feb" +STR_WEATHER_MONTH_MAR: "Mar" +STR_WEATHER_MONTH_APR: "Apr" +STR_WEATHER_MONTH_MAY: "May" +STR_WEATHER_MONTH_JUN: "Jun" +STR_WEATHER_MONTH_JUL: "Jul" +STR_WEATHER_MONTH_AUG: "Aug" +STR_WEATHER_MONTH_SEP: "Sep" +STR_WEATHER_MONTH_OCT: "Oct" +STR_WEATHER_MONTH_NOV: "Nov" +STR_WEATHER_MONTH_DEC: "Dec" +STR_WEATHER_MOON_NEW: "New Moon" +STR_WEATHER_MOON_WAXING_CRESCENT: "Waxing Crescent" +STR_WEATHER_MOON_FIRST_QUARTER: "First Quarter" +STR_WEATHER_MOON_WAXING_GIBBOUS: "Waxing Gibbous" +STR_WEATHER_MOON_FULL: "Full Moon" +STR_WEATHER_MOON_WANING_GIBBOUS: "Waning Gibbous" +STR_WEATHER_MOON_LAST_QUARTER: "Last Quarter" +STR_WEATHER_MOON_WANING_CRESCENT: "Waning Crescent" +STR_WEATHER_DESC_CLEAR_SKY: "Clear sky" +STR_WEATHER_DESC_MAINLY_CLEAR: "Mainly clear" +STR_WEATHER_DESC_PARTLY_CLOUDY: "Partly cloudy" +STR_WEATHER_DESC_OVERCAST: "Overcast" +STR_WEATHER_DESC_FOG: "Fog" +STR_WEATHER_DESC_RIME_FOG: "Rime fog" +STR_WEATHER_DESC_LIGHT_DRIZZLE: "Light drizzle" +STR_WEATHER_DESC_DRIZZLE: "Drizzle" +STR_WEATHER_DESC_DENSE_DRIZZLE: "Dense drizzle" +STR_WEATHER_DESC_FREEZING_DRIZZLE: "Freezing drizzle" +STR_WEATHER_DESC_DENSE_FREEZING_DRIZZLE: "Dense freezing drizzle" +STR_WEATHER_DESC_SLIGHT_RAIN: "Slight rain" +STR_WEATHER_DESC_MODERATE_RAIN: "Moderate rain" +STR_WEATHER_DESC_HEAVY_RAIN: "Heavy rain" +STR_WEATHER_DESC_FREEZING_RAIN: "Freezing rain" +STR_WEATHER_DESC_HEAVY_FREEZING_RAIN: "Heavy freezing rain" +STR_WEATHER_DESC_SLIGHT_SNOW: "Slight snow" +STR_WEATHER_DESC_MODERATE_SNOW: "Moderate snow" +STR_WEATHER_DESC_HEAVY_SNOW: "Heavy snow" +STR_WEATHER_DESC_SNOW_GRAINS: "Snow grains" +STR_WEATHER_DESC_SLIGHT_SHOWERS: "Slight showers" +STR_WEATHER_DESC_MODERATE_SHOWERS: "Moderate showers" +STR_WEATHER_DESC_VIOLENT_SHOWERS: "Violent showers" +STR_WEATHER_DESC_SNOW_SHOWERS: "Snow showers" +STR_WEATHER_DESC_HEAVY_SNOW_SHOWERS: "Heavy snow showers" +STR_WEATHER_DESC_THUNDERSTORM: "Thunderstorm" +STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Thunderstorm, hail" +STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Thunderstorm, heavy hail" +STR_WEATHER_DESC_UNKNOWN: "Unknown" +STR_INFO: "Info" +STR_AUTHOR: "Author" +STR_SERIES: "Series" +STR_FILE_SIZE: "Size" +STR_SLEEP_COVER_OVERLAY: "Sleep Screen Info Overlay" +STR_OVERLAY_WHITE: "White" +STR_OVERLAY_GRAY: "Gray" +STR_OVERLAY_BLACK: "Black" +STR_OVERLAY_OFF: "Off" +STR_OVERLAY_READING_PROGRESS: "Reading progress: Page %lu/%u - %.0f%%" +STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "Reading progress: Page %lu" +STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Page %d/%d - %.0f%% read" +STR_SYSTEM_INFO: "System Information" +STR_LOAD_XTC_FAILED: "Failed to load XTC file" +STR_LOAD_EPUB_FAILED: "Failed to load EPUB file" +STR_FW_VERSION: "FW version" +STR_CHIP: "Chip" +STR_CPU: "CPU" +STR_MHZ: "MHz" +STR_FREE_RAM: "Free RAM" +STR_MIN_FREE: "Min free" +STR_MAX_BLOCK: "Max block" +STR_FLASH_USED: "Flash used" +STR_UPTIME: "Uptime" +STR_CHARGING: "Charging" +STR_GATHERING_DATA: "Gathering data..." +STR_READING: "Reading..." +STR_WEATHER_MOON_INFO: "Moon" +STR_WEATHER_SUN_INFO: "Sun" \ No newline at end of file diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index b53ff494..e2bcbbc2 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -320,3 +320,130 @@ STR_UPTIME: "Laufzeit" STR_CHARGING: "Lädt" STR_GATHERING_DATA: "Daten werden gesammelt..." STR_READING: "Lese..." + +STR_WEATHER: "Wetter" +STR_WEATHER_LOCATION: "Ort" +STR_WEATHER_NO_LOCATION: "Kein Ort für Wetter festgelegt" +STR_WEATHER_FETCH_FAILED: "Wetterdaten konnten nicht geladen werden" +STR_WEATHER_SETTINGS: "Wettereinstellungen" +STR_WEATHER_SETTINGS_SHORT: "Einstell." +STR_WEATHER_REFRESH: "Aktual." +STR_WEATHER_FEELS_LIKE: "Gefühlt" +STR_WEATHER_HUMIDITY: "Luftfeuchte" +STR_WEATHER_WIND: "Wind" +STR_WEATHER_PRESSURE: "Luftdruck" +STR_WEATHER_LAST_UPDATED: "Zuletzt aktualisiert" +STR_WEATHER_PRECIP: "Niederschl." +STR_WEATHER_PRECIP_UNIT: "Niederschlagseinheit" +STR_WEATHER_WIND_UNIT: "Windgeschwindigkeitseinheit" +STR_WEATHER_TEMP_UNIT: "Temperatureinheit" +STR_WEATHER_48H_FORECAST: "48-Stunden-Vorhersage" +STR_WEATHER_SEARCH_CITY: "Stadt suchen" +STR_WEATHER_LONGITUDE: "Längengrad" +STR_WEATHER_LATITUDE: "Breitengrad" +STR_WEATHER_SEARCH_RESULTS: "Suchergebnisse" + +STR_WEATHER_DAY_MON: "Mo" +STR_WEATHER_DAY_TUE: "Di" +STR_WEATHER_DAY_WED: "Mi" +STR_WEATHER_DAY_THU: "Do" +STR_WEATHER_DAY_FRI: "Fr" +STR_WEATHER_DAY_SAT: "Sa" +STR_WEATHER_DAY_SUN: "So" +STR_WEATHER_MONTH_JAN: "Jan" +STR_WEATHER_MONTH_FEB: "Feb" +STR_WEATHER_MONTH_MAR: "Mär" +STR_WEATHER_MONTH_APR: "Apr" +STR_WEATHER_MONTH_MAY: "Mai" +STR_WEATHER_MONTH_JUN: "Jun" +STR_WEATHER_MONTH_JUL: "Jul" +STR_WEATHER_MONTH_AUG: "Aug" +STR_WEATHER_MONTH_SEP: "Sep" +STR_WEATHER_MONTH_OCT: "Okt" +STR_WEATHER_MONTH_NOV: "Nov" +STR_WEATHER_MONTH_DEC: "Dez" + +STR_WEATHER_SUN_INFO: "Sonne" +STR_WEATHER_MOON_NEW: "Neumond" +STR_WEATHER_MOON_WAXING_CRESCENT: "zunehm. Halbmond" +STR_WEATHER_MOON_FIRST_QUARTER: "1. Viertel" +STR_WEATHER_MOON_WAXING_GIBBOUS: "zunehm. Mond" +STR_WEATHER_MOON_FULL: "Vollmond" +STR_WEATHER_MOON_WANING_GIBBOUS: "abnehm. Mond" +STR_WEATHER_MOON_LAST_QUARTER: "3. Viertel" +STR_WEATHER_MOON_WANING_CRESCENT: "abneh. Halbmond" + +STR_WEATHER_DESC_CLEAR_SKY: "Klarer Himmel" +STR_WEATHER_DESC_MAINLY_CLEAR: "Überwiegend klar" +STR_WEATHER_DESC_PARTLY_CLOUDY: "Teilweise bewölkt" +STR_WEATHER_DESC_OVERCAST: "Bedeckt" +STR_WEATHER_DESC_FOG: "Nebel" +STR_WEATHER_DESC_RIME_FOG: "Raureifnebel" +STR_WEATHER_DESC_LIGHT_DRIZZLE: "Leichter Nieselregen" +STR_WEATHER_DESC_DRIZZLE: "Nieselregen" +STR_WEATHER_DESC_DENSE_DRIZZLE: "Dichter Nieselregen" +STR_WEATHER_DESC_FREEZING_DRIZZLE: "Gefrierender Nieselregen" +STR_WEATHER_DESC_DENSE_FREEZING_DRIZZLE: "Dichter gefrierender Nieselregen" +STR_WEATHER_DESC_SLIGHT_RAIN: "Leichter Regen" +STR_WEATHER_DESC_MODERATE_RAIN: "Mäßiger Regen" +STR_WEATHER_DESC_HEAVY_RAIN: "Starker Regen" +STR_WEATHER_DESC_FREEZING_RAIN: "Gefrierender Regen" +STR_WEATHER_DESC_HEAVY_FREEZING_RAIN: "Starker gefrierender Regen" +STR_WEATHER_DESC_SLIGHT_SNOW: "Leichter Schneefall" +STR_WEATHER_DESC_MODERATE_SNOW: "Mäßiger Schneefall" +STR_WEATHER_DESC_HEAVY_SNOW: "Starker Schneefall" +STR_WEATHER_DESC_SNOW_GRAINS: "Schneegriesel" +STR_WEATHER_DESC_SLIGHT_SHOWERS: "Leichte Schauer" +STR_WEATHER_DESC_MODERATE_SHOWERS: "Mäßige Schauer" +STR_WEATHER_DESC_VIOLENT_SHOWERS: "Heftige Schauer" +STR_WEATHER_DESC_SNOW_SHOWERS: "Schneeschauer" +STR_WEATHER_DESC_HEAVY_SNOW_SHOWERS: "Starke Schneeschauer" +STR_WEATHER_DESC_THUNDERSTORM: "Gewitter" +STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Gewitter mit Hagel" +STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Gewitter mit starkem Hagel" +STR_WEATHER_DESC_UNKNOWN: "Unbekannt" +STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Ersatz für ungültiges Inhaltsverzeichnis erstellen" +STR_USE_CLOCK: "Uhr anzeigen" +STR_CLOCK_SETTINGS: "Uhr-Einstellungen" +STR_CLOCK_SETTINGS_WARNING: "Verbraucht mehr Akku; Uhr kann abweichen" +STR_CLOCK: "Uhr" +STR_CLOCK_FORMAT: "Uhrformat" +STR_TIMEZONE: "Zeitzone" +STR_24H: "24 Std." +STR_12H: "12 Std." +STR_TZ_UTC: "UTC (GMT/BST)" +STR_TZ_CET: "Mitteleuropa (CET/CEST)" +STR_TZ_EET: "Osteuropa (EET/EEST)" +STR_TZ_EST: "USA Ostküste (EST/EDT)" +STR_TZ_CST: "USA Zentral (CST/CDT)" +STR_TZ_MST: "USA Gebirge (MST/MDT)" +STR_TZ_PST: "USA Westküste (PST/PDT)" +STR_TZ_AEST: "Australien Ost (AEST/AEDT)" +STR_TZ_NZST: "Neuseeland (NZST/NZDT)" +STR_TZ_MSK: "Russland (MSK)" +STR_TZ_UTC_MINUS3: "Südamerika (UTC-3)" +STR_TZ_UTC_PLUS4: "Golfregion (UTC+4)" +STR_TZ_IST: "Indien (UTC+5:30)" +STR_TZ_UTC_PLUS7: "Südostasien (UTC+7)" +STR_TZ_UTC_PLUS8: "China/Südostasien (UTC+8)" +STR_TZ_UTC_PLUS9: "Japan/Korea (UTC+9)" +STR_SYNC_TIME: "Uhrzeit synchronisieren" +STR_DETECT_TIMEZONE: "Zeitzone erkennen" +STR_SYNCING_CLOCK: "Uhr wird synchronisiert..." +STR_DETECTING_TIMEZONE: "Zeitzone wird erkannt..." +STR_TIME_SYNCED: "Uhrzeit synchronisiert" +STR_TIME_SYNC_FAILED: "Zeitsynchronisierung fehlgeschlagen" +STR_CLOCK_DRIFT: "Abweichung: %s" +STR_LAST_NTP_SYNC: "Letzte Synchronisierung: %s" +STR_TIMEZONE_DETECTED: "Zeitzone erkannt" +STR_TIMEZONE_DETECT_FAILED: "Zeitzone konnte nicht erkannt werden" +STR_DST_ACTIVE: "Sommerzeit: aktiv" +STR_DST_INACTIVE: "Sommerzeit: inaktiv" +STR_DST_UNKNOWN: "Sommerzeit: unbekannt" +STR_OVERLAY_WHITE: "Weiß" +STR_OVERLAY_GRAY: "Grau" +STR_OVERLAY_BLACK: "Schwarz" +STR_OVERLAY_OFF: "Aus" +STR_OVERLAY_READING_PROGRESS: "%d/%d (%d%%)" +STR_OVERLAY_READING_PROGRESS_NO_TOTAL: "%d (%d%%)" +STR_OVERLAY_CHAPTER_PAGE_SUFFIX: " - Seite %d/%d - %.0f%%" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index e28524ce..811614e9 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -1,6 +1,6 @@ _language_name: "Magyar" _language_code: "HU" -_order: "19" +_order: "22" STR_CROSSPOINT: "CrossPoint" STR_BOOTING: "INDÍTÁS" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese_br.yaml similarity index 72% rename from lib/I18n/translations/portuguese.yaml rename to lib/I18n/translations/portuguese_br.yaml index 962863ad..851d5552 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese_br.yaml @@ -1,5 +1,5 @@ _language_name: "Português (Brasil)" -_language_code: "PT" +_language_code: "PO" _order: "5" STR_CROSSPOINT: "CrossPoint" @@ -19,7 +19,7 @@ STR_END_OF_BOOK: "Fim do livro" STR_EMPTY_CHAPTER: "Capítulo vazio" STR_INDEXING: "Indexando" STR_MEMORY_ERROR: "Erro de memória" -STR_PAGE_LOAD_ERROR: "Erro página" +STR_PAGE_LOAD_ERROR: "Erro ao carregar a página" STR_EMPTY_FILE: "Arquivo vazio" STR_OUT_OF_BOUNDS: "Fora dos limites" STR_LOADING: "Carregando..." @@ -31,17 +31,17 @@ STR_SCANNING: "Procurando..." STR_CONNECTING: "Conectando..." STR_CONNECTED: "Conectado!" STR_CONNECTION_FAILED: "Falha na conexão" -STR_FORGET_NETWORK: "Esquecer rede?" -STR_SAVE_PASSWORD: "Salvar senha a próxima vez?" -STR_PRESS_OK_SCAN: "Pressione OK procurar novamente" +STR_FORGET_NETWORK: "Esquecer a rede?" +STR_SAVE_PASSWORD: "Salvar a senha para a próxima vez?" +STR_PRESS_OK_SCAN: "Pressione OK para procurar novamente" STR_JOIN_NETWORK: "Entrar em uma rede" STR_CREATE_HOTSPOT: "Criar hotspot" STR_JOIN_DESC: "Conecte-se a uma rede Wi‑Fi existente" -STR_HOTSPOT_DESC: "Crie uma rede Wi‑Fi outras pessoas entrarem" +STR_HOTSPOT_DESC: "Crie uma rede Wi‑Fi à qual outras pessoas possam se conectar" STR_STARTING_HOTSPOT: "Iniciando hotspot..." STR_HOTSPOT_MODE: "Modo hotspot" STR_CONNECT_WIFI_HINT: "Conecte seu dispositivo a esta rede Wi‑Fi" -STR_OPEN_URL_HINT: "Abra este URL seu navegador" +STR_OPEN_URL_HINT: "Abra este URL no seu navegador" STR_OR_HTTP_PREFIX: "ou http://" STR_SCAN_QR_HINT: "ou escaneie o QR code com seu celular:" STR_CALIBRE_WIRELESS: "Calibre sem fio" @@ -54,37 +54,42 @@ STR_TO_PREFIX: "para" STR_CALIBRE_RECEIVING: "Recebendo:" STR_CALIBRE_RECEIVED: "Recebido:" STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader" -STR_CALIBRE_INSTRUCTION_2: "2) Esteja mesma rede Wi‑Fi" -STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar o dispositivo\"" +STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi‑Fi" +STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar para o dispositivo\"" STR_CALIBRE_INSTRUCTION_4: "\"Mantenha esta tela aberta durante o envio\"" STR_CAT_DISPLAY: "Tela" STR_CAT_READER: "Leitor" STR_CAT_CONTROLS: "Controles" STR_CAT_SYSTEM: "Sistema" STR_SLEEP_SCREEN: "Tela de repouso" -STR_SLEEP_COVER_MODE: "Modo capa tela repouso" +STR_SLEEP_COVER_MODE: "Modo de capa da tela de repouso" STR_HIDE_BATTERY: "Ocultar % da bateria" -STR_EXTRA_SPACING: "Espaço de parágrafos extra" +STR_EXTRA_SPACING: "Espaçamento extra entre parágrafos" STR_TEXT_AA: "Suavização de texto" -STR_SHORT_PWR_BTN: "Clique curto botão ligar" +STR_IMAGES: "Imagens" +STR_IMAGES_DISPLAY: "Exibir" +STR_IMAGES_PLACEHOLDER: "Marcador" +STR_IMAGES_SUPPRESS: "Ocultar" +STR_SHORT_PWR_BTN: "Clique curto no botão de ligar" STR_ORIENTATION: "Orientação de leitura" -STR_SIDE_BTN_LAYOUT: "Disposição botões laterais" +STR_SIDE_BTN_LAYOUT: "Disposição dos botões laterais" STR_LONG_PRESS_SKIP: "Pular capítulo com pressão longa" STR_FONT_FAMILY: "Fonte do leitor" -STR_FONT_SIZE: "Tam. fonte UI" +STR_FONT_SIZE: "Tam. da fonte da UI" STR_LINE_SPACING: "Espaçamento entre linhas" STR_SCREEN_MARGIN: "Margens da tela" -STR_PARA_ALIGNMENT: "Alinhamento parágrafo" +STR_PARA_ALIGNMENT: "Alinhamento do parágrafo" STR_HYPHENATION: "Hifenização" -STR_TIME_TO_SLEEP: "Tempo para repousar" -STR_REFRESH_FREQ: "Frequência atualização" +STR_TIME_TO_SLEEP: "Tempo para entrar em repouso" +STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos" +STR_REFRESH_FREQ: "Frequência de atualização" STR_KOREADER_SYNC: "Sincronização KOReader" STR_CHECK_UPDATES: "Verificar atualizações" STR_LANGUAGE: "Idioma" STR_CLEAR_READING_CACHE: "Limpar cache de leitura" STR_USERNAME: "Nome de usuário" STR_PASSWORD: "Senha" -STR_SYNC_SERVER_URL: "URL servidor sincronização" +STR_SYNC_SERVER_URL: "URL do servidor de sincronização" STR_DOCUMENT_MATCHING: "Documento correspondente" STR_AUTHENTICATE: "Autenticar" STR_KOREADER_USERNAME: "Usuário do KOReader" @@ -95,11 +100,11 @@ STR_SET_CREDENTIALS_FIRST: "Defina as credenciais primeiro" STR_WIFI_CONN_FAILED: "Falha na conexão Wi‑Fi" STR_AUTHENTICATING: "Autenticando..." STR_AUTH_SUCCESS: "Autenticado com sucesso!" -STR_KOREADER_AUTH: "Autenticação KOReader" -STR_SYNC_READY: "A sincronização KOReader está pronta uso" +STR_KOREADER_AUTH: "Autenticação do KOReader" +STR_SYNC_READY: "A sincronização do KOReader está pronta para uso" STR_AUTH_FAILED: "Falha na autenticação" STR_DONE: "Feito" -STR_CLEAR_CACHE_WARNING_1: "Isso vai limpar todos os dados livros em cache." +STR_CLEAR_CACHE_WARNING_1: "Isso vai limpar todos os dados de livros em cache." STR_CLEAR_CACHE_WARNING_2: "Todo o progresso de leitura será perdido!" STR_CLEAR_CACHE_WARNING_3: "Os livros precisarão ser reindexados" STR_CLEAR_CACHE_WARNING_4: "quando forem abertos novamente." @@ -108,7 +113,7 @@ STR_CACHE_CLEARED: "Cache limpo" STR_ITEMS_REMOVED: "itens removidos" STR_FAILED_LOWER: "falhou" STR_CLEAR_CACHE_FAILED: "Falha ao limpar o cache" -STR_CHECK_SERIAL_OUTPUT: "Ver saída serial" +STR_CHECK_SERIAL_OUTPUT: "Verifique a saída serial" STR_DARK: "Escuro" STR_LIGHT: "Claro" STR_CUSTOM: "Personalizado" @@ -161,25 +166,26 @@ STR_UPDATING: "Atualizando..." STR_NO_UPDATE: "Nenhuma atualização disponível" STR_UPDATE_FAILED: "Falha na atualização" STR_UPDATE_COMPLETE: "Atualização concluída" -STR_POWER_ON_HINT: "Pressione e segure o botão energia ligar novamente" -STR_NO_ENTRIES: "Nenhum entries encontrado" +STR_POWER_ON_HINT: "Pressione e segure o botão de energia para ligar novamente" +STR_NO_ENTRIES: "Nenhuma entrada encontrada" STR_DOWNLOADING: "Baixando..." STR_DOWNLOAD_FAILED: "Falha no download" STR_ERROR_MSG: "Erro:" STR_UNNAMED: "Sem nome" -STR_NO_SERVER_URL: "Nenhum URL servidor configurado" +STR_NO_SERVER_URL: "Nenhum URL de servidor configurado" STR_FETCH_FEED_FAILED: "Falha ao buscar o feed" STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed" STR_NETWORK_PREFIX: "Rede:" STR_IP_ADDRESS_PREFIX: "Endereço IP:" STR_ERROR_GENERAL_FAILURE: "Erro: falha geral" STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada" -STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite conexão" +STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite de conexão" STR_SD_CARD: "Cartão SD" STR_BACK: "« Voltar" STR_EXIT: "« Sair" STR_HOME: "« Início" STR_SELECT: "Escolher" +STR_SELECTED: "Selecionado" STR_TOGGLE: "Alternar" STR_CONFIRM: "Confirmar" STR_CANCEL: "Cancelar" @@ -189,6 +195,8 @@ STR_DOWNLOAD: "Baixar" STR_RETRY: "Tentar novamente" STR_YES: "Sim" STR_NO: "Não" +STR_SHOW: "Mostrar" +STR_HIDE: "Ocultar" STR_STATE_ON: "LIG." STR_STATE_OFF: "DESL." STR_NOT_SET: "Não definido" @@ -197,8 +205,23 @@ STR_DIR_RIGHT: "Direita" STR_DIR_UP: "Cima" STR_DIR_DOWN: "Baixo" STR_OK_BUTTON: "OK" -STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso" +STR_SLEEP_COVER_FILTER: "Filtro da capa da tela de repouso" STR_FILTER_CONTRAST: "Contraste" +STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de status" +STR_CHAPTER_PAGE_COUNT: "Contagem de páginas do capítulo" +STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de progresso do livro" +STR_PROGRESS_BAR: "Barra de progresso" +STR_PROGRESS_BAR_THICKNESS: "Espessura da barra de progresso" +STR_PROGRESS_BAR_THIN: "Fina" +STR_PROGRESS_BAR_MEDIUM: "Média" +STR_PROGRESS_BAR_THICK: "Grossa" +STR_BOOK: "Livro" +STR_CHAPTER: "Capítulo" +STR_EXAMPLE_CHAPTER: "Capítulo 21" +STR_EXAMPLE_BOOK: "Título do livro" +STR_PREVIEW: "Pré-visualização" +STR_TITLE: "Título" +STR_BATTERY: "Bateria" STR_UI_THEME: "Tema da interface" STR_THEME_CLASSIC: "Clássico" STR_THEME_LYRA: "Lyra" @@ -209,19 +232,19 @@ STR_OPDS_BROWSER: "Navegador OPDS" STR_COVER_CUSTOM: "Capa + personalizado" STR_MENU_RECENT_BOOKS: "Livros recentes" STR_NO_RECENT_BOOKS: "Sem livros recentes" -STR_CALIBRE_DESC: "Usar transferências sem fio Calibre" +STR_CALIBRE_DESC: "Usar transferências sem fio do Calibre" STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a senha salva?" STR_FORGET_BUTTON: "Esquecer" -STR_CALIBRE_STARTING: "Iniciando Calibre..." +STR_CALIBRE_STARTING: "Iniciando o Calibre..." STR_CALIBRE_SETUP: "Configuração" STR_CALIBRE_STATUS: "Status" STR_CLEAR_BUTTON: "Limpar" STR_DEFAULT_VALUE: "Padrão" -STR_REMAP_PROMPT: "Pressione um botão frontal cada função" +STR_REMAP_PROMPT: "Pressione um botão frontal para cada função" STR_UNASSIGNED: "Não atribuído" STR_ALREADY_ASSIGNED: "Já atribuído" -STR_REMAP_RESET_HINT: "Botão lateral cima: redefinir o disposição padrão" -STR_REMAP_CANCEL_HINT: "Botão lateral baixo: cancelar remapeamento" +STR_REMAP_RESET_HINT: "Botão lateral de cima: redefinir a disposição padrão" +STR_REMAP_CANCEL_HINT: "Botão lateral de baixo: cancelar o remapeamento" STR_HW_BACK_LABEL: "Voltar (1º botão)" STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)" STR_HW_LEFT_LABEL: "Esquerda (3º botão)" @@ -231,18 +254,19 @@ STR_GO_HOME_BUTTON: "Ir para o início" STR_SYNC_PROGRESS: "Sincronizar progresso" STR_DELETE_CACHE: "Excluir cache do livro" STR_DELETE: "Excluir" +STR_DISPLAY_QR: "Mostrar página como QR" STR_CHAPTER_PREFIX: "Capítulo:" STR_PAGES_SEPARATOR: "páginas |" STR_BOOK_PREFIX: "Livro:" STR_CALIBRE_URL_HINT: "Para o Calibre, adicione /opds ao seu URL" STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%" -STR_SYNCING_TIME: "Sincronizando horário..." -STR_CALC_HASH: "Calculando hash documento..." -STR_HASH_FAILED: "Falha ao calcular o hash documento" +STR_SYNCING_TIME: "Sincronizando o horário..." +STR_CALC_HASH: "Calculando hash do documento..." +STR_HASH_FAILED: "Falha ao calcular o hash do documento" STR_FETCH_PROGRESS: "Buscando progresso remoto..." STR_UPLOAD_PROGRESS: "Enviando progresso..." STR_NO_CREDENTIALS_MSG: "Nenhuma credencial configurada" -STR_KOREADER_SETUP_HINT: "Configure a conta do KOReader em Config." +STR_KOREADER_SETUP_HINT: "Configure a conta do KOReader em Configurações." STR_PROGRESS_FOUND: "Progresso encontrado!" STR_REMOTE_LABEL: "Remoto:" STR_LOCAL_LABEL: "Local:" @@ -260,4 +284,9 @@ STR_UPLOAD: "Enviar" STR_BOOK_S_STYLE: "Estilo do livro" STR_EMBEDDED_STYLE: "Estilo embutido" STR_OPDS_SERVER_URL: "URL do servidor OPDS" +STR_FOOTNOTES: "Notas de rodapé" +STR_NO_FOOTNOTES: "Sem notas de rodapé nesta página" +STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Capturar tela" +STR_AUTO_TURN_ENABLED: "Virada automática ativada: " +STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)" diff --git a/lib/I18n/translations/portuguese_pt.yaml b/lib/I18n/translations/portuguese_pt.yaml new file mode 100644 index 00000000..9c20ea15 --- /dev/null +++ b/lib/I18n/translations/portuguese_pt.yaml @@ -0,0 +1,292 @@ +_language_name: "Português (Portugal)" +_language_code: "PT" +_order: "19" + +STR_CROSSPOINT: "CrossPoint" +STR_BOOTING: "INICIANDO" +STR_SLEEPING: "EM REPOUSO" +STR_ENTERING_SLEEP: "A entrar em repouso" +STR_BROWSE_FILES: "Ficheiros" +STR_FILE_TRANSFER: "Transferência de ficheiros" +STR_SETTINGS_TITLE: "Definições" +STR_CONTINUE_READING: "Continuar a ler" +STR_NO_OPEN_BOOK: "Nenhum livro aberto" +STR_START_READING: "Comece a ler abaixo" +STR_NO_FILES_FOUND: "Nenhum ficheiro encontrado" +STR_SELECT_CHAPTER: "Escolher capítulo" +STR_NO_CHAPTERS: "Sem capítulos" +STR_END_OF_BOOK: "Fim do livro" +STR_EMPTY_CHAPTER: "Capítulo vazio" +STR_INDEXING: "A indexar" +STR_MEMORY_ERROR: "Erro de memória" +STR_PAGE_LOAD_ERROR: "Erro ao carregar a página" +STR_EMPTY_FILE: "Ficheiro vazio" +STR_OUT_OF_BOUNDS: "Fora dos limites" +STR_LOADING: "A carregar..." +STR_LOADING_POPUP: "A carregar" +STR_WIFI_NETWORKS: "Redes Wi‑Fi" +STR_NO_NETWORKS: "Sem redes" +STR_NETWORKS_FOUND: "%zu redes encontradas" +STR_SCANNING: "A procurar..." +STR_CONNECTING: "A ligar..." +STR_CONNECTED: "Ligado!" +STR_CONNECTION_FAILED: "Falha na ligação" +STR_FORGET_NETWORK: "Esquecer a rede?" +STR_SAVE_PASSWORD: "Guardar palavra-passe para a próxima vez?" +STR_PRESS_OK_SCAN: "Prima OK para procurar novamente" +STR_JOIN_NETWORK: "Ligar a uma rede" +STR_CREATE_HOTSPOT: "Criar hotspot" +STR_JOIN_DESC: "Ligue-se a uma rede Wi‑Fi existente" +STR_HOTSPOT_DESC: "Crie uma rede Wi‑Fi à qual outras pessoas se possam ligar" +STR_STARTING_HOTSPOT: "A iniciar hotspot..." +STR_HOTSPOT_MODE: "Modo hotspot" +STR_CONNECT_WIFI_HINT: "Ligue o seu dispositivo a esta rede Wi‑Fi" +STR_OPEN_URL_HINT: "Abra este URL no seu navegador" +STR_OR_HTTP_PREFIX: "ou http://" +STR_SCAN_QR_HINT: "ou leia o código QR com o seu telemóvel:" +STR_CALIBRE_WIRELESS: "Calibre sem fios" +STR_CALIBRE_WEB_URL: "URL do Calibre Web" +STR_NETWORK_LEGEND: "* = Encriptada | + = Guardada" +STR_MAC_ADDRESS: "Endereço MAC:" +STR_CHECKING_WIFI: "A verificar Wi‑Fi..." +STR_ENTER_WIFI_PASSWORD: "Introduza a palavra-passe do Wi‑Fi" +STR_TO_PREFIX: "para" +STR_CALIBRE_RECEIVING: "A receber:" +STR_CALIBRE_RECEIVED: "Recebido:" +STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader" +STR_CALIBRE_INSTRUCTION_2: "2) Esteja na mesma rede Wi‑Fi" +STR_CALIBRE_INSTRUCTION_3: "3) No Calibre: \"Enviar para o dispositivo\"" +STR_CALIBRE_INSTRUCTION_4: "\"Mantenha este ecrã aberto durante o envio\"" +STR_CAT_DISPLAY: "Ecrã" +STR_CAT_READER: "Leitor" +STR_CAT_CONTROLS: "Controlos" +STR_CAT_SYSTEM: "Sistema" +STR_SLEEP_SCREEN: "Ecrã de repouso" +STR_SLEEP_COVER_MODE: "Modo de capa do ecrã de repouso" +STR_HIDE_BATTERY: "Ocultar % da bateria" +STR_EXTRA_SPACING: "Espaço extra entre parágrafos" +STR_TEXT_AA: "Suavização do texto" +STR_SHORT_PWR_BTN: "Pressão curta do botão de energia" +STR_ORIENTATION: "Orientação de leitura" +STR_SIDE_BTN_LAYOUT: "Disposição dos botões laterais" +STR_LONG_PRESS_SKIP: "Saltar capítulo com pressão longa" +STR_FONT_FAMILY: "Tipo de letra do leitor" +STR_FONT_SIZE: "Tamanho da letra do leitor" +STR_LINE_SPACING: "Espaçamento entre linhas" +STR_SCREEN_MARGIN: "Margens do ecrã" +STR_PARA_ALIGNMENT: "Alinhamento do parágrafo" +STR_HYPHENATION: "Hifenização" +STR_TIME_TO_SLEEP: "Tempo até entrar em repouso" +STR_REFRESH_FREQ: "Frequência de atualização" +STR_KOREADER_SYNC: "Sincronização KOReader" +STR_CHECK_UPDATES: "Procurar atualizações" +STR_LANGUAGE: "Idioma" +STR_CLEAR_READING_CACHE: "Limpar cache de leitura" +STR_USERNAME: "Nome de utilizador" +STR_PASSWORD: "Palavra-passe" +STR_SYNC_SERVER_URL: "URL do servidor de sincronização" +STR_DOCUMENT_MATCHING: "Correspondência de documentos" +STR_AUTHENTICATE: "Autenticar" +STR_KOREADER_USERNAME: "Utilizador do KOReader" +STR_KOREADER_PASSWORD: "Palavra-passe do KOReader" +STR_FILENAME: "Nome do ficheiro" +STR_BINARY: "Binário" +STR_SET_CREDENTIALS_FIRST: "Defina primeiro as credenciais" +STR_WIFI_CONN_FAILED: "Falha na ligação Wi‑Fi" +STR_AUTHENTICATING: "A autenticar..." +STR_AUTH_SUCCESS: "Autenticação bem-sucedida!" +STR_KOREADER_AUTH: "Autenticação do KOReader" +STR_SYNC_READY: "A sincronização do KOReader está pronta a ser utilizada" +STR_AUTH_FAILED: "Falha na autenticação" +STR_DONE: "Concluído" +STR_CLEAR_CACHE_WARNING_1: "Isto irá limpar todos os dados de livros em cache." +STR_CLEAR_CACHE_WARNING_2: "Todo o progresso de leitura será perdido!" +STR_CLEAR_CACHE_WARNING_3: "Os livros terão de ser reindexados" +STR_CLEAR_CACHE_WARNING_4: "quando forem abertos novamente." +STR_CLEARING_CACHE: "A limpar cache..." +STR_CACHE_CLEARED: "Cache limpa" +STR_ITEMS_REMOVED: "itens removidos" +STR_FAILED_LOWER: "falhou" +STR_CLEAR_CACHE_FAILED: "Falha ao limpar a cache" +STR_CHECK_SERIAL_OUTPUT: "Verifique a saída série" +STR_DARK: "Escuro" +STR_LIGHT: "Claro" +STR_CUSTOM: "Personalizado" +STR_COVER: "Capa" +STR_NONE_OPT: "Nenhum" +STR_FIT: "Ajustar" +STR_CROP: "Recortar" +STR_NEVER: "Nunca" +STR_IN_READER: "No leitor" +STR_ALWAYS: "Sempre" +STR_IGNORE: "Ignorar" +STR_SLEEP: "Repouso" +STR_PAGE_TURN: "Virar página" +STR_PORTRAIT: "Retrato" +STR_LANDSCAPE_CW: "Paisagem H" +STR_INVERTED: "Invertido" +STR_LANDSCAPE_CCW: "Paisagem AH" +STR_PREV_NEXT: "Ant./Próx." +STR_NEXT_PREV: "Próx./Ant." +STR_BOOKERLY: "Bookerly" +STR_NOTO_SANS: "Noto Sans" +STR_OPEN_DYSLEXIC: "Open Dyslexic" +STR_SMALL: "Pequeno" +STR_MEDIUM: "Médio" +STR_LARGE: "Grande" +STR_X_LARGE: "Extra grande" +STR_TIGHT: "Apertado" +STR_NORMAL: "Normal" +STR_WIDE: "Largo" +STR_JUSTIFY: "Justificar" +STR_ALIGN_LEFT: "Esquerda" +STR_CENTER: "Centro" +STR_ALIGN_RIGHT: "Direita" +STR_MIN_1: "1 min" +STR_MIN_5: "5 min" +STR_MIN_10: "10 min" +STR_MIN_15: "15 min" +STR_MIN_30: "30 min" +STR_PAGES_1: "1 página" +STR_PAGES_5: "5 páginas" +STR_PAGES_10: "10 páginas" +STR_PAGES_15: "15 páginas" +STR_PAGES_30: "30 páginas" +STR_UPDATE: "Atualizar" +STR_CHECKING_UPDATE: "A procurar atualização..." +STR_NEW_UPDATE: "Nova atualização disponível!" +STR_CURRENT_VERSION: "Versão atual:" +STR_NEW_VERSION: "Nova versão:" +STR_UPDATING: "A atualizar..." +STR_NO_UPDATE: "Não há atualizações disponíveis" +STR_UPDATE_FAILED: "Falha na atualização" +STR_UPDATE_COMPLETE: "Atualização concluída" +STR_POWER_ON_HINT: "Prima continuamente o botão de energia para voltar a ligar" +STR_NO_ENTRIES: "Nenhuma entrada encontrada" +STR_DOWNLOADING: "A transferir..." +STR_DOWNLOAD_FAILED: "Falha na transferência" +STR_ERROR_MSG: "Erro:" +STR_UNNAMED: "Sem nome" +STR_NO_SERVER_URL: "Nenhum URL de servidor configurado" +STR_FETCH_FEED_FAILED: "Falha ao obter o feed" +STR_PARSE_FEED_FAILED: "Falha ao analisar o feed" +STR_NETWORK_PREFIX: "Rede:" +STR_IP_ADDRESS_PREFIX: "Endereço IP:" +STR_ERROR_GENERAL_FAILURE: "Erro: falha geral" +STR_ERROR_NETWORK_NOT_FOUND: "Erro: rede não encontrada" +STR_ERROR_CONNECTION_TIMEOUT: "Erro: tempo limite de ligação" +STR_SD_CARD: "Cartão SD" +STR_BACK: "« Voltar" +STR_EXIT: "« Sair" +STR_HOME: "« Início" +STR_SELECT: "Selecionar" +STR_TOGGLE: "Alternar" +STR_CONFIRM: "Confirmar" +STR_CANCEL: "Cancelar" +STR_CONNECT: "Ligar" +STR_OPEN: "Abrir" +STR_DOWNLOAD: "Transferir" +STR_RETRY: "Tentar de novo" +STR_YES: "Sim" +STR_NO: "Não" +STR_STATE_ON: "LIG." +STR_STATE_OFF: "DESL." +STR_NOT_SET: "Não definido" +STR_DIR_LEFT: "Esquerda" +STR_DIR_RIGHT: "Direita" +STR_DIR_UP: "Cima" +STR_DIR_DOWN: "Baixo" +STR_OK_BUTTON: "OK" +STR_SLEEP_COVER_FILTER: "Filtro da capa do ecrã de repouso" +STR_FILTER_CONTRAST: "Contraste" +STR_UI_THEME: "Tema da interface" +STR_THEME_CLASSIC: "Clássico" +STR_THEME_LYRA: "Lyra" +STR_THEME_LYRA_EXTENDED: "Lyra Extended" +STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol" +STR_REMAP_FRONT_BUTTONS: "Reatribuir botões frontais" +STR_OPDS_BROWSER: "Navegador OPDS" +STR_COVER_CUSTOM: "Capa + personalizado" +STR_MENU_RECENT_BOOKS: "Livros recentes" +STR_NO_RECENT_BOOKS: "Sem livros recentes" +STR_CALIBRE_DESC: "Usar transferências sem fios do Calibre" +STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a palavra-passe guardada?" +STR_FORGET_BUTTON: "Esquecer" +STR_CALIBRE_STARTING: "A iniciar o Calibre..." +STR_CALIBRE_SETUP: "Configuração" +STR_CALIBRE_STATUS: "Estado" +STR_CLEAR_BUTTON: "Limpar" +STR_DEFAULT_VALUE: "Predefinição" +STR_REMAP_PROMPT: "Prima um botão frontal para cada função" +STR_UNASSIGNED: "Não atribuído" +STR_ALREADY_ASSIGNED: "Já atribuído" +STR_REMAP_RESET_HINT: "Botão lateral superior: repor a disposição predefinida" +STR_REMAP_CANCEL_HINT: "Botão lateral inferior: cancelar reatribuição" +STR_HW_BACK_LABEL: "Voltar (1º botão)" +STR_HW_CONFIRM_LABEL: "Confirmar (2º botão)" +STR_HW_LEFT_LABEL: "Esquerda (3º botão)" +STR_HW_RIGHT_LABEL: "Direita (4º botão)" +STR_GO_TO_PERCENT: "Ir para %" +STR_GO_HOME_BUTTON: "Ir para o início" +STR_SYNC_PROGRESS: "Sincronizar progresso" +STR_DELETE_CACHE: "Eliminar cache do livro" +STR_DELETE: "Eliminar" +STR_CHAPTER_PREFIX: "Capítulo:" +STR_PAGES_SEPARATOR: "páginas |" +STR_BOOK_PREFIX: "Livro:" +STR_CALIBRE_URL_HINT: "No Calibre, adicione /opds ao seu URL" +STR_PERCENT_STEP_HINT: "Esq/Dir: 1% Cima/Baixo: 10%" +STR_SYNCING_TIME: "A sincronizar hora..." +STR_CALC_HASH: "A calcular hash do documento..." +STR_HASH_FAILED: "Falha ao calcular o hash do documento" +STR_FETCH_PROGRESS: "A obter progresso remoto..." +STR_UPLOAD_PROGRESS: "A enviar progresso..." +STR_NO_CREDENTIALS_MSG: "Não há credenciais configuradas" +STR_KOREADER_SETUP_HINT: "Configure a conta do KOReader nas Definições" +STR_PROGRESS_FOUND: "Progresso encontrado!" +STR_REMOTE_LABEL: "Remoto:" +STR_LOCAL_LABEL: "Local:" +STR_PAGE_OVERALL_FORMAT: "Página %d, %.2f%% do total" +STR_PAGE_TOTAL_OVERALL_FORMAT: "Página %d/%d, %.2f%% do total" +STR_DEVICE_FROM_FORMAT: "De: %s" +STR_APPLY_REMOTE: "Aplicar progresso remoto" +STR_UPLOAD_LOCAL: "Enviar progresso local" +STR_NO_REMOTE_MSG: "Nenhum progresso remoto encontrado" +STR_UPLOAD_PROMPT: "Enviar posição atual?" +STR_UPLOAD_SUCCESS: "Progresso enviado!" +STR_SYNC_FAILED_MSG: "Falha na sincronização" +STR_SECTION_PREFIX: "Secção" +STR_UPLOAD: "Enviar" +STR_BOOK_S_STYLE: "Estilo do livro" +STR_EMBEDDED_STYLE: "Estilo incorporado" +STR_OPDS_SERVER_URL: "URL do servidor OPDS" +STR_SCREENSHOT_BUTTON: "Capturar ecrã" +STR_SHOW_HIDDEN_FILES: "Mostrar itens ocultos" +STR_IMAGES: "Imagens" +STR_IMAGES_DISPLAY: "Mostrar" +STR_IMAGES_PLACEHOLDER: "Substituir" +STR_IMAGES_SUPPRESS: "Ocultar" +STR_SELECTED: "Selecionado" +STR_SHOW: "Mostrar" +STR_HIDE: "Ocultar" +STR_CUSTOMISE_STATUS_BAR: "Customizar barra estado" +STR_CHAPTER_PAGE_COUNT: "Págs. no cap." +STR_BOOK_PROGRESS_PERCENTAGE: "% do livro" +STR_PROGRESS_BAR: "Barra progresso" +STR_PROGRESS_BAR_THICKNESS: "Espessura Barra progresso" +STR_PROGRESS_BAR_THIN: "Fina" +STR_PROGRESS_BAR_MEDIUM: "Média" +STR_PROGRESS_BAR_THICK: "Grossa" +STR_BOOK: "Livro" +STR_CHAPTER: "Capítulo" +STR_EXAMPLE_CHAPTER: "Capítulo 21" +STR_EXAMPLE_BOOK: "Título do livro" +STR_PREVIEW: "Pré-visualizar" +STR_TITLE: "Título" +STR_BATTERY: "Bateria" +STR_DISPLAY_QR: "Mostrar como QR" +STR_LINK: "[ligação]" +STR_FOOTNOTES: "Notas rodapé" +STR_NO_FOOTNOTES: "Sem notas rodapé na pág." +STR_AUTO_TURN_ENABLED: "Auto-virar ligado: " +STR_AUTO_TURN_PAGES_PER_MIN: "Auto-virar (Págs./min)" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml new file mode 100644 index 00000000..195a5ac0 --- /dev/null +++ b/lib/I18n/translations/slovenian.yaml @@ -0,0 +1,292 @@ +_language_name: "Slovenščina" +_language_code: "SI" +_order: "21" + +STR_CROSSPOINT: "CrossPoint" +STR_BOOTING: "ZAGON" +STR_SLEEPING: "SPANJE" +STR_ENTERING_SLEEP: "Prehajanje v spanje" +STR_BROWSE_FILES: "Prebrskaj datoteke" +STR_FILE_TRANSFER: "Prenos datotek" +STR_SETTINGS_TITLE: "Nastavitve" +STR_CONTINUE_READING: "Nadaljuj z branjem" +STR_NO_OPEN_BOOK: "Ni odprte knjige" +STR_START_READING: "Začni brati spodaj" +STR_NO_FILES_FOUND: "Ni najdenih datotek" +STR_SELECT_CHAPTER: "Izberi poglavje" +STR_NO_CHAPTERS: "Ni poglavij" +STR_END_OF_BOOK: "Konec knjige" +STR_EMPTY_CHAPTER: "Prazno poglavje" +STR_INDEXING: "Indeksiranje" +STR_MEMORY_ERROR: "Napaka pomnilnika" +STR_PAGE_LOAD_ERROR: "Napaka pri nalaganju strani" +STR_EMPTY_FILE: "Prazna datoteka" +STR_OUT_OF_BOUNDS: "Izven meja" +STR_LOADING: "Nalaganje..." +STR_LOADING_POPUP: "Nalaganje" +STR_WIFI_NETWORKS: "WiFi omrežja" +STR_NO_NETWORKS: "Ni najdenih omrežij" +STR_NETWORKS_FOUND: "Najdenih omrežij: %zu" +STR_SCANNING: "Iskanje..." +STR_CONNECTING: "Povezovanje..." +STR_CONNECTED: "Povezano!" +STR_CONNECTION_FAILED: "Povezava ni uspela" +STR_FORGET_NETWORK: "Pozabi omrežje?" +STR_SAVE_PASSWORD: "Shranim geslo za naslednjič?" +STR_PRESS_OK_SCAN: "Pritisni OK za ponovno iskanje" +STR_JOIN_NETWORK: "Poveži se v omrežje" +STR_CREATE_HOTSPOT: "Ustvari dostopno točko" +STR_JOIN_DESC: "Poveži se v obstoječe WiFi omrežje" +STR_HOTSPOT_DESC: "Ustvari WiFi omrežje, v katerega se lahko povežejo drugi" +STR_STARTING_HOTSPOT: "Zaganjanje dostopne točke..." +STR_HOTSPOT_MODE: "Način dostopne točke" +STR_CONNECT_WIFI_HINT: "Poveži svojo napravo v to WiFi omrežje" +STR_OPEN_URL_HINT: "Odpri ta URL v svojem brskalniku" +STR_OR_HTTP_PREFIX: "ali http://" +STR_SCAN_QR_HINT: "ali skeniraj QR kodo s telefonom:" +STR_CALIBRE_WIRELESS: "Brezžični Calibre" +STR_CALIBRE_WEB_URL: "Calibre Web URL" +STR_NETWORK_LEGEND: "* = Šifrirano | + = Shranjeno" +STR_MAC_ADDRESS: "MAC naslov:" +STR_CHECKING_WIFI: "Preverjanje WiFi-ja..." +STR_ENTER_WIFI_PASSWORD: "Vnesi WiFi geslo" +STR_TO_PREFIX: "v " +STR_CALIBRE_RECEIVING: "Prejemanje: " +STR_CALIBRE_RECEIVED: "Prejeto: " +STR_CALIBRE_INSTRUCTION_1: "1) Namesti vtičnik CrossPoint Reader" +STR_CALIBRE_INSTRUCTION_2: "2) Bodi v istem WiFi omrežju" +STR_CALIBRE_INSTRUCTION_3: "3) V Calibre: \"Pošlji v napravo\"" +STR_CALIBRE_INSTRUCTION_4: "\"Med pošiljanjem pusti ta zaslon odprt\"" +STR_CAT_DISPLAY: "Zaslon" +STR_CAT_READER: "Bralnik" +STR_CAT_CONTROLS: "Kontrole" +STR_CAT_SYSTEM: "Sistem" +STR_SLEEP_SCREEN: "Zaslon za spanje" +STR_SLEEP_COVER_MODE: "Način naslovnice v spanju" +STR_HIDE_BATTERY: "Skrij % baterije" +STR_EXTRA_SPACING: "Dodaten razmik med odstavki" +STR_TEXT_AA: "Glajenje besedila (AA)" +STR_IMAGES: "Slike" +STR_IMAGES_DISPLAY: "Prikaži" +STR_IMAGES_PLACEHOLDER: "Oznaka mesta" +STR_IMAGES_SUPPRESS: "Zatdi" +STR_SHORT_PWR_BTN: "Kratek pritisk na gumb za vklop" +STR_ORIENTATION: "Orientacija branja" +STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov" +STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja" +STR_FONT_FAMILY: "Pisava bralnika" +STR_FONT_SIZE: "Velikost pisave" +STR_LINE_SPACING: "Razmik med vrsticami" +STR_SCREEN_MARGIN: "Robovi zaslona" +STR_PARA_ALIGNMENT: "Poravnava odstavkov" +STR_HYPHENATION: "Deljenje besed" +STR_TIME_TO_SLEEP: "Čas do spanja" +STR_SHOW_HIDDEN_FILES: "Prikaži skrite datoteke" +STR_REFRESH_FREQ: "Pogostost osveževanja" +STR_KOREADER_SYNC: "KOReader sinhronizacija" +STR_CHECK_UPDATES: "Preveri posodobitve" +STR_LANGUAGE: "Jezik" +STR_CLEAR_READING_CACHE: "Počisti predpomnilnik branja" +STR_USERNAME: "Uporabniško ime" +STR_PASSWORD: "Geslo" +STR_SYNC_SERVER_URL: "URL strežnika za sinhronizacijo" +STR_DOCUMENT_MATCHING: "Ujemanje dokumentov" +STR_AUTHENTICATE: "Avtentikacija" +STR_KOREADER_USERNAME: "KOReader uporabnik" +STR_KOREADER_PASSWORD: "KOReader geslo" +STR_FILENAME: "Ime datoteke" +STR_BINARY: "Binarno" +STR_SET_CREDENTIALS_FIRST: "Najprej nastavi podatke za prijavo" +STR_WIFI_CONN_FAILED: "WiFi povezava ni uspela" +STR_AUTHENTICATING: "Preverjanje..." +STR_AUTH_SUCCESS: "Uspešna prijava!" +STR_KOREADER_AUTH: "KOReader avtentikacija" +STR_SYNC_READY: "KOReader sinhronizacija je pripravljena" +STR_AUTH_FAILED: "Prijava ni uspela" +STR_DONE: "Končano" +STR_CLEAR_CACHE_WARNING_1: "To bo izbrisalo vse predpomnjene podatke o knjigah." +STR_CLEAR_CACHE_WARNING_2: "Ves napredek pri branju bo izgubljen!" +STR_CLEAR_CACHE_WARNING_3: "Knjige bo treba ob ponovnem odpiranju" +STR_CLEAR_CACHE_WARNING_4: "ponovno indeksirati." +STR_CLEARING_CACHE: "Čiščenje predpomnilnika..." +STR_CACHE_CLEARED: "Predpomnilnik očiščen" +STR_ITEMS_REMOVED: "elementov odstranjenih" +STR_FAILED_LOWER: "ni uspelo" +STR_CLEAR_CACHE_FAILED: "Čiščenje predpomnilnika ni uspelo" +STR_CHECK_SERIAL_OUTPUT: "Za podrobnosti preveri serijski izhod" +STR_DARK: "Temno" +STR_LIGHT: "Svetlo" +STR_CUSTOM: "Po meri" +STR_COVER: "Naslovnica" +STR_NONE_OPT: "Brez" +STR_FIT: "Prilagodi" +STR_CROP: "Obreži" +STR_NEVER: "Nikoli" +STR_IN_READER: "V bralniku" +STR_ALWAYS: "Vedno" +STR_IGNORE: "Prezri" +STR_SLEEP: "Spanje" +STR_PAGE_TURN: "Obračanje strani" +STR_PORTRAIT: "Pokončno" +STR_LANDSCAPE_CW: "Ležeče (v smeri urinega kazalca)" +STR_INVERTED: "Obrnjeno" +STR_LANDSCAPE_CCW: "Ležeče (proti smeri urinega kazalca)" +STR_PREV_NEXT: "Nazaj/Naprej" +STR_NEXT_PREV: "Naprej/Nazaj" +STR_BOOKERLY: "Bookerly" +STR_NOTO_SANS: "Noto Sans" +STR_OPEN_DYSLEXIC: "Open Dyslexic" +STR_SMALL: "Majhno" +STR_MEDIUM: "Srednje" +STR_LARGE: "Veliko" +STR_X_LARGE: "Zelo veliko" +STR_TIGHT: "Tesno" +STR_NORMAL: "Normalno" +STR_WIDE: "Široko" +STR_JUSTIFY: "Obojestransko" +STR_ALIGN_LEFT: "Levo" +STR_CENTER: "Sredinsko" +STR_ALIGN_RIGHT: "Desno" +STR_MIN_1: "1 min" +STR_MIN_5: "5 min" +STR_MIN_10: "10 min" +STR_MIN_15: "15 min" +STR_MIN_30: "30 min" +STR_PAGES_1: "1 stran" +STR_PAGES_5: "5 strani" +STR_PAGES_10: "10 strani" +STR_PAGES_15: "15 strani" +STR_PAGES_30: "30 strani" +STR_UPDATE: "Posodobi" +STR_CHECKING_UPDATE: "Preverjanje posodobitev..." +STR_NEW_UPDATE: "Na voljo je nova posodobitev!" +STR_CURRENT_VERSION: "Trenutna različica: " +STR_NEW_VERSION: "Nova različica: " +STR_UPDATING: "Posodabljanje..." +STR_NO_UPDATE: "Ni novih posodobitev" +STR_UPDATE_FAILED: "Posodobitev ni uspela" +STR_UPDATE_COMPLETE: "Posodobitev končana" +STR_POWER_ON_HINT: "Pridrži gumb za vklop, da napravo znova vklopiš" +STR_NO_ENTRIES: "Ni najdenih vnosov" +STR_DOWNLOADING: "Prenašanje..." +STR_DOWNLOAD_FAILED: "Prenos ni uspel" +STR_ERROR_MSG: "Napaka:" +STR_UNNAMED: "Neimenovano" +STR_NO_SERVER_URL: "URL strežnika ni nastavljen" +STR_FETCH_FEED_FAILED: "Nalaganje vira ni uspelo" +STR_PARSE_FEED_FAILED: "Razčlenjevanje vira ni uspelo" +STR_NETWORK_PREFIX: "Omrežje: " +STR_IP_ADDRESS_PREFIX: "IP naslov: " +STR_ERROR_GENERAL_FAILURE: "Napaka: Splošna napaka" +STR_ERROR_NETWORK_NOT_FOUND: "Napaka: Omrežje ni najdeno" +STR_ERROR_CONNECTION_TIMEOUT: "Napaka: Časovna omejitev povezave" +STR_SD_CARD: "SD kartica" +STR_BACK: "« Nazaj" +STR_EXIT: "« Izhod" +STR_HOME: "« Domov" +STR_SELECT: "Izberi" +STR_SELECTED: "Izbrano" +STR_TOGGLE: "Preklopi" +STR_CONFIRM: "Potrdi" +STR_CANCEL: "Prekliči" +STR_CONNECT: "Poveži" +STR_OPEN: "Odpri" +STR_DOWNLOAD: "Prenesi" +STR_RETRY: "Poskusi znova" +STR_YES: "Da" +STR_NO: "Ne" +STR_SHOW: "Prikaži" +STR_HIDE: "Skrij" +STR_STATE_ON: "VKLOP" +STR_STATE_OFF: "IZKLOP" +STR_NOT_SET: "Ni nastavljeno" +STR_DIR_LEFT: "Levo" +STR_DIR_RIGHT: "Desno" +STR_DIR_UP: "Gor" +STR_DIR_DOWN: "Dol" +STR_OK_BUTTON: "V redu" +STR_SLEEP_COVER_FILTER: "Filter naslovnice v spanju" +STR_FILTER_CONTRAST: "Kontrast" +STR_CUSTOMISE_STATUS_BAR: "Prilagodi vrstico stanja" +STR_CHAPTER_PAGE_COUNT: "Število strani v poglavju" +STR_BOOK_PROGRESS_PERCENTAGE: "Odstotek napredka v knjigi" +STR_PROGRESS_BAR: "Vrstica napredka" +STR_PROGRESS_BAR_THICKNESS: "Debelina vrstice napredka" +STR_PROGRESS_BAR_THIN: "Tanko" +STR_PROGRESS_BAR_MEDIUM: "Srednje" +STR_PROGRESS_BAR_THICK: "Debelo" +STR_BOOK: "Knjiga" +STR_CHAPTER: "Poglavje" +STR_EXAMPLE_CHAPTER: "Poglavje 21" +STR_EXAMPLE_BOOK: "Naslov knjige" +STR_PREVIEW: "Predogled" +STR_TITLE: "Naslov" +STR_BATTERY: "Baterija" +STR_UI_THEME: "Tema uporabniškega vmesnika" +STR_THEME_CLASSIC: "Klasična" +STR_THEME_LYRA: "Lyra" +STR_THEME_LYRA_EXTENDED: "Lyra razširjena" +STR_SUNLIGHT_FADING_FIX: "Popravek bledenja na soncu" +STR_REMAP_FRONT_BUTTONS: "Prenastavi sprednje gumbe" +STR_OPDS_BROWSER: "OPDS brskalnik" +STR_COVER_CUSTOM: "Naslovnica + po meri" +STR_MENU_RECENT_BOOKS: "Zadnje knjige" +STR_NO_RECENT_BOOKS: "Ni zadnjih knjig" +STR_CALIBRE_DESC: "Uporabi brezžični prenos Calibre" +STR_FORGET_AND_REMOVE: "Pozabi omrežje in odstrani shranjeno geslo?" +STR_FORGET_BUTTON: "Pozabi" +STR_CALIBRE_STARTING: "Zaganjanje Calibre..." +STR_CALIBRE_SETUP: "Nastavitev" +STR_CALIBRE_STATUS: "Stanje" +STR_CLEAR_BUTTON: "Počisti" +STR_DEFAULT_VALUE: "Privzeto" +STR_REMAP_PROMPT: "Pritisni sprednji gumb za vsako vlogo" +STR_UNASSIGNED: "Nedodeljeno" +STR_ALREADY_ASSIGNED: "Že dodeljeno" +STR_REMAP_RESET_HINT: "Stranski gumb gor: Ponastavi na privzeto" +STR_REMAP_CANCEL_HINT: "Stranski gumb dol: Prekliči nastavljanje" +STR_HW_BACK_LABEL: "Nazaj (1. gumb)" +STR_HW_CONFIRM_LABEL: "Potrdi (2. gumb)" +STR_HW_LEFT_LABEL: "Levo (3. gumb)" +STR_HW_RIGHT_LABEL: "Desno (4. gumb)" +STR_GO_TO_PERCENT: "Pojdi na %" +STR_GO_HOME_BUTTON: "Pojdi domov" +STR_SYNC_PROGRESS: "Sinhroniziraj napredek" +STR_DELETE_CACHE: "Izbriši predpomnilnik knjige" +STR_DELETE: "Izbriši" +STR_DISPLAY_QR: "Prikaži stran kot QR" +STR_CHAPTER_PREFIX: "Poglavje: " +STR_PAGES_SEPARATOR: " strani | " +STR_BOOK_PREFIX: "Knjiga: " +STR_CALIBRE_URL_HINT: "Za Calibre dodaj /opds svojemu URL-ju" +STR_PERCENT_STEP_HINT: "Levo/desno: 1% Gor/dol: 10%" +STR_SYNCING_TIME: "Sinhronizacija časa..." +STR_CALC_HASH: "Izračunavanje podpisa dokumenta..." +STR_HASH_FAILED: "Izračun podpisa dokumenta ni uspel" +STR_FETCH_PROGRESS: "Pridobivanje napredka iz oblaka..." +STR_UPLOAD_PROGRESS: "Nalaganje napredka..." +STR_NO_CREDENTIALS_MSG: "Podatki za prijavo niso nastavljeni" +STR_KOREADER_SETUP_HINT: "Nastavi KOReader račun v nastavitvah" +STR_PROGRESS_FOUND: "Najden napredek!" +STR_REMOTE_LABEL: "Oddaljeno:" +STR_LOCAL_LABEL: "Lokalno:" +STR_PAGE_OVERALL_FORMAT: "Stran %d, %.2f%% skupno" +STR_PAGE_TOTAL_OVERALL_FORMAT: "Stran %d/%d, %.2f%% skupno" +STR_DEVICE_FROM_FORMAT: " Iz: %s" +STR_APPLY_REMOTE: "Uporabi oddaljen napredek" +STR_UPLOAD_LOCAL: "Naloži lokalni napredek" +STR_NO_REMOTE_MSG: "Oddaljen napredek ni bil najden" +STR_UPLOAD_PROMPT: "Naložim trenutno pozicijo?" +STR_UPLOAD_SUCCESS: "Napredek naložen!" +STR_SYNC_FAILED_MSG: "Sinhronizacija ni uspela" +STR_SECTION_PREFIX: "Razdelek " +STR_UPLOAD: "Naloži" +STR_BOOK_S_STYLE: "Slog knjige" +STR_EMBEDDED_STYLE: "Vgrajen slog" +STR_OPDS_SERVER_URL: "URL OPDS strežnika" +STR_FOOTNOTES: "Opombe" +STR_NO_FOOTNOTES: "Na tej strani ni opomb" +STR_LINK: "[povezava]" +STR_SCREENSHOT_BUTTON: "Naredi posnetek zaslona" +STR_AUTO_TURN_ENABLED: "Samodejno obračanje: " +STR_AUTO_TURN_PAGES_PER_MIN: "Samodejno obračanje (strani na minuto)" diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 4b87d632..0dd78727 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -2,22 +2,15 @@ #include #include +#include #include -#include #include #include +#include #include "BitmapHelpers.h" -// Context structure for picojpeg callback -struct JpegReadContext { - FsFile& file; - uint8_t buffer[512]; - size_t bufferPos; - size_t bufferFilled; -}; - // ============================================================================ // IMAGE PROCESSING OPTIONS - Toggle these to test different configurations // ============================================================================ @@ -165,103 +158,292 @@ static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) } } -// Callback function for picojpeg to read JPEG data -unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size, - unsigned char* pBytes_actually_read, void* pCallback_data) { - auto* context = static_cast(pCallback_data); +namespace { - if (!context || !context->file) { - return PJPG_STREAM_READ_ERROR; +// Max MCU height supported by any JPEG (4:2:0 chroma = 16 rows, 4:4:4 = 8 rows) +constexpr int MAX_MCU_HEIGHT = 16; +constexpr size_t JPEG_DECODER_SIZE = 20 * 1024; +constexpr size_t MIN_FREE_HEAP = JPEG_DECODER_SIZE + 32 * 1024; + +// Static file pointer for JPEGDEC open callback. +// Safe in single-threaded embedded context; never accessed concurrently. +static FsFile* s_jpegFile = nullptr; + +void* bmpJpegOpen(const char* /*filename*/, int32_t* size) { + if (!s_jpegFile || !*s_jpegFile) return nullptr; + s_jpegFile->seek(0); + *size = static_cast(s_jpegFile->size()); + return s_jpegFile; +} + +void bmpJpegClose(void* /*handle*/) { + // Caller owns the file — do not close it here +} + +int32_t bmpJpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) { + auto* f = reinterpret_cast(pFile->fHandle); + if (!f) return 0; + int32_t n = f->read(pBuf, len); + if (n < 0) n = 0; + pFile->iPos += n; + return n; +} + +int32_t bmpJpegSeek(JPEGFILE* pFile, int32_t pos) { + auto* f = reinterpret_cast(pFile->fHandle); + if (!f || !f->seek(pos)) return -1; + pFile->iPos = pos; + return pos; +} + +// Context passed to the JPEGDEC draw callback via setUserPointer() +struct BmpConvertCtx { + Print* bmpOut; + int srcWidth; + int srcHeight; + int outWidth; + int outHeight; + bool oneBit; + int bytesPerRow; + bool needsScaling; + uint32_t scaleX_fp; // source pixels per output pixel, 16.16 fixed-point + uint32_t scaleY_fp; + + // Accumulates one MCU row (up to MAX_MCU_HEIGHT source rows × srcWidth pixels) + // Filled column-by-column as JPEGDEC callbacks arrive for the same MCU row + uint8_t* mcuBuf; + + // Y-axis area averaging accumulators (needsScaling only) + int currentOutY; + uint32_t nextOutY_srcStart; // 16.16 fixed-point boundary for the next output row + uint32_t* rowAccum; + uint32_t* rowCount; + + uint8_t* bmpRow; + + AtkinsonDitherer* atkinsonDitherer; + FloydSteinbergDitherer* fsDitherer; + Atkinson1BitDitherer* atkinson1BitDitherer; + + bool error; +}; + +// Write a fully-assembled output row (grayscale bytes, length outWidth) to BMP +static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) { + memset(ctx->bmpRow, 0, ctx->bytesPerRow); + + if (USE_8BIT_OUTPUT && !ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + ctx->bmpRow[x] = adjustPixel(srcRow[x]); + } + } else if (ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(srcRow[x], x) + : quantize1bit(srcRow[x], x, outY); + ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8))); + } + if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); + } else { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = adjustPixel(srcRow[x]); + uint8_t twoBit; + if (ctx->atkinsonDitherer) { + twoBit = ctx->atkinsonDitherer->processPixel(gray, x); + } else if (ctx->fsDitherer) { + twoBit = ctx->fsDitherer->processPixel(gray, x); + } else { + twoBit = quantize(gray, x, outY); + } + ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); + } + if (ctx->atkinsonDitherer) + ctx->atkinsonDitherer->nextRow(); + else if (ctx->fsDitherer) + ctx->fsDitherer->nextRow(); } - // Check if we need to refill our context buffer - if (context->bufferPos >= context->bufferFilled) { - context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer)); - context->bufferPos = 0; + ctx->bmpOut->write(ctx->bmpRow, ctx->bytesPerRow); +} - if (context->bufferFilled == 0) { - // EOF or error - *pBytes_actually_read = 0; - return 0; // Success (EOF is normal) +// Flush one scaled output row from Y-axis accumulators and advance currentOutY +static void flushScaledRow(BmpConvertCtx* ctx) { + memset(ctx->bmpRow, 0, ctx->bytesPerRow); + + if (USE_8BIT_OUTPUT && !ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; + ctx->bmpRow[x] = adjustPixel(gray); + } + } else if (ctx->oneBit) { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = (ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0; + const uint8_t bit = ctx->atkinson1BitDitherer ? ctx->atkinson1BitDitherer->processPixel(gray, x) + : quantize1bit(gray, x, ctx->currentOutY); + ctx->bmpRow[x / 8] |= (bit << (7 - (x % 8))); + } + if (ctx->atkinson1BitDitherer) ctx->atkinson1BitDitherer->nextRow(); + } else { + for (int x = 0; x < ctx->outWidth; x++) { + const uint8_t gray = adjustPixel((ctx->rowCount[x] > 0) ? (ctx->rowAccum[x] / ctx->rowCount[x]) : 0); + uint8_t twoBit; + if (ctx->atkinsonDitherer) { + twoBit = ctx->atkinsonDitherer->processPixel(gray, x); + } else if (ctx->fsDitherer) { + twoBit = ctx->fsDitherer->processPixel(gray, x); + } else { + twoBit = quantize(gray, x, ctx->currentOutY); + } + ctx->bmpRow[(x * 2) / 8] |= (twoBit << (6 - ((x * 2) % 8))); + } + if (ctx->atkinsonDitherer) + ctx->atkinsonDitherer->nextRow(); + else if (ctx->fsDitherer) + ctx->fsDitherer->nextRow(); + } + + ctx->bmpOut->write(ctx->bmpRow, ctx->bytesPerRow); + ctx->currentOutY++; +} + +// JPEGDEC draw callback — receives one MCU-width × MCU-height block at a time, +// in left-to-right, top-to-bottom order (baseline JPEG). +// Accumulates columns into mcuBuf; once the last column arrives (completing the MCU +// row), applies scaling + dithering and writes packed BMP rows to bmpOut. +int bmpDrawCallback(JPEGDRAW* pDraw) { + auto* ctx = reinterpret_cast(pDraw->pUser); + if (!ctx || ctx->error) return 0; + + const uint8_t* pixels = reinterpret_cast(pDraw->pPixels); + const int stride = pDraw->iWidth; + const int validW = pDraw->iWidthUsed; + const int blockH = pDraw->iHeight; + const int blockX = pDraw->x; + const int blockY = pDraw->y; + + // Copy block pixels into MCU row buffer + for (int r = 0; r < blockH && r < MAX_MCU_HEIGHT; r++) { + const int copyW = (blockX + validW <= ctx->srcWidth) ? validW : (ctx->srcWidth - blockX); + if (copyW <= 0) continue; + memcpy(ctx->mcuBuf + r * ctx->srcWidth + blockX, pixels + r * stride, copyW); + } + + // Wait for the last MCU column before processing any rows + if (blockX + validW < ctx->srcWidth) return 1; + + // Process each complete source row in this MCU row + const int endRow = blockY + blockH; + + for (int y = blockY; y < endRow && y < ctx->srcHeight; y++) { + const uint8_t* srcRow = ctx->mcuBuf + (y - blockY) * ctx->srcWidth; + + if (!ctx->needsScaling) { + // 1:1 — outWidth == srcWidth, write directly + writeOutputRow(ctx, srcRow, y); + } else { + // Fixed-point area averaging on X axis + for (int outX = 0; outX < ctx->outWidth; outX++) { + const int srcXStart = (static_cast(outX) * ctx->scaleX_fp) >> 16; + const int srcXEnd = (static_cast(outX + 1) * ctx->scaleX_fp) >> 16; + int sum = 0; + int count = 0; + for (int srcX = srcXStart; srcX < srcXEnd && srcX < ctx->srcWidth; srcX++) { + sum += srcRow[srcX]; + count++; + } + if (count == 0 && srcXStart < ctx->srcWidth) { + sum = srcRow[srcXStart]; + count = 1; + } + ctx->rowAccum[outX] += sum; + ctx->rowCount[outX] += count; + } + + // Flush output row(s) whose Y boundary we've crossed + const uint32_t srcY_fp = static_cast(y + 1) << 16; + while (srcY_fp >= ctx->nextOutY_srcStart && ctx->currentOutY < ctx->outHeight) { + flushScaledRow(ctx); + ctx->nextOutY_srcStart = static_cast(ctx->currentOutY + 1) * ctx->scaleY_fp; + if (srcY_fp >= ctx->nextOutY_srcStart) continue; + memset(ctx->rowAccum, 0, ctx->outWidth * sizeof(uint32_t)); + memset(ctx->rowCount, 0, ctx->outWidth * sizeof(uint32_t)); + } } } - // Copy available bytes to picojpeg's buffer - const size_t available = context->bufferFilled - context->bufferPos; - const size_t toRead = available < buf_size ? available : buf_size; - - memcpy(pBuf, context->buffer + context->bufferPos, toRead); - context->bufferPos += toRead; - *pBytes_actually_read = static_cast(toRead); - - return 0; // Success + return ctx->error ? 0 : 1; } +} // namespace + // Internal implementation with configurable target size and bit depth bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit, bool crop) { LOG_DBG("JPG", "Converting JPEG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight); - // Setup context for picojpeg callback - JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; - - // Initialize picojpeg decoder - pjpeg_image_info_t imageInfo; - const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); - if (status != 0) { - LOG_ERR("JPG", "JPEG decode init failed with error code: %d", status); + if (ESP.getFreeHeap() < MIN_FREE_HEAP) { + LOG_ERR("JPG", "Not enough heap for JPEG decoder (%u free, need %u)", ESP.getFreeHeap(), MIN_FREE_HEAP); return false; } - LOG_DBG("JPG", "JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d", imageInfo.m_width, imageInfo.m_height, - imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); + s_jpegFile = &jpegFile; + + JPEGDEC* jpeg = new (std::nothrow) JPEGDEC(); + if (!jpeg) { + LOG_ERR("JPG", "Failed to allocate JPEG decoder"); + return false; + } + + int rc = jpeg->open("", bmpJpegOpen, bmpJpegClose, bmpJpegRead, bmpJpegSeek, bmpDrawCallback); + if (rc != 1) { + LOG_ERR("JPG", "JPEG open failed (err=%d)", jpeg->getLastError()); + delete jpeg; + return false; + } + + const int srcWidth = jpeg->getWidth(); + const int srcHeight = jpeg->getHeight(); + + LOG_DBG("JPG", "JPEG dimensions: %dx%d", srcWidth, srcHeight); - // Safety limits to prevent memory issues on ESP32 constexpr int MAX_IMAGE_WIDTH = 2048; constexpr int MAX_IMAGE_HEIGHT = 3072; - constexpr int MAX_MCU_ROW_BYTES = 65536; - if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) { - LOG_DBG("JPG", "Image too large (%dx%d), max supported: %dx%d", imageInfo.m_width, imageInfo.m_height, - MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); + if (srcWidth <= 0 || srcHeight <= 0 || srcWidth > MAX_IMAGE_WIDTH || srcHeight > MAX_IMAGE_HEIGHT) { + LOG_DBG("JPG", "Image too large or invalid (%dx%d), max supported: %dx%d", srcWidth, srcHeight, MAX_IMAGE_WIDTH, + MAX_IMAGE_HEIGHT); + jpeg->close(); + delete jpeg; return false; } // Calculate output dimensions (pre-scale to fit display exactly) - int outWidth = imageInfo.m_width; - int outHeight = imageInfo.m_height; - // Use fixed-point scaling (16.16) for sub-pixel accuracy + int outWidth = srcWidth; + int outHeight = srcHeight; uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point uint32_t scaleY_fp = 65536; bool needsScaling = false; - if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width != targetWidth || imageInfo.m_height != targetHeight)) { - // Calculate scale to fit/fill target dimensions while maintaining aspect ratio - const float scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; - const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; - // We scale to the smaller dimension, so we can potentially crop later. - float scale = 1.0; - if (crop) { // if we will crop, scale to the smaller dimension + if (targetWidth > 0 && targetHeight > 0 && (srcWidth != targetWidth || srcHeight != targetHeight)) { + const float scaleToFitWidth = static_cast(targetWidth) / srcWidth; + const float scaleToFitHeight = static_cast(targetHeight) / srcHeight; + float scale = 1.0f; + if (crop) { scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; - } else { // else, scale to the larger dimension to fit + } else { scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; } - outWidth = static_cast(imageInfo.m_width * scale); - outHeight = static_cast(imageInfo.m_height * scale); - - // Ensure at least 1 pixel + outWidth = static_cast(srcWidth * scale); + outHeight = static_cast(srcHeight * scale); if (outWidth < 1) outWidth = 1; if (outHeight < 1) outHeight = 1; - // Calculate fixed-point scale factors (source pixels per output pixel) - // scaleX_fp = (srcWidth << 16) / outWidth - scaleX_fp = (static_cast(imageInfo.m_width) << 16) / outWidth; - scaleY_fp = (static_cast(imageInfo.m_height) << 16) / outHeight; + scaleX_fp = (static_cast(srcWidth) << 16) / outWidth; + scaleY_fp = (static_cast(srcHeight) << 16) / outHeight; needsScaling = true; - LOG_DBG("JPG", "Scaling %dx%d -> %dx%d (target %dx%d)", imageInfo.m_width, imageInfo.m_height, outWidth, outHeight, - targetWidth, targetHeight); + LOG_DBG("JPG", "Scaling %dx%d -> %dx%d (target %dx%d)", srcWidth, srcHeight, outWidth, outHeight, targetWidth, + targetHeight); } // Write BMP header with output dimensions @@ -271,285 +453,84 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm bytesPerRow = (outWidth + 3) / 4 * 4; } else if (oneBit) { writeBmpHeader1bit(bmpOut, outWidth, outHeight); - bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel + bytesPerRow = (outWidth + 31) / 32 * 4; } else { writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; } - uint8_t* rowBuffer = nullptr; - uint8_t* mcuRowBuffer = nullptr; - AtkinsonDitherer* atkinsonDitherer = nullptr; - FloydSteinbergDitherer* fsDitherer = nullptr; - Atkinson1BitDitherer* atkinson1BitDitherer = nullptr; - uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums) - uint32_t* rowCount = nullptr; // Count of source pixels accumulated per output X + BmpConvertCtx ctx = {}; + ctx.bmpOut = &bmpOut; + ctx.srcWidth = srcWidth; + ctx.srcHeight = srcHeight; + ctx.outWidth = outWidth; + ctx.outHeight = outHeight; + ctx.oneBit = oneBit; + ctx.bytesPerRow = bytesPerRow; + ctx.needsScaling = needsScaling; + ctx.scaleX_fp = scaleX_fp; + ctx.scaleY_fp = scaleY_fp; + ctx.error = false; - // RAII guard: frees all heap resources on any return path, including early exits. - // Holds references so it always sees the latest pointer values assigned below. + // RAII guard: frees all heap resources on any return path struct Cleanup { - uint8_t*& rowBuffer; - uint8_t*& mcuRowBuffer; - AtkinsonDitherer*& atkinsonDitherer; - FloydSteinbergDitherer*& fsDitherer; - Atkinson1BitDitherer*& atkinson1BitDitherer; - uint32_t*& rowAccum; - uint32_t*& rowCount; + BmpConvertCtx& ctx; + JPEGDEC* jpeg; ~Cleanup() { - delete[] rowAccum; - delete[] rowCount; - delete atkinsonDitherer; - delete fsDitherer; - delete atkinson1BitDitherer; - free(mcuRowBuffer); - free(rowBuffer); + delete[] ctx.rowAccum; + delete[] ctx.rowCount; + delete ctx.atkinsonDitherer; + delete ctx.fsDitherer; + delete ctx.atkinson1BitDitherer; + free(ctx.mcuBuf); + free(ctx.bmpRow); + jpeg->close(); + delete jpeg; } - } cleanup{rowBuffer, mcuRowBuffer, atkinsonDitherer, fsDitherer, atkinson1BitDitherer, rowAccum, rowCount}; + } cleanup{ctx, jpeg}; - // Allocate row buffer - rowBuffer = static_cast(malloc(bytesPerRow)); - if (!rowBuffer) { - LOG_ERR("JPG", "Failed to allocate row buffer"); + // MCU row buffer: MAX_MCU_HEIGHT rows × srcWidth columns of grayscale + ctx.mcuBuf = static_cast(malloc(MAX_MCU_HEIGHT * srcWidth)); + if (!ctx.mcuBuf) { + LOG_ERR("JPG", "Failed to allocate MCU buffer (%d bytes)", MAX_MCU_HEIGHT * srcWidth); return false; } + memset(ctx.mcuBuf, 0, MAX_MCU_HEIGHT * srcWidth); - // Allocate a buffer for one MCU row worth of grayscale pixels - // This is the minimal memory needed for streaming conversion - const int mcuPixelHeight = imageInfo.m_MCUHeight; - const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight; - - // Validate MCU row buffer size before allocation - if (mcuRowPixels > MAX_MCU_ROW_BYTES) { - LOG_DBG("JPG", "MCU row buffer too large (%d bytes), max: %d", mcuRowPixels, MAX_MCU_ROW_BYTES); + ctx.bmpRow = static_cast(malloc(bytesPerRow)); + if (!ctx.bmpRow) { + LOG_ERR("JPG", "Failed to allocate BMP row buffer"); return false; } - mcuRowBuffer = static_cast(malloc(mcuRowPixels)); - if (!mcuRowBuffer) { - LOG_ERR("JPG", "Failed to allocate MCU row buffer (%d bytes)", mcuRowPixels); - return false; - } - - // Create ditherer if enabled - // Use OUTPUT dimensions for dithering (after prescaling) - if (oneBit) { - // For 1-bit output, use Atkinson dithering for better quality - atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth); - } else if (!USE_8BIT_OUTPUT) { - if (USE_ATKINSON) { - atkinsonDitherer = new AtkinsonDitherer(outWidth); - } else if (USE_FLOYD_STEINBERG) { - fsDitherer = new FloydSteinbergDitherer(outWidth); - } - } - - // For scaling: accumulate source rows into scaled output rows - // We need to track which source Y maps to which output Y - // Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format) - int currentOutY = 0; // Current output row being accumulated - uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point) - if (needsScaling) { - rowAccum = new uint32_t[outWidth](); - rowCount = new uint32_t[outWidth](); - nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1) + ctx.rowAccum = new (std::nothrow) uint32_t[outWidth](); + ctx.rowCount = new (std::nothrow) uint32_t[outWidth](); + if (!ctx.rowAccum || !ctx.rowCount) { + LOG_ERR("JPG", "Failed to allocate scaling buffers"); + return false; + } + ctx.nextOutY_srcStart = scaleY_fp; } - // Process MCUs row-by-row and write to BMP as we go (top-down) - const int mcuPixelWidth = imageInfo.m_MCUWidth; - - for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) { - // Clear the MCU row buffer - memset(mcuRowBuffer, 0, mcuRowPixels); - - // Decode one row of MCUs - for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) { - const unsigned char mcuStatus = pjpeg_decode_mcu(); - if (mcuStatus != 0) { - if (mcuStatus == PJPG_NO_MORE_BLOCKS) { - LOG_ERR("JPG", "Unexpected end of blocks at MCU (%d, %d)", mcuX, mcuY); - } else { - LOG_ERR("JPG", "JPEG decode MCU failed at (%d, %d) with error code: %d", mcuX, mcuY, mcuStatus); - } - return false; - } - - // picojpeg stores MCU data in 8x8 blocks - // Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128 - for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { - for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { - const int pixelX = mcuX * mcuPixelWidth + blockX; - if (pixelX >= imageInfo.m_width) continue; - - // Calculate proper block offset for picojpeg buffer - const int blockCol = blockX / 8; - const int blockRow = blockY / 8; - const int localX = blockX % 8; - const int localY = blockY % 8; - const int blocksPerRow = mcuPixelWidth / 8; - const int blockIndex = blockRow * blocksPerRow + blockCol; - const int pixelOffset = blockIndex * 64 + localY * 8 + localX; - - uint8_t gray; - if (imageInfo.m_comps == 1) { - gray = imageInfo.m_pMCUBufR[pixelOffset]; - } else { - const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset]; - const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset]; - const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset]; - gray = (r * 25 + g * 50 + b * 25) / 100; - } - - mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray; - } - } + if (oneBit) { + ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(outWidth); + } else if (!USE_8BIT_OUTPUT) { + if (USE_ATKINSON) { + ctx.atkinsonDitherer = new (std::nothrow) AtkinsonDitherer(outWidth); + } else if (USE_FLOYD_STEINBERG) { + ctx.fsDitherer = new (std::nothrow) FloydSteinbergDitherer(outWidth); } + } - // Process source rows from this MCU row - const int startRow = mcuY * mcuPixelHeight; - const int endRow = (mcuY + 1) * mcuPixelHeight; + jpeg->setPixelType(EIGHT_BIT_GRAYSCALE); + jpeg->setUserPointer(&ctx); - for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) { - const int bufferY = y - startRow; + rc = jpeg->decode(0, 0, 0); - if (!needsScaling) { - // No scaling - direct output (1:1 mapping) - memset(rowBuffer, 0, bytesPerRow); - - if (USE_8BIT_OUTPUT && !oneBit) { - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; - rowBuffer[x] = adjustPixel(gray); - } - } else if (oneBit) { - // 1-bit output with Atkinson dithering for better quality - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; - const uint8_t bit = - atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); - // Pack 1-bit value: MSB first, 8 pixels per byte - const int byteIndex = x / 8; - const int bitOffset = 7 - (x % 8); - rowBuffer[byteIndex] |= (bit << bitOffset); - } - if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); - } else { - // 2-bit output - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]); - uint8_t twoBit; - if (atkinsonDitherer) { - twoBit = atkinsonDitherer->processPixel(gray, x); - } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x); - } else { - twoBit = quantize(gray, x, y); - } - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); - rowBuffer[byteIndex] |= (twoBit << bitOffset); - } - if (atkinsonDitherer) - atkinsonDitherer->nextRow(); - else if (fsDitherer) - fsDitherer->nextRow(); - } - bmpOut.write(rowBuffer, bytesPerRow); - } else { - // Fixed-point area averaging for exact fit scaling - // For each output pixel X, accumulate source pixels that map to it - // srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16) - const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width; - - for (int outX = 0; outX < outWidth; outX++) { - // Calculate source X range for this output pixel - const int srcXStart = (static_cast(outX) * scaleX_fp) >> 16; - const int srcXEnd = (static_cast(outX + 1) * scaleX_fp) >> 16; - - // Accumulate all source pixels in this range - int sum = 0; - int count = 0; - for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) { - sum += srcRow[srcX]; - count++; - } - - // Handle edge case: if no pixels in range, use nearest - if (count == 0 && srcXStart < imageInfo.m_width) { - sum = srcRow[srcXStart]; - count = 1; - } - - rowAccum[outX] += sum; - rowCount[outX] += count; - } - - // Check if we've crossed into the next output row(s) - // Current source Y in fixed point: y << 16 - const uint32_t srcY_fp = static_cast(y + 1) << 16; - - // Output all rows whose boundaries we've crossed (handles both up and downscaling) - // For upscaling, one source row may produce multiple output rows - while (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { - memset(rowBuffer, 0, bytesPerRow); - - if (USE_8BIT_OUTPUT && !oneBit) { - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; - rowBuffer[x] = adjustPixel(gray); - } - } else if (oneBit) { - // 1-bit output with Atkinson dithering for better quality - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; - const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) - : quantize1bit(gray, x, currentOutY); - // Pack 1-bit value: MSB first, 8 pixels per byte - const int byteIndex = x / 8; - const int bitOffset = 7 - (x % 8); - rowBuffer[byteIndex] |= (bit << bitOffset); - } - if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); - } else { - // 2-bit output - for (int x = 0; x < outWidth; x++) { - const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); - uint8_t twoBit; - if (atkinsonDitherer) { - twoBit = atkinsonDitherer->processPixel(gray, x); - } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x); - } else { - twoBit = quantize(gray, x, currentOutY); - } - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); - rowBuffer[byteIndex] |= (twoBit << bitOffset); - } - if (atkinsonDitherer) - atkinsonDitherer->nextRow(); - else if (fsDitherer) - fsDitherer->nextRow(); - } - - bmpOut.write(rowBuffer, bytesPerRow); - currentOutY++; - - // Update boundary for next output row - nextOutY_srcStart = static_cast(currentOutY + 1) * scaleY_fp; - - // For upscaling: don't reset accumulators if next output row uses same source data - // Only reset when we'll move to a new source row - if (srcY_fp >= nextOutY_srcStart) { - // More output rows to emit from same source - keep accumulator data - continue; - } - // Moving to next source row - reset accumulators - memset(rowAccum, 0, outWidth * sizeof(uint32_t)); - memset(rowCount, 0, outWidth * sizeof(uint32_t)); - } - } - } + if (rc != 1 || ctx.error) { + LOG_ERR("JPG", "JPEG decode failed (rc=%d, err=%d)", rc, jpeg->getLastError()); + return false; } LOG_DBG("JPG", "Successfully converted JPEG to BMP"); diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index 125692e4..66f77f67 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -6,8 +6,6 @@ class Print; class ZipFile; class JpegToBmpConverter { - static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, - unsigned char* pBytes_actually_read, void* pCallback_data); static bool jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, bool oneBit, bool crop = true); diff --git a/lib/KOReaderSync/ChapterXPathForwardMapper.cpp b/lib/KOReaderSync/ChapterXPathForwardMapper.cpp new file mode 100644 index 00000000..d6120166 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathForwardMapper.cpp @@ -0,0 +1,170 @@ +#include "ChapterXPathForwardMapper.h" + +#include +#include +#include + +#include +#include +#include + +#include "ChapterXPathIndexerInternal.h" +#include "ChapterXPathIndexerState.h" + +namespace ChapterXPathIndexerInternal { + +namespace { + +// Forward mapper: translate intra-spine progress to a KOReader-compatible XPath. +// Strategy: +// 1) Count total visible text bytes in chapter. +// 2) Stream parse again and stop when target byte offset is reached. +// 3) Emit either an element path or /text()[N].M when at body text-node level. + +struct ForwardState : StackState { + int spineIndex; + size_t targetOffset; + std::string result; + bool found = false; + XML_Parser parser = nullptr; + + int bodyTextNodeCount = 0; + size_t codepointsInBodyTextNode = 0; + bool inBodyTextNode = false; + + ForwardState(const int spineIndex, const size_t targetOffset) : spineIndex(spineIndex), targetOffset(targetOffset) {} + + void onStartElement(const XML_Char* rawName) { + inBodyTextNode = false; + pushElement(rawName); + } + + void onEndElement() { + inBodyTextNode = false; + popElement(); + } + + void onCharData(const XML_Char* text, const int len) { + if (shouldSkipText(len) || found) { + return; + } + + const bool atBodyLevel = bodyIdx() + 1 == static_cast(stack.size()); + if (atBodyLevel && !inBodyTextNode) { + inBodyTextNode = true; + bodyTextNodeCount++; + codepointsInBodyTextNode = 0; + } + + if (isWhitespaceOnly(text, len)) { + if (atBodyLevel) { + codepointsInBodyTextNode += countUtf8Codepoints(text, len); + } + return; + } + + const size_t visible = countVisibleBytes(text, len); + if (totalTextBytes + visible >= targetOffset) { + if (atBodyLevel && bodyTextNodeCount > 0) { + // KOReader/crengine text-point semantics use codepoint offsets. + const size_t targetVisibleByteInChunk = targetOffset - totalTextBytes; + const size_t cpInChunk = codepointAtVisibleByte(text, len, targetVisibleByteInChunk); + const size_t charOff = codepointsInBodyTextNode + cpInChunk; + result = + currentXPath(spineIndex) + "/text()[" + std::to_string(bodyTextNodeCount) + "]." + std::to_string(charOff); + } else { + result = currentXPath(spineIndex); + } + found = true; + if (parser) { + XML_StopParser(parser, XML_FALSE); + } + return; + } + + totalTextBytes += visible; + if (atBodyLevel) { + codepointsInBodyTextNode += countUtf8Codepoints(text, len); + } + } +}; + +std::string makeSpineCacheKey(const std::shared_ptr& epub, const int spineIndex) { + if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { + return ""; + } + const auto spineItem = epub->getSpineItem(spineIndex); + return epub->getCachePath() + "|" + std::to_string(spineIndex) + "|" + spineItem.href; +} + +size_t getTotalTextBytesCached(const std::shared_ptr& epub, const int spineIndex, const std::string& tmpPath) { + static std::unordered_map sTotalBytesBySpine; + static std::string sCachedBookPath; + + const std::string currentBookPath = epub ? epub->getCachePath() : std::string(); + if (currentBookPath != sCachedBookPath) { + sTotalBytesBySpine.clear(); + sCachedBookPath = currentBookPath; + } + + const std::string key = makeSpineCacheKey(epub, spineIndex); + if (!key.empty()) { + const auto it = sTotalBytesBySpine.find(key); + if (it != sTotalBytesBySpine.end()) { + return it->second; + } + } + + const size_t totalTextBytes = countTotalTextBytes(tmpPath); + if (!key.empty()) { + sTotalBytesBySpine[key] = totalTextBytes; + } + return totalTextBytes; +} + +} // namespace + +std::string findXPathForProgressInternal(const std::shared_ptr& epub, const int spineIndex, + const float intraSpineProgress) { + const std::string tmpPath = decompressToTempFile(epub, spineIndex); + if (tmpPath.empty()) { + return ""; + } + + const size_t totalTextBytes = getTotalTextBytesCached(epub, spineIndex, tmpPath); + if (totalTextBytes == 0) { + Storage.remove(tmpPath.c_str()); + const std::string base = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; + LOG_DBG("KOX", "Forward: spine=%d no text, returning base xpath", spineIndex); + return base; + } + + const float clamped = std::max(0.0f, std::min(1.0f, intraSpineProgress)); + const size_t targetOffset = static_cast(clamped * static_cast(totalTextBytes)); + + ForwardState state(spineIndex, targetOffset); + XML_Parser parser = XML_ParserCreate(nullptr); + if (!parser) { + Storage.remove(tmpPath.c_str()); + return ""; + } + + state.parser = parser; + XML_SetUserData(parser, &state); + XML_SetElementHandler(parser, parserStartCb, parserEndCb); + XML_SetCharacterDataHandler(parser, parserCharCb); + XML_SetDefaultHandlerExpand(parser, parserDefaultCb); + runParse(parser, tmpPath); + XML_ParserFree(parser); + Storage.remove(tmpPath.c_str()); + + if (state.result.empty()) { + state.result = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; + } + + LOG_DBG("KOX", "Forward: spine=%d progress=%.3f target=%zu/%zu -> %s", spineIndex, intraSpineProgress, targetOffset, + totalTextBytes, state.result.c_str()); + return state.result; +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathForwardMapper.h b/lib/KOReaderSync/ChapterXPathForwardMapper.h new file mode 100644 index 00000000..d37dc054 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathForwardMapper.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +#include +#include + +namespace ChapterXPathIndexerInternal { + +std::string findXPathForProgressInternal(const std::shared_ptr& epub, int spineIndex, float intraSpineProgress); + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathIndexer.cpp b/lib/KOReaderSync/ChapterXPathIndexer.cpp new file mode 100644 index 00000000..61e5ce74 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexer.cpp @@ -0,0 +1,103 @@ +#include "ChapterXPathIndexer.h" + +#include + +#include +#include +#include +#include + +#include "ChapterXPathForwardMapper.h" +#include "ChapterXPathIndexerInternal.h" +#include "ChapterXPathReverseMapper.h" + +using namespace ChapterXPathIndexerInternal; + +// Public facade used by ProgressMapper. It intentionally stays thin and delegates +// heavy parsing/mapping work to the internal forward/reverse modules. + +std::string ChapterXPathIndexer::findXPathForProgress(const std::shared_ptr& epub, const int spineIndex, + const float intraSpineProgress) { + return findXPathForProgressInternal(epub, spineIndex, intraSpineProgress); +} + +bool ChapterXPathIndexer::findProgressForXPath(const std::shared_ptr& epub, const int spineIndex, + const std::string& xpath, float& outIntraSpineProgress, + bool& outExactMatch) { + return findProgressForXPathInternal(epub, spineIndex, xpath, outIntraSpineProgress, outExactMatch); +} + +bool ChapterXPathIndexer::tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex) { + outSpineIndex = -1; + if (xpath.empty()) { + return false; + } + + const std::string normalized = normalizeXPath(xpath); + const std::string key = "/docfragment["; + const size_t pos = normalized.find(key); + if (pos == std::string::npos) { + LOG_DBG("KOX", "No DocFragment in xpath: '%s'", xpath.c_str()); + return false; + } + + const size_t start = pos + key.size(); + size_t end = start; + while (end < normalized.size() && std::isdigit(static_cast(normalized[end]))) { + end++; + } + + if (end == start || end >= normalized.size() || normalized[end] != ']') { + return false; + } + + const std::string value = normalized.substr(start, end - start); + const long parsed = std::strtol(value.c_str(), nullptr, 10); + // XPath uses 1-based predicates; internal spine indexing is 0-based. + if (parsed < 1 || parsed > std::numeric_limits::max()) { + return false; + } + + outSpineIndex = static_cast(parsed) - 1; + return true; +} + +bool ChapterXPathIndexer::tryExtractParagraphIndexFromXPath(const std::string& xpath, uint16_t& outParagraphIndex) { + outParagraphIndex = 0; + if (xpath.empty()) { + return false; + } + + const std::string normalized = normalizeXPath(xpath); + + const std::string bodyKey = "/body"; + size_t secondBody = normalized.find(bodyKey); + if (secondBody != std::string::npos) { + secondBody = normalized.find(bodyKey, secondBody + bodyKey.size()); + } + + const std::string pKey = "/p["; + const size_t pos = normalized.find(pKey, secondBody != std::string::npos ? secondBody : 0); + if (pos == std::string::npos) { + return false; + } + + const size_t start = pos + pKey.size(); + size_t end = start; + while (end < normalized.size() && std::isdigit(static_cast(normalized[end]))) { + end++; + } + + if (end == start || end >= normalized.size() || normalized[end] != ']') { + return false; + } + + const long parsed = std::strtol(normalized.substr(start, end - start).c_str(), nullptr, 10); + // Paragraph index is preserved as 1-based to match XPath p[N] convention. + if (parsed < 1 || parsed > UINT16_MAX) { + return false; + } + + outParagraphIndex = static_cast(parsed); + return true; +} diff --git a/lib/KOReaderSync/ChapterXPathIndexer.h b/lib/KOReaderSync/ChapterXPathIndexer.h new file mode 100644 index 00000000..97adf655 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexer.h @@ -0,0 +1,79 @@ +#pragma once + +#include + +#include +#include + +/** + * Lightweight XPath/progress bridge for KOReader sync. + * + * Why this exists: + * - CrossPoint stores reading position as chapter/page. + * - KOReader sync uses XPath + percentage. + * + * This utility reparses exactly one spine XHTML item with Expat to translate + * between the two formats. It streams through the parse using O(1) memory + * (no anchor list), so it handles arbitrarily large chapters without OOM. + * + * Design constraints (ESP32-C3): + * - No persistent full-book structures. + * - Parse-on-demand and free memory immediately. + * - Keep fallback behavior deterministic if parsing/matching fails. + */ +class ChapterXPathIndexer { + public: + /** + * Convert an intra-spine progress ratio to the nearest element-level XPath. + * + * @param epub Loaded EPUB instance + * @param spineIndex Current spine item index + * @param intraSpineProgress Position within the spine item [0.0, 1.0] + * @return Best matching XPath for KOReader, or empty string on failure + */ + static std::string findXPathForProgress(const std::shared_ptr& epub, int spineIndex, float intraSpineProgress); + + /** + * Resolve a KOReader XPath to an intra-spine progress ratio. + * + * Matching strategy: + * 1) exact anchor path match, + * 2) index-insensitive path match, + * 3) ancestor fallback. + * + * @param epub Loaded EPUB instance + * @param spineIndex Spine item index to parse + * @param xpath Incoming KOReader XPath + * @param outIntraSpineProgress Resolved position within spine [0.0, 1.0] + * @param outExactMatch True only for full exact path match + * @return true if any match was resolved; false means caller should fallback + */ + static bool findProgressForXPath(const std::shared_ptr& epub, int spineIndex, const std::string& xpath, + float& outIntraSpineProgress, bool& outExactMatch); + + /** + * Parse DocFragment index from KOReader-style path segment: + * /body/DocFragment[N]/body/... + * + * KOReader uses 1-based DocFragment indices; N is converted to the 0-based + * spine index stored in outSpineIndex (i.e. outSpineIndex = N - 1). + * + * @param xpath KOReader XPath + * @param outSpineIndex 0-based spine index derived from DocFragment[N] + * @return true when DocFragment[N] exists and N is a valid integer >= 1 + * (converted to 0-based outSpineIndex); false otherwise + */ + static bool tryExtractSpineIndexFromXPath(const std::string& xpath, int& outSpineIndex); + + /** + * Extract the paragraph index from a KOReader XPath. + * Looks for the first /p[N] segment after /body/ and returns N (1-based). + * + * Example: "/body/DocFragment[7]/body/p[685]/text().96" → outParagraphIndex = 685 + * + * @param xpath KOReader XPath + * @param outParagraphIndex 1-based paragraph index + * @return true if a /p[N] segment was found + */ + static bool tryExtractParagraphIndexFromXPath(const std::string& xpath, uint16_t& outParagraphIndex); +}; diff --git a/lib/KOReaderSync/ChapterXPathIndexerInternal.cpp b/lib/KOReaderSync/ChapterXPathIndexerInternal.cpp new file mode 100644 index 00000000..d622be64 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexerInternal.cpp @@ -0,0 +1,350 @@ +#include "ChapterXPathIndexerInternal.h" + +#include +#include + +#include +#include +#include +#include + +namespace ChapterXPathIndexerInternal { + +std::string toLowerStr(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +bool isSkippableTag(const std::string& tag) { return tag == "head" || tag == "script" || tag == "style"; } + +bool isWhitespaceOnly(const XML_Char* text, const int len) { + for (int i = 0; i < len; i++) { + if (!std::isspace(static_cast(text[i]))) { + return false; + } + } + return true; +} + +size_t countVisibleBytes(const XML_Char* text, const int len) { + size_t count = 0; + for (int i = 0; i < len; i++) { + if (!std::isspace(static_cast(text[i]))) { + count++; + } + } + return count; +} + +size_t countUtf8Codepoints(const XML_Char* text, const int len) { + size_t count = 0; + for (int i = 0; i < len; i++) { + if ((static_cast(text[i]) & 0xC0) != 0x80) { + count++; + } + } + return count; +} + +size_t codepointAtVisibleByte(const XML_Char* text, const int len, const size_t targetVisibleByte) { + size_t codepoints = 0; + size_t visibleBytes = 0; + for (int i = 0; i < len; i++) { + const unsigned char uc = static_cast(text[i]); + const bool isLeadByte = (uc & 0xC0) != 0x80; + if (isLeadByte) { + codepoints++; + } + if (!std::isspace(uc)) { + if (visibleBytes == targetVisibleByte) { + return codepoints - 1; + } + visibleBytes++; + } + } + return codepoints; +} + +size_t visibleBytesBeforeCodepoint(const XML_Char* text, const int len, const size_t targetCodepointOffset) { + size_t visibleBytes = 0; + size_t codepointIndex = 0; + + int i = 0; + while (i < len) { + if (codepointIndex >= targetCodepointOffset) { + break; + } + + const int cpStart = i; + i++; + while (i < len && (static_cast(text[i]) & 0xC0) == 0x80) { + i++; + } + + for (int j = cpStart; j < i; j++) { + if (!std::isspace(static_cast(text[j]))) { + visibleBytes++; + } + } + + codepointIndex++; + } + + return visibleBytes; +} + +std::string normalizeXPath(const std::string& input) { + if (input.empty()) { + return ""; + } + + std::string out; + out.reserve(input.size()); + for (const char c : input) { + const unsigned char uc = static_cast(c); + if (std::isspace(uc)) { + continue; + } + out.push_back(static_cast(std::tolower(uc))); + } + + const std::string textTag = "/text()"; + const size_t textPos = out.rfind(textTag); + if (textPos != std::string::npos) { + const size_t afterText = textPos + textTag.size(); + if (afterText == out.size() || out[afterText] == '.' || out[afterText] == '[') { + out.erase(textPos); + } + } + + const size_t lastSlash = out.rfind('/'); + if (lastSlash != std::string::npos) { + const size_t dotPos = out.find('.', lastSlash + 1); + if (dotPos != std::string::npos && dotPos + 1 < out.size()) { + bool allDigits = true; + for (size_t i = dotPos + 1; i < out.size(); i++) { + if (!std::isdigit(static_cast(out[i]))) { + allDigits = false; + break; + } + } + if (allDigits) { + out.erase(dotPos); + } + } + } + + while (!out.empty() && out.back() == '/') { + out.pop_back(); + } + + // KOReader sometimes omits the [1] predicate for elements that are the sole + // child of their type (e.g. /body/div/p[55] instead of /body/div[1]/p[55]). + // In XPath, an unqualified name is equivalent to name[1] when there is only + // one sibling of that type, but our parser always generates explicit indices. + // Insert [1] for any bare element path segment so comparisons match. + std::string normalized; + normalized.reserve(out.size() + 16); + size_t i = 0; + while (i < out.size()) { + if (out[i] == '/') { + normalized.push_back('/'); + i++; + // Copy element name (letters, digits, hyphens, underscores, dots) + const size_t nameStart = i; + while (i < out.size() && out[i] != '/' && out[i] != '[') { + i++; + } + normalized.append(out, nameStart, i - nameStart); + if (i < out.size() && out[i] == '[') { + // Already has a predicate – copy it verbatim + while (i < out.size() && out[i] != ']') { + normalized.push_back(out[i++]); + } + if (i < out.size()) { + normalized.push_back(out[i++]); // ']' + } + } else if (i - nameStart > 0) { + // Bare element name – insert implicit [1] + normalized.append("[1]"); + } + } else { + normalized.push_back(out[i++]); + } + } + + return normalized; +} + +std::string removeIndices(const std::string& xpath) { + std::string out; + out.reserve(xpath.size()); + bool inBracket = false; + for (const char c : xpath) { + if (c == '[') { + inBracket = true; + continue; + } + if (c == ']') { + inBracket = false; + continue; + } + if (!inBracket) { + out.push_back(c); + } + } + return out; +} + +int pathDepth(const std::string& xpath) { + int depth = 0; + for (const char c : xpath) { + if (c == '/') { + depth++; + } + } + return depth; +} + +bool isAncestorPath(const std::string& prefix, const std::string& path) { + return path.size() > prefix.size() && path.compare(0, prefix.size(), prefix) == 0 && path[prefix.size()] == '/'; +} + +std::string decompressToTempFile(const std::shared_ptr& epub, const int spineIndex) { + if (!epub || spineIndex < 0 || spineIndex >= epub->getSpineItemsCount()) { + return ""; + } + + const auto spineItem = epub->getSpineItem(spineIndex); + if (spineItem.href.empty()) { + return ""; + } + + const std::string tmpPath = epub->getCachePath() + "/.tmp_kox_" + std::to_string(spineIndex) + ".html"; + if (Storage.exists(tmpPath.c_str())) { + Storage.remove(tmpPath.c_str()); + } + + FsFile tmpFile; + if (!Storage.openFileForWrite("KOX", tmpPath, tmpFile)) { + LOG_ERR("KOX", "Failed to create temp file for spine=%d", spineIndex); + return ""; + } + + constexpr size_t kChunkSize = 1024; + const bool ok = epub->readItemContentsToStream(spineItem.href, tmpFile, kChunkSize); + tmpFile.close(); + + if (!ok) { + Storage.remove(tmpPath.c_str()); + LOG_ERR("KOX", "Failed to decompress spine=%d to temp file", spineIndex); + return ""; + } + + return tmpPath; +} + +bool runParse(XML_Parser parser, const std::string& path) { + FsFile file; + if (!Storage.openFileForRead("KOX", path, file)) { + return false; + } + + constexpr size_t kBufSize = 1024; + bool ok = true; + int done; + do { + void* const buf = XML_GetBuffer(parser, kBufSize); + if (!buf) { + ok = false; + break; + } + const size_t len = file.read(buf, kBufSize); + done = file.available() == 0; + if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { + ok = (XML_GetErrorCode(parser) == XML_ERROR_ABORTED); + break; + } + } while (!done); + + file.close(); + return ok; +} + +bool isEntityRef(const XML_Char* text, const int len) { + if (len < 3 || text[0] != '&' || text[len - 1] != ';') { + return false; + } + for (int i = 1; i < len - 1; ++i) { + if (text[i] == '<' || text[i] == '>') { + return false; + } + } + return true; +} + +namespace { + +struct ByteCounter { + int skipDepth = -1; + int bodyStartDepth = -1; + int depth = 0; + size_t totalTextBytes = 0; +}; + +void XMLCALL bcStart(void* ud, const XML_Char* name, const XML_Char**) { + auto* s = static_cast(ud); + const std::string tag = toLowerStr(name ? name : ""); + if (tag == "body" && s->bodyStartDepth < 0) { + s->bodyStartDepth = s->depth; + } + if (s->skipDepth < 0 && isSkippableTag(tag)) { + s->skipDepth = s->depth; + } + s->depth++; +} + +void XMLCALL bcEnd(void* ud, const XML_Char*) { + auto* s = static_cast(ud); + s->depth--; + if (s->depth == s->skipDepth) { + s->skipDepth = -1; + } + if (s->depth == s->bodyStartDepth) { + s->bodyStartDepth = -1; + } +} + +void XMLCALL bcChar(void* ud, const XML_Char* text, const int len) { + auto* s = static_cast(ud); + if (s->skipDepth >= 0 || s->bodyStartDepth < 0 || len <= 0 || isWhitespaceOnly(text, len)) { + return; + } + s->totalTextBytes += countVisibleBytes(text, len); +} + +void XMLCALL bcDefault(void* ud, const XML_Char* text, const int len) { + if (isEntityRef(text, len)) { + bcChar(ud, text, len); + } +} + +} // namespace + +size_t countTotalTextBytes(const std::string& tmpPath) { + ByteCounter state; + XML_Parser parser = XML_ParserCreate(nullptr); + if (!parser) { + return 0; + } + XML_SetUserData(parser, &state); + XML_SetElementHandler(parser, bcStart, bcEnd); + XML_SetCharacterDataHandler(parser, bcChar); + XML_SetDefaultHandlerExpand(parser, bcDefault); + runParse(parser, tmpPath); + XML_ParserFree(parser); + return state.totalTextBytes; +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathIndexerInternal.h b/lib/KOReaderSync/ChapterXPathIndexerInternal.h new file mode 100644 index 00000000..4a9485b2 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexerInternal.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include +#include + +namespace ChapterXPathIndexerInternal { + +std::string toLowerStr(std::string value); + +bool isSkippableTag(const std::string& tag); +bool isWhitespaceOnly(const XML_Char* text, int len); + +size_t countVisibleBytes(const XML_Char* text, int len); +size_t countUtf8Codepoints(const XML_Char* text, int len); +size_t codepointAtVisibleByte(const XML_Char* text, int len, size_t targetVisibleByte); +size_t visibleBytesBeforeCodepoint(const XML_Char* text, int len, size_t targetCodepointOffset); + +std::string normalizeXPath(const std::string& input); +std::string removeIndices(const std::string& xpath); +int pathDepth(const std::string& xpath); +bool isAncestorPath(const std::string& prefix, const std::string& path); + +std::string decompressToTempFile(const std::shared_ptr& epub, int spineIndex); +bool runParse(XML_Parser parser, const std::string& path); +bool isEntityRef(const XML_Char* text, int len); +size_t countTotalTextBytes(const std::string& tmpPath); + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathIndexerState.h b/lib/KOReaderSync/ChapterXPathIndexerState.h new file mode 100644 index 00000000..fddbea12 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathIndexerState.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include +#include + +#include "ChapterXPathIndexerInternal.h" + +namespace ChapterXPathIndexerInternal { + +// Shared parser state used by both forward and reverse mappers. +// It centralizes DOM-stack bookkeeping and XPath reconstruction so each mapper +// only implements its own match/emit logic. + +struct StackNode { + std::string tag; + int index = 1; + // Reserved for future text-node heuristics; intentionally unused for now. + bool hasText = false; +}; + +struct StackState { + int skipDepth = -1; + size_t totalTextBytes = 0; + std::vector stack; + std::vector> siblingCounters; + + StackState() { siblingCounters.emplace_back(); } + + void pushElement(const XML_Char* rawName) { + std::string name = toLowerStr(rawName ? rawName : ""); + const size_t depth = stack.size(); + if (siblingCounters.size() <= depth) { + siblingCounters.resize(depth + 1); + } + const int sibIdx = ++siblingCounters[depth][name]; + stack.push_back({name, sibIdx, false}); + siblingCounters.emplace_back(); + if (skipDepth < 0 && isSkippableTag(name)) { + skipDepth = static_cast(stack.size()) - 1; + } + } + + void popElement() { + if (stack.empty()) { + return; + } + if (skipDepth == static_cast(stack.size()) - 1) { + skipDepth = -1; + } + stack.pop_back(); + if (!siblingCounters.empty()) { + siblingCounters.pop_back(); + } + } + + int bodyIdx() const { + for (int i = static_cast(stack.size()) - 1; i >= 0; i--) { + if (stack[i].tag == "body") { + return i; + } + } + return -1; + } + + bool insideBody() const { return bodyIdx() >= 0; } + + std::string currentXPath(const int spineIndex) const { + const int bi = bodyIdx(); + std::string xpath = "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; + if (bi < 0) { + return xpath; + } + for (size_t i = static_cast(bi + 1); i < stack.size(); i++) { + xpath += "/" + stack[i].tag + "[" + std::to_string(stack[i].index) + "]"; + } + return xpath; + } + + bool shouldSkipText(const int len) const { return skipDepth >= 0 || len <= 0 || !insideBody(); } +}; + +template +void XMLCALL parserStartCb(void* ud, const XML_Char* name, const XML_Char**) { + static_cast(ud)->onStartElement(name); +} + +template +void XMLCALL parserEndCb(void* ud, const XML_Char*) { + static_cast(ud)->onEndElement(); +} + +template +void XMLCALL parserCharCb(void* ud, const XML_Char* text, const int len) { + static_cast(ud)->onCharData(text, len); +} + +template +void XMLCALL parserDefaultCb(void* ud, const XML_Char* text, const int len) { + if (isEntityRef(text, len)) { + static_cast(ud)->onCharData(text, len); + } +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathReverseMapper.cpp b/lib/KOReaderSync/ChapterXPathReverseMapper.cpp new file mode 100644 index 00000000..b9cc9d81 --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathReverseMapper.cpp @@ -0,0 +1,248 @@ +#include "ChapterXPathReverseMapper.h" + +#include +#include +#include + +#include +#include +#include + +#include "ChapterXPathIndexerInternal.h" +#include "ChapterXPathIndexerState.h" + +namespace ChapterXPathIndexerInternal { + +namespace { + +// Reverse mapper: translate KOReader XPath to intra-spine progress. +// Matching preference order is strict and deterministic: +// exact > exact-no-index > ancestor > ancestor-no-index. +// For /text()[N].M, M is treated as codepoint offset and converted back to +// internal visible-byte progress. + +enum class MatchTier : int { + NONE = 0, + ANCESTOR_NO_IDX = 1, + ANCESTOR = 2, + EXACT_NO_IDX = 3, + EXACT = 4, +}; + +struct ReverseState : StackState { + int spineIndex; + std::string targetNorm; + std::string targetNoIndex; + + int targetTextNodeIndex = 0; + int targetCharOffset = 0; + bool inParentTextNode = false; + size_t codepointsInCurrentTextNode = 0; + int currentTextNodeCount = 0; + + MatchTier bestTier = MatchTier::NONE; + int bestDepth = -1; + size_t bestOffset = 0; + bool bestExact = false; + const char* bestTierName = nullptr; + + ReverseState(const int spineIndex, const std::string& xpath) : spineIndex(spineIndex) { + // Parse optional /text()[N].M suffix before normalizing for element matching. + std::string raw = xpath; + for (char& c : raw) c = static_cast(std::tolower(static_cast(c))); + const std::string tnPat = "/text()["; + const size_t tnPos = raw.rfind(tnPat); + if (tnPos != std::string::npos) { + const size_t numStart = tnPos + tnPat.size(); + size_t numEnd = numStart; + while (numEnd < raw.size() && std::isdigit(static_cast(raw[numEnd]))) { + numEnd++; + } + if (numEnd > numStart && numEnd < raw.size() && raw[numEnd] == ']') { + const long nodeIdx = std::strtol(raw.substr(numStart, numEnd - numStart).c_str(), nullptr, 10); + if (nodeIdx >= 1) { + targetTextNodeIndex = static_cast(nodeIdx); + size_t after = numEnd + 1; + if (after < raw.size() && raw[after] == '.') { + after++; + size_t charEnd = after; + while (charEnd < raw.size() && std::isdigit(static_cast(raw[charEnd]))) { + charEnd++; + } + if (charEnd > after) { + const long charOff = std::strtol(raw.substr(after, charEnd - after).c_str(), nullptr, 10); + if (charOff >= 0) { + targetCharOffset = static_cast(charOff); + } + } + } + } + } + } + targetNorm = normalizeXPath(xpath); + targetNoIndex = removeIndices(targetNorm); + } + + void onStartElement(const XML_Char* rawName) { + inParentTextNode = false; + pushElement(rawName); + } + + void onEndElement() { + // Empty/textless elements can still be a valid anchor location. + if (!stack.empty() && !stack.back().hasText) { + checkMatch(); + } + inParentTextNode = false; + popElement(); + } + + void onCharData(const XML_Char* text, const int len) { + if (shouldSkipText(len)) { + return; + } + + const size_t visible = countVisibleBytes(text, len); + const size_t codepoints = countUtf8Codepoints(text, len); + + if (targetTextNodeIndex > 0 && !stack.empty()) { + const std::string xpath = normalizeXPath(currentXPath(spineIndex)); + if (xpath == targetNorm) { + stack.back().hasText = true; + if (!inParentTextNode) { + inParentTextNode = true; + currentTextNodeCount++; + codepointsInCurrentTextNode = 0; + } + if (currentTextNodeCount == targetTextNodeIndex && bestTier < MatchTier::EXACT) { + const size_t charOff = static_cast(targetCharOffset); + if (charOff >= codepointsInCurrentTextNode && charOff <= codepointsInCurrentTextNode + codepoints) { + const size_t cpInChunk = charOff - codepointsInCurrentTextNode; + const size_t pos = totalTextBytes + visibleBytesBeforeCodepoint(text, len, cpInChunk); + bestTier = MatchTier::EXACT; + bestDepth = pathDepth(xpath); + bestOffset = pos; + bestExact = true; + bestTierName = "text-node-exact"; + } + } + codepointsInCurrentTextNode += codepoints; + totalTextBytes += visible; + return; + } + } + + if (isWhitespaceOnly(text, len)) { + return; + } + + if (!stack.empty() && !stack.back().hasText) { + stack.back().hasText = true; + checkMatch(); + } + + totalTextBytes += visible; + } + + void checkMatch() { + const std::string xpath = normalizeXPath(currentXPath(spineIndex)); + const int depth = pathDepth(xpath); + + const bool targetIsTextSelector = targetTextNodeIndex > 0; + + if (xpath == targetNorm) { + // For /text()[N].M targets, the normalized parent element path is equal to + // targetNorm. Treat that as an ancestor-level anchor so text-node exact + // matching can still determine the real intra-node offset. + if (targetIsTextSelector) { + tryUpdate(MatchTier::ANCESTOR, depth, "text-parent", false); + } else { + tryUpdate(MatchTier::EXACT, depth, "exact", true); + } + return; + } + if (isAncestorPath(xpath, targetNorm)) { + tryUpdate(MatchTier::ANCESTOR, depth, "ancestor", false); + return; + } + + const std::string xpathNoIdx = removeIndices(xpath); + if (xpathNoIdx == targetNoIndex) { + tryUpdate(MatchTier::EXACT_NO_IDX, depth, "index-insensitive", false); + } else if (isAncestorPath(xpathNoIdx, targetNoIndex)) { + tryUpdate(MatchTier::ANCESTOR_NO_IDX, depth, "index-insensitive-ancestor", false); + } + } + + void tryUpdate(const MatchTier tier, const int depth, const char* tierName, const bool isExact) { + if (tier > bestTier || (tier == bestTier && depth > bestDepth)) { + bestTier = tier; + bestDepth = depth; + bestOffset = totalTextBytes; + bestExact = isExact; + bestTierName = tierName; + } + } +}; + +} // namespace + +bool findProgressForXPathInternal(const std::shared_ptr& epub, const int spineIndex, const std::string& xpath, + float& outIntraSpineProgress, bool& outExactMatch) { + outIntraSpineProgress = 0.0f; + outExactMatch = false; + + if (xpath.empty()) { + return false; + } + + const std::string tmpPath = decompressToTempFile(epub, spineIndex); + if (tmpPath.empty()) { + return false; + } + + ReverseState state(spineIndex, xpath); + XML_Parser parser = XML_ParserCreate(nullptr); + if (!parser) { + Storage.remove(tmpPath.c_str()); + return false; + } + + XML_SetUserData(parser, &state); + XML_SetElementHandler(parser, parserStartCb, parserEndCb); + XML_SetCharacterDataHandler(parser, parserCharCb); + XML_SetDefaultHandlerExpand(parser, parserDefaultCb); + const bool parseOk = runParse(parser, tmpPath); + + if (!parseOk) { + LOG_ERR("KOX", "XPath parse failed for spine=%d at line %lu: %s", spineIndex, XML_GetCurrentLineNumber(parser), + XML_ErrorString(XML_GetErrorCode(parser))); + } + XML_ParserFree(parser); + Storage.remove(tmpPath.c_str()); + + if (!parseOk || state.bestTier == MatchTier::NONE) { + LOG_DBG("KOX", "Reverse: spine=%d no match for '%s'", spineIndex, xpath.c_str()); + return false; + } + + outExactMatch = state.bestExact; + if (state.totalTextBytes == 0) { + outIntraSpineProgress = 0.0f; + } else { + outIntraSpineProgress = static_cast(state.bestOffset) / static_cast(state.totalTextBytes); + outIntraSpineProgress = std::max(0.0f, std::min(1.0f, outIntraSpineProgress)); + } + + if (state.targetTextNodeIndex > 0) { + LOG_DBG("KOX", "Reverse: spine=%d %s match textNode=%d char=%d offset=%zu/%zu -> progress=%.3f for '%s'", + spineIndex, state.bestTierName, state.targetTextNodeIndex, state.targetCharOffset, state.bestOffset, + state.totalTextBytes, outIntraSpineProgress, xpath.c_str()); + } else { + LOG_DBG("KOX", "Reverse: spine=%d %s match offset=%zu/%zu -> progress=%.3f for '%s'", spineIndex, + state.bestTierName, state.bestOffset, state.totalTextBytes, outIntraSpineProgress, xpath.c_str()); + } + return true; +} + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/ChapterXPathReverseMapper.h b/lib/KOReaderSync/ChapterXPathReverseMapper.h new file mode 100644 index 00000000..9b2faf9b --- /dev/null +++ b/lib/KOReaderSync/ChapterXPathReverseMapper.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include +#include + +namespace ChapterXPathIndexerInternal { + +bool findProgressForXPathInternal(const std::shared_ptr& epub, int spineIndex, const std::string& xpath, + float& outIntraSpineProgress, bool& outExactMatch); + +} // namespace ChapterXPathIndexerInternal diff --git a/lib/KOReaderSync/KOReaderCredentialStore.cpp b/lib/KOReaderSync/KOReaderCredentialStore.cpp index 574f2b8e..05a2b3ab 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.cpp +++ b/lib/KOReaderSync/KOReaderCredentialStore.cpp @@ -153,16 +153,22 @@ void KOReaderCredentialStore::setServerUrl(const std::string& url) { } std::string KOReaderCredentialStore::getBaseUrl() const { + std::string url; if (serverUrl.empty()) { - return DEFAULT_SERVER_URL; + url = DEFAULT_SERVER_URL; + } else if (serverUrl.find("://") == std::string::npos) { + // Normalize URL: add http:// if no protocol specified (local servers typically don't have SSL) + url = "http://" + serverUrl; + } else { + url = serverUrl; } - // Normalize URL: add http:// if no protocol specified (local servers typically don't have SSL) - if (serverUrl.find("://") == std::string::npos) { - return "http://" + serverUrl; + // Strip trailing slashes to avoid double-slash in API paths + while (!url.empty() && url.back() == '/') { + url.pop_back(); } - return serverUrl; + return url; } void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) { diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index efb18d1b..0d5ea9b3 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -4,6 +4,8 @@ #include #include +#include + namespace { // Extract filename from path (everything after last '/') std::string getFilename(const std::string& path) { @@ -15,6 +17,130 @@ std::string getFilename(const std::string& path) { } } // namespace +std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) { + // Mirror the Epub cache directory convention so the hash file shares the + // same per-book folder as other cached data. + return std::string("/.crosspoint/epub_") + std::to_string(std::hash{}(filePath)) + "/koreader_docid.txt"; +} + +std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& currentFingerprint) { + if (!Storage.exists(cacheFilePath.c_str())) { + return ""; + } + + const String content = Storage.readFile(cacheFilePath.c_str()); + if (content.isEmpty()) { + return ""; + } + + // Format: ":\n<32-char-hex-hash>" + const int newlinePos = content.indexOf('\n'); + if (newlinePos < 0) { + return ""; + } + + const String header = content.substring(0, newlinePos); + const int colonPos = header.indexOf(':'); + if (colonPos < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: header missing fingerprint"); + return ""; + } + + const String sizeTok = header.substring(0, colonPos); + const String fpTok = header.substring(colonPos + 1); + + // Validate the filesize token – it must consist of ASCII digits and parse + // correctly to the expected size. + bool digitsOnly = true; + for (size_t i = 0; i < sizeTok.length(); ++i) { + const char ch = sizeTok[i]; + if (ch < '0' || ch > '9') { + digitsOnly = false; + break; + } + } + if (!digitsOnly) { + LOG_DBG("KODoc", "Hash cache invalidated: size token not numeric ('%s')", sizeTok.c_str()); + return ""; + } + + const long parsed = sizeTok.toInt(); + if (parsed < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: size token parse error ('%s')", sizeTok.c_str()); + return ""; + } + const size_t cachedSize = static_cast(parsed); + if (cachedSize != fileSize) { + LOG_DBG("KODoc", "Hash cache invalidated: file size or fingerprint changed (%zu -> %zu)", cachedSize, fileSize); + return ""; + } + + // Validate stored fingerprint format (8 hex characters) + if (fpTok.length() != 8) { + LOG_DBG("KODoc", "Hash cache invalidated: bad fingerprint length (%zu)", fpTok.length()); + return ""; + } + for (size_t i = 0; i < fpTok.length(); ++i) { + char c = fpTok[i]; + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in fingerprint", c); + return ""; + } + } + + { + String currentFpStr(currentFingerprint.c_str()); + if (fpTok != currentFpStr) { + LOG_DBG("KODoc", "Hash cache invalidated: fingerprint changed (%s != %s)", fpTok.c_str(), + currentFingerprint.c_str()); + return ""; + } + } + + std::string hash = content.substring(newlinePos + 1).c_str(); + // Trim any trailing whitespace / line endings + while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) { + hash.pop_back(); + } + + // Hash must be exactly 32 hex characters. + if (hash.size() != 32) { + LOG_DBG("KODoc", "Hash cache invalidated: wrong hash length (%zu)", hash.size()); + return ""; + } + for (char c : hash) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in hash", c); + return ""; + } + } + + LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str()); + return hash; +} + +void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& fingerprint, const std::string& hash) { + // Ensure the book's cache directory exists before writing + const size_t lastSlash = cacheFilePath.rfind('/'); + if (lastSlash != std::string::npos) { + Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str()); + } + + // Format: ":\n" + String content(std::to_string(fileSize).c_str()); + content += ':'; + content += fingerprint.c_str(); + content += '\n'; + content += hash.c_str(); + + if (!Storage.writeFile(cacheFilePath.c_str(), content)) { + LOG_DBG("KODoc", "Failed to write hash cache to %s", cacheFilePath.c_str()); + } +} + std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) { const std::string filename = getFilename(filePath); if (filename.empty()) { @@ -49,6 +175,30 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { } const size_t fileSize = file.fileSize(); + + // Compute a lightweight fingerprint from the file's modification time. + // The underlying FsFile API provides getModifyDateTime which returns two + // packed 16-bit values (date and time). Concatenate these as eight hex + // digits to produce the token stored in the cache header. + uint16_t date = 0, time = 0; + if (!file.getModifyDateTime(&date, &time)) { + // If timestamp isn't available for some reason, fall back to a sentinel. + date = 0; + time = 0; + } + char fpBuf[9]; + // two 16-bit numbers => 4 hex digits each + sprintf(fpBuf, "%04x%04x", date, time); + const std::string fingerprintTok(fpBuf); + + // Return persisted hash if the file size and fingerprint haven't changed. + const std::string cacheFilePath = getCacheFilePath(filePath); + const std::string cached = loadCachedHash(cacheFilePath, fileSize, fingerprintTok); + if (!cached.empty()) { + file.close(); + return cached; + } + LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize); // Initialize MD5 builder @@ -92,5 +242,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead); + saveCachedHash(cacheFilePath, fileSize, fingerprintTok, result); + return result; } diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index 2b6189e2..5f226eb5 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -42,4 +42,31 @@ class KOReaderDocumentId { // Calculate offset for index i: 1024 << (2*i) static size_t getOffset(int i); + + // Hash cache helpers + // Returns the path to the per-book cache file that stores the precomputed hash. + // Uses the same directory convention as the Epub cache (/.crosspoint/epub_/). + static std::string getCacheFilePath(const std::string& filePath); + + // Returns the cached hash if the file size and fingerprint match, or empty + // string on miss/invalidation. + // + // The fingerprint is derived from the file's modification timestamp. We + // call `FsFile::getModifyDateTime` to retrieve two 16‑bit packed values + // supplied by the filesystem: one for the date and one for the time. These + // are concatenated and represented as eight hexadecimal digits in the form + //

index from XPath (0 if unavailable) + bool hasParagraphIndex = false; // True when paragraphIndex was resolved from XPath }; /** @@ -27,9 +29,16 @@ struct KOReaderPosition { * CrossPoint tracks position as (spineIndex, pageNumber). * KOReader uses XPath-like strings + percentage. * - * Since CrossPoint discards HTML structure during parsing, we generate - * synthetic XPath strings based on spine index, using percentage as the - * primary sync mechanism. + * Forward mapping (CrossPoint -> KOReader): + * - Prefer element-level XPath extracted from current spine XHTML. + * - Fallback to synthetic chapter XPath if extraction fails. + * + * Reverse mapping (KOReader -> CrossPoint): + * - Prefer incoming XPath (DocFragment + element path) when resolvable. + * - Fallback to percentage-based approximation when XPath is missing/invalid. + * + * This keeps behavior stable on low-memory devices while improving round-trip + * sync precision when KOReader provides detailed paths. */ class ProgressMapper { public: @@ -45,8 +54,9 @@ class ProgressMapper { /** * Convert KOReader position to CrossPoint format. * - * Note: The returned pageNumber may be approximate since different - * rendering settings produce different page counts. + * Uses XPath-first resolution when possible and percentage fallback otherwise. + * Returned pageNumber can still be approximate because page counts differ + * across renderer/font/layout settings. * * @param epub The EPUB book * @param koPos KOReader position @@ -60,8 +70,7 @@ class ProgressMapper { private: /** * Generate XPath for KOReader compatibility. - * Format: /body/DocFragment[spineIndex+1]/body - * Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning. + * Fallback format: /body/DocFragment[spineIndex + 1]/body */ - static std::string generateXPath(int spineIndex, int pageNumber, int totalPages); + static std::string generateXPath(int spineIndex); }; diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index d670f5fb..2aec1081 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -1,5 +1,7 @@ #include "Logging.h" +#include + #include #define MAX_ENTRY_LEN 256 @@ -41,7 +43,14 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) { // add the timestamp { unsigned long ms = millis(); - int len = snprintf(c, sizeof(buf), "[%lu] ", ms); + char wallClock[12]; + HalClock::formatLogTime(wallClock, sizeof(wallClock)); + int len; + if (wallClock[0] != '\0') { + len = snprintf(c, sizeof(buf), "[%lu %s] ", ms, wallClock); + } else { + len = snprintf(c, sizeof(buf), "[%lu] ", ms); + } if (len < 0) { return; // encoding error, skip logging } diff --git a/lib/QRCode/library.properties b/lib/QRCode/library.properties new file mode 100644 index 00000000..0e09de5b --- /dev/null +++ b/lib/QRCode/library.properties @@ -0,0 +1,10 @@ +name=QRCode +version=1.8.0 +author=Nayuki +maintainer=Nayuki +sentence=QR Code generator library (C port) +paragraph=High-quality QR Code generator library with ECI support. Ported from https://github.com/nayuki/QR-Code-generator +category=Other +url=https://github.com/nayuki/QR-Code-generator +architectures=* +includes=qrcodegen.h diff --git a/lib/QRCode/src/qrcodegen.c b/lib/QRCode/src/qrcodegen.c new file mode 100644 index 00000000..6058110f --- /dev/null +++ b/lib/QRCode/src/qrcodegen.c @@ -0,0 +1,977 @@ +/* + * QR Code generator library (C) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +#include "qrcodegen.h" + +#include +#include +#include +#include + +#ifndef QRCODEGEN_TEST +#define testable static // Keep functions private +#else +#define testable // Expose private functions +#endif + +/*---- Forward declarations for private functions ----*/ + +// Regarding all public and private functions defined in this source file: +// - They require all pointer/array arguments to be not null unless the array length is zero. +// - They only read input scalar/array arguments, write to output pointer/array +// arguments, and return scalar values; they are "pure" functions. +// - They don't read mutable global variables or write to any global variables. +// - They don't perform I/O, read the clock, print to console, etc. +// - They allocate a small and constant amount of stack memory. +// - They don't allocate or free any memory on the heap. +// - They don't recurse or mutually recurse. All the code +// could be inlined into the top-level public functions. +// - They run in at most quadratic time with respect to input arguments. +// Most functions run in linear time, and some in constant time. +// There are no unbounded loops or non-obvious termination conditions. +// - They are completely thread-safe if the caller does not give the +// same writable buffer to concurrent calls to these functions. + +testable void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int* bitLen); + +testable void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]); +testable int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl); +testable int getNumRawDataModules(int ver); + +testable void reedSolomonComputeDivisor(int degree, uint8_t result[]); +testable void reedSolomonComputeRemainder(const uint8_t data[], int dataLen, const uint8_t generator[], int degree, + uint8_t result[]); +testable uint8_t reedSolomonMultiply(uint8_t x, uint8_t y); + +testable void initializeFunctionModules(int version, uint8_t qrcode[]); +static void drawLightFunctionModules(uint8_t qrcode[], int version); +static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]); +testable int getAlignmentPatternPositions(int version, uint8_t result[7]); +static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]); + +static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]); +static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask); +static long getPenaltyScore(const uint8_t qrcode[]); +static int finderPenaltyCountPatterns(const int runHistory[7], int qrsize); +static int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, int runHistory[7], int qrsize); +static void finderPenaltyAddHistory(int currentRunLength, int runHistory[7], int qrsize); + +testable bool getModuleBounded(const uint8_t qrcode[], int x, int y); +testable void setModuleBounded(uint8_t qrcode[], int x, int y, bool isDark); +testable void setModuleUnbounded(uint8_t qrcode[], int x, int y, bool isDark); +static bool getBit(int x, int i); + +testable int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars); +testable int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version); +static int numCharCountBits(enum qrcodegen_Mode mode, int version); + +/*---- Private tables of constants ----*/ + +// The set of all legal characters in alphanumeric mode, where each character +// value maps to the index in the string. For checking text and encoding segments. +static const char* ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + +// Sentinel value for use in only some functions. +#define LENGTH_OVERFLOW -1 + +// For generating error correction codes. +testable const int8_t ECC_CODEWORDS_PER_BLOCK[4][41] = { + // Version: (note that index 0 is for padding, and is set to an illegal value) + // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, + // 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + {-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, + 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Low + {-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, + 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, // Medium + {-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, + 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Quartile + {-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, + 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // High +}; + +#define qrcodegen_REED_SOLOMON_DEGREE_MAX 30 // Based on the table above + +// For generating error correction codes. +testable const int8_t NUM_ERROR_CORRECTION_BLOCKS[4][41] = { + // Version: (note that index 0 is for padding, and is set to an illegal value) + // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + // 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + {-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, + 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25}, // Low + {-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, + 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49}, // Medium + {-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, + 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68}, // Quartile + {-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, + 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81}, // High +}; + +// For automatic mask pattern selection. +static const int PENALTY_N1 = 3; +static const int PENALTY_N2 = 3; +static const int PENALTY_N3 = 40; +static const int PENALTY_N4 = 10; + +/*---- High-level QR Code encoding functions ----*/ + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeText(const char* text, uint8_t tempBuffer[], uint8_t qrcode[], enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) { + size_t textLen = strlen(text); + if (textLen == 0) + return qrcodegen_encodeSegmentsAdvanced(NULL, 0, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode); + size_t bufLen = (size_t)qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion); + + struct qrcodegen_Segment seg; + if (qrcodegen_isNumeric(text)) { + if (qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_NUMERIC, textLen) > bufLen) goto fail; + seg = qrcodegen_makeNumeric(text, tempBuffer); + } else if (qrcodegen_isAlphanumeric(text)) { + if (qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_ALPHANUMERIC, textLen) > bufLen) goto fail; + seg = qrcodegen_makeAlphanumeric(text, tempBuffer); + } else { + if (textLen > bufLen) goto fail; + for (size_t i = 0; i < textLen; i++) tempBuffer[i] = (uint8_t)text[i]; + seg.mode = qrcodegen_Mode_BYTE; + seg.bitLength = calcSegmentBitLength(seg.mode, textLen); + if (seg.bitLength == LENGTH_OVERFLOW) goto fail; + seg.numChars = (int)textLen; + seg.data = tempBuffer; + } + return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, tempBuffer, qrcode); + +fail: + qrcode[0] = 0; // Set size to invalid value for safety + return false; +} + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl) { + struct qrcodegen_Segment seg; + seg.mode = qrcodegen_Mode_BYTE; + seg.bitLength = calcSegmentBitLength(seg.mode, dataLen); + if (seg.bitLength == LENGTH_OVERFLOW) { + qrcode[0] = 0; // Set size to invalid value for safety + return false; + } + seg.numChars = (int)dataLen; + seg.data = dataAndTemp; + return qrcodegen_encodeSegmentsAdvanced(&seg, 1, ecl, minVersion, maxVersion, mask, boostEcl, dataAndTemp, qrcode); +} + +// Appends the given number of low-order bits of the given value to the given byte-based +// bit buffer, increasing the bit length. Requires 0 <= numBits <= 16 and val < 2^numBits. +testable void appendBitsToBuffer(unsigned int val, int numBits, uint8_t buffer[], int* bitLen) { + assert(0 <= numBits && numBits <= 16 && (unsigned long)val >> numBits == 0); + for (int i = numBits - 1; i >= 0; i--, (*bitLen)++) buffer[*bitLen >> 3] |= ((val >> i) & 1) << (7 - (*bitLen & 7)); +} + +/*---- Low-level QR Code encoding functions ----*/ + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + uint8_t tempBuffer[], uint8_t qrcode[]) { + return qrcodegen_encodeSegmentsAdvanced(segs, len, ecl, qrcodegen_VERSION_MIN, qrcodegen_VERSION_MAX, + qrcodegen_Mask_AUTO, true, tempBuffer, qrcode); +} + +// Public function - see documentation comment in header file. +bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl, + uint8_t tempBuffer[], uint8_t qrcode[]) { + assert(segs != NULL || len == 0); + assert(qrcodegen_VERSION_MIN <= minVersion && minVersion <= maxVersion && maxVersion <= qrcodegen_VERSION_MAX); + assert(0 <= (int)ecl && (int)ecl <= 3 && -1 <= (int)mask && (int)mask <= 7); + + // Find the minimal version number to use + int version, dataUsedBits; + for (version = minVersion;; version++) { + int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; // Number of data bits available + dataUsedBits = getTotalBits(segs, len, version); + if (dataUsedBits != LENGTH_OVERFLOW && dataUsedBits <= dataCapacityBits) + break; // This version number is found to be suitable + if (version >= maxVersion) { // All versions in the range could not fit the given data + qrcode[0] = 0; // Set size to invalid value for safety + return false; + } + } + assert(dataUsedBits != LENGTH_OVERFLOW); + + // Increase the error correction level while the data still fits in the current version number + for (int i = (int)qrcodegen_Ecc_MEDIUM; i <= (int)qrcodegen_Ecc_HIGH; i++) { // From low to high + if (boostEcl && dataUsedBits <= getNumDataCodewords(version, (enum qrcodegen_Ecc)i) * 8) + ecl = (enum qrcodegen_Ecc)i; + } + + // Concatenate all segments to create the data bit string + memset(qrcode, 0, (size_t)qrcodegen_BUFFER_LEN_FOR_VERSION(version) * sizeof(qrcode[0])); + int bitLen = 0; + for (size_t i = 0; i < len; i++) { + const struct qrcodegen_Segment* seg = &segs[i]; + appendBitsToBuffer((unsigned int)seg->mode, 4, qrcode, &bitLen); + appendBitsToBuffer((unsigned int)seg->numChars, numCharCountBits(seg->mode, version), qrcode, &bitLen); + for (int j = 0; j < seg->bitLength; j++) { + int bit = (seg->data[j >> 3] >> (7 - (j & 7))) & 1; + appendBitsToBuffer((unsigned int)bit, 1, qrcode, &bitLen); + } + } + assert(bitLen == dataUsedBits); + + // Add terminator and pad up to a byte if applicable + int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; + assert(bitLen <= dataCapacityBits); + int terminatorBits = dataCapacityBits - bitLen; + if (terminatorBits > 4) terminatorBits = 4; + appendBitsToBuffer(0, terminatorBits, qrcode, &bitLen); + appendBitsToBuffer(0, (8 - bitLen % 8) % 8, qrcode, &bitLen); + assert(bitLen % 8 == 0); + + // Pad with alternating bytes until data capacity is reached + for (uint8_t padByte = 0xEC; bitLen < dataCapacityBits; padByte ^= 0xEC ^ 0x11) + appendBitsToBuffer(padByte, 8, qrcode, &bitLen); + + // Compute ECC, draw modules + addEccAndInterleave(qrcode, version, ecl, tempBuffer); + initializeFunctionModules(version, qrcode); + drawCodewords(tempBuffer, getNumRawDataModules(version) / 8, qrcode); + drawLightFunctionModules(qrcode, version); + initializeFunctionModules(version, tempBuffer); + + // Do masking + if (mask == qrcodegen_Mask_AUTO) { // Automatically choose best mask + long minPenalty = LONG_MAX; + for (int i = 0; i < 8; i++) { + enum qrcodegen_Mask msk = (enum qrcodegen_Mask)i; + applyMask(tempBuffer, qrcode, msk); + drawFormatBits(ecl, msk, qrcode); + long penalty = getPenaltyScore(qrcode); + if (penalty < minPenalty) { + mask = msk; + minPenalty = penalty; + } + applyMask(tempBuffer, qrcode, msk); // Undoes the mask due to XOR + } + } + assert(0 <= (int)mask && (int)mask <= 7); + applyMask(tempBuffer, qrcode, mask); // Apply the final choice of mask + drawFormatBits(ecl, mask, qrcode); // Overwrite old format bits + return true; +} + +/*---- Error correction code generation functions ----*/ + +// Appends error correction bytes to each block of the given data array, then interleaves +// bytes from the blocks and stores them in the result array. data[0 : dataLen] contains +// the input data. data[dataLen : rawCodewords] is used as a temporary work area and will +// be clobbered by this function. The final answer is stored in result[0 : rawCodewords]. +testable void addEccAndInterleave(uint8_t data[], int version, enum qrcodegen_Ecc ecl, uint8_t result[]) { + // Calculate parameter numbers + assert(0 <= (int)ecl && (int)ecl < 4 && qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX); + int numBlocks = NUM_ERROR_CORRECTION_BLOCKS[(int)ecl][version]; + int blockEccLen = ECC_CODEWORDS_PER_BLOCK[(int)ecl][version]; + int rawCodewords = getNumRawDataModules(version) / 8; + int dataLen = getNumDataCodewords(version, ecl); + int numShortBlocks = numBlocks - rawCodewords % numBlocks; + int shortBlockDataLen = rawCodewords / numBlocks - blockEccLen; + + // Split data into blocks, calculate ECC, and interleave + // (not concatenate) the bytes into a single sequence + uint8_t rsdiv[qrcodegen_REED_SOLOMON_DEGREE_MAX]; + reedSolomonComputeDivisor(blockEccLen, rsdiv); + const uint8_t* dat = data; + for (int i = 0; i < numBlocks; i++) { + int datLen = shortBlockDataLen + (i < numShortBlocks ? 0 : 1); + uint8_t* ecc = &data[dataLen]; // Temporary storage + reedSolomonComputeRemainder(dat, datLen, rsdiv, blockEccLen, ecc); + for (int j = 0, k = i; j < datLen; j++, k += numBlocks) { // Copy data + if (j == shortBlockDataLen) k -= numShortBlocks; + result[k] = dat[j]; + } + for (int j = 0, k = dataLen + i; j < blockEccLen; j++, k += numBlocks) // Copy ECC + result[k] = ecc[j]; + dat += datLen; + } +} + +// Returns the number of 8-bit codewords that can be used for storing data (not ECC), +// for the given version number and error correction level. The result is in the range [9, 2956]. +testable int getNumDataCodewords(int version, enum qrcodegen_Ecc ecl) { + int v = version, e = (int)ecl; + assert(0 <= e && e < 4); + return getNumRawDataModules(v) / 8 - ECC_CODEWORDS_PER_BLOCK[e][v] * NUM_ERROR_CORRECTION_BLOCKS[e][v]; +} + +// Returns the number of data bits that can be stored in a QR Code of the given version number, after +// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. +// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. +testable int getNumRawDataModules(int ver) { + assert(qrcodegen_VERSION_MIN <= ver && ver <= qrcodegen_VERSION_MAX); + int result = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + int numAlign = ver / 7 + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) result -= 36; + } + assert(208 <= result && result <= 29648); + return result; +} + +/*---- Reed-Solomon ECC generator functions ----*/ + +// Computes a Reed-Solomon ECC generator polynomial for the given degree, storing in result[0 : degree]. +// This could be implemented as a lookup table over all possible parameter values, instead of as an algorithm. +testable void reedSolomonComputeDivisor(int degree, uint8_t result[]) { + assert(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX); + // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1. + // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array {255, 8, 93}. + memset(result, 0, (size_t)degree * sizeof(result[0])); + result[degree - 1] = 1; // Start off with the monomial x^0 + + // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}), + // drop the highest monomial term which is always 1x^degree. + // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D). + uint8_t root = 1; + for (int i = 0; i < degree; i++) { + // Multiply the current product by (x - r^i) + for (int j = 0; j < degree; j++) { + result[j] = reedSolomonMultiply(result[j], root); + if (j + 1 < degree) result[j] ^= result[j + 1]; + } + root = reedSolomonMultiply(root, 0x02); + } +} + +// Computes the Reed-Solomon error correction codeword for the given data and divisor polynomials. +// The remainder when data[0 : dataLen] is divided by divisor[0 : degree] is stored in result[0 : degree]. +// All polynomials are in big endian, and the generator has an implicit leading 1 term. +testable void reedSolomonComputeRemainder(const uint8_t data[], int dataLen, const uint8_t generator[], int degree, + uint8_t result[]) { + assert(1 <= degree && degree <= qrcodegen_REED_SOLOMON_DEGREE_MAX); + memset(result, 0, (size_t)degree * sizeof(result[0])); + for (int i = 0; i < dataLen; i++) { // Polynomial division + uint8_t factor = data[i] ^ result[0]; + memmove(&result[0], &result[1], (size_t)(degree - 1) * sizeof(result[0])); + result[degree - 1] = 0; + for (int j = 0; j < degree; j++) result[j] ^= reedSolomonMultiply(generator[j], factor); + } +} + +#undef qrcodegen_REED_SOLOMON_DEGREE_MAX + +// Returns the product of the two given field elements modulo GF(2^8/0x11D). +// All inputs are valid. This could be implemented as a 256*256 lookup table. +testable uint8_t reedSolomonMultiply(uint8_t x, uint8_t y) { + // Russian peasant multiplication + uint8_t z = 0; + for (int i = 7; i >= 0; i--) { + z = (uint8_t)((z << 1) ^ ((z >> 7) * 0x11D)); + z ^= ((y >> i) & 1) * x; + } + return z; +} + +/*---- Drawing function modules ----*/ + +// Clears the given QR Code grid with light modules for the given +// version's size, then marks every function module as dark. +testable void initializeFunctionModules(int version, uint8_t qrcode[]) { + // Initialize QR Code + int qrsize = version * 4 + 17; + memset(qrcode, 0, (size_t)((qrsize * qrsize + 7) / 8 + 1) * sizeof(qrcode[0])); + qrcode[0] = (uint8_t)qrsize; + + // Fill horizontal and vertical timing patterns + fillRectangle(6, 0, 1, qrsize, qrcode); + fillRectangle(0, 6, qrsize, 1, qrcode); + + // Fill 3 finder patterns (all corners except bottom right) and format bits + fillRectangle(0, 0, 9, 9, qrcode); + fillRectangle(qrsize - 8, 0, 8, 9, qrcode); + fillRectangle(0, qrsize - 8, 9, 8, qrcode); + + // Fill numerous alignment patterns + uint8_t alignPatPos[7]; + int numAlign = getAlignmentPatternPositions(version, alignPatPos); + for (int i = 0; i < numAlign; i++) { + for (int j = 0; j < numAlign; j++) { + // Don't draw on the three finder corners + if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0))) + fillRectangle(alignPatPos[i] - 2, alignPatPos[j] - 2, 5, 5, qrcode); + } + } + + // Fill version blocks + if (version >= 7) { + fillRectangle(qrsize - 11, 0, 3, 6, qrcode); + fillRectangle(0, qrsize - 11, 6, 3, qrcode); + } +} + +// Draws light function modules and possibly some dark modules onto the given QR Code, without changing +// non-function modules. This does not draw the format bits. This requires all function modules to be previously +// marked dark (namely by initializeFunctionModules()), because this may skip redrawing dark function modules. +static void drawLightFunctionModules(uint8_t qrcode[], int version) { + // Draw horizontal and vertical timing patterns + int qrsize = qrcodegen_getSize(qrcode); + for (int i = 7; i < qrsize - 7; i += 2) { + setModuleBounded(qrcode, 6, i, false); + setModuleBounded(qrcode, i, 6, false); + } + + // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules) + for (int dy = -4; dy <= 4; dy++) { + for (int dx = -4; dx <= 4; dx++) { + int dist = abs(dx); + if (abs(dy) > dist) dist = abs(dy); + if (dist == 2 || dist == 4) { + setModuleUnbounded(qrcode, 3 + dx, 3 + dy, false); + setModuleUnbounded(qrcode, qrsize - 4 + dx, 3 + dy, false); + setModuleUnbounded(qrcode, 3 + dx, qrsize - 4 + dy, false); + } + } + } + + // Draw numerous alignment patterns + uint8_t alignPatPos[7]; + int numAlign = getAlignmentPatternPositions(version, alignPatPos); + for (int i = 0; i < numAlign; i++) { + for (int j = 0; j < numAlign; j++) { + if ((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)) + continue; // Don't draw on the three finder corners + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) + setModuleBounded(qrcode, alignPatPos[i] + dx, alignPatPos[j] + dy, dx == 0 && dy == 0); + } + } + } + + // Draw version blocks + if (version >= 7) { + // Calculate error correction code and pack bits + int rem = version; // version is uint6, in the range [7, 40] + for (int i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >> 11) * 0x1F25); + long bits = (long)version << 12 | rem; // uint18 + assert(bits >> 18 == 0); + + // Draw two copies + for (int i = 0; i < 6; i++) { + for (int j = 0; j < 3; j++) { + int k = qrsize - 11 + j; + setModuleBounded(qrcode, k, i, (bits & 1) != 0); + setModuleBounded(qrcode, i, k, (bits & 1) != 0); + bits >>= 1; + } + } + } +} + +// Draws two copies of the format bits (with its own error correction code) based +// on the given mask and error correction level. This always draws all modules of +// the format bits, unlike drawLightFunctionModules() which might skip dark modules. +static void drawFormatBits(enum qrcodegen_Ecc ecl, enum qrcodegen_Mask mask, uint8_t qrcode[]) { + // Calculate error correction code and pack bits + assert(0 <= (int)mask && (int)mask <= 7); + static const int table[] = {1, 0, 3, 2}; + int data = table[(int)ecl] << 3 | (int)mask; // errCorrLvl is uint2, mask is uint3 + int rem = data; + for (int i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) * 0x537); + int bits = (data << 10 | rem) ^ 0x5412; // uint15 + assert(bits >> 15 == 0); + + // Draw first copy + for (int i = 0; i <= 5; i++) setModuleBounded(qrcode, 8, i, getBit(bits, i)); + setModuleBounded(qrcode, 8, 7, getBit(bits, 6)); + setModuleBounded(qrcode, 8, 8, getBit(bits, 7)); + setModuleBounded(qrcode, 7, 8, getBit(bits, 8)); + for (int i = 9; i < 15; i++) setModuleBounded(qrcode, 14 - i, 8, getBit(bits, i)); + + // Draw second copy + int qrsize = qrcodegen_getSize(qrcode); + for (int i = 0; i < 8; i++) setModuleBounded(qrcode, qrsize - 1 - i, 8, getBit(bits, i)); + for (int i = 8; i < 15; i++) setModuleBounded(qrcode, 8, qrsize - 15 + i, getBit(bits, i)); + setModuleBounded(qrcode, 8, qrsize - 8, true); // Always dark +} + +// Calculates and stores an ascending list of positions of alignment patterns +// for this version number, returning the length of the list (in the range [0,7]). +// Each position is in the range [0,177), and are used on both the x and y axes. +// This could be implemented as lookup table of 40 variable-length lists of unsigned bytes. +testable int getAlignmentPatternPositions(int version, uint8_t result[7]) { + if (version == 1) return 0; + int numAlign = version / 7 + 2; + int step = (version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4) * 2; + for (int i = numAlign - 1, pos = version * 4 + 10; i >= 1; i--, pos -= step) result[i] = (uint8_t)pos; + result[0] = 6; + return numAlign; +} + +// Sets every module in the range [left : left + width] * [top : top + height] to dark. +static void fillRectangle(int left, int top, int width, int height, uint8_t qrcode[]) { + for (int dy = 0; dy < height; dy++) { + for (int dx = 0; dx < width; dx++) setModuleBounded(qrcode, left + dx, top + dy, true); + } +} + +/*---- Drawing data modules and masking ----*/ + +// Draws the raw codewords (including data and ECC) onto the given QR Code. This requires the initial state of +// the QR Code to be dark at function modules and light at codeword modules (including unused remainder bits). +static void drawCodewords(const uint8_t data[], int dataLen, uint8_t qrcode[]) { + int qrsize = qrcodegen_getSize(qrcode); + int i = 0; // Bit index into the data + // Do the funny zigzag scan + for (int right = qrsize - 1; right >= 1; right -= 2) { // Index of right column in each column pair + if (right == 6) right = 5; + for (int vert = 0; vert < qrsize; vert++) { // Vertical counter + for (int j = 0; j < 2; j++) { + int x = right - j; // Actual x coordinate + bool upward = ((right + 1) & 2) == 0; + int y = upward ? qrsize - 1 - vert : vert; // Actual y coordinate + if (!getModuleBounded(qrcode, x, y) && i < dataLen * 8) { + bool dark = getBit(data[i >> 3], 7 - (i & 7)); + setModuleBounded(qrcode, x, y, dark); + i++; + } + // If this QR Code has any remainder bits (0 to 7), they were assigned as + // 0/false/light by the constructor and are left unchanged by this method + } + } + } + assert(i == dataLen * 8); +} + +// XORs the codeword modules in this QR Code with the given mask pattern +// and given pattern of function modules. The codeword bits must be drawn +// before masking. Due to the arithmetic of XOR, calling applyMask() with +// the same mask value a second time will undo the mask. A final well-formed +// QR Code needs exactly one (not zero, two, etc.) mask applied. +static void applyMask(const uint8_t functionModules[], uint8_t qrcode[], enum qrcodegen_Mask mask) { + assert(0 <= (int)mask && (int)mask <= 7); // Disallows qrcodegen_Mask_AUTO + int qrsize = qrcodegen_getSize(qrcode); + for (int y = 0; y < qrsize; y++) { + for (int x = 0; x < qrsize; x++) { + if (getModuleBounded(functionModules, x, y)) continue; + bool invert; + switch ((int)mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (x / 3 + y / 2) % 2 == 0; + break; + case 5: + invert = x * y % 2 + x * y % 3 == 0; + break; + case 6: + invert = (x * y % 2 + x * y % 3) % 2 == 0; + break; + case 7: + invert = ((x + y) % 2 + x * y % 3) % 2 == 0; + break; + default: + assert(false); + return; + } + bool val = getModuleBounded(qrcode, x, y); + setModuleBounded(qrcode, x, y, val ^ invert); + } + } +} + +// Calculates and returns the penalty score based on state of the given QR Code's current modules. +// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. +static long getPenaltyScore(const uint8_t qrcode[]) { + int qrsize = qrcodegen_getSize(qrcode); + long result = 0; + + // Adjacent modules in row having same color, and finder-like patterns + for (int y = 0; y < qrsize; y++) { + bool runColor = false; + int runX = 0; + int runHistory[7] = {0}; + for (int x = 0; x < qrsize; x++) { + if (getModuleBounded(qrcode, x, y) == runColor) { + runX++; + if (runX == 5) + result += PENALTY_N1; + else if (runX > 5) + result++; + } else { + finderPenaltyAddHistory(runX, runHistory, qrsize); + if (!runColor) result += finderPenaltyCountPatterns(runHistory, qrsize) * PENALTY_N3; + runColor = getModuleBounded(qrcode, x, y); + runX = 1; + } + } + result += finderPenaltyTerminateAndCount(runColor, runX, runHistory, qrsize) * PENALTY_N3; + } + // Adjacent modules in column having same color, and finder-like patterns + for (int x = 0; x < qrsize; x++) { + bool runColor = false; + int runY = 0; + int runHistory[7] = {0}; + for (int y = 0; y < qrsize; y++) { + if (getModuleBounded(qrcode, x, y) == runColor) { + runY++; + if (runY == 5) + result += PENALTY_N1; + else if (runY > 5) + result++; + } else { + finderPenaltyAddHistory(runY, runHistory, qrsize); + if (!runColor) result += finderPenaltyCountPatterns(runHistory, qrsize) * PENALTY_N3; + runColor = getModuleBounded(qrcode, x, y); + runY = 1; + } + } + result += finderPenaltyTerminateAndCount(runColor, runY, runHistory, qrsize) * PENALTY_N3; + } + + // 2*2 blocks of modules having same color + for (int y = 0; y < qrsize - 1; y++) { + for (int x = 0; x < qrsize - 1; x++) { + bool color = getModuleBounded(qrcode, x, y); + if (color == getModuleBounded(qrcode, x + 1, y) && color == getModuleBounded(qrcode, x, y + 1) && + color == getModuleBounded(qrcode, x + 1, y + 1)) + result += PENALTY_N2; + } + } + + // Balance of dark and light modules + int dark = 0; + for (int y = 0; y < qrsize; y++) { + for (int x = 0; x < qrsize; x++) { + if (getModuleBounded(qrcode, x, y)) dark++; + } + } + int total = qrsize * qrsize; // Note that size is odd, so dark/total != 1/2 + // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)% + int k = (int)((labs(dark * 20L - total * 10L) + total - 1) / total) - 1; + assert(0 <= k && k <= 9); + result += k * PENALTY_N4; + assert(0 <= result && result <= 2568888L); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4 + return result; +} + +// Can only be called immediately after a light run is added, and +// returns either 0, 1, or 2. A helper function for getPenaltyScore(). +static int finderPenaltyCountPatterns(const int runHistory[7], int qrsize) { + int n = runHistory[1]; + assert(n <= qrsize * 3); + (void)qrsize; + bool core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; + // The maximum QR Code size is 177, hence the dark run length n <= 177. + // Arithmetic is promoted to int, so n*4 will not overflow. + return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); +} + +// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). +static int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, int runHistory[7], int qrsize) { + if (currentRunColor) { // Terminate dark run + finderPenaltyAddHistory(currentRunLength, runHistory, qrsize); + currentRunLength = 0; + } + currentRunLength += qrsize; // Add light border to final run + finderPenaltyAddHistory(currentRunLength, runHistory, qrsize); + return finderPenaltyCountPatterns(runHistory, qrsize); +} + +// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). +static void finderPenaltyAddHistory(int currentRunLength, int runHistory[7], int qrsize) { + if (runHistory[0] == 0) currentRunLength += qrsize; // Add light border to initial run + memmove(&runHistory[1], &runHistory[0], 6 * sizeof(runHistory[0])); + runHistory[0] = currentRunLength; +} + +/*---- Basic QR Code information ----*/ + +// Public function - see documentation comment in header file. +int qrcodegen_getSize(const uint8_t qrcode[]) { + assert(qrcode != NULL); + int result = qrcode[0]; + assert((qrcodegen_VERSION_MIN * 4 + 17) <= result && result <= (qrcodegen_VERSION_MAX * 4 + 17)); + return result; +} + +// Public function - see documentation comment in header file. +bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y) { + assert(qrcode != NULL); + int qrsize = qrcode[0]; + return (0 <= x && x < qrsize && 0 <= y && y < qrsize) && getModuleBounded(qrcode, x, y); +} + +// Returns the color of the module at the given coordinates, which must be in bounds. +testable bool getModuleBounded(const uint8_t qrcode[], int x, int y) { + int qrsize = qrcode[0]; + assert(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize); + int index = y * qrsize + x; + return getBit(qrcode[(index >> 3) + 1], index & 7); +} + +// Sets the color of the module at the given coordinates, which must be in bounds. +testable void setModuleBounded(uint8_t qrcode[], int x, int y, bool isDark) { + int qrsize = qrcode[0]; + assert(21 <= qrsize && qrsize <= 177 && 0 <= x && x < qrsize && 0 <= y && y < qrsize); + int index = y * qrsize + x; + int bitIndex = index & 7; + int byteIndex = (index >> 3) + 1; + if (isDark) + qrcode[byteIndex] |= 1 << bitIndex; + else + qrcode[byteIndex] &= (1 << bitIndex) ^ 0xFF; +} + +// Sets the color of the module at the given coordinates, doing nothing if out of bounds. +testable void setModuleUnbounded(uint8_t qrcode[], int x, int y, bool isDark) { + int qrsize = qrcode[0]; + if (0 <= x && x < qrsize && 0 <= y && y < qrsize) setModuleBounded(qrcode, x, y, isDark); +} + +// Returns true iff the i'th bit of x is set to 1. Requires x >= 0 and 0 <= i <= 14. +static bool getBit(int x, int i) { return ((x >> i) & 1) != 0; } + +/*---- Segment handling ----*/ + +// Public function - see documentation comment in header file. +bool qrcodegen_isNumeric(const char* text) { + assert(text != NULL); + for (; *text != '\0'; text++) { + if (*text < '0' || *text > '9') return false; + } + return true; +} + +// Public function - see documentation comment in header file. +bool qrcodegen_isAlphanumeric(const char* text) { + assert(text != NULL); + for (; *text != '\0'; text++) { + if (strchr(ALPHANUMERIC_CHARSET, *text) == NULL) return false; + } + return true; +} + +// Public function - see documentation comment in header file. +size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars) { + int temp = calcSegmentBitLength(mode, numChars); + if (temp == LENGTH_OVERFLOW) return SIZE_MAX; + assert(0 <= temp && temp <= INT16_MAX); + return ((size_t)temp + 7) / 8; +} + +// Returns the number of data bits needed to represent a segment +// containing the given number of characters using the given mode. Notes: +// - Returns LENGTH_OVERFLOW on failure, i.e. numChars > INT16_MAX +// or the number of needed bits exceeds INT16_MAX (i.e. 32767). +// - Otherwise, all valid results are in the range [0, INT16_MAX]. +// - For byte mode, numChars measures the number of bytes, not Unicode code points. +// - For ECI mode, numChars must be 0, and the worst-case number of bits is returned. +// An actual ECI segment can have shorter data. For non-ECI modes, the result is exact. +testable int calcSegmentBitLength(enum qrcodegen_Mode mode, size_t numChars) { + // All calculations are designed to avoid overflow on all platforms + if (numChars > (unsigned int)INT16_MAX) return LENGTH_OVERFLOW; + long result = (long)numChars; + if (mode == qrcodegen_Mode_NUMERIC) + result = (result * 10 + 2) / 3; // ceil(10/3 * n) + else if (mode == qrcodegen_Mode_ALPHANUMERIC) + result = (result * 11 + 1) / 2; // ceil(11/2 * n) + else if (mode == qrcodegen_Mode_BYTE) + result *= 8; + else if (mode == qrcodegen_Mode_KANJI) + result *= 13; + else if (mode == qrcodegen_Mode_ECI && numChars == 0) + result = 3 * 8; + else { // Invalid argument + assert(false); + return LENGTH_OVERFLOW; + } + assert(result >= 0); + if (result > INT16_MAX) return LENGTH_OVERFLOW; + return (int)result; +} + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]) { + assert(data != NULL || len == 0); + struct qrcodegen_Segment result; + result.mode = qrcodegen_Mode_BYTE; + result.bitLength = calcSegmentBitLength(result.mode, len); + assert(result.bitLength != LENGTH_OVERFLOW); + result.numChars = (int)len; + if (len > 0) memcpy(buf, data, len * sizeof(buf[0])); + result.data = buf; + return result; +} + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeNumeric(const char* digits, uint8_t buf[]) { + assert(digits != NULL); + struct qrcodegen_Segment result; + size_t len = strlen(digits); + result.mode = qrcodegen_Mode_NUMERIC; + int bitLen = calcSegmentBitLength(result.mode, len); + assert(bitLen != LENGTH_OVERFLOW); + result.numChars = (int)len; + if (bitLen > 0) memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0])); + result.bitLength = 0; + + unsigned int accumData = 0; + int accumCount = 0; + for (; *digits != '\0'; digits++) { + char c = *digits; + assert('0' <= c && c <= '9'); + accumData = accumData * 10 + (unsigned int)(c - '0'); + accumCount++; + if (accumCount == 3) { + appendBitsToBuffer(accumData, 10, buf, &result.bitLength); + accumData = 0; + accumCount = 0; + } + } + if (accumCount > 0) // 1 or 2 digits remaining + appendBitsToBuffer(accumData, accumCount * 3 + 1, buf, &result.bitLength); + assert(result.bitLength == bitLen); + result.data = buf; + return result; +} + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char* text, uint8_t buf[]) { + assert(text != NULL); + struct qrcodegen_Segment result; + size_t len = strlen(text); + result.mode = qrcodegen_Mode_ALPHANUMERIC; + int bitLen = calcSegmentBitLength(result.mode, len); + assert(bitLen != LENGTH_OVERFLOW); + result.numChars = (int)len; + if (bitLen > 0) memset(buf, 0, ((size_t)bitLen + 7) / 8 * sizeof(buf[0])); + result.bitLength = 0; + + unsigned int accumData = 0; + int accumCount = 0; + for (; *text != '\0'; text++) { + const char* temp = strchr(ALPHANUMERIC_CHARSET, *text); + assert(temp != NULL); + accumData = accumData * 45 + (unsigned int)(temp - ALPHANUMERIC_CHARSET); + accumCount++; + if (accumCount == 2) { + appendBitsToBuffer(accumData, 11, buf, &result.bitLength); + accumData = 0; + accumCount = 0; + } + } + if (accumCount > 0) // 1 character remaining + appendBitsToBuffer(accumData, 6, buf, &result.bitLength); + assert(result.bitLength == bitLen); + result.data = buf; + return result; +} + +// Public function - see documentation comment in header file. +struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]) { + struct qrcodegen_Segment result; + result.mode = qrcodegen_Mode_ECI; + result.numChars = 0; + result.bitLength = 0; + if (assignVal < 0) + assert(false); + else if (assignVal < (1 << 7)) { + memset(buf, 0, 1 * sizeof(buf[0])); + appendBitsToBuffer((unsigned int)assignVal, 8, buf, &result.bitLength); + } else if (assignVal < (1 << 14)) { + memset(buf, 0, 2 * sizeof(buf[0])); + appendBitsToBuffer(2, 2, buf, &result.bitLength); + appendBitsToBuffer((unsigned int)assignVal, 14, buf, &result.bitLength); + } else if (assignVal < 1000000L) { + memset(buf, 0, 3 * sizeof(buf[0])); + appendBitsToBuffer(6, 3, buf, &result.bitLength); + appendBitsToBuffer((unsigned int)(assignVal >> 10), 11, buf, &result.bitLength); + appendBitsToBuffer((unsigned int)(assignVal & 0x3FF), 10, buf, &result.bitLength); + } else + assert(false); + result.data = buf; + return result; +} + +// Calculates the number of bits needed to encode the given segments at the given version. +// Returns a non-negative number if successful. Otherwise returns LENGTH_OVERFLOW if a segment +// has too many characters to fit its length field, or the total bits exceeds INT16_MAX. +testable int getTotalBits(const struct qrcodegen_Segment segs[], size_t len, int version) { + assert(segs != NULL || len == 0); + long result = 0; + for (size_t i = 0; i < len; i++) { + int numChars = segs[i].numChars; + int bitLength = segs[i].bitLength; + assert(0 <= numChars && numChars <= INT16_MAX); + assert(0 <= bitLength && bitLength <= INT16_MAX); + int ccbits = numCharCountBits(segs[i].mode, version); + assert(0 <= ccbits && ccbits <= 16); + if (numChars >= (1L << ccbits)) return LENGTH_OVERFLOW; // The segment's length doesn't fit the field's bit width + result += 4L + ccbits + bitLength; + if (result > INT16_MAX) return LENGTH_OVERFLOW; // The sum might overflow an int type + } + assert(0 <= result && result <= INT16_MAX); + return (int)result; +} + +// Returns the bit width of the character count field for a segment in the given mode +// in a QR Code at the given version number. The result is in the range [0, 16]. +static int numCharCountBits(enum qrcodegen_Mode mode, int version) { + assert(qrcodegen_VERSION_MIN <= version && version <= qrcodegen_VERSION_MAX); + int i = (version + 7) / 17; + switch (mode) { + case qrcodegen_Mode_NUMERIC: { + static const int temp[] = {10, 12, 14}; + return temp[i]; + } + case qrcodegen_Mode_ALPHANUMERIC: { + static const int temp[] = {9, 11, 13}; + return temp[i]; + } + case qrcodegen_Mode_BYTE: { + static const int temp[] = {8, 16, 16}; + return temp[i]; + } + case qrcodegen_Mode_KANJI: { + static const int temp[] = {8, 10, 12}; + return temp[i]; + } + case qrcodegen_Mode_ECI: + return 0; + default: + assert(false); + return -1; // Dummy value + } +} + +#undef LENGTH_OVERFLOW diff --git a/lib/QRCode/src/qrcodegen.h b/lib/QRCode/src/qrcodegen.h new file mode 100644 index 00000000..53fda7f9 --- /dev/null +++ b/lib/QRCode/src/qrcodegen.h @@ -0,0 +1,363 @@ +/* + * QR Code generator library (C) + * + * Copyright (c) Project Nayuki. (MIT License) + * https://www.nayuki.io/page/qr-code-generator-library + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * - The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * - The Software is provided "as is", without warranty of any kind, express or + * implied, including but not limited to the warranties of merchantability, + * fitness for a particular purpose and noninfringement. In no event shall the + * authors or copyright holders be liable for any claim, damages or other + * liability, whether in an action of contract, tort or otherwise, arising from, + * out of or in connection with the Software or the use or other dealings in the + * Software. + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * This library creates QR Code symbols, which is a type of two-dimension barcode. + * Invented by Denso Wave and described in the ISO/IEC 18004 standard. + * A QR Code structure is an immutable square grid of dark and light cells. + * The library provides functions to create a QR Code from text or binary data. + * The library covers the QR Code Model 2 specification, supporting all versions (sizes) + * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. + * + * Ways to create a QR Code object: + * - High level: Take the payload data and call qrcodegen_encodeText() or qrcodegen_encodeBinary(). + * - Low level: Custom-make the list of segments and call + * qrcodegen_encodeSegments() or qrcodegen_encodeSegmentsAdvanced(). + * (Note that all ways require supplying the desired error correction level and various byte buffers.) + */ + +/*---- Enum and struct types----*/ + +/* + * The error correction level in a QR Code symbol. + */ +enum qrcodegen_Ecc { + // Must be declared in ascending order of error protection + // so that an internal qrcodegen function works properly + qrcodegen_Ecc_LOW = 0, // The QR Code can tolerate about 7% erroneous codewords + qrcodegen_Ecc_MEDIUM, // The QR Code can tolerate about 15% erroneous codewords + qrcodegen_Ecc_QUARTILE, // The QR Code can tolerate about 25% erroneous codewords + qrcodegen_Ecc_HIGH, // The QR Code can tolerate about 30% erroneous codewords +}; + +/* + * The mask pattern used in a QR Code symbol. + */ +enum qrcodegen_Mask { + // A special value to tell the QR Code encoder to + // automatically select an appropriate mask pattern + qrcodegen_Mask_AUTO = -1, + // The eight actual mask patterns + qrcodegen_Mask_0 = 0, + qrcodegen_Mask_1, + qrcodegen_Mask_2, + qrcodegen_Mask_3, + qrcodegen_Mask_4, + qrcodegen_Mask_5, + qrcodegen_Mask_6, + qrcodegen_Mask_7, +}; + +/* + * Describes how a segment's data bits are interpreted. + */ +enum qrcodegen_Mode { + qrcodegen_Mode_NUMERIC = 0x1, + qrcodegen_Mode_ALPHANUMERIC = 0x2, + qrcodegen_Mode_BYTE = 0x4, + qrcodegen_Mode_KANJI = 0x8, + qrcodegen_Mode_ECI = 0x7, +}; + +/* + * A segment of character/binary/control data in a QR Code symbol. + * The mid-level way to create a segment is to take the payload data + * and call a factory function such as qrcodegen_makeNumeric(). + * The low-level way to create a segment is to custom-make the bit buffer + * and initialize a qrcodegen_Segment struct with appropriate values. + * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. + * Any segment longer than this is meaningless for the purpose of generating QR Codes. + * Moreover, the maximum allowed bit length is 32767 because + * the largest QR Code (version 40) has 31329 modules. + */ +struct qrcodegen_Segment { + // The mode indicator of this segment. + enum qrcodegen_Mode mode; + + // The length of this segment's unencoded data. Measured in characters for + // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. + // Always zero or positive. Not the same as the data's bit length. + int numChars; + + // The data bits of this segment, packed in bitwise big endian. + // Can be null if the bit length is zero. + uint8_t* data; + + // The number of valid data bits used in the buffer. Requires + // 0 <= bitLength <= 32767, and bitLength <= (capacity of data array) * 8. + // The character count (numChars) must agree with the mode and the bit buffer length. + int bitLength; +}; + +/*---- Macro constants and functions ----*/ + +#define qrcodegen_VERSION_MIN 1 // The minimum version number supported in the QR Code Model 2 standard +#define qrcodegen_VERSION_MAX 40 // The maximum version number supported in the QR Code Model 2 standard + +// Calculates the number of bytes needed to store any QR Code up to and including the given version number, +// as a compile-time constant. For example, 'uint8_t buffer[qrcodegen_BUFFER_LEN_FOR_VERSION(25)];' +// can store any single QR Code from version 1 to 25 (inclusive). The result fits in an int (or int16). +// Requires qrcodegen_VERSION_MIN <= n <= qrcodegen_VERSION_MAX. +#define qrcodegen_BUFFER_LEN_FOR_VERSION(n) ((((n) * 4 + 17) * ((n) * 4 + 17) + 7) / 8 + 1) + +// The worst-case number of bytes needed to store one QR Code, up to and including +// version 40. This value equals 3918, which is just under 4 kilobytes. +// Use this more convenient value to avoid calculating tighter memory bounds for buffers. +#define qrcodegen_BUFFER_LEN_MAX qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX) + +/*---- Functions (high level) to generate QR Codes ----*/ + +/* + * Encodes the given text string to a QR Code, returning true if successful. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * + * The input text must be encoded in UTF-8 and contain no NULs. + * Requires 1 <= minVersion <= maxVersion <= 40. + * + * The smallest possible QR Code version within the given range is automatically + * chosen for the output. Iff boostEcl is true, then the ECC level of the result + * may be higher than the ecl argument if it can be done without increasing the + * version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or + * qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow). + * + * About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion): + * - Before calling the function: + * - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The initial state of both ranges can be uninitialized + * because the function always writes before reading. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - tempBuffer contains no useful data and should be treated as entirely uninitialized. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * If successful, the resulting QR Code may use numeric, + * alphanumeric, or byte mode to encode the text. + * + * In the most optimistic case, a QR Code at version 40 with low ECC + * can hold any UTF-8 string up to 2953 bytes, or any alphanumeric string + * up to 4296 characters, or any digit string up to 7089 characters. + * These numbers represent the hard upper limit of the QR Code standard. + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + */ +bool qrcodegen_encodeText(const char* text, uint8_t tempBuffer[], uint8_t qrcode[], enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl); + +/* + * Encodes the given binary data to a QR Code, returning true if successful. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * + * Requires 1 <= minVersion <= maxVersion <= 40. + * + * The smallest possible QR Code version within the given range is automatically + * chosen for the output. Iff boostEcl is true, then the ECC level of the result + * may be higher than the ecl argument if it can be done without increasing the + * version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or + * qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow). + * + * About the arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(maxVersion): + * - Before calling the function: + * - The array ranges dataAndTemp[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The input array range dataAndTemp[0 : dataLen] should normally be + * valid UTF-8 text, but is not required by the QR Code standard. + * - The initial state of dataAndTemp[dataLen : len] and qrcode[0 : len] + * can be uninitialized because the function always writes before reading. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - dataAndTemp contains no useful data and should be treated as entirely uninitialized. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * If successful, the resulting QR Code will use byte mode to encode the data. + * + * In the most optimistic case, a QR Code at version 40 with low ECC can hold any byte + * sequence up to length 2953. This is the hard upper limit of the QR Code standard. + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + */ +bool qrcodegen_encodeBinary(uint8_t dataAndTemp[], size_t dataLen, uint8_t qrcode[], enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl); + +/*---- Functions (low level) to generate QR Codes ----*/ + +/* + * Encodes the given segments to a QR Code, returning true if successful. + * If the data is too long to fit in any version at the given ECC level, + * then false is returned. + * + * The smallest possible QR Code version is automatically chosen for + * the output. The ECC level of the result may be higher than the + * ecl argument if it can be done without increasing the version. + * + * About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX): + * - Before calling the function: + * - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The initial state of both ranges can be uninitialized + * because the function always writes before reading. + * - The input array segs can contain segments whose data buffers overlap with tempBuffer. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - tempBuffer contains no useful data and should be treated as entirely uninitialized. + * - Any segment whose data buffer overlaps with tempBuffer[0 : len] + * must be treated as having invalid values in that array. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + * + * This function allows the user to create a custom sequence of segments that switches + * between modes (such as alphanumeric and byte) to encode text in less space. + * This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary(). + */ +bool qrcodegen_encodeSegments(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + uint8_t tempBuffer[], uint8_t qrcode[]); + +/* + * Encodes the given segments to a QR Code, returning true if successful. + * If the data is too long to fit in any version in the given range + * at the given ECC level, then false is returned. + * + * Requires 1 <= minVersion <= maxVersion <= 40. + * + * The smallest possible QR Code version within the given range is automatically + * chosen for the output. Iff boostEcl is true, then the ECC level of the result + * may be higher than the ecl argument if it can be done without increasing the + * version. The mask is either between qrcodegen_Mask_0 to 7 to force that mask, or + * qrcodegen_Mask_AUTO to automatically choose an appropriate mask (which may be slow). + * + * About the byte arrays, letting len = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX): + * - Before calling the function: + * - The array ranges tempBuffer[0 : len] and qrcode[0 : len] must allow + * reading and writing; hence each array must have a length of at least len. + * - The two ranges must not overlap (aliasing). + * - The initial state of both ranges can be uninitialized + * because the function always writes before reading. + * - The input array segs can contain segments whose data buffers overlap with tempBuffer. + * - After the function returns: + * - Both ranges have no guarantee on which elements are initialized and what values are stored. + * - tempBuffer contains no useful data and should be treated as entirely uninitialized. + * - Any segment whose data buffer overlaps with tempBuffer[0 : len] + * must be treated as having invalid values in that array. + * - If successful, qrcode can be passed into qrcodegen_getSize() and qrcodegen_getModule(). + * + * Please consult the QR Code specification for information on + * data capacities per version, ECC level, and text encoding mode. + * + * This function allows the user to create a custom sequence of segments that switches + * between modes (such as alphanumeric and byte) to encode text in less space. + * This is a low-level API; the high-level API is qrcodegen_encodeText() and qrcodegen_encodeBinary(). + */ +bool qrcodegen_encodeSegmentsAdvanced(const struct qrcodegen_Segment segs[], size_t len, enum qrcodegen_Ecc ecl, + int minVersion, int maxVersion, enum qrcodegen_Mask mask, bool boostEcl, + uint8_t tempBuffer[], uint8_t qrcode[]); + +/* + * Tests whether the given string can be encoded as a segment in numeric mode. + * A string is encodable iff each character is in the range 0 to 9. + */ +bool qrcodegen_isNumeric(const char* text); + +/* + * Tests whether the given string can be encoded as a segment in alphanumeric mode. + * A string is encodable iff each character is in the following set: 0 to 9, A to Z + * (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + */ +bool qrcodegen_isAlphanumeric(const char* text); + +/* + * Returns the number of bytes (uint8_t) needed for the data buffer of a segment + * containing the given number of characters using the given mode. Notes: + * - Returns SIZE_MAX on failure, i.e. numChars > INT16_MAX or the internal + * calculation of the number of needed bits exceeds INT16_MAX (i.e. 32767). + * - Otherwise, all valid results are in the range [0, ceil(INT16_MAX / 8)], i.e. at most 4096. + * - It is okay for the user to allocate more bytes for the buffer than needed. + * - For byte mode, numChars measures the number of bytes, not Unicode code points. + * - For ECI mode, numChars must be 0, and the worst-case number of bytes is returned. + * An actual ECI segment can have shorter data. For non-ECI modes, the result is exact. + */ +size_t qrcodegen_calcSegmentBufferSize(enum qrcodegen_Mode mode, size_t numChars); + +/* + * Returns a segment representing the given binary data encoded in + * byte mode. All input byte arrays are acceptable. Any text string + * can be converted to UTF-8 bytes and encoded as a byte mode segment. + */ +struct qrcodegen_Segment qrcodegen_makeBytes(const uint8_t data[], size_t len, uint8_t buf[]); + +/* + * Returns a segment representing the given string of decimal digits encoded in numeric mode. + */ +struct qrcodegen_Segment qrcodegen_makeNumeric(const char* digits, uint8_t buf[]); + +/* + * Returns a segment representing the given text string encoded in alphanumeric mode. + * The characters allowed are: 0 to 9, A to Z (uppercase only), space, + * dollar, percent, asterisk, plus, hyphen, period, slash, colon. + */ +struct qrcodegen_Segment qrcodegen_makeAlphanumeric(const char* text, uint8_t buf[]); + +/* + * Returns a segment representing an Extended Channel Interpretation + * (ECI) designator with the given assignment value. + */ +struct qrcodegen_Segment qrcodegen_makeEci(long assignVal, uint8_t buf[]); + +/*---- Functions to extract raw data from QR Codes ----*/ + +/* + * Returns the side length of the given QR Code, assuming that encoding succeeded. + * The result is in the range [21, 177]. Note that the length of the array buffer + * is related to the side length - every 'uint8_t qrcode[]' must have length at least + * qrcodegen_BUFFER_LEN_FOR_VERSION(version), which equals ceil(size^2 / 8 + 1). + */ +int qrcodegen_getSize(const uint8_t qrcode[]); + +/* + * Returns the color of the module (pixel) at the given coordinates, which is false + * for light or true for dark. The top left corner has the coordinates (x=0, y=0). + * If the given coordinates are out of bounds, then false (light) is returned. + */ +bool qrcodegen_getModule(const uint8_t qrcode[], int x, int y); + +#ifdef __cplusplus +} +#endif diff --git a/lib/Serialization/Serialization.h b/lib/Serialization/Serialization.h index 1308822f..e5a885cc 100644 --- a/lib/Serialization/Serialization.h +++ b/lib/Serialization/Serialization.h @@ -36,17 +36,31 @@ static void writeString(FsFile& file, const std::string& s) { file.write(reinterpret_cast(s.data()), len); } -static void readString(std::istream& is, std::string& s) { +constexpr uint32_t MAX_STRING_LENGTH = 4096; + +static bool readString(std::istream& is, std::string& s) { uint32_t len; readPod(is, len); + if (len > MAX_STRING_LENGTH) { + is.seekg(len, std::ios::cur); // skip payload to keep stream aligned + return false; + } s.resize(len); is.read(&s[0], len); + return true; } -static void readString(FsFile& file, std::string& s) { +static bool readString(FsFile& file, std::string& s) { uint32_t len; readPod(file, len); + if (len > MAX_STRING_LENGTH) { + if (!file.seekCur(static_cast(len))) { // skip payload to keep file position aligned + return false; + } + return false; + } s.resize(len); - file.read(&s[0], len); + file.read(reinterpret_cast(&s[0]), len); + return true; } } // namespace serialization diff --git a/lib/Weather/WeatherClient.cpp b/lib/Weather/WeatherClient.cpp new file mode 100644 index 00000000..84c0d251 --- /dev/null +++ b/lib/Weather/WeatherClient.cpp @@ -0,0 +1,391 @@ +#include "WeatherClient.h" + +#include +#include +#include + +#include +#include + +#include "../../src/network/HttpDownloader.h" + +namespace { +constexpr char WEATHER_CACHE_FILE[] = "/.crosspoint/weather_cache.json"; + +std::string buildForecastUrl(const WeatherSettingsStore& settings) { + std::string url = "https://api.open-meteo.com/v1/forecast?"; + url += "latitude=" + std::to_string(settings.getLatitude()); + url += "&longitude=" + std::to_string(settings.getLongitude()); + url += + "¤t=temperature_2m,relative_humidity_2m,apparent_temperature," + "weather_code,wind_speed_10m,wind_direction_10m,is_day,precipitation,uv_index"; + url += "&hourly=temperature_2m,precipitation,precipitation_probability,weather_code,is_day"; + url += + "&daily=temperature_2m_max,temperature_2m_min,weather_code," + "precipitation_sum,sunrise,sunset,uv_index_max"; + url += "&timezone=auto&timeformat=unixtime"; + url += std::string("&temperature_unit=") + settings.getTempUnitParam(); + url += std::string("&wind_speed_unit=") + settings.getWindUnitParam(); + url += std::string("&precipitation_unit=") + settings.getPrecipUnitParam(); + url += "&forecast_days=" + std::to_string(settings.getForecastDays()); + // Request 48 hours of hourly data + url += "&forecast_hours=48"; + return url; +} + +std::string urlEncode(const std::string& value) { + std::string out; + out.reserve(value.size() * 3); + for (unsigned char c : value) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out.push_back(c); + } else { + char buf[4]; + snprintf(buf, sizeof(buf), "%%%02X", c); + out.append(buf); + } + } + return out; +} + +std::string buildRequestSignature(const WeatherSettingsStore& settings) { + char latitude[24]; + char longitude[24]; + snprintf(latitude, sizeof(latitude), "%.6f", settings.getLatitude()); + snprintf(longitude, sizeof(longitude), "%.6f", settings.getLongitude()); + + std::string signature = "lat="; + signature += latitude; + signature += "|lon="; + signature += longitude; + signature += "|temp="; + signature += settings.getTempUnitParam(); + signature += "|wind="; + signature += settings.getWindUnitParam(); + signature += "|precip="; + signature += settings.getPrecipUnitParam(); + signature += "|days="; + signature += std::to_string(settings.getForecastDays()); + return signature; +} +} // namespace + +std::string WeatherClient::buildRequestSignature(const WeatherSettingsStore& settings) { + return ::buildRequestSignature(settings); +} + +WeatherData WeatherClient::getWeather(const WeatherSettingsStore& settings, bool forceRefresh) { + if (!settings.hasLocation()) { + WeatherData data; + data.errorMessage = "No location configured"; + return data; + } + + const std::string requestSignature = buildRequestSignature(settings); + + if (!forceRefresh) { + WeatherData cached; + if (loadCache(cached) && cached.valid) { + if (cached.requestSignature != requestSignature) { + LOG_DBG("WEA", "Ignoring cache with mismatched request signature"); + Storage.remove(WEATHER_CACHE_FILE); + } else { + time_t now; + time(&now); + if (now - cached.fetchedAt < CACHE_TTL_SECONDS) { + LOG_DBG("WEA", "Using cached weather data (age: %ld s)", (long)(now - cached.fetchedAt)); + return cached; + } + LOG_DBG("WEA", "Cache expired (age: %ld s)", (long)(now - cached.fetchedAt)); + // Cache-first behavior: return stale cache and let the caller decide + // whether/when to perform a network refresh. + return cached; + } + } + + LOG_DBG("WEA", "No cache available; caller should establish network and force refresh"); + WeatherData data; + data.errorMessage = "No cache"; + return data; + } + + return fetchFromApi(settings); +} + +WeatherData WeatherClient::fetchFromApi(const WeatherSettingsStore& settings) { + WeatherData data; + data.requestSignature = buildRequestSignature(settings); + std::string url = buildForecastUrl(settings); + LOG_DBG("WEA", "fetchFromApi[1] start"); + LOG_DBG("WEA", "fetchFromApi[2] url length=%zu", url.size()); + LOG_DBG("WEA", "Fetching weather from API"); + + std::string response; + LOG_DBG("WEA", "fetchFromApi[3] HttpDownloader::fetchUrl before"); + if (!HttpDownloader::fetchUrl(url, response)) { + data.errorMessage = "Network error"; + LOG_ERR("WEA", "Failed to fetch weather data"); + return data; + } + LOG_DBG("WEA", "fetchFromApi[4] HttpDownloader::fetchUrl after; bytes=%zu", response.size()); + + LOG_DBG("WEA", "fetchFromApi[5] parseWeatherJson before"); + if (!parseWeatherJson(response, data)) { + LOG_ERR("WEA", "Failed to parse weather JSON"); + return data; + } + LOG_DBG("WEA", "fetchFromApi[6] parseWeatherJson after"); + + data.valid = true; + time(&data.fetchedAt); + saveCache(data); + LOG_DBG("WEA", "fetchFromApi[7] Weather data fetched and cached"); + return data; +} + +bool WeatherClient::parseWeatherJson(const std::string& json, WeatherData& data) { + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + data.errorMessage = "JSON parse error"; + LOG_ERR("WEA", "JSON parse error: %s", error.c_str()); + return false; + } + + // Check for API error + if (doc["error"] | false) { + data.errorMessage = doc["reason"] | "API error"; + LOG_ERR("WEA", "API error: %s", data.errorMessage.c_str()); + return false; + } + + data.timezone = doc["timezone"] | std::string(""); + data.utcOffsetSeconds = doc["utc_offset_seconds"] | 0; + + // Parse current weather + JsonObject current = doc["current"]; + if (current) { + data.current.temperature = current["temperature_2m"] | 0.0f; + data.current.apparentTemperature = current["apparent_temperature"] | 0.0f; + data.current.humidity = current["relative_humidity_2m"] | 0; + data.current.weatherCode = current["weather_code"] | 0; + data.current.windSpeed = current["wind_speed_10m"] | 0.0f; + data.current.windDirection = current["wind_direction_10m"] | 0; + data.current.precipitation = current["precipitation"] | 0.0f; + data.current.uvIndex = current["uv_index"] | 0.0f; + data.current.isDay = (current["is_day"] | 1) != 0; + } + + // Parse daily forecast + JsonObject daily = doc["daily"]; + if (daily) { + JsonArray times = daily["time"]; + JsonArray tempMax = daily["temperature_2m_max"]; + JsonArray tempMin = daily["temperature_2m_min"]; + JsonArray codes = daily["weather_code"]; + JsonArray precip = daily["precipitation_sum"]; + JsonArray sunrise = daily["sunrise"]; + JsonArray sunset = daily["sunset"]; + JsonArray uvMax = daily["uv_index_max"]; + + size_t count = times.size(); + data.daily.reserve(count); + for (size_t i = 0; i < count; i++) { + DailyForecast day; + day.date = times[i] | (time_t)0; + day.tempMax = tempMax[i] | 0.0f; + day.tempMin = tempMin[i] | 0.0f; + day.weatherCode = codes[i] | 0; + day.precipSum = precip[i] | 0.0f; + day.sunrise = sunrise[i] | (time_t)0; + day.sunset = sunset[i] | (time_t)0; + day.uvIndexMax = uvMax[i] | 0.0f; + day.moonPhase = daily["moon_phase"] ? (daily["moon_phase"][i] | -1.0f) : -1.0f; + data.daily.push_back(day); + } + } + + // Parse hourly forecast + JsonObject hourly = doc["hourly"]; + if (hourly) { + JsonArray times = hourly["time"]; + JsonArray temp = hourly["temperature_2m"]; + JsonArray precip = hourly["precipitation"]; + JsonArray precipProb = hourly["precipitation_probability"]; + JsonArray codes = hourly["weather_code"]; + JsonArray isDay = hourly["is_day"]; + + size_t count = times.size(); + data.hourly.reserve(count); + for (size_t i = 0; i < count; i++) { + HourlyForecast hour; + hour.time = times[i] | (time_t)0; + hour.temperature = temp[i] | 0.0f; + hour.precipitation = precip[i] | 0.0f; + hour.precipitationProbability = precipProb[i] | 0; + hour.weatherCode = codes[i] | 0; + hour.isDay = (isDay[i] | 1) != 0; + data.hourly.push_back(hour); + } + } + + return true; +} + +bool WeatherClient::saveCache(const WeatherData& data) { + Storage.mkdir("/.crosspoint"); + + JsonDocument doc; + doc["requestSignature"] = data.requestSignature; + doc["fetchedAt"] = data.fetchedAt; + doc["timezone"] = data.timezone; + doc["utcOffsetSeconds"] = data.utcOffsetSeconds; + + // Current + JsonObject cur = doc["current"].to(); + cur["temperature"] = data.current.temperature; + cur["apparentTemperature"] = data.current.apparentTemperature; + cur["humidity"] = data.current.humidity; + cur["weatherCode"] = data.current.weatherCode; + cur["windSpeed"] = data.current.windSpeed; + cur["windDirection"] = data.current.windDirection; + cur["precipitation"] = data.current.precipitation; + cur["uvIndex"] = data.current.uvIndex; + cur["isDay"] = data.current.isDay; + + // Daily + JsonArray dailyArr = doc["daily"].to(); + for (const auto& day : data.daily) { + JsonObject d = dailyArr.add(); + d["date"] = day.date; + d["tempMax"] = day.tempMax; + d["tempMin"] = day.tempMin; + d["weatherCode"] = day.weatherCode; + d["precipSum"] = day.precipSum; + d["uvIndexMax"] = day.uvIndexMax; + d["sunrise"] = day.sunrise; + d["sunset"] = day.sunset; + d["moonPhase"] = day.moonPhase; + d["moonPhaseApiName"] = "moon_phase"; // for compatibility/tracing + } + + // Hourly + JsonArray hourlyArr = doc["hourly"].to(); + for (const auto& hour : data.hourly) { + JsonObject h = hourlyArr.add(); + h["time"] = hour.time; + h["temperature"] = hour.temperature; + h["precipitation"] = hour.precipitation; + h["precipProb"] = hour.precipitationProbability; + h["weatherCode"] = hour.weatherCode; + h["isDay"] = hour.isDay; + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(WEATHER_CACHE_FILE, json); +} + +bool WeatherClient::loadCache(WeatherData& data) { + if (!Storage.exists(WEATHER_CACHE_FILE)) { + return false; + } + + String json = Storage.readFile(WEATHER_CACHE_FILE); + if (json.isEmpty()) { + return false; + } + + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("WEA", "Cache parse error: %s", error.c_str()); + return false; + } + + data.fetchedAt = doc["fetchedAt"] | (time_t)0; + data.requestSignature = doc["requestSignature"] | std::string(""); + data.timezone = doc["timezone"] | std::string(""); + data.utcOffsetSeconds = doc["utcOffsetSeconds"] | 0; + + // Current + JsonObject cur = doc["current"]; + if (cur) { + data.current.temperature = cur["temperature"] | 0.0f; + data.current.apparentTemperature = cur["apparentTemperature"] | 0.0f; + data.current.humidity = cur["humidity"] | 0; + data.current.weatherCode = cur["weatherCode"] | 0; + data.current.windSpeed = cur["windSpeed"] | 0.0f; + data.current.windDirection = cur["windDirection"] | 0; + data.current.precipitation = cur["precipitation"] | 0.0f; + data.current.uvIndex = cur["uvIndex"] | 0.0f; + data.current.isDay = cur["isDay"] | true; + } + + // Daily + JsonArray dailyArr = doc["daily"].as(); + for (JsonObject d : dailyArr) { + DailyForecast day; + day.date = d["date"] | (time_t)0; + day.tempMax = d["tempMax"] | 0.0f; + day.tempMin = d["tempMin"] | 0.0f; + day.weatherCode = d["weatherCode"] | 0; + day.precipSum = d["precipSum"] | 0.0f; + day.uvIndexMax = d["uvIndexMax"] | 0.0f; + day.sunrise = d["sunrise"] | (time_t)0; + day.sunset = d["sunset"] | (time_t)0; + day.moonPhase = d["moonPhase"] | -1.0f; + data.daily.push_back(day); + } + + // Hourly + JsonArray hourlyArr = doc["hourly"].as(); + for (JsonObject h : hourlyArr) { + HourlyForecast hour; + hour.time = h["time"] | (time_t)0; + hour.temperature = h["temperature"] | 0.0f; + hour.precipitation = h["precipitation"] | 0.0f; + hour.precipitationProbability = h["precipProb"] | 0; + hour.weatherCode = h["weatherCode"] | 0; + hour.isDay = h["isDay"] | true; + data.hourly.push_back(hour); + } + + data.valid = true; + LOG_DBG("WEA", "Cache loaded, fetchedAt=%ld", (long)data.fetchedAt); + return true; +} + +std::vector WeatherClient::searchCity(const std::string& query) { + std::vector results; + + std::string url = "https://geocoding-api.open-meteo.com/v1/search?name=" + urlEncode(query); + url += "&count=5&language=en"; + + std::string response; + if (!HttpDownloader::fetchUrl(url, response)) { + LOG_ERR("WEA", "Geocoding request failed"); + return results; + } + + JsonDocument doc; + auto error = deserializeJson(doc, response); + if (error) { + LOG_ERR("WEA", "Geocoding parse error: %s", error.c_str()); + return results; + } + + JsonArray arr = doc["results"].as(); + for (JsonObject obj : arr) { + GeocodingResult r; + r.name = obj["name"] | std::string(""); + r.country = obj["country"] | std::string(""); + r.admin1 = obj["admin1"] | std::string(""); + r.latitude = obj["latitude"] | 0.0f; + r.longitude = obj["longitude"] | 0.0f; + results.push_back(r); + } + + LOG_DBG("WEA", "Geocoding found %zu results for '%s'", results.size(), query.c_str()); + return results; +} diff --git a/lib/Weather/WeatherClient.h b/lib/Weather/WeatherClient.h new file mode 100644 index 00000000..a0de4818 --- /dev/null +++ b/lib/Weather/WeatherClient.h @@ -0,0 +1,25 @@ +#pragma once +#include + +#include "WeatherData.h" +#include "WeatherSettingsStore.h" + +class WeatherClient { + public: + /// Fetch weather data, using cache if valid (< 30 min old). + /// If forceRefresh is true, always fetches from the API. + static WeatherData getWeather(const WeatherSettingsStore& settings, bool forceRefresh = false); + + /// Search for cities by name via Open-Meteo geocoding API. + /// Returns up to 5 results. + static std::vector searchCity(const std::string& query); + + private: + static WeatherData fetchFromApi(const WeatherSettingsStore& settings); + static bool parseWeatherJson(const std::string& json, WeatherData& data); + static std::string buildRequestSignature(const WeatherSettingsStore& settings); + static bool saveCache(const WeatherData& data); + static bool loadCache(WeatherData& data); + + static constexpr int CACHE_TTL_SECONDS = 30 * 60; // 30 minutes +}; diff --git a/lib/Weather/WeatherData.h b/lib/Weather/WeatherData.h new file mode 100644 index 00000000..50db4b8b --- /dev/null +++ b/lib/Weather/WeatherData.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include + +struct CurrentWeather { + float temperature = 0; + float apparentTemperature = 0; + int humidity = 0; + int weatherCode = 0; + float windSpeed = 0; + int windDirection = 0; + float precipitation = 0; + float uvIndex = 0; + bool isDay = true; +}; + +struct DailyForecast { + time_t date = 0; + float tempMax = 0; + float tempMin = 0; + int weatherCode = 0; + float precipSum = 0; + float uvIndexMax = 0; + time_t sunrise = 0; + time_t sunset = 0; + float moonPhase = -1.0f; // -1 means unknown, 0.0 new, 0.5 full +}; + +struct HourlyForecast { + time_t time = 0; + float temperature = 0; + float precipitation = 0; + int precipitationProbability = 0; + int weatherCode = 0; + bool isDay = true; +}; + +struct WeatherData { + CurrentWeather current; + std::vector daily; + std::vector hourly; + std::string requestSignature; + std::string timezone; + int utcOffsetSeconds = 0; + time_t fetchedAt = 0; + bool valid = false; + std::string errorMessage; +}; + +struct GeocodingResult { + std::string name; + std::string country; + std::string admin1; // State/region + float latitude = 0; + float longitude = 0; +}; diff --git a/lib/Weather/WeatherIcons.cpp b/lib/Weather/WeatherIcons.cpp new file mode 100644 index 00000000..e3749955 --- /dev/null +++ b/lib/Weather/WeatherIcons.cpp @@ -0,0 +1,58 @@ +#include "WeatherIcons.h" + +#include "WeatherIconsLarge.h" + +// Weather icon glyph source attribution: +// https://github.com/erikflowers/weather-icons +// Large icons are provided by generated WI_LARGE_* arrays in WeatherIconsLarge.h. +// These large icons are 64x64, and the code uses WEATHER_ICON_SIZE=64. + +// ============================================================================ +// 24x24 Small Weather Icons (1-bit, MSB-first) +// Each row = 24 pixels = 3 bytes. Total = 24 * 3 = 72 bytes per icon. +// ============================================================================ + +// ============================================================================ +// Lookup functions +// ============================================================================ + +const uint8_t* getWeatherIconLarge(WeatherIconType type) { + switch (type) { + case WeatherIconType::CLEAR_DAY: + return WI_LARGE_CLEAR_DAY; + case WeatherIconType::CLEAR_NIGHT: + return WI_LARGE_CLEAR_NIGHT; + case WeatherIconType::PARTLY_CLOUDY_DAY: + return WI_LARGE_PARTLY_CLOUDY_DAY; + case WeatherIconType::PARTLY_CLOUDY_NIGHT: + return WI_LARGE_PARTLY_CLOUDY_NIGHT; + case WeatherIconType::OVERCAST: + return WI_LARGE_OVERCAST; + case WeatherIconType::FOG: + return WI_LARGE_FOG; + case WeatherIconType::DRIZZLE: + return WI_LARGE_DRIZZLE; + case WeatherIconType::RAIN: + case WeatherIconType::SHOWERS: + return WI_LARGE_RAIN; + case WeatherIconType::SNOW: + return WI_LARGE_SNOW; + case WeatherIconType::THUNDERSTORM: + return WI_LARGE_THUNDERSTORM; + default: + return WI_LARGE_OVERCAST; + } +} + +const char* getWindDirectionText(int degrees) { + // Normalize to 0-360 + degrees = ((degrees % 360) + 360) % 360; + if (degrees >= 338 || degrees < 23) return "N"; + if (degrees < 68) return "NE"; + if (degrees < 113) return "E"; + if (degrees < 158) return "SE"; + if (degrees < 203) return "S"; + if (degrees < 248) return "SW"; + if (degrees < 293) return "W"; + return "NW"; +} diff --git a/lib/Weather/WeatherIcons.h b/lib/Weather/WeatherIcons.h new file mode 100644 index 00000000..c66e7b1e --- /dev/null +++ b/lib/Weather/WeatherIcons.h @@ -0,0 +1,78 @@ +#pragma once +#include + +// Weather icon size constants +#include "WeatherIconsLarge.h" + +// WMO weather code to icon category mapping +enum class WeatherIconType { + CLEAR_DAY, + CLEAR_NIGHT, + PARTLY_CLOUDY_DAY, + PARTLY_CLOUDY_NIGHT, + OVERCAST, + FOG, + DRIZZLE, + RAIN, + SNOW, + SHOWERS, + THUNDERSTORM, + UNKNOWN +}; + +// Map WMO weather code + day/night to icon type +inline WeatherIconType getWeatherIconType(int wmoCode, bool isDay) { + switch (wmoCode) { + case 0: + return isDay ? WeatherIconType::CLEAR_DAY : WeatherIconType::CLEAR_NIGHT; + case 1: + case 2: + return isDay ? WeatherIconType::PARTLY_CLOUDY_DAY : WeatherIconType::PARTLY_CLOUDY_NIGHT; + case 3: + return WeatherIconType::OVERCAST; + case 45: + case 48: + return WeatherIconType::FOG; + case 51: + case 53: + case 55: + case 56: + case 57: + return WeatherIconType::DRIZZLE; + case 61: + case 63: + case 65: + case 66: + case 67: + return WeatherIconType::RAIN; + case 71: + case 73: + case 75: + case 77: + return WeatherIconType::SNOW; + case 80: + case 81: + case 82: + return WeatherIconType::SHOWERS; + case 85: + case 86: + return WeatherIconType::SNOW; + case 95: + case 96: + case 99: + return WeatherIconType::THUNDERSTORM; + default: + return WeatherIconType::UNKNOWN; + } +} + +// Get the appropriate large icon bitmap (64x64, 1-bit, MSB first) +const uint8_t* getWeatherIconLarge(WeatherIconType type); + +// Get the appropriate small icon bitmap (24x24, 1-bit, MSB first) +#if WEATHER_ENABLE_SMALL_ICONS +const uint8_t* getWeatherIconSmall(WeatherIconType type); +#endif + +// Wind direction arrow text (N, NE, E, SE, S, SW, W, NW) +const char* getWindDirectionText(int degrees); diff --git a/lib/Weather/WeatherIconsLarge.h b/lib/Weather/WeatherIconsLarge.h new file mode 100644 index 00000000..aaf49378 --- /dev/null +++ b/lib/Weather/WeatherIconsLarge.h @@ -0,0 +1,359 @@ +#pragma once +#include + +// Generated from erikflowers/weather-icons SVGs. +// 64x64, 1-bit, MSB-first, row-major. +// Regenerate with: python scripts/generate_weather_icons.py --fetch +// clang-format off +constexpr int WEATHER_ICON_SIZE = 64; // Large icons for weather + +static const uint8_t WI_LARGE_CLEAR_DAY[] = { + 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFC, 0x3F, 0xFF, 0xE1, 0xFF, + 0xFF, 0x83, 0xFF, 0xFC, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, + 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFE, 0x03, 0xFF, + 0xFF, 0xE0, 0x3F, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, + 0xFF, 0xF8, 0x3F, 0xF8, 0x3F, 0xFC, 0x1F, 0xFF, 0xFF, 0xFC, 0x7F, 0xC0, 0x03, 0xFE, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x01, 0x80, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x1F, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x3F, 0xFC, 0x07, 0xFF, 0xFF, + 0xFF, 0xFF, 0xC0, 0x7F, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, + 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, + 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xC0, 0x3F, 0x83, 0xFF, 0xFF, 0xC1, 0xFC, 0x03, + 0x80, 0x1F, 0x03, 0xFF, 0xFF, 0xE1, 0xF8, 0x01, 0x00, 0x1F, 0x07, 0xFF, 0xFF, 0xE0, 0xF8, 0x00, + 0x00, 0x1F, 0x07, 0xFF, 0xFF, 0xE0, 0xF8, 0x00, 0x80, 0x1F, 0x03, 0xFF, 0xFF, 0xE1, 0xF8, 0x01, + 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, + 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFE, 0x03, 0xFF, 0xFF, + 0xFF, 0xFF, 0xE0, 0x3F, 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xF8, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x01, 0x80, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x3F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x7F, 0xC0, 0x03, 0xFE, 0x3F, 0xFF, 0xFF, 0xF8, 0x3F, 0xFE, 0x7F, 0xFC, 0x1F, 0xFF, + 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFF, 0xE0, 0x3F, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, + 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, + 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x83, 0xFF, 0xFC, 0x3F, 0xFF, 0xC1, 0xFF, + 0xFF, 0xC7, 0xFF, 0xFC, 0x3F, 0xFF, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_CLEAR_NIGHT[] = { + 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x03, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF8, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x06, 0x03, 0xFF, 0xFF, 0xFF, + 0xFF, 0xC0, 0x00, 0x7E, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0xFE, 0x01, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0x07, 0xFE, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xFF, 0x01, 0xFF, 0xFF, 0xFF, + 0xFE, 0x00, 0x3F, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, + 0xF8, 0x00, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, + 0xF0, 0x03, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x3F, 0xFF, 0xFF, + 0xE0, 0x0F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, + 0xC0, 0x1F, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, + 0xC0, 0x3F, 0xFF, 0xFF, 0xF8, 0x00, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, + 0x80, 0x3F, 0xFF, 0xFF, 0xFE, 0x00, 0x1F, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x03, 0xFF, + 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x03, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x01, + 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x01, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x01, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, + 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, + 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, + 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0x80, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03, + 0x80, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03, 0xC0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x03, + 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07, + 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x0F, + 0xF0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, + 0xF8, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x3F, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x3F, + 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFE, 0x00, 0x1F, 0xFF, 0xFF, 0xF8, 0x00, 0xFF, + 0xFF, 0x00, 0x0F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0x80, 0x01, 0xFF, + 0xFF, 0xC0, 0x00, 0x7F, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xE0, 0x00, 0x07, 0xC0, 0x00, 0x07, 0xFF, + 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, + 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF, + 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_PARTLY_CLOUDY_DAY[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF9, 0xFF, 0xF8, 0x7F, 0xFC, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xF8, 0xFF, 0xF8, 0x7F, + 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, 0xFF, 0xE0, 0xFF, + 0xFF, 0xFF, 0xFC, 0x7F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xC3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0C, 0x00, 0x01, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0x00, 0x0F, 0x80, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x3F, 0xE0, 0x7F, 0xFF, + 0xFF, 0xF8, 0x00, 0x00, 0x7F, 0xF0, 0x7F, 0xFF, 0xFF, 0xF0, 0x1F, 0xE0, 0x3F, 0xF8, 0x3F, 0xFF, + 0xFF, 0xE0, 0x7F, 0xF8, 0x3F, 0xFC, 0x3F, 0xFF, 0xFF, 0xE0, 0xFF, 0xFC, 0x1F, 0xFC, 0x1F, 0xFF, + 0xFF, 0xC1, 0xFF, 0xFE, 0x0F, 0xFE, 0x1F, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0x0F, 0xFE, 0x1E, 0x00, + 0xFF, 0x83, 0xFF, 0xFF, 0x0F, 0xFE, 0x1E, 0x00, 0xFF, 0x87, 0xFF, 0xFF, 0x87, 0xFE, 0x1E, 0x00, + 0xFE, 0x07, 0xFF, 0xFF, 0x80, 0x3E, 0x1F, 0xEF, 0xF8, 0x07, 0xFF, 0xFF, 0x80, 0x0C, 0x1F, 0xFF, + 0xF0, 0x07, 0xFF, 0xFF, 0x80, 0x04, 0x3F, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xDC, 0x00, 0x3F, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFF, + 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, + 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, + 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x71, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x70, 0xFF, + 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x70, 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xF8, 0x7F, + 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0xFC, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, + 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, + 0xF8, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xFF, + 0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_PARTLY_CLOUDY_NIGHT[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x03, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xF0, 0x0F, 0x83, 0xFF, + 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x3F, 0x83, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x7F, 0x83, 0xFF, + 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0xFF, 0xC1, 0xFF, + 0xFF, 0xFF, 0x00, 0x1C, 0x00, 0x7F, 0xC0, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0x80, 0x3F, 0xE0, 0xFF, + 0xFF, 0xFC, 0x07, 0xFF, 0xE0, 0x1F, 0xE0, 0x7F, 0xFF, 0xFC, 0x0F, 0xFF, 0xF8, 0x1F, 0xF0, 0x3F, + 0xFF, 0xF8, 0x1F, 0xFF, 0xFC, 0x0F, 0xF0, 0x0F, 0xFF, 0xF8, 0x3F, 0xFF, 0xFC, 0x0F, 0xF8, 0x01, + 0xFF, 0xF0, 0x3F, 0xFF, 0xFE, 0x07, 0xFC, 0x00, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0x07, 0xFE, 0x00, + 0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0x03, 0xFF, 0x81, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xC1, + 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x07, 0x81, 0xFC, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0x03, + 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x03, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x07, + 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, + 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, + 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, + 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, + 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xE0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x3F, + 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, + 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, + 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_OVERCAST[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03, 0xC0, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xF0, 0x3F, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0x3F, 0xFC, 0x1F, 0xFF, + 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFE, 0x0F, 0xFF, + 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFC, 0x01, 0x80, 0x3F, 0xFF, 0x00, 0xFF, + 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0x00, 0x1F, 0xFF, 0xF0, 0x3F, 0xFC, 0x0F, 0xFF, 0x80, 0x0F, + 0xFF, 0xE0, 0xFF, 0xFE, 0x07, 0xFF, 0x80, 0x07, 0xFF, 0xE0, 0xFF, 0xFF, 0x07, 0xFF, 0xFE, 0x03, + 0xFF, 0xC1, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xC3, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, + 0xFF, 0xC3, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xE1, 0xFE, 0x03, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xE0, + 0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xF0, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xF0, + 0xE0, 0x0F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, 0xF0, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xF0, + 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xE0, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0xE1, + 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xC1, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0x83, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1C, 0x03, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x07, + 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x3F, + 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, + 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, + 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFF, + 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFF, + 0xF8, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, + 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_FOG[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x3F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x1F, 0xF0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFC, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFE, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, + 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, + 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, + 0xFF, 0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, + 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0xC0, 0x00, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, + 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, + 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, + 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_DRIZZLE[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x00, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xFF, + 0xFF, 0xFC, 0x07, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, + 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, + 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, + 0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xE0, 0x01, 0xFF, + 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F, + 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, 0xF0, 0x0F, 0xFF, 0xFF, 0xCF, 0xFF, 0xE0, 0x0F, + 0xE0, 0x3F, 0xFF, 0xFF, 0x87, 0xFF, 0xFC, 0x07, 0xC0, 0x7F, 0xFF, 0xFF, 0x87, 0xFF, 0xFE, 0x03, + 0xC0, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0x81, + 0x83, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xC1, 0x83, 0xFF, 0xFC, 0xFF, 0x87, 0xFF, 0xFF, 0xC1, + 0x03, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xFF, 0xE0, + 0x07, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xE0, + 0x03, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xC0, + 0x83, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xC1, 0x81, 0xFF, 0xE0, 0x1F, 0xCF, 0xFF, 0xFF, 0xC1, + 0x81, 0xFF, 0xF0, 0x3F, 0x87, 0xFF, 0xFF, 0x81, 0xC0, 0xFF, 0xF8, 0x7F, 0x03, 0xFF, 0xFF, 0x03, + 0xC0, 0x7F, 0xFF, 0xFE, 0x03, 0xFF, 0xFE, 0x03, 0xE0, 0x3F, 0xFF, 0xFC, 0x01, 0xFF, 0xFC, 0x07, + 0xF0, 0x0F, 0xFF, 0xFC, 0x00, 0xFF, 0xF0, 0x0F, 0xF8, 0x01, 0xFF, 0xF8, 0x00, 0xFF, 0x80, 0x0F, + 0xFC, 0x01, 0xFF, 0xF0, 0x00, 0x7F, 0x80, 0x3F, 0xFE, 0x01, 0xFF, 0xF0, 0x00, 0x7F, 0x80, 0x7F, + 0xFF, 0x81, 0xFF, 0xF0, 0x00, 0x3F, 0x80, 0xFF, 0xFF, 0xE1, 0xFF, 0xF0, 0x00, 0x3F, 0x87, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x01, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_RAIN[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x80, 0x0F, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, + 0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, + 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, + 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, + 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xE0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, + 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, + 0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07, + 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, + 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, + 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFC, 0x3F, 0x8F, 0xE1, 0xFF, 0xE0, + 0x03, 0xFF, 0xFC, 0x1F, 0x07, 0xC1, 0xFF, 0xC0, 0x03, 0xFF, 0xF8, 0x1F, 0x07, 0xC1, 0xFF, 0xC0, + 0x83, 0xFF, 0xF8, 0x1F, 0x07, 0xC1, 0xFF, 0xC1, 0x81, 0xFF, 0xF8, 0x3E, 0x07, 0xC1, 0xFF, 0x81, + 0x80, 0xFF, 0xF8, 0x3E, 0x0F, 0x81, 0xFF, 0x01, 0xC0, 0xFF, 0xF0, 0x3E, 0x0F, 0x83, 0xFF, 0x03, + 0xE0, 0x3F, 0xF0, 0x3E, 0x0F, 0x83, 0xFC, 0x07, 0xE0, 0x1F, 0xF0, 0x7C, 0x0F, 0x03, 0xF8, 0x07, + 0xF0, 0x01, 0xE0, 0x7C, 0x1F, 0x07, 0x80, 0x0F, 0xF8, 0x01, 0xE0, 0x7C, 0x1F, 0x07, 0x80, 0x1F, + 0xFC, 0x01, 0xE0, 0xF8, 0x1F, 0x07, 0x80, 0x3F, 0xFF, 0x01, 0xE0, 0xF8, 0x1E, 0x07, 0x80, 0xFF, + 0xFF, 0xC1, 0xC0, 0xF8, 0x3E, 0x0F, 0x83, 0xFF, 0xFF, 0xFD, 0xC0, 0xF8, 0x3E, 0x0F, 0xBF, 0xFF, + 0xFF, 0xFF, 0xC1, 0xF0, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x81, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xE0, 0x7C, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x83, 0xE0, 0x78, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xE0, 0xF8, 0x3F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x87, 0xE0, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_SNOW[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x80, 0x1F, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFE, 0x03, 0xFF, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, + 0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, + 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, + 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, + 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, + 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, + 0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07, + 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, + 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, + 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, + 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x81, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0x81, + 0x81, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0x01, 0xC0, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0x03, + 0xC0, 0x7F, 0xFF, 0xFC, 0x1F, 0xFF, 0xFC, 0x07, 0xE0, 0x1F, 0xFF, 0xFC, 0x3F, 0xFF, 0xF8, 0x07, + 0xF0, 0x01, 0xFD, 0xFF, 0xFF, 0x9F, 0x80, 0x0F, 0xF8, 0x01, 0xF0, 0xFF, 0xFF, 0x0F, 0x80, 0x1F, + 0xFC, 0x01, 0xF0, 0x7F, 0xFE, 0x07, 0x80, 0x3F, 0xFE, 0x01, 0xF0, 0x7F, 0xFE, 0x0F, 0x80, 0xFF, + 0xFF, 0x81, 0xF0, 0xFF, 0xFF, 0x0F, 0x83, 0xFF, 0xFF, 0xFD, 0xFD, 0xFF, 0xFF, 0xFF, 0xBF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFE, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF0, 0x7F, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFD, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + +static const uint8_t WI_LARGE_THUNDERSTORM[] = { + 0xFF, 0xFF, 0xFF, 0xC0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x7F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x80, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x00, 0xFF, 0xF0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xFF, + 0xFF, 0xFC, 0x07, 0xFF, 0xFE, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, + 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, + 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, + 0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xE0, 0x01, 0xFF, + 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F, + 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, 0xF0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x0F, + 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, + 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, + 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, + 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, + 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, + 0x03, 0xFF, 0x00, 0x3F, 0x87, 0xE1, 0xFF, 0xE0, 0x03, 0xFF, 0x00, 0x3F, 0x07, 0xE1, 0xFF, 0xC0, + 0x03, 0xFE, 0x00, 0x7F, 0x07, 0xC0, 0xFF, 0xC0, 0x83, 0xFE, 0x00, 0x7F, 0x07, 0xC1, 0xFF, 0xC1, + 0x81, 0xFE, 0x00, 0xFE, 0x07, 0xC1, 0xFF, 0x81, 0xC0, 0xFC, 0x00, 0xFE, 0x0F, 0x81, 0xFF, 0x01, + 0xC0, 0x7C, 0x01, 0xFE, 0x0F, 0x81, 0xFE, 0x03, 0xE0, 0x3C, 0x01, 0xFE, 0x0F, 0x83, 0xFC, 0x07, + 0xE0, 0x08, 0x03, 0xFC, 0x0F, 0x83, 0xF0, 0x07, 0xF0, 0x00, 0x03, 0xFC, 0x1F, 0x03, 0x80, 0x0F, + 0xF8, 0x00, 0x07, 0xFC, 0x1F, 0x07, 0x80, 0x1F, 0xFC, 0x00, 0x07, 0xFC, 0x1F, 0x07, 0x80, 0x3F, + 0xFF, 0x00, 0x0F, 0xF8, 0x1F, 0x07, 0x80, 0xFF, 0xFF, 0xC0, 0x1F, 0xF8, 0x3E, 0x07, 0x83, 0xFF, + 0xFF, 0xE0, 0x1F, 0xF8, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x38, 0x3E, 0x0F, 0xFF, 0xFF, + 0xFF, 0xC0, 0x00, 0x70, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0xF0, 0x7C, 0x0F, 0xFF, 0xFF, + 0xFF, 0x80, 0x00, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0xE0, 0x7C, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0x03, 0xE0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0xE0, 0xFE, 0x3F, 0xFF, 0xFF, + 0xFF, 0xFE, 0x07, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFE, 0x0F, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFE, 0x3F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFC, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xFF, 0xEF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFB, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; + diff --git a/lib/Weather/WeatherSettingsStore.cpp b/lib/Weather/WeatherSettingsStore.cpp new file mode 100644 index 00000000..4a11c257 --- /dev/null +++ b/lib/Weather/WeatherSettingsStore.cpp @@ -0,0 +1,106 @@ +#include "WeatherSettingsStore.h" + +#include +#include +#include + +WeatherSettingsStore WeatherSettingsStore::instance; + +namespace { +constexpr char WEATHER_SETTINGS_FILE[] = "/.crosspoint/weather_settings.json"; +} + +bool WeatherSettingsStore::saveToFile() const { + Storage.mkdir("/.crosspoint"); + + JsonDocument doc; + doc["latitude"] = latitude; + doc["longitude"] = longitude; + doc["locationConfigured"] = locationConfigured; + doc["locationName"] = locationName; + doc["tempUnit"] = static_cast(tempUnit); + doc["windUnit"] = static_cast(windUnit); + doc["precipUnit"] = static_cast(precipUnit); + doc["forecastDays"] = forecastDays; + + String json; + serializeJson(doc, json); + return Storage.writeFile(WEATHER_SETTINGS_FILE, json); +} + +bool WeatherSettingsStore::loadFromFile() { + if (!Storage.exists(WEATHER_SETTINGS_FILE)) { + LOG_DBG("WEA", "No weather settings file found"); + return false; + } + + String json = Storage.readFile(WEATHER_SETTINGS_FILE); + if (json.isEmpty()) { + return false; + } + + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("WEA", "JSON parse error: %s", error.c_str()); + return false; + } + + latitude = doc["latitude"] | 0.0f; + longitude = doc["longitude"] | 0.0f; + locationConfigured = doc["locationConfigured"] | (latitude != 0.0f || longitude != 0.0f); + locationName = doc["locationName"] | std::string(""); + tempUnit = static_cast(doc["tempUnit"] | (uint8_t)0); + windUnit = static_cast(doc["windUnit"] | (uint8_t)0); + precipUnit = static_cast(doc["precipUnit"] | (uint8_t)0); + forecastDays = doc["forecastDays"] | (uint8_t)3; + + if (forecastDays < 1) forecastDays = 1; + if (forecastDays > 5) forecastDays = 5; + + LOG_DBG("WEA", "Loaded weather settings: %s (%.2f, %.2f)", locationName.c_str(), latitude, longitude); + return true; +} + +void WeatherSettingsStore::setLocation(float lat, float lon, const std::string& name) { + latitude = lat; + longitude = lon; + locationConfigured = true; + locationName = name; + LOG_DBG("WEA", "Set location: %s (%.4f, %.4f)", name.c_str(), lat, lon); +} + +void WeatherSettingsStore::clearLocation() { + latitude = 0; + longitude = 0; + locationConfigured = false; + locationName.clear(); +} + +void WeatherSettingsStore::setForecastDays(uint8_t days) { + if (days < 1) days = 1; + if (days > 5) days = 5; + forecastDays = days; +} + +const char* WeatherSettingsStore::getTempUnitParam() const { + return tempUnit == WeatherTempUnit::FAHRENHEIT ? "fahrenheit" : "celsius"; +} + +const char* WeatherSettingsStore::getWindUnitParam() const { + switch (windUnit) { + case WeatherWindUnit::MS: + return "ms"; + case WeatherWindUnit::MPH: + return "mph"; + case WeatherWindUnit::KNOTS: + return "kn"; + case WeatherWindUnit::KMH: + default: + return "kmh"; + } +} + +const char* WeatherSettingsStore::getPrecipUnitParam() const { + return precipUnit == WeatherPrecipUnit::INCH ? "inch" : "mm"; +} diff --git a/lib/Weather/WeatherSettingsStore.h b/lib/Weather/WeatherSettingsStore.h new file mode 100644 index 00000000..95938683 --- /dev/null +++ b/lib/Weather/WeatherSettingsStore.h @@ -0,0 +1,59 @@ +#pragma once +#include +#include + +enum class WeatherTempUnit : uint8_t { CELSIUS = 0, FAHRENHEIT = 1 }; +enum class WeatherWindUnit : uint8_t { KMH = 0, MS = 1, MPH = 2, KNOTS = 3 }; +enum class WeatherPrecipUnit : uint8_t { MM = 0, INCH = 1 }; + +class WeatherSettingsStore { + private: + static WeatherSettingsStore instance; + + float latitude = 0; + float longitude = 0; + bool locationConfigured = false; + std::string locationName; + WeatherTempUnit tempUnit = WeatherTempUnit::CELSIUS; + WeatherWindUnit windUnit = WeatherWindUnit::KMH; + WeatherPrecipUnit precipUnit = WeatherPrecipUnit::MM; + uint8_t forecastDays = 3; + + WeatherSettingsStore() = default; + + public: + WeatherSettingsStore(const WeatherSettingsStore&) = delete; + WeatherSettingsStore& operator=(const WeatherSettingsStore&) = delete; + + static WeatherSettingsStore& getInstance() { return instance; } + + bool saveToFile() const; + bool loadFromFile(); + + // Location + void setLocation(float lat, float lon, const std::string& name); + void clearLocation(); + float getLatitude() const { return latitude; } + float getLongitude() const { return longitude; } + const std::string& getLocationName() const { return locationName; } + bool hasLocation() const { return locationConfigured; } + + // Units + void setTempUnit(WeatherTempUnit unit) { tempUnit = unit; } + WeatherTempUnit getTempUnit() const { return tempUnit; } + void setWindUnit(WeatherWindUnit unit) { windUnit = unit; } + WeatherWindUnit getWindUnit() const { return windUnit; } + void setPrecipUnit(WeatherPrecipUnit unit) { precipUnit = unit; } + WeatherPrecipUnit getPrecipUnit() const { return precipUnit; } + + // Forecast + void setForecastDays(uint8_t days); + uint8_t getForecastDays() const { return forecastDays; } + + // API parameter strings + const char* getTempUnitParam() const; + const char* getWindUnitParam() const; + const char* getPrecipUnitParam() const; +}; + +#define WEATHER_SETTINGS WeatherSettingsStore::getInstance() diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 53d32cac..b18a69ac 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -13,11 +13,14 @@ bool Xtc::load() { LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str()); + // Ensure the per-book cache exists before the parser tries to create page_table.bin. + setupCacheDir(); + // Initialize parser parser.reset(new xtc::XtcParser()); - // Open XTC file - xtc::XtcError err = parser->open(filepath.c_str()); + // Open XTC file and initialize its cache-backed page table + xtc::XtcError err = parser->open(filepath.c_str(), cachePath.c_str()); if (err != xtc::XtcError::OK) { LOG_ERR("XTC", "Failed to load: %s", xtc::errorToString(err)); parser.reset(); @@ -619,3 +622,10 @@ xtc::XtcError Xtc::getLastError() const { } return parser->getLastError(); } + +void Xtc::prefetchPages(uint32_t pageIndex) const { + if (!loaded || !parser) { + return; + } + parser->prefetchWindow(pageIndex); +} diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index 9b75f586..1c79416f 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -50,6 +50,11 @@ class Xtc { */ void setupCacheDir() const; + /** + * Preload window around specified page (for page turn optimization) + */ + void prefetchPages(uint32_t pageIndex) const; + // Path accessors const std::string& getCachePath() const { return cachePath; } const std::string& getPath() const { return filepath; } diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index 12e8a61d..083391eb 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -10,11 +10,50 @@ #include #include #include +#include #include +#include namespace xtc { +namespace { + +constexpr size_t MAX_CHAPTERS = 4096; + +bool canSeekToOffset(const uint64_t offset) { + return offset <= static_cast(std::numeric_limits::max()); +} + +bool seekToOffset(FsFile& file, const uint64_t offset) { + if (!canSeekToOffset(offset)) { + return false; + } + return file.seek(static_cast(offset)); +} + +} // namespace + +void XtcParser::safeDeserializeHeader(const uint8_t* buf, PageTableCacheHeader& header) { + memcpy(&header.magic, buf + 0, 4); + memcpy(&header.version, buf + 4, 4); + memcpy(&header.pageCount, buf + 8, 4); + memcpy(&header.originalHash, buf + 12, 4); + memcpy(&header.originalSize, buf + 16, 8); + memcpy(&header.entrySize, buf + 24, 4); + memcpy(&header.reserved, buf + 28, 4); +} + +void XtcParser::safeSerializeHeader(uint8_t* buf, const PageTableCacheHeader& header) { + memcpy(buf + 0, &header.magic, 4); + memcpy(buf + 4, &header.version, 4); + memcpy(buf + 8, &header.pageCount, 4); + memcpy(buf + 12, &header.originalHash, 4); + memcpy(buf + 16, &header.originalSize, 8); + memcpy(buf + 24, &header.entrySize, 4); + memcpy(buf + 28, &header.reserved, 4); +} + XtcParser::XtcParser() : m_isOpen(false), m_defaultWidth(DISPLAY_WIDTH), @@ -23,17 +62,28 @@ XtcParser::XtcParser() m_hasChapters(false), m_lastError(XtcError::OK) { memset(&m_header, 0, sizeof(m_header)); + + for (auto& entry : m_l1Cache) { + entry.pageIndex = 0xFFFFFFFF; + entry.lastAccess = 0; + } } XtcParser::~XtcParser() { close(); } -XtcError XtcParser::open(const char* filepath) { - // Close if already open +XtcError XtcParser::open(const char* filepath, const char* cacheDir) { + // Close any previous file state before reopening if (m_isOpen) { close(); } - // Open file + m_originalPath = filepath; + m_cacheDir = cacheDir; + + uint32_t fileHash = calculateFileHash(filepath); + m_cacheFilePath = std::string(cacheDir) + "/xtc_" + std::to_string(fileHash) + "/page_table.bin"; + + // Open the original XTC file just long enough to read metadata and validate the header. if (!Storage.openFileForRead("XTC", filepath, m_file)) { m_lastError = XtcError::FILE_NOT_FOUND; return m_lastError; @@ -47,57 +97,416 @@ XtcError XtcParser::open(const char* filepath) { return m_lastError; } - // Read title & author if available + if (m_header.pageCount == 0) { + LOG_ERR("XTC", "File has no pages"); + m_file.close(); + m_lastError = XtcError::CORRUPTED_HEADER; + return m_lastError; + } + + // Metadata strings are small, so keep them in memory even when the page table is moved to cache. if (m_header.hasMetadata) { - m_lastError = readTitle(); + readTitle(); + readAuthor(); + m_title.shrink_to_fit(); + m_author.shrink_to_fit(); + LOG_INF("XTC", "Metadata strings: titleLen=%u cap=%u, authorLen=%u cap=%u", + static_cast(m_title.size()), static_cast(m_title.capacity()), + static_cast(m_author.size()), static_cast(m_author.capacity())); + } + + // Defer chapter parsing until the reader actually needs the table of contents. + m_pageTableOffset = m_header.pageTableOffset; + m_hasChapters = (m_header.hasChapters == 1) && (m_header.chapterOffset != 0); + LOG_INF("XTC", "Chapter metadata deferred: available=%s", m_hasChapters ? "yes" : "no"); + + m_file.close(); + + // Build or reuse the on-disk page table cache before marking the parser open. + if (!isPageTableCacheValid()) { + LOG_INF("XTC", "Building page table cache for %u pages", m_header.pageCount); + m_lastError = buildPageTableCache(); if (m_lastError != XtcError::OK) { - LOG_DBG("XTC", "Failed to read title: %s", errorToString(m_lastError)); - m_file.close(); + LOG_ERR("XTC", "Failed to build page table cache"); return m_lastError; } - m_lastError = readAuthor(); - if (m_lastError != XtcError::OK) { - LOG_DBG("XTC", "Failed to read author: %s", errorToString(m_lastError)); - m_file.close(); - return m_lastError; + const size_t heapBefore = ESP.getMaxAllocHeap(); + LOG_DBG("XTC", "Cache built, heap before defrag: free=%zu, maxAlloc=%zu", ESP.getFreeHeap(), heapBefore); + + // Defragment heap: small delay allows heap coalescing after file handles are closed + // This typically improves MaxAlloc by 10-20KB, enabling 96KB page buffer for grayscale + LOG_DBG("XTC", "Defragmenting heap (waiting 50ms)..."); + vTaskDelay(pdMS_TO_TICKS(50)); + + const size_t heapAfter = ESP.getMaxAllocHeap(); + const size_t heapGain = heapAfter > heapBefore ? (heapAfter - heapBefore) : 0; + if (heapGain > 0) { + LOG_INF("XTC", "Heap defragmented: +%zu bytes contiguous (now %zu)", heapGain, heapAfter); + } else { + LOG_DBG("XTC", "Heap after defrag: free=%zu, maxAlloc=%zu", ESP.getFreeHeap(), heapAfter); } } - // Read page table - m_lastError = readPageTable(); - if (m_lastError != XtcError::OK) { - LOG_DBG("XTC", "Failed to read page table: %s", errorToString(m_lastError)); - m_file.close(); + if (!openCacheFile()) { + LOG_ERR("XTC", "Failed to open cache file"); + m_lastError = XtcError::FILE_NOT_FOUND; return m_lastError; } - // Read chapters if present - m_lastError = readChapters(); - if (m_lastError != XtcError::OK) { - LOG_DBG("XTC", "Failed to read chapters: %s", errorToString(m_lastError)); - m_file.close(); - return m_lastError; - } + // Prime the sliding L2 window with the first chunk of page metadata. + loadL2Window(0); + + LOG_DBG("XTC", "File opened, heap: free=%zu, maxAlloc=%zu", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); m_isOpen = true; - LOG_DBG("XTC", "Opened file: %s (%u pages, %dx%d)", filepath, m_header.pageCount, m_defaultWidth, m_defaultHeight); + LOG_DBG("XTC", "Opened file: %s (%u pages, cache: %s)", filepath, m_header.pageCount, m_cacheFilePath.c_str()); return XtcError::OK; } void XtcParser::close() { - if (m_isOpen) { + closeCacheFile(); + + if (m_isOpen && m_file.isOpen()) { m_file.close(); - m_isOpen = false; } - m_pageTable.clear(); + m_isOpen = false; + m_l2Valid = false; + m_l2WindowCount = 0; + m_chaptersLoaded = false; + + for (auto& entry : m_l1Cache) { + entry.pageIndex = 0xFFFFFFFF; + } m_chapters.clear(); m_title.clear(); - m_hasChapters = false; + m_author.clear(); memset(&m_header, 0, sizeof(m_header)); } +void XtcParser::ensureChaptersLoaded() { + if (m_chaptersLoaded || !m_hasChapters) { + return; + } + + // Chapter parsing allocates variable-length strings, so keep it lazy. + const XtcError err = readChapters(); + if (err != XtcError::OK) { + LOG_ERR("XTC", "Failed to lazy-load chapters: %s", errorToString(err)); + m_hasChapters = false; + m_chapters.clear(); + m_chapters.shrink_to_fit(); + } + m_chaptersLoaded = true; +} + +bool XtcParser::openCacheFile() { + if (m_cacheFile.isOpen()) { + return true; + } + return Storage.openFileForRead("XTC", m_cacheFilePath.c_str(), m_cacheFile); +} + +void XtcParser::closeCacheFile() { + if (m_cacheFile.isOpen()) { + m_cacheFile.close(); + } +} + +bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) { + if (pageIndex >= m_header.pageCount) { + return false; + } + + // L1 is the hot cache for the most recently used pages. + if (lookupL1(pageIndex, info)) { + LOG_DBG("XTC", "L1 hit: page %u", pageIndex); + return true; + } + + // L2 is the sliding window around the reader's current position. + if (lookupL2(pageIndex, info)) { + updateL1(pageIndex, info); + LOG_DBG("XTC", "L2 hit: page %u", pageIndex); + return true; + } + + // Fall back to the SD-backed cache file, then refresh L2/L1. + LOG_DBG("XTC", "L3 load: page %u", pageIndex); + loadL2Window(pageIndex); + + if (lookupL2(pageIndex, info)) { + updateL1(pageIndex, info); + return true; + } + + return false; +} + +void XtcParser::prefetchWindow(uint32_t pageIndex) { + if (pageIndex >= m_header.pageCount) { + return; + } + + // Avoid reloading the same window when the requested page is already covered. + if (m_l2Valid && pageIndex >= m_l2WindowStart && pageIndex < m_l2WindowStart + m_l2WindowCount) { + return; + } + + loadL2Window(pageIndex); +} + +bool XtcParser::lookupL1(uint32_t pageIndex, PageInfo& info) { + for (const auto& entry : m_l1Cache) { + if (entry.pageIndex == pageIndex) { + info = entry.info; + return true; + } + } + return false; +} + +void XtcParser::updateL1(uint32_t pageIndex, const PageInfo& info) { + for (auto& entry : m_l1Cache) { + if (entry.pageIndex == pageIndex) { + entry.lastAccess = ++m_accessCounter; + return; + } + } + + // Replace the least-recently-used entry, or fill the first empty slot. + uint32_t oldestAccess = m_accessCounter; + size_t oldestIndex = 0; + bool foundEmpty = false; + + for (size_t i = 0; i < m_l1Cache.size(); i++) { + if (m_l1Cache[i].pageIndex == 0xFFFFFFFF) { + oldestIndex = i; + foundEmpty = true; + break; + } + if (m_l1Cache[i].lastAccess < oldestAccess) { + oldestAccess = m_l1Cache[i].lastAccess; + oldestIndex = i; + } + } + + m_l1Cache[oldestIndex].pageIndex = pageIndex; + m_l1Cache[oldestIndex].info = info; + m_l1Cache[oldestIndex].lastAccess = ++m_accessCounter; +} + +bool XtcParser::lookupL2(uint32_t pageIndex, PageInfo& info) { + if (!m_l2Valid) { + return false; + } + + if (pageIndex >= m_l2WindowStart && pageIndex < m_l2WindowStart + m_l2WindowCount) { + size_t idx = pageIndex - m_l2WindowStart; + info = m_l2Window[idx]; + return true; + } + return false; +} + +void XtcParser::loadL2Window(uint32_t centerPage) { + // Center the sliding window around the requested page when possible. + uint32_t halfWindow = L2_WINDOW_SIZE / 2; + uint32_t windowStart = (centerPage > halfWindow) ? centerPage - halfWindow : 0; + uint32_t windowEnd = windowStart + L2_WINDOW_SIZE; + + if (windowEnd > m_header.pageCount) { + windowEnd = m_header.pageCount; + windowStart = (windowEnd > L2_WINDOW_SIZE) ? windowEnd - L2_WINDOW_SIZE : 0; + } + + size_t windowSize = windowEnd - windowStart; + if (windowSize == 0) { + m_l2Valid = false; + m_l2WindowCount = 0; + return; + } + + if (!m_cacheFile.isOpen() && !openCacheFile()) { + LOG_ERR("XTC", "Cache file not available"); + m_l2Valid = false; + m_l2WindowCount = 0; + return; + } + + size_t entryOffset = sizeof(PageTableCacheHeader) + windowStart * sizeof(PageInfo); + if (!m_cacheFile.seek(entryOffset)) { + LOG_ERR("XTC", "Failed to seek in page table cache"); + m_l2Valid = false; + m_l2WindowCount = 0; + return; + } + + size_t readCount = 0; + for (size_t i = 0; i < windowSize; i++) { + PageInfo info; + if (m_cacheFile.read(reinterpret_cast(&info), sizeof(PageInfo)) != sizeof(PageInfo)) { + LOG_ERR("XTC", "Failed to read page info %zu", windowStart + i); + break; + } + m_l2Window[i] = info; + readCount++; + } + + m_l2WindowStart = windowStart; + m_l2WindowCount = readCount; + m_l2Valid = (readCount > 0); + + LOG_DBG("XTC", "L2 window loaded: [%u, %u] (%zu pages)", windowStart, windowStart + readCount - 1, readCount); +} + +bool XtcParser::isPageTableCacheValid() const { + if (!Storage.exists(m_cacheFilePath.c_str())) { + return false; + } + + FsFile cacheFile; + if (!Storage.openFileForRead("XTC", m_cacheFilePath.c_str(), cacheFile)) { + return false; + } + + uint8_t headerBuf[sizeof(PageTableCacheHeader)]; + if (cacheFile.read(headerBuf, sizeof(headerBuf)) != sizeof(headerBuf)) { + cacheFile.close(); + return false; + } + + PageTableCacheHeader header; + safeDeserializeHeader(headerBuf, header); + + if (header.magic != PAGE_TABLE_CACHE_MAGIC || header.version != PAGE_TABLE_CACHE_VERSION) { + cacheFile.close(); + return false; + } + + // The cache must match both the page count and the original file size. + if (header.pageCount != m_header.pageCount) { + cacheFile.close(); + return false; + } + + uint32_t expectedSize = sizeof(PageTableCacheHeader) + header.pageCount * sizeof(PageInfo); + if (cacheFile.size() < expectedSize) { + cacheFile.close(); + return false; + } + + if (header.originalSize > 0) { + FsFile originalFile; + if (Storage.openFileForRead("XTC", m_originalPath.c_str(), originalFile)) { + uint64_t currentSize = originalFile.size(); + originalFile.close(); + if (currentSize != header.originalSize) { + LOG_INF("XTC", "Cache invalidated: file size changed"); + cacheFile.close(); + return false; + } + } + } + + cacheFile.close(); + return true; +} + +XtcError XtcParser::buildPageTableCache() { + FsFile originalFile; + if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), originalFile)) { + return XtcError::FILE_NOT_FOUND; + } + + size_t lastSlash = m_cacheFilePath.find_last_of('/'); + if (lastSlash != std::string::npos) { + std::string cacheDir = m_cacheFilePath.substr(0, lastSlash); + Storage.mkdir(cacheDir.c_str()); + } + + FsFile cacheFile; + if (!Storage.openFileForWrite("XTC", m_cacheFilePath.c_str(), cacheFile)) { + originalFile.close(); + return XtcError::WRITE_ERROR; + } + + // Persist a compact PageInfo array so we do not need to hold the full table in RAM. + PageTableCacheHeader header; + header.magic = PAGE_TABLE_CACHE_MAGIC; + header.version = PAGE_TABLE_CACHE_VERSION; + header.pageCount = m_header.pageCount; + header.originalHash = calculateFileHash(m_originalPath.c_str()); + header.originalSize = originalFile.size(); + header.entrySize = sizeof(PageInfo); + header.reserved = 0; + + uint8_t headerBuf[sizeof(PageTableCacheHeader)]; + safeSerializeHeader(headerBuf, header); + if (cacheFile.write(headerBuf, sizeof(headerBuf)) != sizeof(headerBuf)) { + cacheFile.close(); + originalFile.close(); + return XtcError::WRITE_ERROR; + } + + if (!seekToOffset(originalFile, m_pageTableOffset)) { + cacheFile.close(); + originalFile.close(); + return XtcError::READ_ERROR; + } + + // Convert the source page table entries into the cached PageInfo layout. + for (uint16_t i = 0; i < m_header.pageCount; i++) { + PageTableEntry entry; + if (originalFile.read(reinterpret_cast(&entry), sizeof(PageTableEntry)) != sizeof(PageTableEntry)) { + LOG_ERR("XTC", "Failed to read page table entry %u", i); + cacheFile.close(); + originalFile.close(); + return XtcError::READ_ERROR; + } + + PageInfo info; + info.offset = entry.dataOffset; + info.size = entry.dataSize; + info.width = entry.width; + info.height = entry.height; + info.bitDepth = m_bitDepth; + info.padding = 0; + + if (cacheFile.write(reinterpret_cast(&info), sizeof(info)) != sizeof(info)) { + cacheFile.close(); + originalFile.close(); + return XtcError::WRITE_ERROR; + } + } + + cacheFile.close(); + originalFile.close(); + + LOG_INF("XTC", "Page table cache built: %u entries", m_header.pageCount); + return XtcError::OK; +} + +uint32_t XtcParser::calculateFileHash(const char* filepath) const { + uint32_t hash = 0; + size_t len = strlen(filepath); + + for (size_t i = 0; i < len; i++) { + hash = hash * 31 + static_cast(filepath[i]); + } + + FsFile file; + if (Storage.openFileForRead("XTC", filepath, file)) { + uint64_t size = file.size(); + hash ^= static_cast(size); + hash ^= static_cast(size >> 32); + file.close(); + } + + return hash; +} + XtcError XtcParser::readHeader() { - // Read first 56 bytes of header + // Read the fixed-size XTC header first. size_t bytesRead = m_file.read(reinterpret_cast(&m_header), sizeof(XtcHeader)); if (bytesRead != sizeof(XtcHeader)) { return XtcError::READ_ERROR; @@ -163,49 +572,17 @@ XtcError XtcParser::readAuthor() { return XtcError::OK; } -XtcError XtcParser::readPageTable() { - if (m_header.pageTableOffset == 0) { - LOG_DBG("XTC", "Page table offset is 0, cannot read"); - return XtcError::CORRUPTED_HEADER; - } - - // Seek to page table - if (!m_file.seek(m_header.pageTableOffset)) { - LOG_DBG("XTC", "Failed to seek to page table at %llu", m_header.pageTableOffset); - return XtcError::READ_ERROR; - } - - m_pageTable.resize(m_header.pageCount); - - // Read page table entries - for (uint16_t i = 0; i < m_header.pageCount; i++) { - PageTableEntry entry; - size_t bytesRead = m_file.read(reinterpret_cast(&entry), sizeof(PageTableEntry)); - if (bytesRead != sizeof(PageTableEntry)) { - LOG_DBG("XTC", "Failed to read page table entry %u", i); - return XtcError::READ_ERROR; - } - - m_pageTable[i].offset = static_cast(entry.dataOffset); - m_pageTable[i].size = entry.dataSize; - m_pageTable[i].width = entry.width; - m_pageTable[i].height = entry.height; - m_pageTable[i].bitDepth = m_bitDepth; - - // Update default dimensions from first page - if (i == 0) { - m_defaultWidth = entry.width; - m_defaultHeight = entry.height; - } - } - - LOG_DBG("XTC", "Read %u page table entries", m_header.pageCount); - return XtcError::OK; -} - XtcError XtcParser::readChapters() { m_hasChapters = false; m_chapters.clear(); + m_chapters.shrink_to_fit(); + + // Reopen the original file on demand because open() closes it after cache initialization. + if (!m_file.isOpen()) { + if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), m_file)) { + return XtcError::FILE_NOT_FOUND; + } + } uint8_t hasChaptersFlag = 0; if (!m_file.seek(0x0B)) { @@ -232,7 +609,13 @@ XtcError XtcParser::readChapters() { } const uint64_t fileSize = m_file.size(); - if (chapterOffset < sizeof(XtcHeader) || chapterOffset >= fileSize || chapterOffset + 96 > fileSize) { + constexpr size_t chapterSize = 96; + + if (chapterOffset < sizeof(XtcHeader) || chapterOffset >= fileSize) { + return XtcError::OK; + } + + if (fileSize - chapterOffset < chapterSize) { return XtcError::OK; } @@ -249,18 +632,32 @@ XtcError XtcParser::readChapters() { return XtcError::OK; } - constexpr size_t chapterSize = 96; const uint64_t available = maxOffset - chapterOffset; - const size_t chapterCount = static_cast(available / chapterSize); + const uint64_t chapterCount64 = available / chapterSize; + if (chapterCount64 == 0) { + return XtcError::OK; + } + + if (chapterCount64 > MAX_CHAPTERS || chapterCount64 > std::numeric_limits::max()) { + LOG_ERR("XTC", "Chapter table too large: available=%llu chapterCount=%llu", + static_cast(available), static_cast(chapterCount64)); + return XtcError::CORRUPTED_HEADER; + } + + const size_t chapterCount = static_cast(chapterCount64); if (chapterCount == 0) { return XtcError::OK; } - if (!m_file.seek(chapterOffset)) { + const size_t freeHeapBefore = ESP.getFreeHeap(); + const size_t maxAllocBefore = ESP.getMaxAllocHeap(); + + if (!seekToOffset(m_file, chapterOffset)) { return XtcError::READ_ERROR; } std::vector chapterBuf(chapterSize); + m_chapters.reserve(chapterCount); for (size_t i = 0; i < chapterCount; i++) { if (m_file.read(chapterBuf.data(), chapterSize) != chapterSize) { return XtcError::READ_ERROR; @@ -304,17 +701,27 @@ XtcError XtcParser::readChapters() { m_chapters.push_back(std::move(chapter)); } + m_chapters.shrink_to_fit(); m_hasChapters = !m_chapters.empty(); - LOG_DBG("XTC", "Chapters: %u", static_cast(m_chapters.size())); + size_t chapterNameBytes = 0; + for (const auto& chapter : m_chapters) { + chapterNameBytes += chapter.name.capacity() + 1; + } + const size_t chapterVectorBytes = m_chapters.capacity() * sizeof(ChapterInfo); + const size_t totalChapterBytes = chapterVectorBytes + chapterNameBytes; + const size_t freeHeapAfter = ESP.getFreeHeap(); + const size_t maxAllocAfter = ESP.getMaxAllocHeap(); + const int heapDelta = static_cast(freeHeapBefore) - static_cast(freeHeapAfter); + const int maxAllocDelta = static_cast(maxAllocBefore) - static_cast(maxAllocAfter); + LOG_INF("XTC", "Chapter metadata: count=%u, vector~=%zu, names~=%zu, total~=%zu, heapDelta=%d, maxAllocDelta=%d", + static_cast(m_chapters.size()), chapterVectorBytes, chapterNameBytes, totalChapterBytes, + heapDelta, maxAllocDelta); return XtcError::OK; } -bool XtcParser::getPageInfo(uint32_t pageIndex, PageInfo& info) const { - if (pageIndex >= m_pageTable.size()) { - return false; - } - info = m_pageTable[pageIndex]; - return true; +const std::vector& XtcParser::getChapters() { + ensureChaptersLoaded(); + return m_chapters; } size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize) { @@ -328,11 +735,23 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz return 0; } - const PageInfo& page = m_pageTable[pageIndex]; + // Resolve the page location through the cache hierarchy before touching the data file. + PageInfo info; + if (!getPageInfo(pageIndex, info)) { + m_lastError = XtcError::READ_ERROR; + return 0; + } - // Seek to page data - if (!m_file.seek(page.offset)) { - LOG_DBG("XTC", "Failed to seek to page %u at offset %lu", pageIndex, page.offset); + // Reopen the source file lazily because normal parser open() does not keep it pinned. + if (!m_file.isOpen()) { + if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), m_file)) { + m_lastError = XtcError::FILE_NOT_FOUND; + return 0; + } + } + + if (!seekToOffset(m_file, info.offset)) { + LOG_DBG("XTC", "Failed to seek to page %u at offset %llu", pageIndex, static_cast(info.offset)); m_lastError = XtcError::READ_ERROR; return 0; } @@ -366,14 +785,14 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height; } - // Check buffer size + // The caller owns the buffer, so fail early if it is too small. if (bufferSize < bitmapSize) { LOG_DBG("XTC", "Buffer too small: need %u, have %u", bitmapSize, bufferSize); m_lastError = XtcError::MEMORY_ERROR; return 0; } - // Read bitmap data + // Read the bitmap payload into the caller-provided buffer. size_t bytesRead = m_file.read(buffer, bitmapSize); if (bytesRead != bitmapSize) { LOG_DBG("XTC", "Page read error: expected %u, got %u", bitmapSize, bytesRead); @@ -396,14 +815,25 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex, return XtcError::PAGE_OUT_OF_RANGE; } - const PageInfo& page = m_pageTable[pageIndex]; - - // Seek to page data - if (!m_file.seek(page.offset)) { + // Streaming uses the same cache lookup path but reads the payload in chunks. + PageInfo info; + if (!getPageInfo(pageIndex, info)) { return XtcError::READ_ERROR; } - // Read and skip page header (XTG for 1-bit, XTH for 2-bit) + // Reopen the source file on demand for streaming reads as well. + if (!m_file.isOpen()) { + if (!Storage.openFileForRead("XTC", m_originalPath.c_str(), m_file)) { + return XtcError::FILE_NOT_FOUND; + } + } + + if (!seekToOffset(m_file, info.offset)) { + LOG_DBG("XTC", "Failed to seek to page %u at offset %llu", pageIndex, static_cast(info.offset)); + return XtcError::READ_ERROR; + } + + // Read and validate the page header before yielding any bitmap bytes. XtgPageHeader pageHeader; size_t headerRead = m_file.read(reinterpret_cast(&pageHeader), sizeof(XtgPageHeader)); const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC; @@ -414,6 +844,7 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex, // Calculate bitmap size based on bit depth // XTG (1-bit): Row-major, ((width+7)/8) * height bytes // XTH (2-bit): Two bit planes, ((width * height + 7) / 8) * 2 bytes + // Match the bitmap sizing rules used by the non-streaming path. size_t bitmapSize; if (m_bitDepth == 2) { bitmapSize = ((static_cast(pageHeader.width) * pageHeader.height + 7) / 8) * 2; @@ -421,7 +852,7 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex, bitmapSize = ((pageHeader.width + 7) / 8) * pageHeader.height; } - // Read in chunks + // Feed the bitmap to the callback in bounded chunks to keep peak memory low. std::vector chunk(chunkSize); size_t totalRead = 0; diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h index 19f3b75d..38e00862 100644 --- a/lib/Xtc/Xtc/XtcParser.h +++ b/lib/Xtc/Xtc/XtcParser.h @@ -9,6 +9,8 @@ #include +#include +#include #include #include #include @@ -30,7 +32,7 @@ class XtcParser { ~XtcParser(); // File open/close - XtcError open(const char* filepath); + XtcError open(const char* filepath, const char* cacheDir); void close(); bool isOpen() const { return m_isOpen; } @@ -41,28 +43,14 @@ class XtcParser { uint16_t getHeight() const { return m_defaultHeight; } uint8_t getBitDepth() const { return m_bitDepth; } // 1 = XTC/XTG, 2 = XTCH/XTH - // Page information - bool getPageInfo(uint32_t pageIndex, PageInfo& info) const; + // Page information - three-tier cache interface + bool getPageInfo(uint32_t pageIndex, PageInfo& info); - /** - * Load page bitmap (raw 1-bit data, skipping XTG header) - * - * @param pageIndex Page index (0-based) - * @param buffer Output buffer (caller allocated) - * @param bufferSize Buffer size - * @return Number of bytes read on success, 0 on failure - */ + // Preload window around specified page (optimize sequential page turns) + void prefetchWindow(uint32_t pageIndex); + + // Load page bitmap (unchanged) size_t loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSize); - - /** - * Streaming page load - * Memory-efficient method that reads page data in chunks. - * - * @param pageIndex Page index - * @param callback Callback function to receive data chunks - * @param chunkSize Chunk size (default: 1024 bytes) - * @return Error code - */ XtcError loadPageStreaming(uint32_t pageIndex, std::function callback, size_t chunkSize = 1024); @@ -72,7 +60,7 @@ class XtcParser { std::string getAuthor() const { return m_author; } bool hasChapters() const { return m_hasChapters; } - const std::vector& getChapters() const { return m_chapters; } + const std::vector& getChapters(); // Validation static bool isValidXtcFile(const char* filepath); @@ -82,24 +70,62 @@ class XtcParser { private: FsFile m_file; + FsFile m_cacheFile; bool m_isOpen; XtcHeader m_header; - std::vector m_pageTable; - std::vector m_chapters; + std::string m_cacheDir; + std::string m_cacheFilePath; + std::string m_originalPath; std::string m_title; std::string m_author; uint16_t m_defaultWidth; uint16_t m_defaultHeight; - uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit) + uint8_t m_bitDepth; bool m_hasChapters; + bool m_chaptersLoaded = false; XtcError m_lastError; + uint32_t m_accessCounter = 0; + + // L1: Hot cache (fixed 4 entries) + std::array m_l1Cache; + + // L2: Sliding window (fixed size array) + std::array m_l2Window; + uint32_t m_l2WindowStart = 0; + size_t m_l2WindowCount = 0; + bool m_l2Valid = false; + + // Chapters (usually few, keep in memory) + std::vector m_chapters; + + // Original Page Table offset (for rebuilding cache) + uint64_t m_pageTableOffset = 0; // Internal helper functions XtcError readHeader(); - XtcError readPageTable(); XtcError readTitle(); XtcError readAuthor(); XtcError readChapters(); + void ensureChaptersLoaded(); + + // L3 cache management + bool isPageTableCacheValid() const; + XtcError buildPageTableCache(); + bool openCacheFile(); + void closeCacheFile(); + + // L1/L2 cache operations + bool lookupL1(uint32_t pageIndex, PageInfo& info); + void updateL1(uint32_t pageIndex, const PageInfo& info); + bool lookupL2(uint32_t pageIndex, PageInfo& info); + void loadL2Window(uint32_t centerPage); + + // Safe deserialization (alignment-safe for ESP32-C3) + static void safeDeserializeHeader(const uint8_t* buf, PageTableCacheHeader& header); + static void safeSerializeHeader(uint8_t* buf, const PageTableCacheHeader& header); + + // Utility functions + uint32_t calculateFileHash(const char* filepath) const; }; } // namespace xtc diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h index 5cee691d..b86c8934 100644 --- a/lib/Xtc/Xtc/XtcTypes.h +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -86,15 +86,15 @@ struct XtgPageHeader { }; #pragma pack(pop) -// Page information (internal use, optimized for memory) +// Page information (internal use) struct PageInfo { - uint32_t offset; // File offset to page data (max 4GB file size) + uint64_t offset; // File offset to page data uint32_t size; // Data size (bytes) uint16_t width; // Page width uint16_t height; // Page height uint8_t bitDepth; // 1 = XTG (1-bit), 2 = XTH (2-bit grayscale) uint8_t padding; // Alignment padding -}; // 16 bytes total +}; // 20 bytes total struct ChapterInfo { std::string name; @@ -102,6 +102,33 @@ struct ChapterInfo { uint16_t endPage; }; +// Cache configuration +constexpr size_t L1_CACHE_SIZE = 4; // L1 cache entries +constexpr size_t L2_WINDOW_SIZE = 100; // L2 window size (reduced for 2-bit memory) +constexpr uint32_t PAGE_TABLE_CACHE_VERSION = 1; // Cache file version + +// Cache magic number +constexpr uint32_t PAGE_TABLE_CACHE_MAGIC = 0x50435458; // "XTCP" + +// Cache file header - NOTE: Do NOT read directly from file buffer! +// Use safeDeserializeHeader() function for alignment-safe access. +struct PageTableCacheHeader { + uint32_t magic; // 'XTCP' = 0x50435458 + uint32_t version; // Cache version + uint32_t pageCount; // Total pages + uint32_t originalHash; // Original file hash (for validation) + uint64_t originalSize; // Original file size (for validation) + uint32_t entrySize; // PageInfo size (16) + uint32_t reserved; // Reserved +}; + +// L1 cache entry +struct L1CacheEntry { + uint32_t pageIndex = 0xFFFFFFFF; // 0xFFFFFFFF = invalid + PageInfo info{}; + uint32_t lastAccess = 0; // Timestamp for LRU +}; + // Error codes enum class XtcError { OK = 0, diff --git a/lib/hal/HalClock.cpp b/lib/hal/HalClock.cpp new file mode 100644 index 00000000..324eb58c --- /dev/null +++ b/lib/hal/HalClock.cpp @@ -0,0 +1,512 @@ +#include "HalClock.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +// ---- RTC-memory state (survives deep sleep, not cold boot) ---------------- + +static constexpr uint32_t CLOCK_RTC_MAGIC = 0xC10C4B1D; +static constexpr uint32_t CLOCK_RTC_FLAG_LP_VALID = 0x00000001u; + +// Temperature drift model for ESP32 RTC-based timekeeping. +// +// The chip's low-power (slow) clock frequency depends on temperature. +// ESP32 variants can drift by about 2 minutes per day per °C from the +// initial captured operating temperature. +// +// - dt_drift ≈ 120 seconds/day/°C +// - relative frequency error per second per °C = 120 / 86400 +// +// At restore() we apply a first-order correction over the sleep interval: +// corrected_interval = raw_interval × (1 + ΔT × drift_factor), where +// drift_factor = 120 / 86400. +// +// Experimental source: https://www.reddit.com/r/esp32/comments/11cikkp/the_clock_on_the_esp_is_wrong/ +static constexpr float CLOCK_TEMP_DRIFT_SECONDS_PER_SECOND_PER_DEG = 120.0f / 86400.0f; + +RTC_NOINIT_ATTR static uint32_t rtcClockMagic; +RTC_NOINIT_ATTR static uint32_t rtcClockFlags; +RTC_NOINIT_ATTR static time_t rtcEpoch; // last-known unix epoch +RTC_NOINIT_ATTR static uint64_t rtcLpTimeUs; // esp_clk_rtc_time() at capture +RTC_NOINIT_ATTR static uint32_t rtcSlowCal; // esp_clk_slowclk_cal_get() at capture +RTC_NOINIT_ATTR static float rtcTemperatureC; // captured chip temperature at save + +static bool clockApproximate = true; + +// Drift correction scale factor (learned from NTP sync results). +// +// Raw temp drift model uses 2 min/day/°C -> factor = 120/86400. This is a +// generic base model. The actual board may behave a bit differently. On each +// NTP sync we estimate how the local clock error compares to the model and +// update this scale factor slightly to converge toward real world behavior. +// +// rtcDriftScale = 1.0 means we trust 2 min/day/°C exactly. If the device is +// slower/faster than that, NTP drift calibration adjusts this factor. +static float rtcDriftScale = 1.0f; + +static unsigned long lastPeriodicUpdateMs = 0; +static constexpr unsigned long PERIODIC_UPDATE_INTERVAL_MS = 10UL * 60UL * 1000UL; + +struct TimeZoneEntry { + const char* tz; +}; + +static constexpr TimeZoneEntry TIMEZONES[] = { + {"GMT0BST,M3.5.0/1,M10.5.0/2"}, + {"CET-1CEST,M3.5.0/2,M10.5.0/3"}, + {"EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"MSK-3"}, + {"UTC-4"}, + {"UTC-5:30"}, + {"UTC-7"}, + {"UTC-8"}, + {"UTC-9"}, + {"AEST-10AEDT,M10.1.0/2,M4.1.0/3"}, + {"NZST-12NZDT,M9.5.0/2,M4.1.0/3"}, + {"UTC+3"}, + {"EST5EDT,M3.2.0/2,M11.1.0/2"}, + {"CST6CDT,M3.2.0/2,M11.1.0/2"}, + {"MST7MDT,M3.2.0/2,M11.1.0/2"}, + {"PST8PDT,M3.2.0/2,M11.1.0/2"}, +}; + +// ---- NVS helpers ---------------------------------------------------------- + +// If the last NTP sync is older than this, treat a cold-boot restore as +// unsynced rather than showing a potentially very wrong time. +static constexpr int64_t STALE_THRESHOLD_S = 72 * 3600; // 72 hours + +static constexpr char NVS_NAMESPACE[] = "halclock"; +static constexpr char NVS_KEY[] = "epoch"; +static constexpr char NVS_SYNC_KEY[] = "lastsync"; +static constexpr char NVS_DRIFT_KEY[] = "driftcoef"; +static constexpr char NVS_TEMP_KEY[] = "lasttemp"; + +static void nvsWrite(time_t epoch) { + Preferences prefs; + if (prefs.begin(NVS_NAMESPACE, false)) { + prefs.putLong64(NVS_KEY, (int64_t)epoch); + prefs.end(); + } +} + +static void nvsWriteDriftScale(float driftScale) { + Preferences prefs; + if (prefs.begin(NVS_NAMESPACE, false)) { + prefs.putFloat(NVS_DRIFT_KEY, driftScale); + prefs.end(); + } +} + +static float nvsReadDriftScale() { + Preferences prefs; + float result = 1.0f; + if (prefs.begin(NVS_NAMESPACE, true)) { + result = prefs.getFloat(NVS_DRIFT_KEY, 1.0f); + prefs.end(); + } + // Guard against NaN, Inf, or out-of-range values from corrupted NVS. + if (!std::isfinite(result) || result < 0.1f || result > 5.0f) { + result = 1.0f; + } + return result; +} + +static void nvsWriteLastSyncTemp(float tempC) { + Preferences prefs; + if (prefs.begin(NVS_NAMESPACE, false)) { + prefs.putFloat(NVS_TEMP_KEY, tempC); + prefs.end(); + } +} + +static float nvsReadLastSyncTemp() { + Preferences prefs; + float result = 0.0f; + if (prefs.begin(NVS_NAMESPACE, true)) { + result = prefs.getFloat(NVS_TEMP_KEY, 0.0f); + prefs.end(); + } + return result; +} + +static void nvsWriteSyncTime(time_t syncEpoch) { + Preferences prefs; + if (prefs.begin(NVS_NAMESPACE, false)) { + prefs.putLong64(NVS_SYNC_KEY, (int64_t)syncEpoch); + prefs.end(); + } +} + +static time_t nvsRead() { + Preferences prefs; + time_t epoch = 0; + if (prefs.begin(NVS_NAMESPACE, true)) { + epoch = (time_t)prefs.getLong64(NVS_KEY, 0); + prefs.end(); + } + return epoch; +} + +static time_t nvsReadSyncTime() { + Preferences prefs; + time_t syncEpoch = 0; + if (prefs.begin(NVS_NAMESPACE, true)) { + syncEpoch = (time_t)prefs.getLong64(NVS_SYNC_KEY, 0); + prefs.end(); + } + return syncEpoch; +} + +// ---- internal helpers ----------------------------------------------------- + +static float readChipTemperatureC() { + // ESP32 and ESP32-C3 use the internal ADC temperature sensor. + return (float)temperatureRead(); +} + +static void setSystemClock(time_t epoch) { + struct timeval tv = {}; + tv.tv_sec = epoch; + settimeofday(&tv, nullptr); +} + +static bool rtcValid() { return rtcClockMagic == CLOCK_RTC_MAGIC && rtcEpoch > 0; } + +/// Compute temperature-corrected elapsed seconds from LP timer delta. +/// Uses the trapezoidal rule (average of start + end temperature) as a +/// first-order approximation of the temperature integral over the interval. +/// Returns the corrected elapsed seconds and updates lpNowOut/calNowOut +/// for the caller to re-baseline. +static double computeCorrectedElapsedSec(uint64_t lpNow, float tempNow) { + uint32_t calNow = esp_clk_slowclk_cal_get(); + uint64_t elapsedUs; + if (rtcSlowCal != 0 && calNow != 0) { + // rtcLpTimeUs was computed with rtcSlowCal; convert it to the + // current calibration basis so the subtraction is consistent. + uint64_t lpThenCorrected = (uint64_t)((double)rtcLpTimeUs * calNow / rtcSlowCal); + elapsedUs = lpNow - lpThenCorrected; + } else { + elapsedUs = lpNow - rtcLpTimeUs; + } + + // Use the full temperature delta between the average over the interval + // and the calibration reference (which is the capture-time temperature). + // avgTemp approximates the mean temperature during the interval. + // The drift model says the RTC runs (1 + deltaT * driftRate) times + // faster/slower than nominal, so the true elapsed wall-clock time + // differs from the raw LP-derived time by that factor. + float avgTemp = (rtcTemperatureC + tempNow) * 0.5f; + // Positive when COOLED DOWN relative to capture temperature. + // ESP32 RC oscillator has a positive temperature coefficient: it runs faster + // when hotter, causing the LP timer to over-count. To recover true elapsed + // time we must REDUCE the raw LP-derived seconds when the device is warmer + // than at capture (and INCREASE them when cooler). Hence the sign inversion. + float tempDelta = rtcTemperatureC - avgTemp; // = (rtcTemperatureC - tempNow) / 2 + float tempFactor = 1.0f + tempDelta * CLOCK_TEMP_DRIFT_SECONDS_PER_SECOND_PER_DEG * rtcDriftScale; + if (tempFactor < 0.5f) { + tempFactor = 0.5f; + } else if (tempFactor > 1.5f) { + tempFactor = 1.5f; + } + + double elapsedSec = (double)elapsedUs / 1000000.0; + double correctedSec = elapsedSec * (double)tempFactor; + + LOG_DBG("CLK", "Drift calc: startT=%.1fC nowT=%.1fC dT=%.3f factor=%.6f raw=%.3fs corr=%.3fs", rtcTemperatureC, + tempNow, tempDelta, tempFactor, elapsedSec, correctedSec); + + return correctedSec; +} + +/// Capture current time + LP timer into RTC memory, and epoch into NVS. +static void capture(bool lpValid) { + rtcEpoch = time(nullptr); + rtcLpTimeUs = esp_clk_rtc_time(); + rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = readChipTemperatureC(); + rtcClockMagic = CLOCK_RTC_MAGIC; + rtcClockFlags = lpValid ? CLOCK_RTC_FLAG_LP_VALID : 0; + nvsWrite(rtcEpoch); +} + +// ---- public API ----------------------------------------------------------- + +namespace HalClock { + +void applyTimezone(uint8_t timeZoneSetting) { + const size_t index = timeZoneSetting < (sizeof(TIMEZONES) / sizeof(TIMEZONES[0])) ? timeZoneSetting : 0; + setenv("TZ", TIMEZONES[index].tz, 1); + tzset(); + LOG_DBG("CLK", "Timezone applied: %s", TIMEZONES[index].tz); +} + +bool syncNtp() { + time_t preSyncTime = time(nullptr); + time_t prevSyncTime = nvsReadSyncTime(); + float prevSyncTemp = nvsReadLastSyncTemp(); + + if (esp_sntp_enabled()) { + esp_sntp_stop(); + } + + esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); + esp_sntp_setservername(0, "pool.ntp.org"); + esp_sntp_init(); + + int retry = 0; + constexpr int maxRetries = 50; // 5 seconds + while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) { + vTaskDelay(100 / portTICK_PERIOD_MS); + retry++; + } + + if (retry >= maxRetries) { + LOG_ERR("CLK", "NTP sync timeout"); + return false; + } + + capture(false); + nvsWriteSyncTime(rtcEpoch); + + float currentTemp = rtcTemperatureC; + if (currentTemp != 0.0f) { + nvsWriteLastSyncTemp(currentTemp); + } + + if (prevSyncTime > 0 && preSyncTime > 0 && rtcEpoch > prevSyncTime) { + float interval = (float)(rtcEpoch - prevSyncTime); + // error = how far the local clock was off before NTP corrected it. + // Negative means local clock was behind (NTP jumped us forward). + // Positive means local clock was ahead (NTP pulled us back). + float error = (float)(preSyncTime - rtcEpoch); + if (interval >= 60.0f) { + // Convert to seconds-of-drift per day. + float observedDriftPerDay = error * 86400.0f / interval; + + // Adaptive model calibration: + // - Observed drift is derived from the difference between local clock + // reading just before NTP and the true time reported by NTP, scaled + // to a per-day rate over the interval since the previous sync. + // - The baseline model expects 120 sec/day per °C. + // - Measure temp delta since last sync (from stored NVS temp). + // - If large enough, compute an empirical scale to apply to the model + // so future drift corrections are better aligned with actual hardware. + // - The scale is persisted to NVS via saveBeforeSleep(). + float effectiveScale = rtcDriftScale; + float tempDelta = currentTemp - prevSyncTemp; + if (std::fabs(tempDelta) > 0.1f) { + float modelDriftPerDay = 120.0f * tempDelta; + if (std::fabs(modelDriftPerDay) > 0.01f) { + float measuredScale = observedDriftPerDay / modelDriftPerDay; + effectiveScale = 0.9f * rtcDriftScale + 0.1f * measuredScale; + effectiveScale = std::max(0.1f, std::min(5.0f, effectiveScale)); + rtcDriftScale = effectiveScale; + } + } + + LOG_DBG("CLK", "NTP drift: interval=%.0fs error=%.3fs perDay=%.3f scale=%.3f deltaT=%.2f", interval, error, + observedDriftPerDay, rtcDriftScale, tempDelta); + } + } + + clockApproximate = false; + LOG_INF("CLK", "NTP synced, epoch %lld", (long long)rtcEpoch); + return true; +} + +void saveBeforeSleep(bool keepLpAlive) { + if (!isSynced()) { + return; + } + capture(keepLpAlive); + // Persist learned drift scale and last temperature to NVS so they survive + // cold boot. We only write here (not periodically) to minimise flash wear. + nvsWriteDriftScale(rtcDriftScale); + nvsWriteLastSyncTemp(rtcTemperatureC); + LOG_DBG("CLK", "Saved epoch %lld before sleep (driftScale=%.3f)", (long long)rtcEpoch, rtcDriftScale); +} + +void restore() { + rtcDriftScale = nvsReadDriftScale(); + + const bool lpValid = (rtcClockFlags & CLOCK_RTC_FLAG_LP_VALID) != 0; + if (rtcValid() && lpValid) { + // RTC memory survived — we woke from deep sleep. + // + // We restore the wall clock by computing elapsed real time from the + // LP timer delta and applying both frequency calibration and temperature + // drift correction. + // + // Steps: + // 1) Read current LP timer and slow-clock calibration. + // 2) Compute raw elapsed LP ticks, on the same calibration basis used + // when capture() was called. + // 3) Convert elapsed ticks to seconds. + // 4) Apply temperature drift correction based on measured RTC memory + // capture temperature and current chip temp. + // 5) Set system time to rtcEpoch + corrected elapsed seconds. + // + // This is an approximation: we use the average of start/end measured + // temperature as a simple integral proxy. More advanced models could + // sample temperature continuously, but this is a good tradeoff for low + // cost and better accuracy vs no temperature compensation. + uint64_t lpNow = esp_clk_rtc_time(); + time_t estimated = rtcEpoch; + if (lpNow > rtcLpTimeUs) { + float tempNow = readChipTemperatureC(); + double correctedSec = computeCorrectedElapsedSec(lpNow, tempNow); + estimated += (time_t)correctedSec; + } + + setSystemClock(estimated); + // Re-baseline LP timer and temperature for next interval. + rtcEpoch = estimated; + rtcLpTimeUs = esp_clk_rtc_time(); + rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = readChipTemperatureC(); + clockApproximate = true; + LOG_INF("CLK", "Restored from RTC + LP timer, epoch %lld", (long long)estimated); + return; + } + + // Cold boot — try NVS. No elapsed correction possible. + time_t epoch = nvsRead(); + if (epoch > 0) { + time_t lastSync = nvsReadSyncTime(); + if (lastSync > 0 && (epoch - lastSync) > STALE_THRESHOLD_S) { + LOG_ERR("CLK", "NVS epoch %lld is stale (last NTP sync %lld, %lld h ago), discarding", (long long)epoch, + (long long)lastSync, (long long)((epoch - lastSync) / 3600)); + return; + } + setSystemClock(epoch); + rtcEpoch = epoch; + rtcLpTimeUs = esp_clk_rtc_time(); + rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = nvsReadLastSyncTemp(); + if (rtcTemperatureC == 0.0f) { + rtcTemperatureC = readChipTemperatureC(); + } + rtcClockMagic = CLOCK_RTC_MAGIC; + rtcClockFlags = 0; + clockApproximate = true; + LOG_INF("CLK", "Restored from NVS, epoch %lld (no elapsed correction)", (long long)epoch); + } +} + +time_t now() { + if (!isSynced()) { + return 0; + } + return time(nullptr); +} + +void updatePeriodic() { + if (!isSynced()) { + return; + } + unsigned long nowMs = millis(); + if (nowMs - lastPeriodicUpdateMs < PERIODIC_UPDATE_INTERVAL_MS) { + return; + } + lastPeriodicUpdateMs = nowMs; + + // Compute temperature-corrected elapsed time since last baseline and apply + // only the drift delta (correction - raw) to the system clock. The kernel + // clock already advanced by the raw amount, so we must not re-add it. + uint64_t lpNow = esp_clk_rtc_time(); + if (lpNow <= rtcLpTimeUs) { + return; + } + + float tempNow = readChipTemperatureC(); + double correctedSec = computeCorrectedElapsedSec(lpNow, tempNow); + + // Raw elapsed seconds (what the kernel clock already counted). + uint64_t rawElapsedUs = lpNow - rtcLpTimeUs; + double rawSec = (double)rawElapsedUs / 1000000.0; + + // The drift delta is the difference between what really elapsed + // (temperature-corrected) and what the kernel counted (raw). + double driftDeltaSec = correctedSec - rawSec; + + // Re-baseline LP timer and temperature for the next interval. + rtcLpTimeUs = lpNow; + rtcSlowCal = esp_clk_slowclk_cal_get(); + rtcTemperatureC = tempNow; + + // Only nudge the system clock if the drift delta is meaningful (>50 ms). + // This avoids unnecessary settimeofday calls for negligible corrections. + if (std::fabs(driftDeltaSec) > 0.05) { + rtcEpoch = time(nullptr) + (time_t)driftDeltaSec; + setSystemClock(rtcEpoch); + LOG_DBG("CLK", "Periodic drift nudge: raw=%.3fs corr=%.3fs delta=%.3fs scale=%.3f", rawSec, correctedSec, + driftDeltaSec, rtcDriftScale); + } +} + +bool isSynced() { + return time(nullptr) > 1577836800; // > 2020-01-01 +} + +bool isApproximate() { return clockApproximate; } + +time_t lastSyncTime() { return nvsReadSyncTime(); } + +void formatTime(char* buf, size_t bufSize, bool use24h) { + if (!isSynced()) { + snprintf(buf, bufSize, "--:--"); + return; + } + + time_t t = time(nullptr); + struct tm timeinfo; + localtime_r(&t, &timeinfo); + + const char* prefix = isApproximate() ? "~" : ""; + + if (use24h) { + snprintf(buf, bufSize, "%s%02d:%02d", prefix, timeinfo.tm_hour, timeinfo.tm_min); + } else { + int hour = timeinfo.tm_hour % 12; + if (hour == 0) hour = 12; + const char* ampm = timeinfo.tm_hour < 12 ? "AM" : "PM"; + snprintf(buf, bufSize, "%s%d:%02d%s", prefix, hour, timeinfo.tm_min, ampm); + } +} + +void formatLogTime(char* buf, size_t bufSize) { + if (!isSynced()) { + buf[0] = '\0'; + return; + } + + time_t t = time(nullptr); + struct tm timeinfo; + localtime_r(&t, &timeinfo); + snprintf(buf, bufSize, "%02d:%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec); +} + +void wifiOff(bool skipNtpSync) { + if (!skipNtpSync && isApproximate() && WiFi.getMode() == WIFI_STA && WiFi.status() == WL_CONNECTED) { + syncNtp(); + } + if (esp_sntp_enabled()) { + esp_sntp_stop(); + } + WiFi.disconnect(false); + delay(100); + WiFi.mode(WIFI_OFF); + delay(100); +} + +} // namespace HalClock diff --git a/lib/hal/HalClock.h b/lib/hal/HalClock.h new file mode 100644 index 00000000..976003e2 --- /dev/null +++ b/lib/hal/HalClock.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include + +/// Lightweight wall-clock facade. +/// +/// The ESP32-C3 has no battery-backed RTC, so wall-clock time is lost on every +/// deep-sleep / power cycle. HalClock bridges this gap using three layers: +/// +/// - **LP timer** (`esp_clk_rtc_time()`) — keeps running during deep sleep +/// when `keepClockAlive` is enabled (GPIO13 stays HIGH). Used to compute +/// elapsed time and correct the stored epoch on wake. +/// - **RTC memory** (`RTC_NOINIT_ATTR`) — survives deep sleep, lost on cold +/// boot. Stores the epoch + LP timer value captured before sleep. +/// - **NVS** (flash key-value store) — survives power cycles. Fallback when +/// RTC memory is unavailable (cold boot). +/// +/// Usage: +/// 1. On boot, call `restore()` to seed the system clock from the best +/// available source (RTC memory + LP correction > NVS). +/// 2. After a successful NTP sync, call `syncNtp()`. +/// 3. Before entering deep sleep, call `saveBeforeSleep()`. +/// +/// `now()` returns the best-effort epoch (0 if never synced). +namespace HalClock { + +/// Perform an NTP sync (requires WiFi to be connected). Starts SNTP, +/// waits up to 5 seconds for completion, then captures the result. +/// Returns true if the sync succeeded. +bool syncNtp(); + +/// Apply timezone/DST rules via the POSIX TZ string for the given setting. +void applyTimezone(uint8_t timeZoneSetting); + +/// Call just before deep sleep. Snapshots the current system time to RTC +/// memory and NVS so it can be restored on wake / cold boot. Pass true when +/// the LP timer is kept alive during sleep. +void saveBeforeSleep(bool keepLpAlive); + +/// Call on boot to seed the system clock from the best available stored +/// value. When RTC memory is valid (deep-sleep wake) and the LP timer was +/// running, the restored time includes elapsed-time correction. Falls back +/// to NVS for cold boot (stale, but better than nothing). +void restore(); + +/// Returns the current best-effort wall-clock epoch, or 0 if the clock was +/// never set. +time_t now(); + +/// True if the clock has been set at least once (NTP or restore). +bool isSynced(); + +/// Periodic callback (called from main loop) to compensate temperature-induced +/// RTC drift while the device is awake. Runs at a 10-minute interval. +/// Computes the drift delta since the last baseline using the temperature +/// model and nudges the system clock by only that delta (the kernel clock +/// already advanced the raw amount). Drift state is persisted to NVS only +/// in saveBeforeSleep() to minimise flash wear. +void updatePeriodic(); + +/// True if the last restore was from a backup (not NTP) — i.e. the clock +/// may have drifted. Cleared on NTP sync. +bool isApproximate(); + +/// Returns the epoch of the last successful NTP sync (from NVS), or 0 if +/// no sync has ever been recorded. +time_t lastSyncTime(); + +/// Format the current time for display. Returns "--:--" if the clock was +/// never synced, prefixes with "~" if approximate. +/// When use24h is false, formats as "2:05pm" / "12:30am". +/// Output is written to `buf` (must be at least 16 bytes). +void formatTime(char* buf, size_t bufSize, bool use24h); + +/// Format the current time for log timestamps. Returns "HH:MM:SS" if +/// synced, or an empty string if not. +void formatLogTime(char* buf, size_t bufSize); + +/// Tear down WiFi cleanly. When skipNtpSync is false (default) and the +/// clock is approximate, performs an opportunistic NTP sync before +/// disconnecting — essentially free since we already have a connection. +void wifiOff(bool skipNtpSync = false); + +} // namespace HalClock diff --git a/lib/hal/HalPowerManager.cpp b/lib/hal/HalPowerManager.cpp index a40c81ed..ff976bc5 100644 --- a/lib/hal/HalPowerManager.cpp +++ b/lib/hal/HalPowerManager.cpp @@ -60,26 +60,35 @@ void HalPowerManager::setPowerSaving(bool enabled) { // Otherwise, no change needed } -void HalPowerManager::startDeepSleep(HalGPIO& gpio) const { +void HalPowerManager::startDeepSleep(HalGPIO& gpio, bool keepClockAlive) const { // Ensure that the power button has been released to avoid immediately turning back on if you're holding it while (gpio.isPressed(HalGPIO::BTN_POWER)) { delay(50); gpio.update(); } - // Pre-sleep routines from the original firmware - // GPIO13 is connected to battery latch MOSFET, we need to make sure it's low during sleep - // Note that this means the MCU will be completely powered off during sleep, including RTC + // GPIO13 is connected to the battery latch MOSFET. + // When keepClockAlive is false (default): GPIO13 goes LOW, the MCU is + // completely powered off during sleep (including the LP timer / RTC memory). + // When keepClockAlive is true: GPIO13 stays HIGH, the MCU remains powered + // at ~3-4 mA so the LP timer keeps running and RTC memory is preserved. + // This allows HalClock to accurately compute elapsed sleep time on wake. constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13; + // Release any GPIO hold from a previous sleep cycle (keepClockAlive=true leaves GPIO13 held after wake). + // Without this, gpio_set_level() below silently fails and GPIO13 is stuck in its prior state, + // causing the device to enter a sleep/wake loop that requires a hardware reset to escape. + gpio_hold_dis(GPIO_SPIWP); + gpio_deep_sleep_hold_dis(); gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT); - gpio_set_level(GPIO_SPIWP, 0); + gpio_set_level(GPIO_SPIWP, keepClockAlive ? 1 : 0); esp_sleep_config_gpio_isolate(); gpio_deep_sleep_hold_en(); gpio_hold_en(GPIO_SPIWP); pinMode(InputManager::POWER_BUTTON_PIN, INPUT_PULLUP); // Arm the wakeup trigger *after* the button is released - // Note: this is only useful for waking up on USB power. On battery, the MCU will be completely powered off, so the - // power button is hard-wired to briefly provide power to the MCU, waking it up regardless of the wakeup source - // configuration + // Note: when keepClockAlive is false, this is only useful for waking up on USB power. On battery, the MCU will be + // completely powered off, so the power button is hard-wired to briefly provide power to the MCU, waking it up + // regardless of the wakeup source configuration. + // When keepClockAlive is true, this is the actual wakeup mechanism since the MCU stays powered. esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Enter Deep Sleep esp_deep_sleep_start(); diff --git a/lib/hal/HalPowerManager.h b/lib/hal/HalPowerManager.h index bf78623c..f7f02eed 100644 --- a/lib/hal/HalPowerManager.h +++ b/lib/hal/HalPowerManager.h @@ -37,9 +37,11 @@ class HalPowerManager { // Control CPU frequency for power saving void setPowerSaving(bool enabled); - // Setup wake up GPIO and enter deep sleep - // Should be called inside main loop() to handle the currentLockMode - void startDeepSleep(HalGPIO& gpio) const; + // Setup wake up GPIO and enter deep sleep. + // When keepClockAlive is true, GPIO13 stays HIGH so the LP timer keeps + // running during sleep (~3-4 mA extra). This allows HalClock to compute + // elapsed sleep time and restore the wall clock accurately on wake. + void startDeepSleep(HalGPIO& gpio, bool keepClockAlive = false) const; // Get battery percentage (range 0-100) uint16_t getBatteryPercentage() const; diff --git a/lib/hal/HalStorage.cpp b/lib/hal/HalStorage.cpp index 24395e4f..82b6e03c 100644 --- a/lib/hal/HalStorage.cpp +++ b/lib/hal/HalStorage.cpp @@ -4,6 +4,7 @@ #include // need to be included before SdFat.h for compatibility with FS.h's File class #include #include +#include #include @@ -54,6 +55,23 @@ bool HalStorage::writeFile(const char* path, const String& content) { bool HalStorage::ensureDirectoryExists(const char* path) { HAL_STORAGE_WRAPPED_CALL(ensureDirectoryExists, path); } +uint64_t HalStorage::sdTotalBytes() const { + StorageLock lock; + return SDCard.sdTotalBytes(); +} + +uint64_t HalStorage::sdUsedBytes() { + StorageLock lock; + return SDCard.sdUsedBytes(); +} + +uint64_t HalStorage::sdFreeBytes() { + uint64_t total = sdTotalBytes(); + uint64_t used = sdUsedBytes(); + if (total <= used) return 0; + return total - used; +} + class HalFile::Impl { public: Impl(FsFile&& fsFile) : file(std::move(fsFile)) {} @@ -147,6 +165,9 @@ int HalFile::read() { HAL_FILE_WRAPPED_CALL(read, ); } size_t HalFile::write(const void* buf, size_t count) { HAL_FILE_WRAPPED_CALL(write, buf, count); } size_t HalFile::write(uint8_t b) { HAL_FILE_WRAPPED_CALL(write, b); } bool HalFile::rename(const char* newPath) { HAL_FILE_WRAPPED_CALL(rename, newPath); } +bool HalFile::getModifyDateTime(uint16_t* pdate, uint16_t* ptime) { + HAL_FILE_WRAPPED_CALL(getModifyDateTime, pdate, ptime); +} bool HalFile::isDirectory() const { HAL_FILE_FORWARD_CALL(isDirectory, ); } // already thread-safe, no need to wrap void HalFile::rewindDirectory() { HAL_FILE_WRAPPED_CALL(rewindDirectory, ); } bool HalFile::close() { HAL_FILE_WRAPPED_CALL(close, ); } diff --git a/lib/hal/HalStorage.h b/lib/hal/HalStorage.h index d5824bae..d9bee706 100644 --- a/lib/hal/HalStorage.h +++ b/lib/hal/HalStorage.h @@ -45,6 +45,10 @@ class HalStorage { bool openFileForWrite(const char* moduleName, const String& path, HalFile& file); bool removeDir(const char* path); + uint64_t sdTotalBytes() const; + uint64_t sdUsedBytes(); + uint64_t sdFreeBytes(); + static HalStorage& getInstance() { return instance; } class StorageLock; // private class, used internally @@ -86,6 +90,7 @@ class HalFile : public Print { size_t write(const void* buf, size_t count); size_t write(uint8_t b) override; bool rename(const char* newPath); + bool getModifyDateTime(uint16_t* pdate, uint16_t* ptime); bool isDirectory() const; void rewindDirectory(); bool close(); diff --git a/lib/picojpeg/picojpeg.c b/lib/picojpeg/picojpeg.c deleted file mode 100644 index f612b73c..00000000 --- a/lib/picojpeg/picojpeg.c +++ /dev/null @@ -1,2087 +0,0 @@ -//------------------------------------------------------------------------------ -// picojpeg.c v1.1 - Public domain, Rich Geldreich -// Nov. 27, 2010 - Initial release -// Feb. 9, 2013 - Added H1V2/H2V1 support, cleaned up macros, signed shift fixes -// Also integrated and tested changes from Chris Phoenix . -//------------------------------------------------------------------------------ -#include "picojpeg.h" -//------------------------------------------------------------------------------ -// Set to 1 if right shifts on signed ints are always unsigned (logical) shifts -// When 1, arithmetic right shifts will be emulated by using a logical shift -// with special case code to ensure the sign bit is replicated. -#define PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED 0 - -// Define PJPG_INLINE to "inline" if your C compiler supports explicit inlining -#define PJPG_INLINE -//------------------------------------------------------------------------------ -typedef unsigned char uint8; -typedef unsigned short uint16; -typedef signed char int8; -typedef signed short int16; -//------------------------------------------------------------------------------ -#if PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED -static int16 replicateSignBit16(int8 n) { - switch (n) { - case 0: - return 0x0000; - case 1: - return 0x8000; - case 2: - return 0xC000; - case 3: - return 0xE000; - case 4: - return 0xF000; - case 5: - return 0xF800; - case 6: - return 0xFC00; - case 7: - return 0xFE00; - case 8: - return 0xFF00; - case 9: - return 0xFF80; - case 10: - return 0xFFC0; - case 11: - return 0xFFE0; - case 12: - return 0xFFF0; - case 13: - return 0xFFF8; - case 14: - return 0xFFFC; - case 15: - return 0xFFFE; - default: - return 0xFFFF; - } -} -static PJPG_INLINE int16 arithmeticRightShiftN16(int16 x, int8 n) { - int16 r = (uint16)x >> (uint8)n; - if (x < 0) r |= replicateSignBit16(n); - return r; -} -static PJPG_INLINE long arithmeticRightShift8L(long x) { - long r = (unsigned long)x >> 8U; - if (x < 0) r |= ~(~(unsigned long)0U >> 8U); - return r; -} -#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) arithmeticRightShiftN16(x, n) -#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) arithmeticRightShift8L(x) -#else -#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) ((x) >> (n)) -#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) ((x) >> 8) -#endif -//------------------------------------------------------------------------------ -// Change as needed - the PJPG_MAX_WIDTH/PJPG_MAX_HEIGHT checks are only present -// to quickly detect bogus files. -#define PJPG_MAX_WIDTH 16384 -#define PJPG_MAX_HEIGHT 16384 -#define PJPG_MAXCOMPSINSCAN 3 -//------------------------------------------------------------------------------ -typedef enum { - M_SOF0 = 0xC0, - M_SOF1 = 0xC1, - M_SOF2 = 0xC2, - M_SOF3 = 0xC3, - - M_SOF5 = 0xC5, - M_SOF6 = 0xC6, - M_SOF7 = 0xC7, - - M_JPG = 0xC8, - M_SOF9 = 0xC9, - M_SOF10 = 0xCA, - M_SOF11 = 0xCB, - - M_SOF13 = 0xCD, - M_SOF14 = 0xCE, - M_SOF15 = 0xCF, - - M_DHT = 0xC4, - - M_DAC = 0xCC, - - M_RST0 = 0xD0, - M_RST1 = 0xD1, - M_RST2 = 0xD2, - M_RST3 = 0xD3, - M_RST4 = 0xD4, - M_RST5 = 0xD5, - M_RST6 = 0xD6, - M_RST7 = 0xD7, - - M_SOI = 0xD8, - M_EOI = 0xD9, - M_SOS = 0xDA, - M_DQT = 0xDB, - M_DNL = 0xDC, - M_DRI = 0xDD, - M_DHP = 0xDE, - M_EXP = 0xDF, - - M_APP0 = 0xE0, - M_APP15 = 0xEF, - - M_JPG0 = 0xF0, - M_JPG13 = 0xFD, - M_COM = 0xFE, - - M_TEM = 0x01, - - M_ERROR = 0x100, - - RST0 = 0xD0 -} JPEG_MARKER; -//------------------------------------------------------------------------------ -static const int8 ZAG[] = { - 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, - 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, - 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, -}; -//------------------------------------------------------------------------------ -// 128 bytes -static int16 gCoeffBuf[8 * 8]; - -// 8*8*4 bytes * 3 = 768 -static uint8 gMCUBufR[256]; -static uint8 gMCUBufG[256]; -static uint8 gMCUBufB[256]; - -// 256 bytes -static int16 gQuant0[8 * 8]; -static int16 gQuant1[8 * 8]; - -// 6 bytes -static int16 gLastDC[3]; - -typedef struct HuffTableT { - uint16 mMinCode[16]; - uint16 mMaxCode[16]; - uint8 mValPtr[16]; -} HuffTable; - -// DC - 192 -static HuffTable gHuffTab0; - -static uint8 gHuffVal0[16]; - -static HuffTable gHuffTab1; -static uint8 gHuffVal1[16]; - -// AC - 672 -static HuffTable gHuffTab2; -static uint8 gHuffVal2[256]; - -static HuffTable gHuffTab3; -static uint8 gHuffVal3[256]; - -static uint8 gValidHuffTables; -static uint8 gValidQuantTables; - -static uint8 gTemFlag; -#define PJPG_MAX_IN_BUF_SIZE 256 -static uint8 gInBuf[PJPG_MAX_IN_BUF_SIZE]; -static uint8 gInBufOfs; -static uint8 gInBufLeft; - -static uint16 gBitBuf; -static uint8 gBitsLeft; -//------------------------------------------------------------------------------ -static uint16 gImageXSize; -static uint16 gImageYSize; -static uint8 gCompsInFrame; -static uint8 gCompIdent[3]; -static uint8 gCompHSamp[3]; -static uint8 gCompVSamp[3]; -static uint8 gCompQuant[3]; - -static uint16 gRestartInterval; -static uint16 gNextRestartNum; -static uint16 gRestartsLeft; - -static uint8 gCompsInScan; -static uint8 gCompList[3]; -static uint8 gCompDCTab[3]; // 0,1 -static uint8 gCompACTab[3]; // 0,1 - -static pjpeg_scan_type_t gScanType; - -static uint8 gMaxBlocksPerMCU; -static uint8 gMaxMCUXSize; -static uint8 gMaxMCUYSize; -static uint16 gMaxMCUSPerRow; -static uint16 gMaxMCUSPerCol; - -static uint16 gNumMCUSRemainingX, gNumMCUSRemainingY; - -static uint8 gMCUOrg[6]; - -static pjpeg_need_bytes_callback_t g_pNeedBytesCallback; -static void* g_pCallback_data; -static uint8 gCallbackStatus; -static uint8 gReduce; -//------------------------------------------------------------------------------ -static void fillInBuf(void) { - unsigned char status; - - // Reserve a few bytes at the beginning of the buffer for putting back ("stuffing") chars. - gInBufOfs = 4; - gInBufLeft = 0; - - status = (*g_pNeedBytesCallback)(gInBuf + gInBufOfs, PJPG_MAX_IN_BUF_SIZE - gInBufOfs, &gInBufLeft, g_pCallback_data); - if (status) { - // The user provided need bytes callback has indicated an error, so record the error and continue trying to decode. - // The highest level pjpeg entrypoints will catch the error and return the non-zero status. - gCallbackStatus = status; - } -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 getChar(void) { - if (!gInBufLeft) { - fillInBuf(); - if (!gInBufLeft) { - gTemFlag = ~gTemFlag; - return gTemFlag ? 0xFF : 0xD9; - } - } - - gInBufLeft--; - return gInBuf[gInBufOfs++]; -} -//------------------------------------------------------------------------------ -static PJPG_INLINE void stuffChar(uint8 i) { - gInBufOfs--; - gInBuf[gInBufOfs] = i; - gInBufLeft++; -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 getOctet(uint8 FFCheck) { - uint8 c = getChar(); - - if ((FFCheck) && (c == 0xFF)) { - uint8 n = getChar(); - - if (n) { - stuffChar(n); - stuffChar(0xFF); - } - } - - return c; -} -//------------------------------------------------------------------------------ -static uint16 getBits(uint8 numBits, uint8 FFCheck) { - uint8 origBits = numBits; - uint16 ret = gBitBuf; - - if (numBits > 8) { - numBits -= 8; - - gBitBuf <<= gBitsLeft; - - gBitBuf |= getOctet(FFCheck); - - gBitBuf <<= (8 - gBitsLeft); - - ret = (ret & 0xFF00) | (gBitBuf >> 8); - } - - if (gBitsLeft < numBits) { - gBitBuf <<= gBitsLeft; - - gBitBuf |= getOctet(FFCheck); - - gBitBuf <<= (numBits - gBitsLeft); - - gBitsLeft = 8 - (numBits - gBitsLeft); - } else { - gBitsLeft = (uint8)(gBitsLeft - numBits); - gBitBuf <<= numBits; - } - - return ret >> (16 - origBits); -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint16 getBits1(uint8 numBits) { return getBits(numBits, 0); } -//------------------------------------------------------------------------------ -static PJPG_INLINE uint16 getBits2(uint8 numBits) { return getBits(numBits, 1); } -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 getBit(void) { - uint8 ret = 0; - if (gBitBuf & 0x8000) ret = 1; - - if (!gBitsLeft) { - gBitBuf |= getOctet(1); - - gBitsLeft += 8; - } - - gBitsLeft--; - gBitBuf <<= 1; - - return ret; -} -//------------------------------------------------------------------------------ -static uint16 getExtendTest(uint8 i) { - switch (i) { - case 0: - return 0; - case 1: - return 0x0001; - case 2: - return 0x0002; - case 3: - return 0x0004; - case 4: - return 0x0008; - case 5: - return 0x0010; - case 6: - return 0x0020; - case 7: - return 0x0040; - case 8: - return 0x0080; - case 9: - return 0x0100; - case 10: - return 0x0200; - case 11: - return 0x0400; - case 12: - return 0x0800; - case 13: - return 0x1000; - case 14: - return 0x2000; - case 15: - return 0x4000; - default: - return 0; - } -} -//------------------------------------------------------------------------------ -static int16 getExtendOffset(uint8 i) { - switch (i) { - case 0: - return 0; - case 1: - return ((-1) << 1) + 1; - case 2: - return ((-1) << 2) + 1; - case 3: - return ((-1) << 3) + 1; - case 4: - return ((-1) << 4) + 1; - case 5: - return ((-1) << 5) + 1; - case 6: - return ((-1) << 6) + 1; - case 7: - return ((-1) << 7) + 1; - case 8: - return ((-1) << 8) + 1; - case 9: - return ((-1) << 9) + 1; - case 10: - return ((-1) << 10) + 1; - case 11: - return ((-1) << 11) + 1; - case 12: - return ((-1) << 12) + 1; - case 13: - return ((-1) << 13) + 1; - case 14: - return ((-1) << 14) + 1; - case 15: - return ((-1) << 15) + 1; - default: - return 0; - } -}; -//------------------------------------------------------------------------------ -static PJPG_INLINE int16 huffExtend(uint16 x, uint8 s) { - return ((x < getExtendTest(s)) ? ((int16)x + getExtendOffset(s)) : (int16)x); -} -//------------------------------------------------------------------------------ -static PJPG_INLINE uint8 huffDecode(const HuffTable* pHuffTable, const uint8* pHuffVal) { - uint8 i = 0; - uint8 j; - uint16 code = getBit(); - - // This func only reads a bit at a time, which on modern CPU's is not terribly efficient. - // But on microcontrollers without strong integer shifting support this seems like a - // more reasonable approach. - for (;;) { - uint16 maxCode; - - if (i == 16) return 0; - - maxCode = pHuffTable->mMaxCode[i]; - if ((code <= maxCode) && (maxCode != 0xFFFF)) break; - - i++; - code <<= 1; - code |= getBit(); - } - - j = pHuffTable->mValPtr[i]; - j = (uint8)(j + (code - pHuffTable->mMinCode[i])); - - return pHuffVal[j]; -} -//------------------------------------------------------------------------------ -static void huffCreate(const uint8* pBits, HuffTable* pHuffTable) { - uint8 i = 0; - uint8 j = 0; - - uint16 code = 0; - - for (;;) { - uint8 num = pBits[i]; - - if (!num) { - pHuffTable->mMinCode[i] = 0x0000; - pHuffTable->mMaxCode[i] = 0xFFFF; - pHuffTable->mValPtr[i] = 0; - } else { - pHuffTable->mMinCode[i] = code; - pHuffTable->mMaxCode[i] = code + num - 1; - pHuffTable->mValPtr[i] = j; - - j = (uint8)(j + num); - - code = (uint16)(code + num); - } - - code <<= 1; - - i++; - if (i > 15) break; - } -} -//------------------------------------------------------------------------------ -static HuffTable* getHuffTable(uint8 index) { - // 0-1 = DC - // 2-3 = AC - switch (index) { - case 0: - return &gHuffTab0; - case 1: - return &gHuffTab1; - case 2: - return &gHuffTab2; - case 3: - return &gHuffTab3; - default: - return 0; - } -} -//------------------------------------------------------------------------------ -static uint8* getHuffVal(uint8 index) { - // 0-1 = DC - // 2-3 = AC - switch (index) { - case 0: - return gHuffVal0; - case 1: - return gHuffVal1; - case 2: - return gHuffVal2; - case 3: - return gHuffVal3; - default: - return 0; - } -} -//------------------------------------------------------------------------------ -static uint16 getMaxHuffCodes(uint8 index) { return (index < 2) ? 12 : 255; } -//------------------------------------------------------------------------------ -static uint8 readDHTMarker(void) { - uint8 bits[16]; - uint16 left = getBits1(16); - - if (left < 2) return PJPG_BAD_DHT_MARKER; - - left -= 2; - - while (left) { - uint8 i, tableIndex, index; - uint8* pHuffVal; - HuffTable* pHuffTable; - uint16 count, totalRead; - - index = (uint8)getBits1(8); - - if (((index & 0xF) > 1) || ((index & 0xF0) > 0x10)) return PJPG_BAD_DHT_INDEX; - - tableIndex = ((index >> 3) & 2) + (index & 1); - - pHuffTable = getHuffTable(tableIndex); - pHuffVal = getHuffVal(tableIndex); - - gValidHuffTables |= (1 << tableIndex); - - count = 0; - for (i = 0; i <= 15; i++) { - uint8 n = (uint8)getBits1(8); - bits[i] = n; - count = (uint16)(count + n); - } - - if (count > getMaxHuffCodes(tableIndex)) return PJPG_BAD_DHT_COUNTS; - - for (i = 0; i < count; i++) pHuffVal[i] = (uint8)getBits1(8); - - totalRead = 1 + 16 + count; - - if (left < totalRead) return PJPG_BAD_DHT_MARKER; - - left = (uint16)(left - totalRead); - - huffCreate(bits, pHuffTable); - } - - return 0; -} -//------------------------------------------------------------------------------ -static void createWinogradQuant(int16* pQuant); - -static uint8 readDQTMarker(void) { - uint16 left = getBits1(16); - - if (left < 2) return PJPG_BAD_DQT_MARKER; - - left -= 2; - - while (left) { - uint8 i; - uint8 n = (uint8)getBits1(8); - uint8 prec = n >> 4; - uint16 totalRead; - - n &= 0x0F; - - if (n > 1) return PJPG_BAD_DQT_TABLE; - - gValidQuantTables |= (n ? 2 : 1); - - // read quantization entries, in zag order - for (i = 0; i < 64; i++) { - uint16 temp = getBits1(8); - - if (prec) temp = (temp << 8) + getBits1(8); - - if (n) - gQuant1[i] = (int16)temp; - else - gQuant0[i] = (int16)temp; - } - - createWinogradQuant(n ? gQuant1 : gQuant0); - - totalRead = 64 + 1; - - if (prec) totalRead += 64; - - if (left < totalRead) return PJPG_BAD_DQT_LENGTH; - - left = (uint16)(left - totalRead); - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 readSOFMarker(void) { - uint8 i; - uint16 left = getBits1(16); - - if (getBits1(8) != 8) return PJPG_BAD_PRECISION; - - gImageYSize = getBits1(16); - - if ((!gImageYSize) || (gImageYSize > PJPG_MAX_HEIGHT)) return PJPG_BAD_HEIGHT; - - gImageXSize = getBits1(16); - - if ((!gImageXSize) || (gImageXSize > PJPG_MAX_WIDTH)) return PJPG_BAD_WIDTH; - - gCompsInFrame = (uint8)getBits1(8); - - if (gCompsInFrame > 3) return PJPG_TOO_MANY_COMPONENTS; - - if (left != (gCompsInFrame + gCompsInFrame + gCompsInFrame + 8)) return PJPG_BAD_SOF_LENGTH; - - for (i = 0; i < gCompsInFrame; i++) { - gCompIdent[i] = (uint8)getBits1(8); - gCompHSamp[i] = (uint8)getBits1(4); - gCompVSamp[i] = (uint8)getBits1(4); - gCompQuant[i] = (uint8)getBits1(8); - - if (gCompQuant[i] > 1) return PJPG_UNSUPPORTED_QUANT_TABLE; - } - - return 0; -} -//------------------------------------------------------------------------------ -// Used to skip unrecognized markers. -static uint8 skipVariableMarker(void) { - uint16 left = getBits1(16); - - if (left < 2) return PJPG_BAD_VARIABLE_MARKER; - - left -= 2; - - while (left) { - getBits1(8); - left--; - } - - return 0; -} -//------------------------------------------------------------------------------ -// Read a define restart interval (DRI) marker. -static uint8 readDRIMarker(void) { - if (getBits1(16) != 4) return PJPG_BAD_DRI_LENGTH; - - gRestartInterval = getBits1(16); - - return 0; -} -//------------------------------------------------------------------------------ -// Read a start of scan (SOS) marker. -static uint8 readSOSMarker(void) { - uint8 i; - uint16 left = getBits1(16); - uint8 spectral_start, spectral_end, successive_high, successive_low; - - gCompsInScan = (uint8)getBits1(8); - - left -= 3; - - if ((left != (gCompsInScan + gCompsInScan + 3)) || (gCompsInScan < 1) || (gCompsInScan > PJPG_MAXCOMPSINSCAN)) - return PJPG_BAD_SOS_LENGTH; - - for (i = 0; i < gCompsInScan; i++) { - uint8 cc = (uint8)getBits1(8); - uint8 c = (uint8)getBits1(8); - uint8 ci; - - left -= 2; - - for (ci = 0; ci < gCompsInFrame; ci++) - if (cc == gCompIdent[ci]) break; - - if (ci >= gCompsInFrame) return PJPG_BAD_SOS_COMP_ID; - - gCompList[i] = ci; - gCompDCTab[ci] = (c >> 4) & 15; - gCompACTab[ci] = (c & 15); - } - - spectral_start = (uint8)getBits1(8); - spectral_end = (uint8)getBits1(8); - successive_high = (uint8)getBits1(4); - successive_low = (uint8)getBits1(4); - - left -= 3; - - while (left) { - getBits1(8); - left--; - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 nextMarker(void) { - uint8 c; - uint8 bytes = 0; - - do { - do { - bytes++; - - c = (uint8)getBits1(8); - - } while (c != 0xFF); - - do { - c = (uint8)getBits1(8); - - } while (c == 0xFF); - - } while (c == 0); - - // If bytes > 0 here, there where extra bytes before the marker (not good). - - return c; -} -//------------------------------------------------------------------------------ -// Process markers. Returns when an SOFx, SOI, EOI, or SOS marker is -// encountered. -static uint8 processMarkers(uint8* pMarker) { - for (;;) { - uint8 c = nextMarker(); - - switch (c) { - case M_SOF0: - case M_SOF1: - case M_SOF2: - case M_SOF3: - case M_SOF5: - case M_SOF6: - case M_SOF7: - // case M_JPG: - case M_SOF9: - case M_SOF10: - case M_SOF11: - case M_SOF13: - case M_SOF14: - case M_SOF15: - case M_SOI: - case M_EOI: - case M_SOS: { - *pMarker = c; - return 0; - } - case M_DHT: { - readDHTMarker(); - break; - } - // Sorry, no arithmetic support at this time. Dumb patents! - case M_DAC: { - return PJPG_NO_ARITHMITIC_SUPPORT; - } - case M_DQT: { - readDQTMarker(); - break; - } - case M_DRI: { - readDRIMarker(); - break; - } - // case M_APP0: /* no need to read the JFIF marker */ - - case M_JPG: - case M_RST0: /* no parameters */ - case M_RST1: - case M_RST2: - case M_RST3: - case M_RST4: - case M_RST5: - case M_RST6: - case M_RST7: - case M_TEM: { - return PJPG_UNEXPECTED_MARKER; - } - default: /* must be DNL, DHP, EXP, APPn, JPGn, COM, or RESn or APP0 */ - { - skipVariableMarker(); - break; - } - } - } - // return 0; -} -//------------------------------------------------------------------------------ -// Finds the start of image (SOI) marker. -static uint8 locateSOIMarker(void) { - uint16 bytesleft; - - uint8 lastchar = (uint8)getBits1(8); - - uint8 thischar = (uint8)getBits1(8); - - /* ok if it's a normal JPEG file without a special header */ - - if ((lastchar == 0xFF) && (thischar == M_SOI)) return 0; - - bytesleft = 4096; // 512; - - for (;;) { - if (--bytesleft == 0) return PJPG_NOT_JPEG; - - lastchar = thischar; - - thischar = (uint8)getBits1(8); - - if (lastchar == 0xFF) { - if (thischar == M_SOI) - break; - else if (thischar == M_EOI) // getBits1 will keep returning M_EOI if we read past the end - return PJPG_NOT_JPEG; - } - } - - /* Check the next character after marker: if it's not 0xFF, it can't - be the start of the next marker, so the file is bad */ - - thischar = (uint8)((gBitBuf >> 8) & 0xFF); - - if (thischar != 0xFF) return PJPG_NOT_JPEG; - - return 0; -} -//------------------------------------------------------------------------------ -// Find a start of frame (SOF) marker. -static uint8 locateSOFMarker(void) { - uint8 c; - - uint8 status = locateSOIMarker(); - if (status) return status; - - status = processMarkers(&c); - if (status) return status; - - switch (c) { - case M_SOF2: { - // Progressive JPEG - not supported by picojpeg (would require too - // much memory, or too many IDCT's for embedded systems). - return PJPG_UNSUPPORTED_MODE; - } - case M_SOF0: /* baseline DCT */ - { - status = readSOFMarker(); - if (status) return status; - - break; - } - case M_SOF9: { - return PJPG_NO_ARITHMITIC_SUPPORT; - } - case M_SOF1: /* extended sequential DCT */ - default: { - return PJPG_UNSUPPORTED_MARKER; - } - } - - return 0; -} -//------------------------------------------------------------------------------ -// Find a start of scan (SOS) marker. -static uint8 locateSOSMarker(uint8* pFoundEOI) { - uint8 c; - uint8 status; - - *pFoundEOI = 0; - - status = processMarkers(&c); - if (status) return status; - - if (c == M_EOI) { - *pFoundEOI = 1; - return 0; - } else if (c != M_SOS) - return PJPG_UNEXPECTED_MARKER; - - return readSOSMarker(); -} -//------------------------------------------------------------------------------ -static uint8 init(void) { - gImageXSize = 0; - gImageYSize = 0; - gCompsInFrame = 0; - gRestartInterval = 0; - gCompsInScan = 0; - gValidHuffTables = 0; - gValidQuantTables = 0; - gTemFlag = 0; - gInBufOfs = 0; - gInBufLeft = 0; - gBitBuf = 0; - gBitsLeft = 8; - - getBits1(8); - getBits1(8); - - return 0; -} -//------------------------------------------------------------------------------ -// This method throws back into the stream any bytes that where read -// into the bit buffer during initial marker scanning. -static void fixInBuffer(void) { - /* In case any 0xFF's where pulled into the buffer during marker scanning */ - - if (gBitsLeft > 0) stuffChar((uint8)gBitBuf); - - stuffChar((uint8)(gBitBuf >> 8)); - - gBitsLeft = 8; - getBits2(8); - getBits2(8); -} -//------------------------------------------------------------------------------ -// Restart interval processing. -static uint8 processRestart(void) { - // Let's scan a little bit to find the marker, but not _too_ far. - // 1536 is a "fudge factor" that determines how much to scan. - uint16 i; - uint8 c = 0; - - for (i = 1536; i > 0; i--) - if (getChar() == 0xFF) break; - - if (i == 0) return PJPG_BAD_RESTART_MARKER; - - for (; i > 0; i--) - if ((c = getChar()) != 0xFF) break; - - if (i == 0) return PJPG_BAD_RESTART_MARKER; - - // Is it the expected marker? If not, something bad happened. - if (c != (gNextRestartNum + M_RST0)) return PJPG_BAD_RESTART_MARKER; - - // Reset each component's DC prediction values. - gLastDC[0] = 0; - gLastDC[1] = 0; - gLastDC[2] = 0; - - gRestartsLeft = gRestartInterval; - - gNextRestartNum = (gNextRestartNum + 1) & 7; - - // Get the bit buffer going again - - gBitsLeft = 8; - getBits2(8); - getBits2(8); - - return 0; -} -//------------------------------------------------------------------------------ -// FIXME: findEOI() is not actually called at the end of the image -// (it's optional, and probably not needed on embedded devices) -static uint8 findEOI(void) { - uint8 c; - uint8 status; - - // Prime the bit buffer - gBitsLeft = 8; - getBits1(8); - getBits1(8); - - // The next marker _should_ be EOI - status = processMarkers(&c); - if (status) - return status; - else if (gCallbackStatus) - return gCallbackStatus; - - // gTotalBytesRead -= in_buf_left; - if (c != M_EOI) return PJPG_UNEXPECTED_MARKER; - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 checkHuffTables(void) { - uint8 i; - - for (i = 0; i < gCompsInScan; i++) { - uint8 compDCTab = gCompDCTab[gCompList[i]]; - uint8 compACTab = gCompACTab[gCompList[i]] + 2; - - if (((gValidHuffTables & (1 << compDCTab)) == 0) || ((gValidHuffTables & (1 << compACTab)) == 0)) - return PJPG_UNDEFINED_HUFF_TABLE; - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 checkQuantTables(void) { - uint8 i; - - for (i = 0; i < gCompsInScan; i++) { - uint8 compQuantMask = gCompQuant[gCompList[i]] ? 2 : 1; - - if ((gValidQuantTables & compQuantMask) == 0) return PJPG_UNDEFINED_QUANT_TABLE; - } - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 initScan(void) { - uint8 foundEOI; - uint8 status = locateSOSMarker(&foundEOI); - if (status) return status; - if (foundEOI) return PJPG_UNEXPECTED_MARKER; - - status = checkHuffTables(); - if (status) return status; - - status = checkQuantTables(); - if (status) return status; - - gLastDC[0] = 0; - gLastDC[1] = 0; - gLastDC[2] = 0; - - if (gRestartInterval) { - gRestartsLeft = gRestartInterval; - gNextRestartNum = 0; - } - - fixInBuffer(); - - return 0; -} -//------------------------------------------------------------------------------ -static uint8 initFrame(void) { - if (gCompsInFrame == 1) { - if ((gCompHSamp[0] != 1) || (gCompVSamp[0] != 1)) return PJPG_UNSUPPORTED_SAMP_FACTORS; - - gScanType = PJPG_GRAYSCALE; - - gMaxBlocksPerMCU = 1; - gMCUOrg[0] = 0; - - gMaxMCUXSize = 8; - gMaxMCUYSize = 8; - } else if (gCompsInFrame == 3) { - if (((gCompHSamp[1] != 1) || (gCompVSamp[1] != 1)) || ((gCompHSamp[2] != 1) || (gCompVSamp[2] != 1))) - return PJPG_UNSUPPORTED_SAMP_FACTORS; - - if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 1)) { - gScanType = PJPG_YH1V1; - - gMaxBlocksPerMCU = 3; - gMCUOrg[0] = 0; - gMCUOrg[1] = 1; - gMCUOrg[2] = 2; - - gMaxMCUXSize = 8; - gMaxMCUYSize = 8; - } else if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 2)) { - gScanType = PJPG_YH1V2; - - gMaxBlocksPerMCU = 4; - gMCUOrg[0] = 0; - gMCUOrg[1] = 0; - gMCUOrg[2] = 1; - gMCUOrg[3] = 2; - - gMaxMCUXSize = 8; - gMaxMCUYSize = 16; - } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 1)) { - gScanType = PJPG_YH2V1; - - gMaxBlocksPerMCU = 4; - gMCUOrg[0] = 0; - gMCUOrg[1] = 0; - gMCUOrg[2] = 1; - gMCUOrg[3] = 2; - - gMaxMCUXSize = 16; - gMaxMCUYSize = 8; - } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 2)) { - gScanType = PJPG_YH2V2; - - gMaxBlocksPerMCU = 6; - gMCUOrg[0] = 0; - gMCUOrg[1] = 0; - gMCUOrg[2] = 0; - gMCUOrg[3] = 0; - gMCUOrg[4] = 1; - gMCUOrg[5] = 2; - - gMaxMCUXSize = 16; - gMaxMCUYSize = 16; - } else - return PJPG_UNSUPPORTED_SAMP_FACTORS; - } else - return PJPG_UNSUPPORTED_COLORSPACE; - - gMaxMCUSPerRow = (gImageXSize + (gMaxMCUXSize - 1)) >> ((gMaxMCUXSize == 8) ? 3 : 4); - gMaxMCUSPerCol = (gImageYSize + (gMaxMCUYSize - 1)) >> ((gMaxMCUYSize == 8) ? 3 : 4); - - // This can overflow on large JPEG's. - // gNumMCUSRemaining = gMaxMCUSPerRow * gMaxMCUSPerCol; - gNumMCUSRemainingX = gMaxMCUSPerRow; - gNumMCUSRemainingY = gMaxMCUSPerCol; - - return 0; -} -//---------------------------------------------------------------------------- -// Winograd IDCT: 5 multiplies per row/col, up to 80 muls for the 2D IDCT - -#define PJPG_DCT_SCALE_BITS 7 - -#define PJPG_DCT_SCALE (1U << PJPG_DCT_SCALE_BITS) - -#define PJPG_DESCALE(x) PJPG_ARITH_SHIFT_RIGHT_N_16(((x) + (1 << (PJPG_DCT_SCALE_BITS - 1))), PJPG_DCT_SCALE_BITS) - -#define PJPG_WFIX(x) ((x) * PJPG_DCT_SCALE + 0.5f) - -#define PJPG_WINOGRAD_QUANT_SCALE_BITS 10 - -const uint8 gWinogradQuant[] = { - 128, 178, 178, 167, 246, 167, 151, 232, 232, 151, 128, 209, 219, 209, 128, 101, 178, 197, 197, 178, 101, 69, - 139, 167, 177, 167, 139, 69, 35, 96, 131, 151, 151, 131, 96, 35, 49, 91, 118, 128, 118, 91, 49, 46, - 81, 101, 101, 81, 46, 42, 69, 79, 69, 42, 35, 54, 54, 35, 28, 37, 28, 19, 19, 10, -}; - -// Multiply quantization matrix by the Winograd IDCT scale factors -static void createWinogradQuant(int16* pQuant) { - uint8 i; - - for (i = 0; i < 64; i++) { - long x = pQuant[i]; - x *= gWinogradQuant[i]; - pQuant[i] = (int16)((x + (1 << (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS - 1))) >> - (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS)); - } -} - -// These multiply helper functions are the 4 types of signed multiplies needed by the Winograd IDCT. -// A smart C compiler will optimize them to use 16x8 = 24 bit muls, if not you may need to tweak -// these functions or drop to CPU specific inline assembly. - -// 1/cos(4*pi/16) -// 362, 256+106 -static PJPG_INLINE int16 imul_b1_b3(int16 w) { - long x = (w * 362L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -// 1/cos(6*pi/16) -// 669, 256+256+157 -static PJPG_INLINE int16 imul_b2(int16 w) { - long x = (w * 669L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -// 1/cos(2*pi/16) -// 277, 256+21 -static PJPG_INLINE int16 imul_b4(int16 w) { - long x = (w * 277L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -// 1/(cos(2*pi/16) + cos(6*pi/16)) -// 196, 196 -static PJPG_INLINE int16 imul_b5(int16 w) { - long x = (w * 196L); - x += 128L; - return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); -} - -static PJPG_INLINE uint8 clamp(int16 s) { - if ((uint16)s > 255U) { - if (s < 0) - return 0; - else if (s > 255) - return 255; - } - - return (uint8)s; -} - -static void idctRows(void) { - uint8 i; - int16* pSrc = gCoeffBuf; - - for (i = 0; i < 8; i++) { - if ((pSrc[1] | pSrc[2] | pSrc[3] | pSrc[4] | pSrc[5] | pSrc[6] | pSrc[7]) == 0) { - // Short circuit the 1D IDCT if only the DC component is non-zero - int16 src0 = *pSrc; - - *(pSrc + 1) = src0; - *(pSrc + 2) = src0; - *(pSrc + 3) = src0; - *(pSrc + 4) = src0; - *(pSrc + 5) = src0; - *(pSrc + 6) = src0; - *(pSrc + 7) = src0; - } else { - int16 src4 = *(pSrc + 5); - int16 src7 = *(pSrc + 3); - int16 x4 = src4 - src7; - int16 x7 = src4 + src7; - - int16 src5 = *(pSrc + 1); - int16 src6 = *(pSrc + 7); - int16 x5 = src5 + src6; - int16 x6 = src5 - src6; - - int16 tmp1 = imul_b5(x4 - x6); - int16 stg26 = imul_b4(x6) - tmp1; - - int16 x24 = tmp1 - imul_b2(x4); - - int16 x15 = x5 - x7; - int16 x17 = x5 + x7; - - int16 tmp2 = stg26 - x17; - int16 tmp3 = imul_b1_b3(x15) - tmp2; - int16 x44 = tmp3 + x24; - - int16 src0 = *(pSrc + 0); - int16 src1 = *(pSrc + 4); - int16 x30 = src0 + src1; - int16 x31 = src0 - src1; - - int16 src2 = *(pSrc + 2); - int16 src3 = *(pSrc + 6); - int16 x12 = src2 - src3; - int16 x13 = src2 + src3; - - int16 x32 = imul_b1_b3(x12) - x13; - - int16 x40 = x30 + x13; - int16 x43 = x30 - x13; - int16 x41 = x31 + x32; - int16 x42 = x31 - x32; - - *(pSrc + 0) = x40 + x17; - *(pSrc + 1) = x41 + tmp2; - *(pSrc + 2) = x42 + tmp3; - *(pSrc + 3) = x43 - x44; - *(pSrc + 4) = x43 + x44; - *(pSrc + 5) = x42 - tmp3; - *(pSrc + 6) = x41 - tmp2; - *(pSrc + 7) = x40 - x17; - } - - pSrc += 8; - } -} - -static void idctCols(void) { - uint8 i; - - int16* pSrc = gCoeffBuf; - - for (i = 0; i < 8; i++) { - if ((pSrc[1 * 8] | pSrc[2 * 8] | pSrc[3 * 8] | pSrc[4 * 8] | pSrc[5 * 8] | pSrc[6 * 8] | pSrc[7 * 8]) == 0) { - // Short circuit the 1D IDCT if only the DC component is non-zero - uint8 c = clamp(PJPG_DESCALE(*pSrc) + 128); - *(pSrc + 0 * 8) = c; - *(pSrc + 1 * 8) = c; - *(pSrc + 2 * 8) = c; - *(pSrc + 3 * 8) = c; - *(pSrc + 4 * 8) = c; - *(pSrc + 5 * 8) = c; - *(pSrc + 6 * 8) = c; - *(pSrc + 7 * 8) = c; - } else { - int16 src4 = *(pSrc + 5 * 8); - int16 src7 = *(pSrc + 3 * 8); - int16 x4 = src4 - src7; - int16 x7 = src4 + src7; - - int16 src5 = *(pSrc + 1 * 8); - int16 src6 = *(pSrc + 7 * 8); - int16 x5 = src5 + src6; - int16 x6 = src5 - src6; - - int16 tmp1 = imul_b5(x4 - x6); - int16 stg26 = imul_b4(x6) - tmp1; - - int16 x24 = tmp1 - imul_b2(x4); - - int16 x15 = x5 - x7; - int16 x17 = x5 + x7; - - int16 tmp2 = stg26 - x17; - int16 tmp3 = imul_b1_b3(x15) - tmp2; - int16 x44 = tmp3 + x24; - - int16 src0 = *(pSrc + 0 * 8); - int16 src1 = *(pSrc + 4 * 8); - int16 x30 = src0 + src1; - int16 x31 = src0 - src1; - - int16 src2 = *(pSrc + 2 * 8); - int16 src3 = *(pSrc + 6 * 8); - int16 x12 = src2 - src3; - int16 x13 = src2 + src3; - - int16 x32 = imul_b1_b3(x12) - x13; - - int16 x40 = x30 + x13; - int16 x43 = x30 - x13; - int16 x41 = x31 + x32; - int16 x42 = x31 - x32; - - // descale, convert to unsigned and clamp to 8-bit - *(pSrc + 0 * 8) = clamp(PJPG_DESCALE(x40 + x17) + 128); - *(pSrc + 1 * 8) = clamp(PJPG_DESCALE(x41 + tmp2) + 128); - *(pSrc + 2 * 8) = clamp(PJPG_DESCALE(x42 + tmp3) + 128); - *(pSrc + 3 * 8) = clamp(PJPG_DESCALE(x43 - x44) + 128); - *(pSrc + 4 * 8) = clamp(PJPG_DESCALE(x43 + x44) + 128); - *(pSrc + 5 * 8) = clamp(PJPG_DESCALE(x42 - tmp3) + 128); - *(pSrc + 6 * 8) = clamp(PJPG_DESCALE(x41 - tmp2) + 128); - *(pSrc + 7 * 8) = clamp(PJPG_DESCALE(x40 - x17) + 128); - } - - pSrc++; - } -} - -/*----------------------------------------------------------------------------*/ -static PJPG_INLINE uint8 addAndClamp(uint8 a, int16 b) { - b = a + b; - - if ((uint16)b > 255U) { - if (b < 0) - return 0; - else if (b > 255) - return 255; - } - - return (uint8)b; -} -/*----------------------------------------------------------------------------*/ -static PJPG_INLINE uint8 subAndClamp(uint8 a, int16 b) { - b = a - b; - - if ((uint16)b > 255U) { - if (b < 0) - return 0; - else if (b > 255) - return 255; - } - - return (uint8)b; -} -/*----------------------------------------------------------------------------*/ -// 103/256 -// R = Y + 1.402 (Cr-128) - -// 88/256, 183/256 -// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) - -// 198/256 -// B = Y + 1.772 (Cb-128) -/*----------------------------------------------------------------------------*/ -// Cb upsample and accumulate, 4x4 to 8x8 -static void upsampleCb(uint8 srcOfs, uint8 dstOfs) { - // Cb - affects G and B - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 4; x++) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - pDstG[0] = subAndClamp(pDstG[0], cbG); - pDstG[1] = subAndClamp(pDstG[1], cbG); - pDstG[8] = subAndClamp(pDstG[8], cbG); - pDstG[9] = subAndClamp(pDstG[9], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - pDstB[0] = addAndClamp(pDstB[0], cbB); - pDstB[1] = addAndClamp(pDstB[1], cbB); - pDstB[8] = addAndClamp(pDstB[8], cbB); - pDstB[9] = addAndClamp(pDstB[9], cbB); - - pDstG += 2; - pDstB += 2; - } - - pSrc = pSrc - 4 + 8; - pDstG = pDstG - 8 + 16; - pDstB = pDstB - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// Cb upsample and accumulate, 4x8 to 8x8 -static void upsampleCbH(uint8 srcOfs, uint8 dstOfs) { - // Cb - affects G and B - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - for (y = 0; y < 8; y++) { - for (x = 0; x < 4; x++) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - pDstG[0] = subAndClamp(pDstG[0], cbG); - pDstG[1] = subAndClamp(pDstG[1], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - pDstB[0] = addAndClamp(pDstB[0], cbB); - pDstB[1] = addAndClamp(pDstB[1], cbB); - - pDstG += 2; - pDstB += 2; - } - - pSrc = pSrc - 4 + 8; - } -} -/*----------------------------------------------------------------------------*/ -// Cb upsample and accumulate, 8x4 to 8x8 -static void upsampleCbV(uint8 srcOfs, uint8 dstOfs) { - // Cb - affects G and B - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 8; x++) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - pDstG[0] = subAndClamp(pDstG[0], cbG); - pDstG[8] = subAndClamp(pDstG[8], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - pDstB[0] = addAndClamp(pDstB[0], cbB); - pDstB[8] = addAndClamp(pDstB[8], cbB); - - ++pDstG; - ++pDstB; - } - - pDstG = pDstG - 8 + 16; - pDstB = pDstB - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// 103/256 -// R = Y + 1.402 (Cr-128) - -// 88/256, 183/256 -// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) - -// 198/256 -// B = Y + 1.772 (Cb-128) -/*----------------------------------------------------------------------------*/ -// Cr upsample and accumulate, 4x4 to 8x8 -static void upsampleCr(uint8 srcOfs, uint8 dstOfs) { - // Cr - affects R and G - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 4; x++) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - pDstR[0] = addAndClamp(pDstR[0], crR); - pDstR[1] = addAndClamp(pDstR[1], crR); - pDstR[8] = addAndClamp(pDstR[8], crR); - pDstR[9] = addAndClamp(pDstR[9], crR); - - crG = ((cr * 183U) >> 8U) - 91; - pDstG[0] = subAndClamp(pDstG[0], crG); - pDstG[1] = subAndClamp(pDstG[1], crG); - pDstG[8] = subAndClamp(pDstG[8], crG); - pDstG[9] = subAndClamp(pDstG[9], crG); - - pDstR += 2; - pDstG += 2; - } - - pSrc = pSrc - 4 + 8; - pDstR = pDstR - 8 + 16; - pDstG = pDstG - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// Cr upsample and accumulate, 4x8 to 8x8 -static void upsampleCrH(uint8 srcOfs, uint8 dstOfs) { - // Cr - affects R and G - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - for (y = 0; y < 8; y++) { - for (x = 0; x < 4; x++) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - pDstR[0] = addAndClamp(pDstR[0], crR); - pDstR[1] = addAndClamp(pDstR[1], crR); - - crG = ((cr * 183U) >> 8U) - 91; - pDstG[0] = subAndClamp(pDstG[0], crG); - pDstG[1] = subAndClamp(pDstG[1], crG); - - pDstR += 2; - pDstG += 2; - } - - pSrc = pSrc - 4 + 8; - } -} -/*----------------------------------------------------------------------------*/ -// Cr upsample and accumulate, 8x4 to 8x8 -static void upsampleCrV(uint8 srcOfs, uint8 dstOfs) { - // Cr - affects R and G - uint8 x, y; - int16* pSrc = gCoeffBuf + srcOfs; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - for (y = 0; y < 4; y++) { - for (x = 0; x < 8; x++) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - pDstR[0] = addAndClamp(pDstR[0], crR); - pDstR[8] = addAndClamp(pDstR[8], crR); - - crG = ((cr * 183U) >> 8U) - 91; - pDstG[0] = subAndClamp(pDstG[0], crG); - pDstG[8] = subAndClamp(pDstG[8], crG); - - ++pDstR; - ++pDstG; - } - - pDstR = pDstR - 8 + 16; - pDstG = pDstG - 8 + 16; - } -} -/*----------------------------------------------------------------------------*/ -// Convert Y to RGB -static void copyY(uint8 dstOfs) { - uint8 i; - uint8* pRDst = gMCUBufR + dstOfs; - uint8* pGDst = gMCUBufG + dstOfs; - uint8* pBDst = gMCUBufB + dstOfs; - int16* pSrc = gCoeffBuf; - - for (i = 64; i > 0; i--) { - uint8 c = (uint8)*pSrc++; - - *pRDst++ = c; - *pGDst++ = c; - *pBDst++ = c; - } -} -/*----------------------------------------------------------------------------*/ -// Cb convert to RGB and accumulate -static void convertCb(uint8 dstOfs) { - uint8 i; - uint8* pDstG = gMCUBufG + dstOfs; - uint8* pDstB = gMCUBufB + dstOfs; - int16* pSrc = gCoeffBuf; - - for (i = 64; i > 0; i--) { - uint8 cb = (uint8)*pSrc++; - int16 cbG, cbB; - - cbG = ((cb * 88U) >> 8U) - 44U; - *pDstG++ = subAndClamp(pDstG[0], cbG); - - cbB = (cb + ((cb * 198U) >> 8U)) - 227U; - *pDstB++ = addAndClamp(pDstB[0], cbB); - } -} -/*----------------------------------------------------------------------------*/ -// Cr convert to RGB and accumulate -static void convertCr(uint8 dstOfs) { - uint8 i; - uint8* pDstR = gMCUBufR + dstOfs; - uint8* pDstG = gMCUBufG + dstOfs; - int16* pSrc = gCoeffBuf; - - for (i = 64; i > 0; i--) { - uint8 cr = (uint8)*pSrc++; - int16 crR, crG; - - crR = (cr + ((cr * 103U) >> 8U)) - 179; - *pDstR++ = addAndClamp(pDstR[0], crR); - - crG = ((cr * 183U) >> 8U) - 91; - *pDstG++ = subAndClamp(pDstG[0], crG); - } -} -/*----------------------------------------------------------------------------*/ -static void transformBlock(uint8 mcuBlock) { - idctRows(); - idctCols(); - - switch (gScanType) { - case PJPG_GRAYSCALE: { - // MCU size: 1, 1 block per MCU - copyY(0); - break; - } - case PJPG_YH1V1: { - // MCU size: 8x8, 3 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - convertCb(0); - break; - } - case 2: { - convertCr(0); - break; - } - } - - break; - } - case PJPG_YH1V2: { - // MCU size: 8x16, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - copyY(128); - break; - } - case 2: { - upsampleCbV(0, 0); - upsampleCbV(4 * 8, 128); - break; - } - case 3: { - upsampleCrV(0, 0); - upsampleCrV(4 * 8, 128); - break; - } - } - - break; - } - case PJPG_YH2V1: { - // MCU size: 16x8, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - copyY(64); - break; - } - case 2: { - upsampleCbH(0, 0); - upsampleCbH(4, 64); - break; - } - case 3: { - upsampleCrH(0, 0); - upsampleCrH(4, 64); - break; - } - } - - break; - } - case PJPG_YH2V2: { - // MCU size: 16x16, 6 blocks per MCU - switch (mcuBlock) { - case 0: { - copyY(0); - break; - } - case 1: { - copyY(64); - break; - } - case 2: { - copyY(128); - break; - } - case 3: { - copyY(192); - break; - } - case 4: { - upsampleCb(0, 0); - upsampleCb(4, 64); - upsampleCb(4 * 8, 128); - upsampleCb(4 + 4 * 8, 192); - break; - } - case 5: { - upsampleCr(0, 0); - upsampleCr(4, 64); - upsampleCr(4 * 8, 128); - upsampleCr(4 + 4 * 8, 192); - break; - } - } - - break; - } - } -} -//------------------------------------------------------------------------------ -static void transformBlockReduce(uint8 mcuBlock) { - uint8 c = clamp(PJPG_DESCALE(gCoeffBuf[0]) + 128); - int16 cbG, cbB, crR, crG; - - switch (gScanType) { - case PJPG_GRAYSCALE: { - // MCU size: 1, 1 block per MCU - gMCUBufR[0] = c; - break; - } - case PJPG_YH1V1: { - // MCU size: 8x8, 3 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - break; - } - case 2: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - break; - } - } - - break; - } - case PJPG_YH1V2: { - // MCU size: 8x16, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - gMCUBufR[128] = c; - gMCUBufG[128] = c; - gMCUBufB[128] = c; - break; - } - case 2: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); - - break; - } - case 3: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); - - break; - } - } - break; - } - case PJPG_YH2V1: { - // MCU size: 16x8, 4 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - gMCUBufR[64] = c; - gMCUBufG[64] = c; - gMCUBufB[64] = c; - break; - } - case 2: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); - - break; - } - case 3: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); - - break; - } - } - break; - } - case PJPG_YH2V2: { - // MCU size: 16x16, 6 blocks per MCU - switch (mcuBlock) { - case 0: { - gMCUBufR[0] = c; - gMCUBufG[0] = c; - gMCUBufB[0] = c; - break; - } - case 1: { - gMCUBufR[64] = c; - gMCUBufG[64] = c; - gMCUBufB[64] = c; - break; - } - case 2: { - gMCUBufR[128] = c; - gMCUBufG[128] = c; - gMCUBufB[128] = c; - break; - } - case 3: { - gMCUBufR[192] = c; - gMCUBufG[192] = c; - gMCUBufB[192] = c; - break; - } - case 4: { - cbG = ((c * 88U) >> 8U) - 44U; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); - gMCUBufG[192] = subAndClamp(gMCUBufG[192], cbG); - - cbB = (c + ((c * 198U) >> 8U)) - 227U; - gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); - gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); - gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); - gMCUBufB[192] = addAndClamp(gMCUBufB[192], cbB); - - break; - } - case 5: { - crR = (c + ((c * 103U) >> 8U)) - 179; - gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); - gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); - gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); - gMCUBufR[192] = addAndClamp(gMCUBufR[192], crR); - - crG = ((c * 183U) >> 8U) - 91; - gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); - gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); - gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); - gMCUBufG[192] = subAndClamp(gMCUBufG[192], crG); - - break; - } - } - break; - } - } -} -//------------------------------------------------------------------------------ -static uint8 decodeNextMCU(void) { - uint8 status; - uint8 mcuBlock; - - if (gRestartInterval) { - if (gRestartsLeft == 0) { - status = processRestart(); - if (status) return status; - } - gRestartsLeft--; - } - - for (mcuBlock = 0; mcuBlock < gMaxBlocksPerMCU; mcuBlock++) { - uint8 componentID = gMCUOrg[mcuBlock]; - uint8 compQuant = gCompQuant[componentID]; - uint8 compDCTab = gCompDCTab[componentID]; - uint8 numExtraBits, compACTab, k; - const int16* pQ = compQuant ? gQuant1 : gQuant0; - uint16 r, dc; - - uint8 s = huffDecode(compDCTab ? &gHuffTab1 : &gHuffTab0, compDCTab ? gHuffVal1 : gHuffVal0); - - r = 0; - numExtraBits = s & 0xF; - if (numExtraBits) r = getBits2(numExtraBits); - dc = huffExtend(r, s); - - dc = dc + gLastDC[componentID]; - gLastDC[componentID] = dc; - - gCoeffBuf[0] = dc * pQ[0]; - - compACTab = gCompACTab[componentID]; - - if (gReduce) { - // Decode, but throw out the AC coefficients in reduce mode. - for (k = 1; k < 64; k++) { - s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); - - numExtraBits = s & 0xF; - if (numExtraBits) getBits2(numExtraBits); - - r = s >> 4; - s &= 15; - - if (s) { - if (r) { - if ((k + r) > 63) return PJPG_DECODE_ERROR; - - k = (uint8)(k + r); - } - } else { - if (r == 15) { - if ((k + 16) > 64) return PJPG_DECODE_ERROR; - - k += (16 - 1); // - 1 because the loop counter is k - } else - break; - } - } - - transformBlockReduce(mcuBlock); - } else { - // Decode and dequantize AC coefficients - for (k = 1; k < 64; k++) { - uint16 extraBits; - - s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); - - extraBits = 0; - numExtraBits = s & 0xF; - if (numExtraBits) extraBits = getBits2(numExtraBits); - - r = s >> 4; - s &= 15; - - if (s) { - int16 ac; - - if (r) { - if ((k + r) > 63) return PJPG_DECODE_ERROR; - - while (r) { - gCoeffBuf[ZAG[k++]] = 0; - r--; - } - } - - ac = huffExtend(extraBits, s); - - gCoeffBuf[ZAG[k]] = ac * pQ[k]; - } else { - if (r == 15) { - if ((k + 16) > 64) return PJPG_DECODE_ERROR; - - for (r = 16; r > 0; r--) gCoeffBuf[ZAG[k++]] = 0; - - k--; // - 1 because the loop counter is k - } else - break; - } - } - - while (k < 64) gCoeffBuf[ZAG[k++]] = 0; - - transformBlock(mcuBlock); - } - } - - return 0; -} -//------------------------------------------------------------------------------ -unsigned char pjpeg_decode_mcu(void) { - uint8 status; - - if (gCallbackStatus) return gCallbackStatus; - - if ((!gNumMCUSRemainingX) && (!gNumMCUSRemainingY)) return PJPG_NO_MORE_BLOCKS; - - status = decodeNextMCU(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - gNumMCUSRemainingX--; - if (!gNumMCUSRemainingX) { - gNumMCUSRemainingY--; - if (gNumMCUSRemainingY > 0) gNumMCUSRemainingX = gMaxMCUSPerRow; - } - - return 0; -} -//------------------------------------------------------------------------------ -unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, - void* pCallback_data, unsigned char reduce) { - uint8 status; - - pInfo->m_width = 0; - pInfo->m_height = 0; - pInfo->m_comps = 0; - pInfo->m_MCUSPerRow = 0; - pInfo->m_MCUSPerCol = 0; - pInfo->m_scanType = PJPG_GRAYSCALE; - pInfo->m_MCUWidth = 0; - pInfo->m_MCUHeight = 0; - pInfo->m_pMCUBufR = (unsigned char*)0; - pInfo->m_pMCUBufG = (unsigned char*)0; - pInfo->m_pMCUBufB = (unsigned char*)0; - - g_pNeedBytesCallback = pNeed_bytes_callback; - g_pCallback_data = pCallback_data; - gCallbackStatus = 0; - gReduce = reduce; - - status = init(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - status = locateSOFMarker(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - status = initFrame(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - status = initScan(); - if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; - - pInfo->m_width = gImageXSize; - pInfo->m_height = gImageYSize; - pInfo->m_comps = gCompsInFrame; - pInfo->m_scanType = gScanType; - pInfo->m_MCUSPerRow = gMaxMCUSPerRow; - pInfo->m_MCUSPerCol = gMaxMCUSPerCol; - pInfo->m_MCUWidth = gMaxMCUXSize; - pInfo->m_MCUHeight = gMaxMCUYSize; - pInfo->m_pMCUBufR = gMCUBufR; - pInfo->m_pMCUBufG = gMCUBufG; - pInfo->m_pMCUBufB = gMCUBufB; - - return 0; -} diff --git a/lib/picojpeg/picojpeg.h b/lib/picojpeg/picojpeg.h deleted file mode 100644 index 11345fb7..00000000 --- a/lib/picojpeg/picojpeg.h +++ /dev/null @@ -1,124 +0,0 @@ -//------------------------------------------------------------------------------ -// picojpeg - Public domain, Rich Geldreich -//------------------------------------------------------------------------------ -#ifndef PICOJPEG_H -#define PICOJPEG_H - -#ifdef __cplusplus -extern "C" { -#endif - -// Error codes -enum { - PJPG_NO_MORE_BLOCKS = 1, - PJPG_BAD_DHT_COUNTS, - PJPG_BAD_DHT_INDEX, - PJPG_BAD_DHT_MARKER, - PJPG_BAD_DQT_MARKER, - PJPG_BAD_DQT_TABLE, - PJPG_BAD_PRECISION, - PJPG_BAD_HEIGHT, - PJPG_BAD_WIDTH, - PJPG_TOO_MANY_COMPONENTS, - PJPG_BAD_SOF_LENGTH, - PJPG_BAD_VARIABLE_MARKER, - PJPG_BAD_DRI_LENGTH, - PJPG_BAD_SOS_LENGTH, - PJPG_BAD_SOS_COMP_ID, - PJPG_W_EXTRA_BYTES_BEFORE_MARKER, - PJPG_NO_ARITHMITIC_SUPPORT, - PJPG_UNEXPECTED_MARKER, - PJPG_NOT_JPEG, - PJPG_UNSUPPORTED_MARKER, - PJPG_BAD_DQT_LENGTH, - PJPG_TOO_MANY_BLOCKS, - PJPG_UNDEFINED_QUANT_TABLE, - PJPG_UNDEFINED_HUFF_TABLE, - PJPG_NOT_SINGLE_SCAN, - PJPG_UNSUPPORTED_COLORSPACE, - PJPG_UNSUPPORTED_SAMP_FACTORS, - PJPG_DECODE_ERROR, - PJPG_BAD_RESTART_MARKER, - PJPG_ASSERTION_ERROR, - PJPG_BAD_SOS_SPECTRAL, - PJPG_BAD_SOS_SUCCESSIVE, - PJPG_STREAM_READ_ERROR, - PJPG_NOTENOUGHMEM, - PJPG_UNSUPPORTED_COMP_IDENT, - PJPG_UNSUPPORTED_QUANT_TABLE, - PJPG_UNSUPPORTED_MODE, // picojpeg doesn't support progressive JPEG's -}; - -// Scan types -typedef enum { PJPG_GRAYSCALE, PJPG_YH1V1, PJPG_YH2V1, PJPG_YH1V2, PJPG_YH2V2 } pjpeg_scan_type_t; - -typedef struct { - // Image resolution - int m_width; - int m_height; - - // Number of components (1 or 3) - int m_comps; - - // Total number of minimum coded units (MCU's) per row/col. - int m_MCUSPerRow; - int m_MCUSPerCol; - - // Scan type - pjpeg_scan_type_t m_scanType; - - // MCU width/height in pixels (each is either 8 or 16 depending on the scan type) - int m_MCUWidth; - int m_MCUHeight; - - // m_pMCUBufR, m_pMCUBufG, and m_pMCUBufB are pointers to internal MCU Y or RGB pixel component buffers. - // Each time pjpegDecodeMCU() is called successfully these buffers will be filled with 8x8 pixel blocks of Y or RGB - // pixels. Each MCU consists of (m_MCUWidth/8)*(m_MCUHeight/8) Y/RGB blocks: 1 for greyscale/no subsampling, 2 for - // H1V2/H2V1, or 4 blocks for H2V2 sampling factors. Each block is a contiguous array of 64 (8x8) bytes of a single - // component: either Y for grayscale images, or R, G or B components for color images. - // - // The 8x8 pixel blocks are organized in these byte arrays like this: - // - // PJPG_GRAYSCALE: Each MCU is decoded to a single block of 8x8 grayscale pixels. - // Only the values in m_pMCUBufR are valid. Each 8 bytes is a row of pixels (raster order: left to right, top to - // bottom) from the 8x8 block. - // - // PJPG_H1V1: Each MCU contains is decoded to a single block of 8x8 RGB pixels. - // - // PJPG_YH2V1: Each MCU is decoded to 2 blocks, or 16x8 pixels. - // The 2 RGB blocks are at byte offsets: 0, 64 - // - // PJPG_YH1V2: Each MCU is decoded to 2 blocks, or 8x16 pixels. - // The 2 RGB blocks are at byte offsets: 0, - // 128 - // - // PJPG_YH2V2: Each MCU is decoded to 4 blocks, or 16x16 pixels. - // The 2x2 block array is organized at byte offsets: 0, 64, - // 128, 192 - // - // It is up to the caller to copy or blit these pixels from these buffers into the destination bitmap. - unsigned char* m_pMCUBufR; - unsigned char* m_pMCUBufG; - unsigned char* m_pMCUBufB; -} pjpeg_image_info_t; - -typedef unsigned char (*pjpeg_need_bytes_callback_t)(unsigned char* pBuf, unsigned char buf_size, - unsigned char* pBytes_actually_read, void* pCallback_data); - -// Initializes the decompressor. Returns 0 on success, or one of the above error codes on failure. -// pNeed_bytes_callback will be called to fill the decompressor's internal input buffer. -// If reduce is 1, only the first pixel of each block will be decoded. This mode is much faster because it skips the AC -// dequantization, IDCT and chroma upsampling of every image pixel. Not thread safe. -unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, - void* pCallback_data, unsigned char reduce); - -// Decompresses the file's next MCU. Returns 0 on success, PJPG_NO_MORE_BLOCKS if no more blocks are available, or an -// error code. Must be called a total of m_MCUSPerRow*m_MCUSPerCol times to completely decompress the image. Not thread -// safe. -unsigned char pjpeg_decode_mcu(void); - -#ifdef __cplusplus -} -#endif - -#endif // PICOJPEG_H diff --git a/open-x4-sdk b/open-x4-sdk index 157d724d..a931d452 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit 157d724d7a7389d49fe108e6dd5da2455a5340ba +Subproject commit a931d452d4bf9f100683705dbb8da6c29283bee0 diff --git a/platformio.ini b/platformio.ini index cb8fd125..cfba6b82 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,7 +3,7 @@ default_envs = default extra_configs = platformio.local.ini [crosspoint] -version = 1.2.0 +version = 1.2.5 [base] platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip @@ -60,7 +60,7 @@ lib_deps = EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager bblanchon/ArduinoJson @ 7.4.2 - ricmoo/QRCode @ 0.0.1 + QRCode=symlink://lib/QRCode bitbank2/PNGdec @ ^1.0.0 bitbank2/JPEGDEC @ ^1.8.0 links2004/WebSockets @ 2.7.3 diff --git a/scripts/build_html.py b/scripts/build_html.py index f2cfb7e9..32847760 100644 --- a/scripts/build_html.py +++ b/scripts/build_html.py @@ -4,27 +4,138 @@ import gzip SRC_DIR = "src" + +def strip_js_comments(js: str) -> str: + """Remove JS comments while preserving string literals and URLs.""" + result = [] + i = 0 + length = len(js) + while i < length: + # String literals — pass through unchanged + if js[i] in ('"', "'", "`"): + quote = js[i] + result.append(js[i]) + i += 1 + while i < length: + if js[i] == "\\" and i + 1 < length: + result.append(js[i : i + 2]) + i += 2 + elif js[i] == quote: + result.append(js[i]) + i += 1 + break + else: + result.append(js[i]) + i += 1 + # Block comment /* ... */ + elif js[i] == "/" and i + 1 < length and js[i + 1] == "*": + end = js.find("*/", i + 2) + i = end + 2 if end != -1 else length + # Line comment // ... + elif js[i] == "/" and i + 1 < length and js[i + 1] == "/": + end = js.find("\n", i) + if end == -1: + i = length + else: + # Keep the newline to preserve line structure + result.append("\n") + i = end + 1 + # Regex literal — pass through unchanged + # Heuristic: / after = ( , ; ! & | ? : [ { } ~ ^ or line start + elif js[i] == "/" and i > 0: + # Look back for operator context (skip whitespace) + j = i - 1 + while j >= 0 and js[j] in " \t": + j -= 1 + if j >= 0 and js[j] in "=(!,;:&|?[{}>~^+-*%": + result.append(js[i]) + i += 1 + while i < length: + if js[i] == "\\" and i + 1 < length: + result.append(js[i : i + 2]) + i += 2 + elif js[i] == "/": + result.append(js[i]) + i += 1 + # Regex flags + while i < length and js[i].isalpha(): + result.append(js[i]) + i += 1 + break + elif js[i] == "[": + # Character class — / doesn't end regex inside [] + result.append(js[i]) + i += 1 + while i < length and js[i] != "]": + if js[i] == "\\" and i + 1 < length: + result.append(js[i : i + 2]) + i += 2 + else: + result.append(js[i]) + i += 1 + else: + result.append(js[i]) + i += 1 + else: + result.append(js[i]) + i += 1 + else: + result.append(js[i]) + i += 1 + return "".join(result) + + def minify_html(html: str) -> str: # Tags where whitespace should be preserved - preserve_tags = ['pre', 'code', 'textarea', 'script', 'style'] - preserve_regex = '|'.join(preserve_tags) + preserve_tags = ["pre", "code", "textarea"] + script_style_tags = ["script", "style"] + preserve_regex = "|".join(preserve_tags) + script_style_regex = "|".join(script_style_tags) - # Protect preserve blocks with placeholders + # Protect preserve blocks (pre/code/textarea) with placeholders preserve_blocks = [] + def preserve(match): preserve_blocks.append(match.group(0)) - return f"__PRESERVE_BLOCK_{len(preserve_blocks)-1}__" + return f"__PRESERVE_BLOCK_{len(preserve_blocks) - 1}__" - html = re.sub(rf'<({preserve_regex})[\s\S]*?', preserve, html, flags=re.IGNORECASE) + html = re.sub( + rf"<({preserve_regex})[\s\S]*?", preserve, html, flags=re.IGNORECASE + ) + + # Strip JS/CSS comments inside + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/move', true); + + xhr.onload = function () { + if (xhr.status === 200) { + window.location.reload(); + } else { + alert('Failed to move: ' + xhr.responseText); + } + closeMoveModal(); + }; + + xhr.onerror = function () { + alert('Failed to move - network error'); + closeMoveModal(); + }; + + xhr.send(formData); + } + hydrate(); + - + + \ No newline at end of file diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index e073e6d8..ef52ed49 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -1,149 +1,255 @@ - - - - CrossPoint Reader - - - -

📚 CrossPoint Reader

+ } - + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 20px; + background-color: var(--bg); + color: var(--font-color); + } -
-

Device Status

-
- Version - -
-
- WiFi Status - Connected -
-
- IP Address - -
-
- Free Memory - -
-
+ h1 { + color: var(--title-color); + border-bottom: 2px solid var(--accent-color); + padding-bottom: 10px; + } -
-

- CrossPoint E-Reader • Open Source -

+ h2 { + color: var(--title-color); + margin-top: 0; + } + + .card { + background: var(--card-bg); + border-radius: 8px; + padding: 20px; + margin: 15px 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .info-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + } + + .info-row:last-child { + border-bottom: none; + } + + .label { + font-weight: 600; + color: var(--label-color); + } + + .value { + color: var(--title-color); + } + + .status { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + background-color: #27ae60; + color: white; + font-size: 0.9em; + } + + .nav-links { + margin: 20px 0; + display: flex; + gap: 10px; + } + + .nav-links a { + padding: 10px 20px; + color: var(--font-color); + text-decoration: none; + border-radius: 4px; + } + + .nav-links a.active { + background-color: var(--accent-color); + color: white; + } + + .nav-links a:not(.active):hover { + background-color: var(--accent-hover-color); + color: white; + } + + + + +

📚 CrossPoint Reader

+ + + +
+

Device Status

+
+ Version + Loading...
+
+ Chip + Loading... +
+
+ CPU + Loading... +
+
+ WiFi Status + Loading... +
+
+ IP Address + Loading... +
+
+ MAC Address + Loading... +
+
+ Free Memory + Loading... +
+
+ Min Free + Loading... +
+
+ Max Block + Loading... +
+
+ Flash Used + Loading... +
+
+ Battery + Loading... +
+
+ Uptime + Loading... +
+
+ SD Card + Loading... +
+
+ +
+

+ CrossPoint E-Reader • Open Source +

+
- - + + + \ No newline at end of file diff --git a/src/util/QrUtils.cpp b/src/util/QrUtils.cpp index 53d25919..a736cfcb 100644 --- a/src/util/QrUtils.cpp +++ b/src/util/QrUtils.cpp @@ -1,66 +1,88 @@ #include "QrUtils.h" #include -#include +#include #include #include #include "Logging.h" -void QrUtils::drawQrCode(const GfxRenderer& renderer, const Rect& bounds, const std::string& textPayload) { - // Dynamically calculate the QR code version based on text length - // Version 4 holds ~114 bytes, Version 10 ~395, Version 20 ~1066, up to 40 - // qrcode.h max version is 40. - // Formula: approx version = size / 26 + 1 (very rough estimate, better to find best fit) - size_t len = textPayload.length(); +namespace { - // Truncate to max QR capacity at a UTF-8 safe boundary to avoid splitting multi-byte sequences - static constexpr size_t MAX_QR_CAPACITY = 2953; // Version 40, ECC_LOW, byte mode - std::string truncated; - const char* payload = textPayload.c_str(); +bool hasNonAscii(const uint8_t* data, size_t len) { + for (size_t i = 0; i < len; i++) { + if (data[i] > 0x7F) return true; + } + return false; +} + +} // namespace + +bool QrUtils::drawQrCode(const GfxRenderer& renderer, const Rect& bounds, const std::string& textPayload) { + bool truncated = false; + size_t len = textPayload.size(); + const char* text = textPayload.c_str(); + + // Truncate at a UTF-8 safe boundary if needed + std::string truncatedStr; if (len > MAX_QR_CAPACITY) { - len = utf8SafeTruncateBuffer(textPayload.c_str(), static_cast(MAX_QR_CAPACITY)); - truncated = textPayload.substr(0, len); - payload = truncated.c_str(); + len = utf8SafeTruncateBuffer(text, static_cast(MAX_QR_CAPACITY)); + truncatedStr = textPayload.substr(0, len); + text = truncatedStr.c_str(); + truncated = true; + LOG_DBG("QR", "Truncated payload from %u to %u bytes", textPayload.size(), len); } - int version = 4; - if (len > 114) version = 10; - if (len > 395) version = 20; - if (len > 1066) version = 30; - if (len > 2110) version = 40; + // Heap-allocate both buffers (each ~3918 bytes for version 40) + constexpr size_t bufLen = qrcodegen_BUFFER_LEN_FOR_VERSION(qrcodegen_VERSION_MAX); + auto qrcode = std::make_unique(bufLen); + auto tempBuf = std::make_unique(bufLen); - // Make sure we have a large enough buffer on the heap to avoid blowing the stack - uint32_t bufferSize = qrcode_getBufferSize(version); - auto qrcodeBytes = std::make_unique(bufferSize); + bool ok = false; + const auto* rawData = reinterpret_cast(text); - QRCode qrcode; - // Initialize the QR code. We use ECC_LOW for max capacity. - int8_t res = qrcode_initText(&qrcode, qrcodeBytes.get(), version, ECC_LOW, payload); + if (hasNonAscii(rawData, len)) { + // Non-ASCII content: use ECI mode 26 (UTF-8) + byte segment via the low-level API + // so scanners know the encoding rather than assuming ISO 8859-1. + uint8_t eciBuf[4] = {}; + struct qrcodegen_Segment eciSeg = qrcodegen_makeEci(26, eciBuf); - if (res == 0) { - // Determine the optimal pixel size. + // Build byte segment — the segment data buffer can overlap with tempBuf + const size_t segBufSize = qrcodegen_calcSegmentBufferSize(qrcodegen_Mode_BYTE, len); + auto segBuf = std::make_unique(segBufSize); + struct qrcodegen_Segment byteSeg = qrcodegen_makeBytes(rawData, len, segBuf.get()); + + struct qrcodegen_Segment segs[2] = {eciSeg, byteSeg}; + ok = qrcodegen_encodeSegmentsAdvanced(segs, 2, qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, qrcodegen_VERSION_MAX, + qrcodegen_Mask_AUTO, false, tempBuf.get(), qrcode.get()); + } else { + // ASCII-only: let the library auto-select the optimal mode (numeric/alphanumeric/byte) + ok = qrcodegen_encodeText(text, tempBuf.get(), qrcode.get(), qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, + qrcodegen_VERSION_MAX, qrcodegen_Mask_AUTO, false); + } + + if (ok) { + const int size = qrcodegen_getSize(qrcode.get()); const int maxDim = std::min(bounds.width, bounds.height); - int px = maxDim / qrcode.size; + int px = maxDim / size; if (px < 1) px = 1; - // Calculate centering X and Y - const int qrDisplaySize = qrcode.size * px; + const int qrDisplaySize = size * px; const int xOff = bounds.x + (bounds.width - qrDisplaySize) / 2; const int yOff = bounds.y + (bounds.height - qrDisplaySize) / 2; - // Draw the QR Code - for (uint8_t cy = 0; cy < qrcode.size; cy++) { - for (uint8_t cx = 0; cx < qrcode.size; cx++) { - if (qrcode_getModule(&qrcode, cx, cy)) { + for (int cy = 0; cy < size; cy++) { + for (int cx = 0; cx < size; cx++) { + if (qrcodegen_getModule(qrcode.get(), cx, cy)) { renderer.fillRect(xOff + px * cx, yOff + px * cy, px, px, true); } } } } else { - // If it fails (e.g. text too large), log an error - LOG_ERR("QR", "Text too large for QR Code version %d", version); + LOG_ERR("QR", "Failed to encode QR code (%u bytes)", len); } + + return truncated; } diff --git a/src/util/QrUtils.h b/src/util/QrUtils.h index 01eced76..75f9a310 100644 --- a/src/util/QrUtils.h +++ b/src/util/QrUtils.h @@ -8,7 +8,11 @@ namespace QrUtils { +// Maximum QR capacity in bytes (Version 40, ECC_LOW, byte mode). +static constexpr size_t MAX_QR_CAPACITY = 2953; + // Renders a QR code with the given text payload within the specified bounding box. -void drawQrCode(const GfxRenderer& renderer, const Rect& bounds, const std::string& textPayload); +// Returns true if the payload was truncated to fit the QR capacity. +bool drawQrCode(const GfxRenderer& renderer, const Rect& bounds, const std::string& textPayload); } // namespace QrUtils diff --git a/test/epubs/test_br_section_break.epub b/test/epubs/test_br_section_break.epub new file mode 100644 index 00000000..8d4a9301 Binary files /dev/null and b/test/epubs/test_br_section_break.epub differ diff --git a/test/epubs/test_spine_toc_edges.epub b/test/epubs/test_spine_toc_edges.epub new file mode 100644 index 00000000..de19b981 Binary files /dev/null and b/test/epubs/test_spine_toc_edges.epub differ diff --git a/test/epubs/test_text_rendering.epub b/test/epubs/test_text_rendering.epub new file mode 100644 index 00000000..2067f55c Binary files /dev/null and b/test/epubs/test_text_rendering.epub differ