15 Commits

Author SHA1 Message Date
Dave Allie
5fdf23f1d2 Cut release 0.12.0 2026-01-03 19:42:14 +11:00
Justinian
2fb417ee90 Feat/sleep and refresh settings (#209)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)
* **What changes are included?**

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).

---------

Co-authored-by: ratedcounsel <hello@ratedcounsel.com>
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-03 19:33:42 +11:00
V
c8f6160fbc Refactor semantic version comparison for OTA updates (#216)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)

This PR refactors the semantic version comparison logic used during OTA
update checks.

Memory stats before : 
RAM:   [===       ]  30.8% (used 101068 bytes from 327680 bytes)
Flash: [========= ]  85.7% (used **5617830** bytes from 6553600 bytes)

Memory stats before : 
RAM:   [===       ]  30.8% (used 101068 bytes from 327680 bytes)
Flash: [========= ]  85.7% (used **5616870** bytes from 6553600 bytes)


* **What changes are included?**

Replaced std::string::substr() and std::stoi() based parsing with a
lightweight, heap-free approach.
Version parsing is now done in a single pass without creating temporary
std::string objects.
Behavior remains identical: versions are still compared as
MAJOR.MINOR.PATCH.

## Additional Context

`std::string::substr() ` creates a new string and performs heap
allocation

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
2026-01-03 19:19:53 +11:00
Brendan O'Leary
8e4484cd22 Add buton hints to keyboard screen (#205)
## Summary

This adds the correctly styled button hints to the keyboard screen as
well as the ability to add hints to the side buttons (and up/down hints
to that screen)

## Additional Context

N/A
2026-01-03 19:17:53 +11:00
Pavel Liashkov
0332e1103a Add EPUB 3 nav.xhtml TOC support (#197)
## Summary

* **What is the goal of this PR?** Add EPUB 3 support by implementing
native navigation document (nav.xhtml) parsing with NCX fallback,
addressing issue Fixes: #143.

  * **What changes are included?**
- New `TocNavParser` for parsing EPUB 3 HTML5 navigation documents
(`<nav epub:type="toc">`)
- Detection of nav documents via `properties="nav"` attribute in OPF
manifest
- Fallback logic: try EPUB 3 nav first, fall back to NCX (EPUB 2) if
unavailable
- Graceful degradation: books without any TOC now load with a warning
instead of failing

  ## Additional Context

* The implementation follows the existing streaming XML parser pattern
using Expat to minimize RAM usage on the ESP32-C3
* EPUB 3 books that include both nav.xhtml and toc.ncx will prefer the
nav document (per EPUB 3 spec recommendation)
* No breaking changes - existing EPUB 2 books continue to work as before
* Tested on examples from
https://idpf.github.io/epub3-samples/30/samples.html
2026-01-03 19:10:35 +11:00
Jonas Diemer
5790d6f5dc Subtract time it took reaching the evaluation from the press duration. (#208)
Adresses #206
2026-01-03 18:54:23 +11:00
Jake Lyell
062d69dc2a Add support for uploading multiple epubs (#202)
Upload multiple files at once in sequence. Add retry button for files
that fail

## Summary

* **What is the goal of this PR?**
Add support for selecting multiple epub files in one go, before
uploading them all to the device
* **What changes are included?**
Allow multiple selections to be submitted to the input field.
Sends each file to the device one by one in a loop
Adds retry logic and UI for easy re-trying of failed uploads

Addresses #201 


button now says "Choose Files", and shows the number of files you
selected
<img width="506" height="199" alt="image"
src="https://github.com/user-attachments/assets/64b0b921-1e67-438e-9cd7-57d5466f2456"
/>

Shows which file is uploading:
<img width="521" height="283" alt="image"
src="https://github.com/user-attachments/assets/17b4d349-0698-4712-984c-b72fcdcb0918"
/>

Failed upload dialog:
<img width="851" height="441" alt="image"
src="https://github.com/user-attachments/assets/e8bf4aa6-d3d2-4c0b-9c7a-420e8c413033"
/>
<img width="834" height="641" alt="image"
src="https://github.com/user-attachments/assets/656a9732-3963-4844-94e3-4d8736f6d9d5"
/>
2026-01-02 18:32:26 +11:00
Maeve Andrews
5e9626eb2a Add paragraph alignment setting (justify/left/center/right) (#191)
## Summary

* **What is the goal of this PR?** 

Add a new user setting for paragraph alignment, instead of hard-coding
full justification.

* **What changes are included?**

One new line in the settings screen, with 4 options
(justify/left/center/right). Default is justified since that's what it
was already. I personally only wanted to disable justification and use
"left", but I included the other options for completeness since they
were already supported.

## Additional Context

Tested on my X4 and looks as expected for each alignment.

Co-authored-by: Maeve Andrews <maeve@git.mail.maeveandrews.com>
2026-01-02 18:21:48 +11:00
Jonas Diemer
00e83af4e8 Show "Entering Sleep" on black, so it's quicker to notice (in book). (#181)
Black popup is easier to notice at higher contrast.
2026-01-02 17:55:21 +11:00
Jonas Diemer
39080c0e51 Skip soft hyphens. (#195)
For now, let's skip the soft hyphens (later, we can treat them in the
layouter). See
https://github.com/daveallie/crosspoint-reader/discussions/17#discussioncomment-15378475
2026-01-02 17:54:46 +11:00
Brendan O'Leary
9e59a5106b Fix race condition with keyboard and Wifi entry (#204)
## Summary

* **What is the goal of this PR?** Fixes a bug -
https://github.com/daveallie/crosspoint-reader/issues/187 - where the
screen would freeze after entering a WiFi password, causing the device
to appear hung.

* **What changes are included?**
- Fixed a race condition in `WifiSelectionActivity::displayTaskLoop()`
that caused rendering of an empty screen when transitioning from the
keyboard subactivity
- Added `vTaskDelay()` when a subactivity is active to prevent CPU
starvation from a tight `continue` loop
- Added a check to skip rendering when in `PASSWORD_ENTRY` state,
allowing the state machine to properly transition to `CONNECTING` before
the display updates

## Additional Context

* **Root cause:** When the keyboard subactivity exited after password
entry, the display task would wake up and attempt to render. However,
the `state` was still `PASSWORD_ENTRY` (before `attemptConnection()`
changed it to `CONNECTING`), and since there was no render case for
`PASSWORD_ENTRY`, the display would show a cleared/empty buffer,
appearing frozen.

* **Performance implications:** The added `vTaskDelay(10)` calls when
subactivity is active or in `PASSWORD_ENTRY` state actually improve
performance by preventing CPU starvation - previously the display task
would spin in a tight loop with `continue` while a subactivity was
present.

* **Testing focus:** Test the full WiFi connection flow:
  1. Enter network selection
  2. Select a network requiring a password
  3. Enter password and press OK
  4. Verify "Connecting..." screen appears
  5. Verify connection completes and prompts to save password
2026-01-02 17:49:16 +11:00
Pavel Liashkov
a922e553ed Prevent device sleep during WiFi file transfer and OTA updates (#203)
## Summary

* **What is the goal of this PR?** Fixes #199 - Device falls asleep
during WiFi file transfer after 10 minutes of inactivity, disconnecting
the web server.

  * **What changes are included?**
    - Add `preventAutoSleep()` virtual method to `Activity` base class
- Modify main loop to reset inactivity timer when `preventAutoSleep()`
returns true
- Override `preventAutoSleep()` in `CrossPointWebServerActivity`
(returns true when web server running)
- Override `preventAutoSleep()` in `OtaUpdateActivity` (returns true
during update check/download)

  ## Additional Context

* The existing `skipLoopDelay()` method controls loop timing (yield vs
delay) for HTTP responsiveness. The new `preventAutoSleep()` method is
semantically separate - it explicitly signals that an activity should
keep the device awake.
* `CrossPointWebServerActivity` uses both methods: `skipLoopDelay()` for
responsive HTTP handling, `preventAutoSleep()` for staying awake.
* `OtaUpdateActivity` only needs `preventAutoSleep()` since the OTA
library handles HTTP internally.
2026-01-02 17:44:17 +11:00
Dave Allie
04ad4e5aa4 Replace book and section bin format images with ImHex hexpat definition (#189)
## Summary

* Replace book and section bin format images with ImHex hexpat
definition
* This should give readers an understanding of the file format but also
supply some utility when validating content/output
2025-12-31 13:28:24 +11:00
Dave Allie
6e9ba1006a Use sane smaller data types for data in section.bin (#188)
## Summary

* Update EpdFontFamily::Style to be u8 instead of u32 (saving 3 bytes
per word)
* Update layout width/height to be u16 from int
* Update page element count to be u16 from u32
* Update text block element count to be u16 from u32
* Bumped section bin version to version 8
2025-12-31 13:11:36 +11:00
Luke Stein
40f9ed485c Enhance USER_GUIDE with links and clarifications (#185)
Updated USER_GUIDE.md for clarity and added links to sections.

## Summary

* Clarify and improve documentation
2025-12-31 10:01:48 +11:00
49 changed files with 1257 additions and 247 deletions

View File

@@ -25,7 +25,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
## Features & Usage ## Features & Usage
- [x] EPUB parsing and rendering - [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
- [ ] Image support within EPUB - [ ] Image support within EPUB
- [x] Saved reading position - [x] Saved reading position
- [x] File explorer with file picker - [x] File explorer with file picker

View File

@@ -5,7 +5,7 @@ the device.
## 1. Hardware Overview ## 1. Hardware Overview
The device utilises the standard buttons on the Xtink X4 in the same layout: The device utilises the standard buttons on the Xtink X4 (in the same layout as the manufacturer firmware, by default):
### Button Layout ### Button Layout
| Location | Buttons | | Location | Buttons |
@@ -13,20 +13,23 @@ The device utilises the standard buttons on the Xtink X4 in the same layout:
| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** | | **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** |
| **Right Side** | **Power**, **Volume Up**, **Volume Down** | | **Right Side** | **Power**, **Volume Up**, **Volume Down** |
Button layout can be customized in **[Settings](#35-settings)**.
--- ---
## 2. Power & Startup ## 2. Power & Startup
### Power On / Off ### Power On / Off
To turn the device on or off, **press and hold the Power button for half a second**. In **Settings** you can configure To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure
the power button to trigger on a short press instead of a long one. the power button to trigger on a short press instead of a long one.
### First Launch ### First Launch
Upon turning the device on for the first time, you will be placed on the **Home** screen. Upon turning the device on for the first time, you will be placed on the **[Home](#31-home-screen)** screen.
> **Note:** On subsequent restarts, the firmware will automatically reopen the last book you were reading. > [!NOTE]
> On subsequent restarts, the firmware will automatically reopen the last book you were reading.
--- ---
@@ -34,10 +37,10 @@ Upon turning the device on for the first time, you will be placed on the **Home*
### 3.1 Home Screen ### 3.1 Home Screen
The Home Screen is the main entry point to the firmware. From here you can navigate to the **Book Selection** screen, The Home Screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, **[Book Selection](#32-book-selection)**,
**Settings** screen, or **File Upload** screen. **[Settings](#35-settings)**, or the **[File Upload](#34-file-upload-screen)** screen.
### 3.2 Book Selection (Read) ### 3.2 Book Selection
The Book Selection acts as a folder and file browser. The Book Selection acts as a folder and file browser.
@@ -45,13 +48,13 @@ The Book Selection acts as a folder and file browser.
and down through folders and books. and down through folders and books.
* **Open Selection:** Press **Confirm** to open a folder or read a selected book. * **Open Selection:** Press **Confirm** to open a folder or read a selected book.
### 3.3 Reading Screen ### 3.3 Reading Mode
See [4. Reading Mode](#4-reading-mode) below for more information. See [Reading Mode](#4-reading-mode) below for more information.
### 3.4 File Upload Screen ### 3.4 File Upload Screen
The File Upload screen allows you to upload new e-books to the device. When you enter the screen you'll be prompted with The File Upload screen allows you to upload new e-books to the device. When you enter the screen, you'll be prompted with
a WiFi selection dialog and then your X4 will start hosting a web server. a WiFi selection dialog and then your X4 will start hosting a web server.
See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files. See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files.
@@ -62,7 +65,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **Sleep Screen**: Which sleep screen to display when the device sleeps, options are: - **Sleep Screen**: Which sleep screen to display when the device sleeps, options are:
- "Dark" (default) - The default dark sleep screen - "Dark" (default) - The default dark sleep screen
- "Light" - The same default sleep screen, on a white background - "Light" - The same default sleep screen, on a white background
- "Custom" - Custom images from the SD card, see [3.6 Sleep Screen](#36-sleep-screen) below for more information - "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information
- "Cover" - The book cover image (Note: this is experimental and may not work as expected) - "Cover" - The book cover image (Note: this is experimental and may not work as expected)
- **Status Bar**: Configure the status bar displayed while reading, options are: - **Status Bar**: Configure the status bar displayed while reading, options are:
- "None" - No status bar - "None" - No status bar
@@ -95,7 +98,7 @@ You can customize the sleep screen by placing custom images in specific location
- **Single Image:** Place a file named `sleep.bmp` in the root directory. - **Single Image:** Place a file named `sleep.bmp` in the root directory.
- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images - **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images
inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be inside. If images are found in this directory, they will take priority over the `sleep.bmp` file, and one will be
randomly selected each time the device sleeps. randomly selected each time the device sleeps.
> [!NOTE] > [!NOTE]
@@ -123,8 +126,9 @@ Once you have opened a book, the button layout changes to facilitate reading.
* **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. * **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release.
### System Navigation ### System Navigation
* **Return to Home:** Press **Back** to close the book and return to the Book Selection screen. * **Return to Book Selection:** Press **Back** to close the book and return to the **[Book Selection](#32-book-selection)** screen.
* **Chapter Menu:** Press **Confirm** to open the Table of Contents/Chapter Selection screen. * **Return to Home:** Press and hold **Back** to close the book and return to the **[Home](#31-home-screen)** screen.
* **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**.
--- ---

View File

@@ -2,8 +2,220 @@
## `book.bin` ## `book.bin`
![](./images/file-formats/book-bin.png) ### Version 3
ImHex Pattern:
```c++
import std.mem;
import std.string;
import std.core;
// === Configuration ===
#define EXPECTED_VERSION 3
#define MAX_STRING_LENGTH 65535
// === String Structure ===
struct String {
u32 length [[hidden, comment("String byte length")]];
if (length > MAX_STRING_LENGTH) {
std::warning(std::format("Unusually large string length: {} bytes", length));
}
char data[length] [[comment("UTF-8 string data")]];
} [[sealed, format("format_string"), comment("Length-prefixed UTF-8 string")]];
fn format_string(String s) {
return s.data;
};
// === Metadata Structure ===
struct Metadata {
String title [[comment("Book title")]];
String author [[comment("Book author")]];
String coverItemHref [[comment("Path to cover image")]];
String textReferenceHref [[comment("Path to guided first text reference")]];
} [[comment("Book metadata information")]];
// === Spine Entry Structure ===
struct SpineEntry {
String href [[comment("Resource path")]];
u32 cumulativeSize [[comment("Cumulative size in bytes"), color("FF6B6B")]];
s16 tocIndex [[comment("Index into TOC (-1 if none)"), color("4ECDC4")]];
} [[comment("Spine entry defining reading order")]];
// === TOC Entry Structure ===
struct TocEntry {
String title [[comment("Chapter/section title")]];
String href [[comment("Resource path")]];
String anchor [[comment("Fragment identifier")]];
u8 level [[comment("Nesting level (0-255)"), color("95E1D3")]];
s16 spineIndex [[comment("Index into spine (-1 if none)"), color("F38181")]];
} [[comment("Table of contents entry")]];
// === Book Bin Structure ===
struct BookBin {
// Header
u8 version [[comment("Format version"), color("FFD93D")]];
// Version validation
if (version != EXPECTED_VERSION) {
std::error(std::format("Unsupported version: {} (expected {})", version, EXPECTED_VERSION));
}
u32 lutOffset [[comment("Offset to lookup tables"), color("6BCB77")]];
u16 spineCount [[comment("Number of spine entries"), color("4D96FF")]];
u16 tocCount [[comment("Number of TOC entries"), color("FF6B9D")]];
// Metadata section
Metadata metadata [[comment("Book metadata")]];
// Validate LUT offset alignment
u32 currentOffset = $;
if (currentOffset != lutOffset) {
std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset));
}
// Lookup Tables
u32 spineLut[spineCount] [[comment("Spine entry offsets"), color("4D96FF")]];
u32 tocLut[tocCount] [[comment("TOC entry offsets"), color("FF6B9D")]];
// Data Entries
SpineEntry spines[spineCount] [[comment("Spine entries (reading order)")]];
TocEntry toc[tocCount] [[comment("Table of contents entries")]];
};
// === File Parsing ===
BookBin book @ 0x00;
// Validate we've consumed the entire file
u32 fileSize = std::mem::size();
u32 parsedSize = $;
if (parsedSize != fileSize) {
std::warning(std::format("Unparsed data detected: {} bytes remaining at offset 0x{:X}", fileSize - parsedSize, parsedSize));
}
```
## `section.bin` ## `section.bin`
![](./images/file-formats/section-bin.png) ### Version 8
ImHex Pattern:
```c++
import std.mem;
import std.string;
import std.core;
// === Configuration ===
#define EXPECTED_VERSION 8
#define MAX_STRING_LENGTH 65535
// === String Structure ===
struct String {
u32 length [[hidden, comment("String byte length")]];
if (length > MAX_STRING_LENGTH) {
std::warning(std::format("Unusually large string length: {} bytes", length));
}
char data[length] [[comment("UTF-8 string data")]];
} [[sealed, format("format_string"), comment("Length-prefixed UTF-8 string")]];
fn format_string(String s) {
return s.data;
};
// === Page Structure ===
enum StorageType : u8 {
PageLine = 1
};
enum WordStyle : u8 {
REGULAR = 0,
BOLD = 1,
ITALIC = 2,
BOLD_ITALIC = 3
};
enum BlockStyle : u8 {
JUSTIFIED = 0,
LEFT_ALIGN = 1,
CENTER_ALIGN = 2,
RIGHT_ALIGN = 3,
};
struct PageLine {
s16 xPos;
s16 yPos;
u16 wordCount;
String words[wordCount];
u16 wordXPos[wordCount];
WordStyle wordStyle[wordCount];
BlockStyle blockStyle;
};
struct PageElement {
u8 pageElementType;
if (pageElementType == 1) {
PageLine pageLine [[inline]];
} else {
std::error(std::format("Unknown page element type: {}", pageElementType));
}
};
struct Page {
u16 elementCount;
PageElement elements[elementCount] [[inline]];
};
// === 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;
u16 viewportWidth;
u16 vieportHeight;
u16 pageCount;
u32 lutOffset;
Page page[pageCount];
// Validate LUT offset alignment
u32 currentOffset = $;
if (currentOffset != lutOffset) {
std::warning(std::format("LUT offset mismatch: expected 0x{:X}, got 0x{:X}", lutOffset, currentOffset));
}
// Lookup Tables
u32 lut[pageCount];
};
// === File Parsing ===
SectionBin book @ 0x00;
// Validate we've consumed the entire file
u32 fileSize = std::mem::size();
u32 parsedSize = $;
if (parsedSize != fileSize) {
std::warning(std::format("Unparsed data detected: {} bytes remaining at offset 0x{:X}", fileSize - parsedSize, parsedSize));
}
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -1,6 +1,6 @@
#include "EpdFontFamily.h" #include "EpdFontFamily.h"
const EpdFont* EpdFontFamily::getFont(const EpdFontStyle style) const { const EpdFont* EpdFontFamily::getFont(const Style style) const {
if (style == BOLD && bold) { if (style == BOLD && bold) {
return bold; return bold;
} }
@@ -22,16 +22,16 @@ const EpdFont* EpdFontFamily::getFont(const EpdFontStyle style) const {
return regular; return regular;
} }
void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const EpdFontStyle style) const { void EpdFontFamily::getTextDimensions(const char* string, int* w, int* h, const Style style) const {
getFont(style)->getTextDimensions(string, w, h); getFont(style)->getTextDimensions(string, w, h);
} }
bool EpdFontFamily::hasPrintableChars(const char* string, const EpdFontStyle style) const { bool EpdFontFamily::hasPrintableChars(const char* string, const Style style) const {
return getFont(style)->hasPrintableChars(string); return getFont(style)->hasPrintableChars(string);
} }
const EpdFontData* EpdFontFamily::getData(const EpdFontStyle style) const { return getFont(style)->data; } const EpdFontData* EpdFontFamily::getData(const Style style) const { return getFont(style)->data; }
const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const EpdFontStyle style) const { const EpdGlyph* EpdFontFamily::getGlyph(const uint32_t cp, const Style style) const {
return getFont(style)->getGlyph(cp); return getFont(style)->getGlyph(cp);
}; };

View File

@@ -1,24 +1,24 @@
#pragma once #pragma once
#include "EpdFont.h" #include "EpdFont.h"
enum EpdFontStyle { REGULAR, BOLD, ITALIC, BOLD_ITALIC };
class EpdFontFamily { class EpdFontFamily {
public:
enum Style : uint8_t { REGULAR = 0, BOLD = 1, ITALIC = 2, BOLD_ITALIC = 3 };
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
~EpdFontFamily() = default;
void getTextDimensions(const char* string, int* w, int* h, Style style = REGULAR) const;
bool hasPrintableChars(const char* string, Style style = REGULAR) const;
const EpdFontData* getData(Style style = REGULAR) const;
const EpdGlyph* getGlyph(uint32_t cp, Style style = REGULAR) const;
private:
const EpdFont* regular; const EpdFont* regular;
const EpdFont* bold; const EpdFont* bold;
const EpdFont* italic; const EpdFont* italic;
const EpdFont* boldItalic; const EpdFont* boldItalic;
const EpdFont* getFont(EpdFontStyle style) const; const EpdFont* getFont(Style style) const;
public:
explicit EpdFontFamily(const EpdFont* regular, const EpdFont* bold = nullptr, const EpdFont* italic = nullptr,
const EpdFont* boldItalic = nullptr)
: regular(regular), bold(bold), italic(italic), boldItalic(boldItalic) {}
~EpdFontFamily() = default;
void getTextDimensions(const char* string, int* w, int* h, EpdFontStyle style = REGULAR) const;
bool hasPrintableChars(const char* string, EpdFontStyle style = REGULAR) const;
const EpdFontData* getData(EpdFontStyle style = REGULAR) const;
const EpdGlyph* getGlyph(uint32_t cp, EpdFontStyle style = REGULAR) const;
}; };

View File

@@ -8,6 +8,7 @@
#include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNavParser.h"
#include "Epub/parsers/TocNcxParser.h" #include "Epub/parsers/TocNcxParser.h"
bool Epub::findContentOpfFile(std::string* contentOpfFile) const { bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
@@ -80,6 +81,10 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
tocNcxItem = opfParser.tocNcxPath; tocNcxItem = opfParser.tocNcxPath;
} }
if (!opfParser.tocNavPath.empty()) {
tocNavItem = opfParser.tocNavPath;
}
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
return true; return true;
} }
@@ -141,6 +146,60 @@ bool Epub::parseTocNcxFile() const {
return true; return true;
} }
bool Epub::parseTocNavFile() const {
// the nav file should have been specified in the content.opf file (EPUB 3)
if (tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] No nav file specified\n", millis());
return false;
}
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str());
const auto tmpNavPath = getCachePath() + "/toc.nav";
FsFile tempNavFile;
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
return false;
}
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
tempNavFile.close();
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
return false;
}
const auto navSize = tempNavFile.size();
TocNavParser navParser(contentBasePath, navSize, bookMetadataCache.get());
if (!navParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
return false;
}
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
if (!navBuffer) {
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis());
return false;
}
while (tempNavFile.available()) {
const auto readSize = tempNavFile.read(navBuffer, 1024);
const auto processedSize = navParser.write(navBuffer, readSize);
if (processedSize != readSize) {
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis());
free(navBuffer);
tempNavFile.close();
return false;
}
}
free(navBuffer);
tempNavFile.close();
SdMan.remove(tmpNavPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis());
return true;
}
// load in the meta data for the epub file // load in the meta data for the epub file
bool Epub::load(const bool buildIfMissing) { bool Epub::load(const bool buildIfMissing) {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
@@ -184,15 +243,31 @@ bool Epub::load(const bool buildIfMissing) {
return false; return false;
} }
// TOC Pass // TOC Pass - try EPUB 3 nav first, fall back to NCX
if (!bookMetadataCache->beginTocPass()) { if (!bookMetadataCache->beginTocPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis()); Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
return false; return false;
} }
if (!parseTocNcxFile()) {
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis()); bool tocParsed = false;
return false;
// Try EPUB 3 nav document first (preferred)
if (!tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis());
tocParsed = parseTocNavFile();
} }
// Fall back to NCX if nav parsing failed or wasn't available
if (!tocParsed && !tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis());
tocParsed = parseTocNcxFile();
}
if (!tocParsed) {
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis());
// Continue anyway - book will work without TOC
}
if (!bookMetadataCache->endTocPass()) { if (!bookMetadataCache->endTocPass()) {
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis()); Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
return false; return false;

View File

@@ -12,8 +12,10 @@
class ZipFile; class ZipFile;
class Epub { class Epub {
// the ncx file // the ncx file (EPUB 2)
std::string tocNcxItem; std::string tocNcxItem;
// the nav file (EPUB 3)
std::string tocNavItem;
// where is the EPUBfile? // where is the EPUBfile?
std::string filepath; std::string filepath;
// the base path for items in the EPUB file // the base path for items in the EPUB file
@@ -26,6 +28,7 @@ class Epub {
bool findContentOpfFile(std::string* contentOpfFile) const; bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata); bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseTocNcxFile() const; bool parseTocNcxFile() const;
bool parseTocNavFile() const;
public: public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {

View File

@@ -32,7 +32,7 @@ void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, co
} }
bool Page::serialize(FsFile& file) const { bool Page::serialize(FsFile& file) const {
const uint32_t count = elements.size(); const uint16_t count = elements.size();
serialization::writePod(file, count); serialization::writePod(file, count);
for (const auto& el : elements) { for (const auto& el : elements) {
@@ -49,10 +49,10 @@ bool Page::serialize(FsFile& file) const {
std::unique_ptr<Page> Page::deserialize(FsFile& file) { std::unique_ptr<Page> Page::deserialize(FsFile& file) {
auto page = std::unique_ptr<Page>(new Page()); auto page = std::unique_ptr<Page>(new Page());
uint32_t count; uint16_t count;
serialization::readPod(file, count); serialization::readPod(file, count);
for (uint32_t i = 0; i < count; i++) { for (uint16_t i = 0; i < count; i++) {
uint8_t tag; uint8_t tag;
serialization::readPod(file, tag); serialization::readPod(file, tag);

View File

@@ -10,7 +10,7 @@
constexpr int MAX_COST = std::numeric_limits<int>::max(); constexpr int MAX_COST = std::numeric_limits<int>::max();
void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) { void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle) {
if (word.empty()) return; if (word.empty()) return;
words.push_back(std::move(word)); words.push_back(std::move(word));
@@ -18,7 +18,7 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
} }
// Consumes data to minimize memory usage // Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int viewportWidth, void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const bool includeLastLine) { const bool includeLastLine) {
if (words.empty()) { if (words.empty()) {
@@ -188,7 +188,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// *** CRITICAL STEP: CONSUME DATA USING SPLICE *** // *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords; std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt); lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontStyle> lineWordStyles; std::list<EpdFontFamily::Style> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt); lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style));

View File

@@ -14,8 +14,8 @@ class GfxRenderer;
class ParsedText { class ParsedText {
std::list<std::string> words; std::list<std::string> words;
std::list<EpdFontStyle> wordStyles; std::list<EpdFontFamily::Style> wordStyles;
TextBlock::BLOCK_STYLE style; TextBlock::Style style;
bool extraParagraphSpacing; bool extraParagraphSpacing;
std::vector<size_t> computeLineBreaks(int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths) const; std::vector<size_t> computeLineBreaks(int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths) const;
@@ -25,16 +25,16 @@ class ParsedText {
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId); std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
public: public:
explicit ParsedText(const TextBlock::BLOCK_STYLE style, const bool extraParagraphSpacing) explicit ParsedText(const TextBlock::Style style, const bool extraParagraphSpacing)
: style(style), extraParagraphSpacing(extraParagraphSpacing) {} : style(style), extraParagraphSpacing(extraParagraphSpacing) {}
~ParsedText() = default; ~ParsedText() = default;
void addWord(std::string word, EpdFontStyle fontStyle); void addWord(std::string word, EpdFontFamily::Style fontStyle);
void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; } void setStyle(const TextBlock::Style style) { this->style = style; }
TextBlock::BLOCK_STYLE getStyle() const { return style; } TextBlock::Style getStyle() const { return style; }
size_t size() const { return words.size(); } size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); } bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int viewportWidth, void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine, const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true); bool includeLastLine = true);
}; };

View File

@@ -7,9 +7,9 @@
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
namespace { namespace {
constexpr uint8_t SECTION_FILE_VERSION = 7; constexpr uint8_t SECTION_FILE_VERSION = 9;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(int) + constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
sizeof(int) + sizeof(int) + sizeof(uint32_t); sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint32_t);
} // namespace } // namespace
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) { uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
@@ -30,19 +30,21 @@ uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
} }
void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing, void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) { const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight) {
if (!file) { if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis()); Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
return; return;
} }
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
sizeof(extraParagraphSpacing) + sizeof(viewportWidth) + sizeof(viewportHeight) + sizeof(extraParagraphSpacing) + sizeof(paragraphAlignment) + sizeof(viewportWidth) +
sizeof(pageCount) + sizeof(uint32_t), sizeof(viewportHeight) + sizeof(pageCount) + sizeof(uint32_t),
"Header size mismatch"); "Header size mismatch");
serialization::writePod(file, SECTION_FILE_VERSION); serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(file, fontId); serialization::writePod(file, fontId);
serialization::writePod(file, lineCompression); serialization::writePod(file, lineCompression);
serialization::writePod(file, extraParagraphSpacing); serialization::writePod(file, extraParagraphSpacing);
serialization::writePod(file, paragraphAlignment);
serialization::writePod(file, viewportWidth); serialization::writePod(file, viewportWidth);
serialization::writePod(file, viewportHeight); serialization::writePod(file, viewportHeight);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written) serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
@@ -50,7 +52,8 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
} }
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) { const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight) {
if (!SdMan.openFileForRead("SCT", filePath, file)) { if (!SdMan.openFileForRead("SCT", filePath, file)) {
return false; return false;
} }
@@ -66,18 +69,21 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
return false; return false;
} }
int fileFontId, fileViewportWidth, fileViewportHeight; int fileFontId;
uint16_t fileViewportWidth, fileViewportHeight;
float fileLineCompression; float fileLineCompression;
bool fileExtraParagraphSpacing; bool fileExtraParagraphSpacing;
uint8_t fileParagraphAlignment;
serialization::readPod(file, fileFontId); serialization::readPod(file, fileFontId);
serialization::readPod(file, fileLineCompression); serialization::readPod(file, fileLineCompression);
serialization::readPod(file, fileExtraParagraphSpacing); serialization::readPod(file, fileExtraParagraphSpacing);
serialization::readPod(file, fileParagraphAlignment);
serialization::readPod(file, fileViewportWidth); serialization::readPod(file, fileViewportWidth);
serialization::readPod(file, fileViewportHeight); serialization::readPod(file, fileViewportHeight);
if (fontId != fileFontId || lineCompression != fileLineCompression || if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth || extraParagraphSpacing != fileExtraParagraphSpacing || paragraphAlignment != fileParagraphAlignment ||
viewportHeight != fileViewportHeight) { viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight) {
file.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
clearCache(); clearCache();
@@ -108,8 +114,8 @@ bool Section::clearCache() const {
} }
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const std::function<void()>& progressSetupFn, const uint16_t viewportHeight, const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) { const std::function<void(int)>& progressFn) {
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href; const auto localPath = epub->getSpineItem(spineIndex).href;
@@ -165,11 +171,13 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
if (!SdMan.openFileForWrite("SCT", filePath, file)) { if (!SdMan.openFileForWrite("SCT", filePath, file)) {
return false; return false;
} }
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight); writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight);
std::vector<uint32_t> lut = {}; std::vector<uint32_t> lut = {};
ChapterHtmlSlimParser visitor( ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
progressFn); progressFn);
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();

View File

@@ -14,12 +14,12 @@ class Section {
std::string filePath; std::string filePath;
FsFile file; FsFile file;
void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
int viewportHeight); uint16_t viewportWidth, uint16_t viewportHeight);
uint32_t onPageComplete(std::unique_ptr<Page> page); uint32_t onPageComplete(std::unique_ptr<Page> page);
public: public:
int pageCount = 0; uint16_t pageCount = 0;
int currentPage = 0; int currentPage = 0;
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer) explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
@@ -28,11 +28,12 @@ class Section {
renderer(renderer), renderer(renderer),
filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {} filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
~Section() = default; ~Section() = default;
bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
int viewportHeight); uint16_t viewportWidth, uint16_t viewportHeight);
bool clearCache() const; bool clearCache() const;
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
int viewportHeight, const std::function<void()>& progressSetupFn = nullptr, uint16_t viewportWidth, uint16_t viewportHeight,
const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr); const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile(); std::unique_ptr<Page> loadPageFromSectionFile();
}; };

View File

@@ -32,7 +32,7 @@ bool TextBlock::serialize(FsFile& file) const {
} }
// Word data // Word data
serialization::writePod(file, static_cast<uint32_t>(words.size())); serialization::writePod(file, static_cast<uint16_t>(words.size()));
for (const auto& w : words) serialization::writeString(file, w); for (const auto& w : words) serialization::writeString(file, w);
for (auto x : wordXpos) serialization::writePod(file, x); for (auto x : wordXpos) serialization::writePod(file, x);
for (auto s : wordStyles) serialization::writePod(file, s); for (auto s : wordStyles) serialization::writePod(file, s);
@@ -44,11 +44,11 @@ bool TextBlock::serialize(FsFile& file) const {
} }
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) { std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
uint32_t wc; uint16_t wc;
std::list<std::string> words; std::list<std::string> words;
std::list<uint16_t> wordXpos; std::list<uint16_t> wordXpos;
std::list<EpdFontStyle> wordStyles; std::list<EpdFontFamily::Style> wordStyles;
BLOCK_STYLE style; Style style;
// Word count // Word count
serialization::readPod(file, wc); serialization::readPod(file, wc);

View File

@@ -8,10 +8,10 @@
#include "Block.h" #include "Block.h"
// represents a block of words in the html document // Represents a line of text on a page
class TextBlock final : public Block { class TextBlock final : public Block {
public: public:
enum BLOCK_STYLE : uint8_t { enum Style : uint8_t {
JUSTIFIED = 0, JUSTIFIED = 0,
LEFT_ALIGN = 1, LEFT_ALIGN = 1,
CENTER_ALIGN = 2, CENTER_ALIGN = 2,
@@ -21,16 +21,16 @@ class TextBlock final : public Block {
private: private:
std::list<std::string> words; std::list<std::string> words;
std::list<uint16_t> wordXpos; std::list<uint16_t> wordXpos;
std::list<EpdFontStyle> wordStyles; std::list<EpdFontFamily::Style> wordStyles;
BLOCK_STYLE style; Style style;
public: public:
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos, std::list<EpdFontStyle> word_styles, explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
const BLOCK_STYLE style) std::list<EpdFontFamily::Style> word_styles, const Style style)
: words(std::move(words)), wordXpos(std::move(word_xpos)), wordStyles(std::move(word_styles)), style(style) {} : words(std::move(words)), wordXpos(std::move(word_xpos)), wordStyles(std::move(word_styles)), style(style) {}
~TextBlock() override = default; ~TextBlock() override = default;
void setStyle(const BLOCK_STYLE style) { this->style = style; } void setStyle(const Style style) { this->style = style; }
BLOCK_STYLE getStyle() const { return style; } Style getStyle() const { return style; }
bool isEmpty() override { return words.empty(); } bool isEmpty() override { return words.empty(); }
void layout(GfxRenderer& renderer) override {}; void layout(GfxRenderer& renderer) override {};
// given a renderer works out where to break the words into lines // given a renderer works out where to break the words into lines

View File

@@ -42,7 +42,7 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
} }
// start a new text block if needed // start a new text block if needed
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::BLOCK_STYLE style) { void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) {
if (currentTextBlock) { if (currentTextBlock) {
// already have a text block running and it is empty - just reuse it // already have a text block running and it is empty - just reuse it
if (currentTextBlock->isEmpty()) { if (currentTextBlock->isEmpty()) {
@@ -97,7 +97,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
if (strcmp(name, "br") == 0) { if (strcmp(name, "br") == 0) {
self->startNewTextBlock(self->currentTextBlock->getStyle()); self->startNewTextBlock(self->currentTextBlock->getStyle());
} else { } else {
self->startNewTextBlock(TextBlock::JUSTIFIED); self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
} }
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
@@ -116,13 +116,13 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
return; return;
} }
EpdFontStyle fontStyle = REGULAR; EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) { if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = BOLD_ITALIC; fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) { } else if (self->boldUntilDepth < self->depth) {
fontStyle = BOLD; fontStyle = EpdFontFamily::BOLD;
} else if (self->italicUntilDepth < self->depth) { } else if (self->italicUntilDepth < self->depth) {
fontStyle = ITALIC; fontStyle = EpdFontFamily::ITALIC;
} }
for (int i = 0; i < len; i++) { for (int i = 0; i < len; i++) {
@@ -137,6 +137,21 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
continue; continue;
} }
// Skip soft-hyphen with UTF-8 representation (U+00AD) = 0xC2 0xAD
const XML_Char SHY_BYTE_1 = static_cast<XML_Char>(0xC2);
const XML_Char SHY_BYTE_2 = static_cast<XML_Char>(0xAD);
// 1. Check for the start of the 2-byte Soft Hyphen sequence
if (s[i] == SHY_BYTE_1) {
// 2. Check if the next byte exists AND if it completes the sequence
// We must check i + 1 < len to prevent reading past the end of the buffer.
if ((i + 1 < len) && (s[i + 1] == SHY_BYTE_2)) {
// Sequence 0xC2 0xAD found!
// Skip the current byte (0xC2) and the next byte (0xAD)
i++; // Increment 'i' one more time to skip the 0xAD byte
continue; // Skip the rest of the loop and move to the next iteration
}
}
// If we're about to run out of space, then cut the word off and start a new one // If we're about to run out of space, then cut the word off and start a new one
if (self->partWordBufferIndex >= MAX_WORD_SIZE) { if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
self->partWordBuffer[self->partWordBufferIndex] = '\0'; self->partWordBuffer[self->partWordBufferIndex] = '\0';
@@ -172,13 +187,13 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1; matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1;
if (shouldBreakText) { if (shouldBreakText) {
EpdFontStyle fontStyle = REGULAR; EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) { if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) {
fontStyle = BOLD_ITALIC; fontStyle = EpdFontFamily::BOLD_ITALIC;
} else if (self->boldUntilDepth < self->depth) { } else if (self->boldUntilDepth < self->depth) {
fontStyle = BOLD; fontStyle = EpdFontFamily::BOLD;
} else if (self->italicUntilDepth < self->depth) { } else if (self->italicUntilDepth < self->depth) {
fontStyle = ITALIC; fontStyle = EpdFontFamily::ITALIC;
} }
self->partWordBuffer[self->partWordBufferIndex] = '\0'; self->partWordBuffer[self->partWordBufferIndex] = '\0';
@@ -206,7 +221,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
} }
bool ChapterHtmlSlimParser::parseAndBuildPages() { bool ChapterHtmlSlimParser::parseAndBuildPages() {
startNewTextBlock(TextBlock::JUSTIFIED); startNewTextBlock((TextBlock::Style)this->paragraphAlignment);
const XML_Parser parser = XML_ParserCreate(nullptr); const XML_Parser parser = XML_ParserCreate(nullptr);
int done; int done;

View File

@@ -33,10 +33,11 @@ class ChapterHtmlSlimParser {
int fontId; int fontId;
float lineCompression; float lineCompression;
bool extraParagraphSpacing; bool extraParagraphSpacing;
int viewportWidth; uint8_t paragraphAlignment;
int viewportHeight; uint16_t viewportWidth;
uint16_t viewportHeight;
void startNewTextBlock(TextBlock::BLOCK_STYLE style); void startNewTextBlock(TextBlock::Style style);
void makePages(); void makePages();
// XML callbacks // XML callbacks
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
@@ -45,8 +46,9 @@ class ChapterHtmlSlimParser {
public: public:
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const bool extraParagraphSpacing, const int viewportWidth, const float lineCompression, const bool extraParagraphSpacing,
const int viewportHeight, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight,
const std::function<void(std::unique_ptr<Page>)>& completePageFn, const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr) const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath), : filepath(filepath),
@@ -54,6 +56,7 @@ class ChapterHtmlSlimParser {
fontId(fontId), fontId(fontId),
lineCompression(lineCompression), lineCompression(lineCompression),
extraParagraphSpacing(extraParagraphSpacing), extraParagraphSpacing(extraParagraphSpacing),
paragraphAlignment(paragraphAlignment),
viewportWidth(viewportWidth), viewportWidth(viewportWidth),
viewportHeight(viewportHeight), viewportHeight(viewportHeight),
completePageFn(completePageFn), completePageFn(completePageFn),

View File

@@ -161,6 +161,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
std::string itemId; std::string itemId;
std::string href; std::string href;
std::string mediaType; std::string mediaType;
std::string properties;
for (int i = 0; atts[i]; i += 2) { for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "id") == 0) { if (strcmp(atts[i], "id") == 0) {
@@ -169,6 +170,8 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
href = self->baseContentPath + atts[i + 1]; href = self->baseContentPath + atts[i + 1];
} else if (strcmp(atts[i], "media-type") == 0) { } else if (strcmp(atts[i], "media-type") == 0) {
mediaType = atts[i + 1]; mediaType = atts[i + 1];
} else if (strcmp(atts[i], "properties") == 0) {
properties = atts[i + 1];
} }
} }
@@ -188,6 +191,15 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
href.c_str()); href.c_str());
} }
} }
// EPUB 3: Check for nav document (properties contains "nav")
if (!properties.empty() && self->tocNavPath.empty()) {
// Properties is space-separated, check if "nav" is present as a word
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
self->tocNavPath = href;
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str());
}
}
return; return;
} }

View File

@@ -35,6 +35,7 @@ class ContentOpfParser final : public Print {
std::string title; std::string title;
std::string author; std::string author;
std::string tocNcxPath; std::string tocNcxPath;
std::string tocNavPath; // EPUB 3 nav document path
std::string coverItemHref; std::string coverItemHref;
std::string textReferenceHref; std::string textReferenceHref;

View File

@@ -0,0 +1,184 @@
#include "TocNavParser.h"
#include <HardwareSerial.h>
#include "../BookMetadataCache.h"
bool TocNavParser::setup() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for parser\n", millis());
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
return true;
}
TocNavParser::~TocNavParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
}
}
size_t TocNavParser::write(const uint8_t data) { return write(&data, 1); }
size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
if (!parser) return 0;
const uint8_t* currentBufferPos = buffer;
auto remainingInBuffer = size;
while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
const auto toRead = remainingInBuffer < 1024 ? remainingInBuffer : 1024;
memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [NAV] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0;
}
currentBufferPos += toRead;
remainingInBuffer -= toRead;
remainingSize -= toRead;
}
return size;
}
void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<TocNavParser*>(userData);
// Track HTML structure loosely - we mainly care about finding <nav epub:type="toc">
if (strcmp(name, "html") == 0) {
self->state = IN_HTML;
return;
}
if (self->state == IN_HTML && strcmp(name, "body") == 0) {
self->state = IN_BODY;
return;
}
// Look for <nav epub:type="toc"> anywhere in body (or nested elements)
if (self->state >= IN_BODY && strcmp(name, "nav") == 0) {
for (int i = 0; atts[i]; i += 2) {
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
self->state = IN_NAV_TOC;
Serial.printf("[%lu] [NAV] Found nav toc element\n", millis());
return;
}
}
return;
}
// Only process ol/li/a if we're inside the toc nav
if (self->state < IN_NAV_TOC) {
return;
}
if (strcmp(name, "ol") == 0) {
self->olDepth++;
self->state = IN_OL;
return;
}
if (self->state == IN_OL && strcmp(name, "li") == 0) {
self->state = IN_LI;
self->currentLabel.clear();
self->currentHref.clear();
return;
}
if (self->state == IN_LI && strcmp(name, "a") == 0) {
self->state = IN_ANCHOR;
// Get href attribute
for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "href") == 0) {
self->currentHref = atts[i + 1];
break;
}
}
return;
}
}
void XMLCALL TocNavParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<TocNavParser*>(userData);
// Only collect text when inside an anchor within the TOC nav
if (self->state == IN_ANCHOR) {
self->currentLabel.append(s, len);
}
}
void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<TocNavParser*>(userData);
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
// Create TOC entry when closing anchor tag (we have all data now)
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
std::string href = self->baseContentPath + self->currentHref;
std::string anchor;
const size_t pos = href.find('#');
if (pos != std::string::npos) {
anchor = href.substr(pos + 1);
href = href.substr(0, pos);
}
if (self->cache) {
// olDepth gives us the nesting level (1-based from the outer ol)
self->cache->createTocEntry(self->currentLabel, href, anchor, self->olDepth);
}
self->currentLabel.clear();
self->currentHref.clear();
}
self->state = IN_LI;
return;
}
if (strcmp(name, "li") == 0 && (self->state == IN_LI || self->state == IN_OL)) {
self->state = IN_OL;
return;
}
if (strcmp(name, "ol") == 0 && self->state >= IN_NAV_TOC) {
self->olDepth--;
if (self->olDepth == 0) {
self->state = IN_NAV_TOC;
} else {
self->state = IN_LI; // Back to parent li
}
return;
}
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
self->state = IN_BODY;
Serial.printf("[%lu] [NAV] Finished parsing nav toc\n", millis());
return;
}
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <Print.h>
#include <expat.h>
#include <string>
class BookMetadataCache;
// Parser for EPUB 3 nav.xhtml navigation documents
// Parses HTML5 nav elements with epub:type="toc" to extract table of contents
class TocNavParser final : public Print {
enum ParserState {
START,
IN_HTML,
IN_BODY,
IN_NAV_TOC, // Inside <nav epub:type="toc">
IN_OL, // Inside <ol>
IN_LI, // Inside <li>
IN_ANCHOR, // Inside <a>
};
const std::string& baseContentPath;
size_t remainingSize;
XML_Parser parser = nullptr;
ParserState state = START;
BookMetadataCache* cache;
// Track nesting depth for <ol> elements to determine TOC depth
uint8_t olDepth = 0;
// Current entry data being collected
std::string currentLabel;
std::string currentHref;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void characterData(void* userData, const XML_Char* s, int len);
static void endElement(void* userData, const XML_Char* name);
public:
explicit TocNavParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
~TocNavParser() override;
bool setup();
size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override;
};

View File

@@ -66,7 +66,7 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
} }
} }
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontStyle style) const { int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return 0; return 0;
@@ -78,13 +78,13 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontS
} }
void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black, void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black,
const EpdFontStyle style) const { const EpdFontFamily::Style style) const {
const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2; const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2;
drawText(fontId, x, y, text, black, style); drawText(fontId, x, y, text, black, style);
} }
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontStyle style) const { const EpdFontFamily::Style style) const {
const int yPos = y + getFontAscenderSize(fontId); const int yPos = y + getFontAscenderSize(fontId);
int xpos = x; int xpos = x;
@@ -239,7 +239,7 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons
} }
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
const EpdFontStyle style) const { const EpdFontFamily::Style style) const {
std::string item = text; std::string item = text;
int itemWidth = getTextWidth(fontId, item.c_str(), style); int itemWidth = getTextWidth(fontId, item.c_str(), style);
while (itemWidth > maxWidth && item.length() > 8) { while (itemWidth > maxWidth && item.length() > 8) {
@@ -284,7 +284,7 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
return 0; return 0;
} }
return fontMap.at(fontId).getGlyph(' ', REGULAR)->advanceX; return fontMap.at(fontId).getGlyph(' ', EpdFontFamily::REGULAR)->advanceX;
} }
int GfxRenderer::getFontAscenderSize(const int fontId) const { int GfxRenderer::getFontAscenderSize(const int fontId) const {
@@ -293,7 +293,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
return 0; return 0;
} }
return fontMap.at(fontId).getData(REGULAR)->ascender; return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
} }
int GfxRenderer::getLineHeight(const int fontId) const { int GfxRenderer::getLineHeight(const int fontId) const {
@@ -302,7 +302,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return 0; return 0;
} }
return fontMap.at(fontId).getData(REGULAR)->advanceY; return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY;
} }
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
@@ -327,6 +327,148 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
} }
} }
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = getScreenWidth();
constexpr int buttonWidth = 40; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 5; // Distance from right edge
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
// Draw rotated text centered in the button
const int textWidth = getTextWidth(fontId, labels[i]);
const int textHeight = getTextHeight(fontId);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
drawTextRotated90CW(fontId, textX, textY, labels[i]);
}
}
}
int GfxRenderer::getTextHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return 0;
}
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
}
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
return;
}
if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
return;
}
const auto font = fontMap.at(fontId);
// No printable characters
if (!font.hasPrintableChars(text, style)) {
return;
}
// For 90° clockwise rotation:
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX)
// Text reads from bottom to top
int yPos = y; // Current Y position (decreases as we draw characters)
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph('?', style);
}
if (!glyph) {
continue;
}
const int is2Bit = font.getData(style)->is2Bit;
const uint32_t offset = glyph->dataOffset;
const uint8_t width = glyph->width;
const uint8_t height = glyph->height;
const int left = glyph->left;
const int top = glyph->top;
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
if (bitmap != nullptr) {
for (int glyphY = 0; glyphY < height; glyphY++) {
for (int glyphX = 0; glyphX < width; glyphX++) {
const int pixelPosition = glyphY * width + glyphX;
// 90° clockwise rotation transformation:
// screenX = x + (ascender - top + glyphY)
// screenY = yPos - (left + glyphX)
const int screenX = x + (font.getData(style)->ascender - top + glyphY);
const int screenY = yPos - left - glyphX;
if (is2Bit) {
const uint8_t byte = bitmap[pixelPosition / 4];
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
if (renderMode == BW && bmpVal < 3) {
drawPixel(screenX, screenY, black);
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
drawPixel(screenX, screenY, false);
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
drawPixel(screenX, screenY, false);
}
} else {
const uint8_t byte = bitmap[pixelPosition / 8];
const uint8_t bit_index = 7 - (pixelPosition % 8);
if ((byte >> bit_index) & 1) {
drawPixel(screenX, screenY, black);
}
}
}
}
}
// Move to next character position (going up, so decrease Y)
yPos -= glyph->advanceX;
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
@@ -447,7 +589,7 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
} }
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
const bool pixelState, const EpdFontStyle style) const { const bool pixelState, const EpdFontFamily::Style style) const {
const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); const EpdGlyph* glyph = fontFamily.getGlyph(cp, style);
if (!glyph) { if (!glyph) {
// TODO: Replace with fallback glyph property? // TODO: Replace with fallback glyph property?

View File

@@ -31,7 +31,7 @@ class GfxRenderer {
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
std::map<int, EpdFontFamily> fontMap; std::map<int, EpdFontFamily> fontMap;
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
EpdFontStyle style) const; EpdFontFamily::Style style) const;
void freeBwBufferChunks(); void freeBwBufferChunks();
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
@@ -69,18 +69,28 @@ class GfxRenderer {
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
// Text // Text
int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const; int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true,
void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
void drawText(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getSpaceWidth(int fontId) const; int getSpaceWidth(int fontId) const;
int getFontAscenderSize(int fontId) const; int getFontAscenderSize(int fontId) const;
int getLineHeight(int fontId) const; int getLineHeight(int fontId) const;
std::string truncatedText(const int fontId, const char* text, const int maxWidth, std::string truncatedText(int fontId, const char* text, int maxWidth,
const EpdFontStyle style = REGULAR) const; EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// UI Components // UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
private:
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getTextHeight(int fontId) const;
public:
// Grayscale functions // Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const; void copyGrayscaleLsbBuffers() const;

View File

@@ -1,5 +1,5 @@
[platformio] [platformio]
crosspoint_version = 0.11.2 crosspoint_version = 0.12.0
default_envs = default default_envs = default
[base] [base]

View File

@@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance;
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 10; constexpr uint8_t SETTINGS_COUNT = 13;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
@@ -37,6 +37,9 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, fontFamily); serialization::writePod(outputFile, fontFamily);
serialization::writePod(outputFile, fontSize); serialization::writePod(outputFile, fontSize);
serialization::writePod(outputFile, lineSpacing); serialization::writePod(outputFile, lineSpacing);
serialization::writePod(outputFile, paragraphAlignment);
serialization::writePod(outputFile, sleepTimeout);
serialization::writePod(outputFile, refreshFrequency);
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@@ -83,6 +86,12 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, lineSpacing); serialization::readPod(inputFile, lineSpacing);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, paragraphAlignment);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sleepTimeout);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, refreshFrequency);
if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);
inputFile.close(); inputFile.close();
@@ -126,6 +135,38 @@ float CrossPointSettings::getReaderLineCompression() const {
} }
} }
unsigned long CrossPointSettings::getSleepTimeoutMs() const {
switch (sleepTimeout) {
case SLEEP_1_MIN:
return 1UL * 60 * 1000;
case SLEEP_5_MIN:
return 5UL * 60 * 1000;
case SLEEP_10_MIN:
default:
return 10UL * 60 * 1000;
case SLEEP_15_MIN:
return 15UL * 60 * 1000;
case SLEEP_30_MIN:
return 30UL * 60 * 1000;
}
}
int CrossPointSettings::getRefreshFrequency() const {
switch (refreshFrequency) {
case REFRESH_1:
return 1;
case REFRESH_5:
return 5;
case REFRESH_10:
return 10;
case REFRESH_15:
default:
return 15;
case REFRESH_30:
return 30;
}
}
int CrossPointSettings::getReaderFontId() const { int CrossPointSettings::getReaderFontId() const {
switch (fontFamily) { switch (fontFamily) {
case BOOKERLY: case BOOKERLY:

View File

@@ -43,6 +43,13 @@ class CrossPointSettings {
// Font size options // Font size options
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 }; enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 }; enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
// Auto-sleep timeout options (in minutes)
enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 };
// E-ink refresh frequency (pages between full refreshes)
enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 };
// Sleep screen settings // Sleep screen settings
uint8_t sleepScreen = DARK; uint8_t sleepScreen = DARK;
@@ -62,6 +69,11 @@ class CrossPointSettings {
uint8_t fontFamily = BOOKERLY; uint8_t fontFamily = BOOKERLY;
uint8_t fontSize = MEDIUM; uint8_t fontSize = MEDIUM;
uint8_t lineSpacing = NORMAL; uint8_t lineSpacing = NORMAL;
uint8_t paragraphAlignment = JUSTIFIED;
// Auto-sleep timeout setting (default 10 minutes)
uint8_t sleepTimeout = SLEEP_10_MIN;
// E-ink refresh frequency (default 15 pages)
uint8_t refreshFrequency = REFRESH_15;
~CrossPointSettings() = default; ~CrossPointSettings() = default;
@@ -75,6 +87,8 @@ class CrossPointSettings {
bool loadFromFile(); bool loadFromFile();
float getReaderLineCompression() const; float getReaderLineCompression() const;
unsigned long getSleepTimeoutMs() const;
int getRefreshFrequency() const;
}; };
// Helper macro to access settings // Helper macro to access settings

View File

@@ -22,4 +22,5 @@ class Activity {
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); } virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }
virtual void loop() {} virtual void loop() {}
virtual bool skipLoopDelay() { return false; } virtual bool skipLoopDelay() { return false; }
virtual bool preventAutoSleep() { return false; }
}; };

View File

@@ -13,7 +13,7 @@ void BootActivity::onEnter() {
renderer.clearScreen(); renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING");
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION);
renderer.displayBuffer(); renderer.displayBuffer();

View File

@@ -5,8 +5,6 @@
#include <SDCardManager.h> #include <SDCardManager.h>
#include <Xtc.h> #include <Xtc.h>
#include <vector>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "fontIds.h" #include "fontIds.h"
@@ -42,16 +40,16 @@ void SleepActivity::onEnter() {
} }
void SleepActivity::renderPopup(const char* message) const { void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message); const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20; constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2; const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117; constexpr int y = 117;
const int w = textWidth + margin * 2; const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2; const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen(); // renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message); renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer(); renderer.displayBuffer();
} }
@@ -129,7 +127,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.clearScreen(); renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
// Make sleep screen dark unless light is selected in settings // Make sleep screen dark unless light is selected in settings

View File

@@ -334,7 +334,7 @@ void CrossPointWebServerActivity::render() const {
} else if (state == WebServerActivityState::AP_STARTING) { } else if (state == WebServerActivityState::AP_STARTING) {
renderer.clearScreen(); renderer.clearScreen();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
} }
} }
@@ -365,13 +365,13 @@ void CrossPointWebServerActivity::renderServerRunning() const {
// Use consistent line spacing // Use consistent line spacing
constexpr int LINE_SPACING = 28; // Space between lines constexpr int LINE_SPACING = 28; // Space between lines
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD);
if (isApMode) { if (isApMode) {
// AP mode display - center the content block // AP mode display - center the content block
int startY = 55; int startY = 55;
renderer.drawCenteredText(UI_10_FONT_ID, startY, "Hotspot Mode", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, startY, "Hotspot Mode", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + connectedSSID; std::string ssidInfo = "Network: " + connectedSSID;
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str());
@@ -387,7 +387,7 @@ void CrossPointWebServerActivity::renderServerRunning() const {
startY += 6 * 29 + 3 * LINE_SPACING; startY += 6 * 29 + 3 * LINE_SPACING;
// Show primary URL (hostname) // Show primary URL (hostname)
std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, EpdFontFamily::BOLD);
// Show IP address as fallback // Show IP address as fallback
std::string ipUrl = "or http://" + connectedIP + "/"; std::string ipUrl = "or http://" + connectedIP + "/";
@@ -412,7 +412,7 @@ void CrossPointWebServerActivity::renderServerRunning() const {
// Show web server URL prominently // Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/"; std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, EpdFontFamily::BOLD);
// Also show hostname URL // Also show hostname URL
std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/";

View File

@@ -70,4 +70,5 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
void onExit() override; void onExit() override;
void loop() override; void loop() override;
bool skipLoopDelay() override { return webServer && webServer->isRunning(); } bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
bool preventAutoSleep() override { return webServer && webServer->isRunning(); }
}; };

View File

@@ -97,7 +97,7 @@ void NetworkModeSelectionActivity::render() const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header // Draw header
renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD);
// Draw subtitle // Draw subtitle
renderer.drawCenteredText(UI_10_FONT_ID, 50, "How would you like to connect?"); renderer.drawCenteredText(UI_10_FONT_ID, 50, "How would you like to connect?");

View File

@@ -447,7 +447,16 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi
void WifiSelectionActivity::displayTaskLoop() { void WifiSelectionActivity::displayTaskLoop() {
while (true) { while (true) {
// If a subactivity is active, yield CPU time but don't render
if (subActivity) { if (subActivity) {
vTaskDelay(10 / portTICK_PERIOD_MS);
continue;
}
// Don't render if we're in PASSWORD_ENTRY state - we're just transitioning
// from the keyboard subactivity back to the main activity
if (state == WifiSelectionState::PASSWORD_ENTRY) {
vTaskDelay(10 / portTICK_PERIOD_MS);
continue; continue;
} }
@@ -496,7 +505,7 @@ void WifiSelectionActivity::renderNetworkList() const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header // Draw header
renderer.drawCenteredText(UI_12_FONT_ID, 15, "WiFi Networks", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15, "WiFi Networks", true, EpdFontFamily::BOLD);
if (networks.empty()) { if (networks.empty()) {
// No networks found or scan failed // No networks found or scan failed
@@ -577,7 +586,7 @@ void WifiSelectionActivity::renderConnecting() const {
if (state == WifiSelectionState::SCANNING) { if (state == WifiSelectionState::SCANNING) {
renderer.drawCenteredText(UI_10_FONT_ID, top, "Scanning..."); renderer.drawCenteredText(UI_10_FONT_ID, top, "Scanning...");
} else { } else {
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connecting...", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connecting...", true, EpdFontFamily::BOLD);
std::string ssidInfo = "to " + selectedSSID; std::string ssidInfo = "to " + selectedSSID;
if (ssidInfo.length() > 25) { if (ssidInfo.length() > 25) {
@@ -592,7 +601,7 @@ void WifiSelectionActivity::renderConnected() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 4) / 2; const auto top = (pageHeight - height * 4) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 30, "Connected!", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 30, "Connected!", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
@@ -612,7 +621,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connected!", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connected!", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
@@ -651,7 +660,7 @@ void WifiSelectionActivity::renderConnectionFailed() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 2) / 2; const auto top = (pageHeight - height * 2) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 20, "Connection Failed", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 20, "Connection Failed", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str());
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue");
} }
@@ -662,7 +671,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {

View File

@@ -13,7 +13,7 @@
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
constexpr int pagesPerRefresh = 15; // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700; constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000; constexpr unsigned long goHomeMs = 1000;
constexpr int topPadding = 5; constexpr int topPadding = 5;
@@ -244,7 +244,7 @@ void EpubReaderActivity::renderScreen() {
// Show end of book screen // Show end of book screen
if (currentSpineIndex == epub->getSpineItemsCount()) { if (currentSpineIndex == epub->getSpineItemsCount()) {
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@@ -263,11 +263,12 @@ void EpubReaderActivity::renderScreen() {
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer)); section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
const auto viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const auto viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight)) { SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions // Progress bar dimensions
@@ -311,8 +312,8 @@ void EpubReaderActivity::renderScreen() {
}; };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, viewportWidth, viewportHeight, progressSetup, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
progressCallback)) { viewportHeight, progressSetup, progressCallback)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset(); section.reset();
return; return;
@@ -332,7 +333,7 @@ void EpubReaderActivity::renderScreen() {
if (section->pageCount == 0) { if (section->pageCount == 0) {
Serial.printf("[%lu] [ERS] No pages to render\n", millis()); Serial.printf("[%lu] [ERS] No pages to render\n", millis());
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
@@ -340,7 +341,7 @@ void EpubReaderActivity::renderScreen() {
if (section->currentPage < 0 || section->currentPage >= section->pageCount) { if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount); Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
@@ -378,7 +379,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh; pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh--; pagesUntilFullRefresh--;

View File

@@ -121,8 +121,9 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems(); const int pageItems = getPageItems();
std::string title = renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, BOLD); const std::string title =
renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, BOLD); renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems; const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);

View File

@@ -173,7 +173,7 @@ void FileSelectionActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Books", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15, "Books", true, EpdFontFamily::BOLD);
// Help text // Help text
const auto labels = mappedInput.mapLabels("« Home", "Open", "", ""); const auto labels = mappedInput.mapLabels("« Home", "Open", "", "");

View File

@@ -68,8 +68,8 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
onGoToXtcReader(std::move(xtc)); onGoToXtcReader(std::move(xtc));
} else { } else {
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load XTC", REGULAR, enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load XTC",
EInkDisplay::HALF_REFRESH)); EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH));
delay(2000); delay(2000);
onGoToFileSelection(); onGoToFileSelection();
} }
@@ -80,8 +80,8 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
onGoToEpubReader(std::move(epub)); onGoToEpubReader(std::move(epub));
} else { } else {
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load epub", REGULAR, enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load epub",
EInkDisplay::HALF_REFRESH)); EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH));
delay(2000); delay(2000);
onGoToFileSelection(); onGoToFileSelection();
} }

View File

@@ -11,13 +11,13 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "XtcReaderChapterSelectionActivity.h" #include "XtcReaderChapterSelectionActivity.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipPageMs = 700; constexpr unsigned long skipPageMs = 700;
constexpr unsigned long goHomeMs = 1000; constexpr unsigned long goHomeMs = 1000;
} // namespace } // namespace
@@ -165,7 +165,7 @@ void XtcReaderActivity::renderScreen() {
if (currentPage >= xtc->getPageCount()) { if (currentPage >= xtc->getPageCount()) {
// Show end of book screen // Show end of book screen
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "End of book", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@@ -194,7 +194,7 @@ void XtcReaderActivity::renderPage() {
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize); Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize);
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@@ -205,7 +205,7 @@ void XtcReaderActivity::renderPage() {
Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage); Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage);
free(pageBuffer); free(pageBuffer);
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@@ -266,7 +266,7 @@ void XtcReaderActivity::renderPage() {
// Display BW with conditional refresh based on pagesUntilFullRefresh // Display BW with conditional refresh based on pagesUntilFullRefresh
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh; pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh--; pagesUntilFullRefresh--;
@@ -346,7 +346,7 @@ void XtcReaderActivity::renderPage() {
// Display with appropriate refresh // Display with appropriate refresh
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh; pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh--; pagesUntilFullRefresh--;

View File

@@ -130,7 +130,7 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems(); const int pageItems = getPageItems();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Chapter", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Chapter", true, EpdFontFamily::BOLD);
const auto& chapters = xtc->getChapters(); const auto& chapters = xtc->getChapters();
if (chapters.empty()) { if (chapters.empty()) {

View File

@@ -127,16 +127,16 @@ void OtaUpdateActivity::render() {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Update", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15, "Update", true, EpdFontFamily::BOLD);
if (state == CHECKING_FOR_UPDATE) { if (state == CHECKING_FOR_UPDATE) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Checking for update...", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 300, "Checking for update...", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
if (state == WAITING_CONFIRMATION) { if (state == WAITING_CONFIRMATION) {
renderer.drawCenteredText(UI_10_FONT_ID, 200, "New update available!", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 200, "New update available!", true, EpdFontFamily::BOLD);
renderer.drawText(UI_10_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION); renderer.drawText(UI_10_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION);
renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str());
@@ -147,7 +147,7 @@ void OtaUpdateActivity::render() {
} }
if (state == UPDATE_IN_PROGRESS) { if (state == UPDATE_IN_PROGRESS) {
renderer.drawCenteredText(UI_10_FONT_ID, 310, "Updating...", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 310, "Updating...", true, EpdFontFamily::BOLD);
renderer.drawRect(20, 350, pageWidth - 40, 50); renderer.drawRect(20, 350, pageWidth - 40, 50);
renderer.fillRect(24, 354, static_cast<int>(updaterProgress * static_cast<float>(pageWidth - 44)), 42); renderer.fillRect(24, 354, static_cast<int>(updaterProgress * static_cast<float>(pageWidth - 44)), 42);
renderer.drawCenteredText(UI_10_FONT_ID, 420, renderer.drawCenteredText(UI_10_FONT_ID, 420,
@@ -160,19 +160,19 @@ void OtaUpdateActivity::render() {
} }
if (state == NO_UPDATE) { if (state == NO_UPDATE) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "No update available", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 300, "No update available", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
if (state == FAILED) { if (state == FAILED) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update failed", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update failed", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
if (state == FINISHED) { if (state == FINISHED) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update complete", true, BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update complete", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 350, "Press and hold power button to turn back on"); renderer.drawCenteredText(UI_10_FONT_ID, 350, "Press and hold power button to turn back on");
renderer.displayBuffer(); renderer.displayBuffer();
state = SHUTTING_DOWN; state = SHUTTING_DOWN;

View File

@@ -41,4 +41,5 @@ class OtaUpdateActivity : public ActivityWithSubactivity {
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;
bool preventAutoSleep() override { return state == CHECKING_FOR_UPDATE || state == UPDATE_IN_PROGRESS; }
}; };

View File

@@ -9,7 +9,7 @@
// Define the static settings list // Define the static settings list
namespace { namespace {
constexpr int settingsCount = 11; constexpr int settingsCount = 14;
const SettingInfo settingsList[settingsCount] = { const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
@@ -34,6 +34,18 @@ const SettingInfo settingsList[settingsCount] = {
{"Bookerly", "Noto Sans", "Open Dyslexic"}}, {"Bookerly", "Noto Sans", "Open Dyslexic"}},
{"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}}, {"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}},
{"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}}, {"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}},
{"Reader Paragraph Alignment",
SettingType::ENUM,
&CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right"}},
{"Time to Sleep",
SettingType::ENUM,
&CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}},
{"Refresh Frequency",
SettingType::ENUM,
&CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}},
{"Check for updates", SettingType::ACTION, nullptr, {}}, {"Check for updates", SettingType::ACTION, nullptr, {}},
}; };
} // namespace } // namespace
@@ -163,7 +175,7 @@ void SettingsActivity::render() const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header // Draw header
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD);
// Draw selection // Draw selection
renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30);

View File

@@ -9,12 +9,12 @@
class FullScreenMessageActivity final : public Activity { class FullScreenMessageActivity final : public Activity {
std::string text; std::string text;
EpdFontStyle style; EpdFontFamily::Style style;
EInkDisplay::RefreshMode refreshMode; EInkDisplay::RefreshMode refreshMode;
public: public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text, explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
const EpdFontStyle style = REGULAR, const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Activity("FullScreenMessage", renderer, mappedInput), : Activity("FullScreenMessage", renderer, mappedInput),
text(std::move(text)), text(std::move(text)),

View File

@@ -329,9 +329,13 @@ void KeyboardEntryActivity::render() const {
} }
} }
// Draw help text at absolute bottom of screen (consistent with other screens) // Draw help text
const auto pageHeight = renderer.getScreenHeight(); const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Draw side button hints for Up/Down navigation
renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down");
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@@ -126,8 +126,6 @@ EpdFont ui12RegularFont(&ubuntu_12_regular);
EpdFont ui12BoldFont(&ubuntu_12_bold); EpdFont ui12BoldFont(&ubuntu_12_bold);
EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont); EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont);
// Auto-sleep timeout (10 minutes of inactivity)
constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000;
// measurement of power button press duration calibration value // measurement of power button press duration calibration value
unsigned long t1 = 0; unsigned long t1 = 0;
unsigned long t2 = 0; unsigned long t2 = 0;
@@ -150,9 +148,11 @@ void verifyWakeupLongPress() {
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
const auto start = millis(); const auto start = millis();
bool abort = false; bool abort = false;
// It takes us some time to wake up from deep sleep, so we need to subtract that from the duration // Subtract the current time, because inputManager only starts counting the HeldTime from the first update()
uint16_t calibration = 29; // This way, we remove the time we already took to reach here from the duration,
uint16_t calibratedPressDuration = // assuming the button was held until now from millis()==0 (i.e. device start time).
const uint16_t calibration = start;
const uint16_t calibratedPressDuration =
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
inputManager.update(); inputManager.update();
@@ -271,7 +271,7 @@ void setup() {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
setupDisplayAndFonts(); setupDisplayAndFonts();
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", BOLD)); enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD));
return; return;
} }
@@ -316,14 +316,16 @@ void loop() {
lastMemPrint = millis(); lastMemPrint = millis();
} }
// Check for any user activity (button press or release) // Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis(); static unsigned long lastActivityTime = millis();
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) { if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
(currentActivity && currentActivity->preventAutoSleep())) {
lastActivityTime = millis(); // Reset inactivity timer lastActivityTime = millis(); // Reset inactivity timer
} }
if (millis() - lastActivityTime >= AUTO_SLEEP_TIMEOUT_MS) { const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), AUTO_SLEEP_TIMEOUT_MS); if (millis() - lastActivityTime >= sleepTimeoutMs) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
enterDeepSleep(); enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return; return;

View File

@@ -3,7 +3,6 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <Update.h> #include <Update.h>
#include <WiFiClientSecure.h>
namespace { namespace {
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest"; constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
@@ -69,44 +68,41 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
return OK; return OK;
} }
bool OtaUpdater::isUpdateNewer() { bool OtaUpdater::isUpdateNewer() const {
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) { if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
return false; return false;
} }
int currentMajor, currentMinor, currentPatch;
int latestMajor, latestMinor, latestPatch;
const auto currentVersion = CROSSPOINT_VERSION;
// semantic version check (only match on 3 segments) // semantic version check (only match on 3 segments)
const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.'))); sscanf(latestVersion.c_str(), "%d.%d.%d", &latestMajor, &latestMinor, &latestPatch);
const auto updateMinor = stoi( sscanf(currentVersion, "%d.%d.%d", &currentMajor, &currentMinor, &currentPatch);
latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1));
const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1));
std::string currentVersion = CROSSPOINT_VERSION; /*
const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.'))); * Compare major versions.
const auto currentMinor = stoi(currentVersion.substr( * If they differ, return true if latest major version greater than current major version
currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1)); * otherwise return false.
const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1)); */
if (latestMajor != currentMajor) return latestMajor > currentMajor;
if (updateMajor > currentMajor) { /*
return true; * Compare minor versions.
} * If they differ, return true if latest minor version greater than current minor version
if (updateMajor < currentMajor) { * otherwise return false.
return false; */
} if (latestMinor != currentMinor) return latestMinor > currentMinor;
if (updateMinor > currentMinor) { /*
return true; * Check patch versions.
} */
if (updateMinor < currentMinor) { return latestPatch > currentPatch;
return false;
}
if (updatePatch > currentPatch) {
return true;
}
return false;
} }
const std::string& OtaUpdater::getLatestVersion() { return latestVersion; } const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; }
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) { OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
if (!isUpdateNewer()) { if (!isUpdateNewer()) {

View File

@@ -23,8 +23,8 @@ class OtaUpdater {
size_t totalSize = 0; size_t totalSize = 0;
OtaUpdater() = default; OtaUpdater() = default;
bool isUpdateNewer(); bool isUpdateNewer() const;
const std::string& getLatestVersion(); const std::string& getLatestVersion() const;
OtaUpdaterError checkForUpdate(); OtaUpdaterError checkForUpdate();
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress); OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
}; };

View File

@@ -341,6 +341,90 @@
width: 60px; width: 60px;
text-align: center; text-align: center;
} }
/* Failed uploads banner */
.failed-uploads-banner {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
display: none;
}
.failed-uploads-banner.show {
display: block;
}
.failed-uploads-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.failed-uploads-title {
font-weight: 600;
color: #856404;
margin: 0;
}
.dismiss-btn {
background: none;
border: none;
font-size: 1.2em;
cursor: pointer;
color: #856404;
padding: 0;
line-height: 1;
}
.dismiss-btn:hover {
color: #533f03;
}
.failed-file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #ffe69c;
}
.failed-file-item:last-child {
border-bottom: none;
}
.failed-file-info {
flex: 1;
}
.failed-file-name {
font-weight: 500;
color: #856404;
}
.failed-file-error {
font-size: 0.85em;
color: #856404;
opacity: 0.8;
}
.retry-btn {
background-color: #ffc107;
color: #533f03;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
}
.retry-btn:hover {
background-color: #e0a800;
}
.retry-all-btn {
background-color: #ffc107;
color: #533f03;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
margin-top: 10px;
}
.retry-all-btn:hover {
background-color: #e0a800;
}
/* Delete modal */ /* Delete modal */
.delete-warning { .delete-warning {
color: #e74c3c; color: #e74c3c;
@@ -505,6 +589,16 @@
</div> </div>
</div> </div>
<!-- Failed Uploads Banner -->
<div class="failed-uploads-banner" id="failedUploadsBanner">
<div class="failed-uploads-header">
<h3 class="failed-uploads-title">⚠️ Some files failed to upload</h3>
<button class="dismiss-btn" onclick="dismissFailedUploads()" title="Dismiss">&times;</button>
</div>
<div id="failedFilesList"></div>
<button class="retry-all-btn" onclick="retryAllFailedUploads()">Retry All Failed Uploads</button>
</div>
<div class="card"> <div class="card">
<div class="contents-header"> <div class="contents-header">
<h2 class="contents-title">Contents</h2> <h2 class="contents-title">Contents</h2>
@@ -531,7 +625,7 @@
<h3>📤 Upload file</h3> <h3>📤 Upload file</h3>
<div class="upload-form"> <div class="upload-form">
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p> <p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" onchange="validateFile()"> <input type="file" id="fileInput" onchange="validateFile()" multiple>
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button> <button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
<div id="progress-container"> <div id="progress-container">
<div id="progress-bar"><div id="progress-fill"></div></div> <div id="progress-bar"><div id="progress-fill"></div></div>
@@ -717,22 +811,21 @@
function validateFile() { function validateFile() {
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn'); const uploadBtn = document.getElementById('uploadBtn');
const file = fileInput.files[0]; const files = fileInput.files;
uploadBtn.disabled = !file; uploadBtn.disabled = !(files.length > 0);
} }
function uploadFile() { let failedUploadsGlobal = [];
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) { function uploadFile() {
alert('Please select a file first!'); const fileInput = document.getElementById('fileInput');
const files = Array.from(fileInput.files);
if (files.length === 0) {
alert('Please select at least one file!');
return; return;
} }
const formData = new FormData();
formData.append('file', file);
const progressContainer = document.getElementById('progress-container'); const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill'); const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text'); const progressText = document.getElementById('progress-text');
@@ -741,41 +834,160 @@
progressContainer.style.display = 'block'; progressContainer.style.display = 'block';
uploadBtn.disabled = true; uploadBtn.disabled = true;
let currentIndex = 0;
const failedFiles = [];
function uploadNextFile() {
if (currentIndex >= files.length) {
// All files processed - show summary
if (failedFiles.length === 0) {
progressFill.style.backgroundColor = '#4caf50';
progressText.textContent = 'All uploads complete!';
setTimeout(() => {
closeUploadModal();
hydrate(); // Refresh file list instead of reloading
}, 1000);
} else {
progressFill.style.backgroundColor = '#e74c3c';
const failedList = failedFiles.map(f => f.name).join(', ');
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
// Store failed files globally and show banner
failedUploadsGlobal = failedFiles;
setTimeout(() => {
closeUploadModal();
showFailedUploadsBanner();
hydrate(); // Refresh file list to show successfully uploaded files
}, 2000);
}
return;
}
const file = files[currentIndex];
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
// Include path as query parameter since multipart form data doesn't make // Include path as query parameter since multipart form data doesn't make
// form fields available until after file upload completes // form fields available until after file upload completes
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.upload.onprogress = function(e) { progressFill.style.width = '0%';
progressFill.style.backgroundColor = '#4caf50';
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`;
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) { if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100); const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%'; progressFill.style.width = percent + '%';
progressText.textContent = 'Uploading: ' + percent + '%'; progressText.textContent =
`Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`;
} }
}; };
xhr.onload = function() { xhr.onload = function () {
if (xhr.status === 200) { if (xhr.status === 200) {
progressText.textContent = 'Upload complete!'; currentIndex++;
setTimeout(function() { uploadNextFile(); // upload next file
window.location.reload();
}, 1000);
} else { } else {
progressText.textContent = 'Upload failed: ' + xhr.responseText; // Track failure and continue with next file
progressFill.style.backgroundColor = '#e74c3c'; failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
uploadBtn.disabled = false; currentIndex++;
uploadNextFile();
} }
}; };
xhr.onerror = function() { xhr.onerror = function () {
progressText.textContent = 'Upload failed - network error'; // Track network error and continue with next file
progressFill.style.backgroundColor = '#e74c3c'; failedFiles.push({ name: file.name, error: 'network error', file: file });
uploadBtn.disabled = false; currentIndex++;
uploadNextFile();
}; };
xhr.send(formData); xhr.send(formData);
} }
uploadNextFile();
}
function showFailedUploadsBanner() {
const banner = document.getElementById('failedUploadsBanner');
const filesList = document.getElementById('failedFilesList');
filesList.innerHTML = '';
failedUploadsGlobal.forEach((failedFile, index) => {
const item = document.createElement('div');
item.className = 'failed-file-item';
item.innerHTML = `
<div class="failed-file-info">
<div class="failed-file-name">📄 ${escapeHtml(failedFile.name)}</div>
<div class="failed-file-error">Error: ${escapeHtml(failedFile.error)}</div>
</div>
<button class="retry-btn" onclick="retrySingleUpload(${index})">Retry</button>
`;
filesList.appendChild(item);
});
// Ensure retry all button is visible
const retryAllBtn = banner.querySelector('.retry-all-btn');
if (retryAllBtn) retryAllBtn.style.display = '';
banner.classList.add('show');
}
function dismissFailedUploads() {
const banner = document.getElementById('failedUploadsBanner');
banner.classList.remove('show');
failedUploadsGlobal = [];
}
function retrySingleUpload(index) {
const failedFile = failedUploadsGlobal[index];
if (!failedFile) return;
// Create a DataTransfer to set the file input
const dt = new DataTransfer();
dt.items.add(failedFile.file);
const fileInput = document.getElementById('fileInput');
fileInput.files = dt.files;
// Remove this file from failed list
failedUploadsGlobal.splice(index, 1);
// If no more failed files, hide banner
if (failedUploadsGlobal.length === 0) {
dismissFailedUploads();
}
// Open modal and trigger upload
openUploadModal();
validateFile();
}
function retryAllFailedUploads() {
if (failedUploadsGlobal.length === 0) return;
// Create a DataTransfer with all failed files
const dt = new DataTransfer();
failedUploadsGlobal.forEach(failedFile => {
dt.items.add(failedFile.file);
});
const fileInput = document.getElementById('fileInput');
fileInput.files = dt.files;
// Clear failed files list
failedUploadsGlobal = [];
dismissFailedUploads();
// Open modal and trigger upload
openUploadModal();
validateFile();
}
function createFolder() { function createFolder() {
const folderName = document.getElementById('folderName').value.trim(); const folderName = document.getElementById('folderName').value.trim();