This commit is contained in:
j2 2026-09-22 22:24:07 -05:00
parent 40abcd751c
commit 144d95a978
19 changed files with 3211 additions and 112 deletions

20
.gitignore vendored
View File

@ -1,4 +1,24 @@
# PlatformIO build output and local editor settings.
.pio/ .pio/
.vscode/ .vscode/
*.bin *.bin
*.elf
*.map
# Python bytecode, test output, and coverage reports.
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
coverage.xml
htmlcov/
# Generated reader content and local source books.
chapters/
*.epub
# Filesystem metadata that may appear when preparing an SD card.
.DS_Store
._*
.Trash/
System Volume Information/

View File

@ -66,21 +66,59 @@ operating systems: <https://docs.platformio.org/en/latest/core/installation/meth
1. Format the microSD card as **FAT32**. 1. Format the microSD card as **FAT32**.
2. Create a directory named `books` at the card's root. 2. Create a directory named `books` at the card's root.
3. Copy UTF-8 plain-text files ending in `.txt` into it. Copy 3. Create one folder per book inside `books`. Each book folder needs a
[`examples/sample.txt`](examples/sample.txt) for the first test. `TOC.txt`, UTF-8 text files for its sections, and matching `.rsvp` sidecars
generated by the converter.
`TOC.txt` controls both section order and title-card text. Each non-empty line
names a section and its file is that name plus `.txt`.
The resulting card must look like this: The resulting card must look like this:
```text ```text
microSD root/ microSD root/
└── books/ └── books/
├── sample.txt └── Example Book/
└── another-book.txt ├── TOC.txt
├── Dedication.txt
├── Dedication.rsvp
├── Chapter 1.txt
├── Chapter 1.rsvp
├── Chapter 2.txt
└── Chapter 2.rsvp
``` ```
Only files directly in `/books` are discovered in this MVP. A book may be up to For the example above, `TOC.txt` contains:
1.5 MiB. The included display font is best for printable ASCII; accented or
non-Latin scripts need a future Unicode-font upgrade. ```text
Dedication
Chapter 1
Chapter 2
```
Generate this layout from an EPUB with the included converter:
```bash
python3 -m pip install -r requirements.txt
python3 epub_to_chapters.py book.epub "Example Book"
```
Copy the resulting `Example Book` directory into `/books` on the card. A
section may be up to 1.5 MiB. The converter writes `CONVERSION_REPORT.txt` in
the book directory. It records any unsupported non-ASCII characters left after
normalizing common punctuation, ligatures, accents, whitespace, fractions, and
ornamental breaks. Review that report before copying the book: transliterate or
replace reported characters deliberately, or defer the book until a
Unicode-capable font is available. The included display font is best for
printable ASCII.
The converter also writes a compact `<section>.rsvp` file alongside every
section. Its versioned records preserve italic and bold EPUB markup—including
semantic tags, CSS classes, and inline CSS—using delta-encoded word indexes,
and include a reserved timing-modifier field for a future reading-timing pass.
The reader validates the sidecar's fingerprint against its `.txt` file and
safely ignores it if it is missing, stale, or malformed. Valid italic and bold
records select matching reader-font variants.
Eject the card safely and insert it in the board before powering the board. Eject the card safely and insert it in the board before powering the board.
@ -94,7 +132,7 @@ pio run
``` ```
On the first run, PlatformIO downloads the pinned ESP32 platform, compiler, and On the first run, PlatformIO downloads the pinned ESP32 platform, compiler, and
Arduino_GFX dependency. A successful build ends with `SUCCESS` and creates the TFT_eSPI dependency. A successful build ends with `SUCCESS` and creates the
firmware under `.pio/build/rsvp_reader/`. firmware under `.pio/build/rsvp_reader/`.
The project targets the T-Display S3 Pro's 16 MB flash and 8 MB OPI PSRAM. It The project targets the T-Display S3 Pro's 16 MB flash and 8 MB OPI PSRAM. It
@ -136,9 +174,9 @@ pio run -t upload --upload-port /dev/ttyACM0
Replace `/dev/ttyACM0` with the port from the previous step. On Windows, for Replace `/dev/ttyACM0` with the port from the previous step. On Windows, for
example, use `--upload-port COM3`. example, use `--upload-port COM3`.
After a successful upload, the display shows the first word of a book and is After a successful upload, the display shows the current section title and is
**paused**. If it shows `NO BOOKS`, check that the card is inserted, FAT32, and **paused**. If it shows `NO BOOKS`, check that the card is inserted, FAT32, and
has at least one `/books/*.txt` file. contains at least one `/books/<book>/TOC.txt` with a listed section file.
If the board is not detected or an upload repeatedly fails, force download mode: If the board is not detected or an upload repeatedly fails, force download mode:
@ -149,11 +187,12 @@ If the board is not detected or an upload repeatedly fails, force download mode:
## 6. Read ## 6. Read
| Control | Paused | Playing | | Control | Reading view | Menu or picker |
| --- | --- | --- | | --- | --- | --- |
| BOOT / GPIO0 | Play or pause | Play or pause | | BOOT / GPIO0 | Open menu | Close/cancel without saving |
| Upper-right / GPIO12 | Previous paragraph | Lower WPM | | Touch edge Home button | Play or pause | Open highlighted option / apply |
| Lower-right / GPIO16 | Next paragraph | Higher WPM | | Upper-right / GPIO12 | Previous paragraph or section | Move selection backward |
| Lower-right / GPIO16 | Next paragraph or section | Move selection forward |
Do not hold BOOT while pressing RESET during normal use; that combination is Do not hold BOOT while pressing RESET during normal use; that combination is
the manual download mode used for recovery above. the manual download mode used for recovery above.
@ -164,11 +203,12 @@ GPIO12 wakes it.
## Resume and power behavior ## Resume and power behavior
When the reader advances, pauses, or sleeps, it saves the exact word index and The reader saves the selected section, WPM, brightness, and font settings in
current WPM in the ESP32's nonvolatile storage. On the next boot or wake it the ESP32's nonvolatile storage. On the next boot or wake it opens the same
opens the same book, moves to the first word of the paragraph containing that section's title card and remains paused; pressing the touch-edge Home button
saved index, renders it, and remains paused. This is intentional: it gives you begins at that section's first word. It defers full chapter-list validation
paragraph context before reading resumes. until chapter browsing is needed, so normal startup opens only the selected
chapter and its sidecar.
The physical power switch only disconnects the battery. USB power is sufficient The physical power switch only disconnects the battery. USB power is sufficient
for development and flashing. The reader intentionally has no Wi-Fi, Bluetooth, for development and flashing. The reader intentionally has no Wi-Fi, Bluetooth,
@ -180,8 +220,9 @@ cloud account, or synchronization feature.
- **Upload cannot connect:** use the BOOT + RESET sequence in step 5, then retry. - **Upload cannot connect:** use the BOOT + RESET sequence in step 5, then retry.
- **Permission denied on Linux:** complete the `dialout` group step and log out - **Permission denied on Linux:** complete the `dialout` group step and log out
and back in. and back in.
- **`NO BOOKS`:** reformat the card FAT32, create `/books`, and copy a `.txt` - **`NO BOOKS`:** reformat the card FAT32, create `/books/<book>`, and add a
file directly into that folder. Confirm the card is fully seated. `TOC.txt` plus the listed `.txt` section files. Confirm the card is fully
seated. Dot-prefixed folders such as `.Trash` are ignored.
- **Black display after boot:** press GPIO12 to wake it if it has slept; otherwise - **Black display after boot:** press GPIO12 to wake it if it has slept; otherwise
reflash and check whether the v1.0 backlight build flag is needed. reflash and check whether the v1.0 backlight build flag is needed.
- **Build fails before compilation:** rerun `pio run` with internet access so - **Build fails before compilation:** rerun `pio run` with internet access so
@ -189,8 +230,8 @@ cloud account, or synchronization feature.
## Limits of the first MVP ## Limits of the first MVP
- It reads up to 1.5 MiB per book and discovers `.txt` files directly in - It reads sections up to 1.5 MiB from `/books/<book>/<section>.txt`, in the
`/books` (no nested folders). order declared by that book's `TOC.txt`.
- The built-in display font is best for printable ASCII text. UTF-8 text files - The built-in display font is best for printable ASCII text. UTF-8 text files
are accepted, but accented/non-Latin characters need a Unicode font addition are accepted, but accented/non-Latin characters need a Unicode font addition
before they will render correctly. before they will render correctly.

1
T-Display-S3-Pro Submodule

@ -0,0 +1 @@
Subproject commit bef8db74e847f1e89a10cb3877f3b3a8544857d6

47
TODO.md Normal file
View File

@ -0,0 +1,47 @@
# TODO
## Add anti-aliased reader fonts
The current reader uses TFT_eSPI's bitmap FreeSans, FreeSerif, and FreeMono
fonts. They are compact and fast, but their edges remain jagged at large sizes.
Use TFT_eSPI smooth-font (`.vlw`) assets for the reading view and font-setting
previews.
1. Generate or obtain licensed `.vlw` files for sans-serif, serif, and
monospace faces at the seven supported reader sizes: 1, 1.5, 2, 2.5, 3,
3.5, and 4.
2. Place the files in a dedicated SD-card directory, for example
`/fonts/<style>-<size>.vlw`; document these required files alongside the
existing `/books/<book>/<chapter>.txt` layout.
3. Change `Board` to load the selected smooth font from SD when a font setting
is applied, and fall back to the current bitmap font if the file is absent
or cannot be loaded.
4. Continue to clear only the union of the old and new word bounds. Smooth
glyphs should be drawn with an explicit black background so anti-aliased
edge pixels blend correctly without redrawing the status bar or dead space.
5. Measure load time, RAM use, and word-to-word rendering latency on the
T-Display-S3-Pro. Keep the cached active font in memory and avoid SD reads
during normal word advances.
6. Verify that chapter-title and `The End` cards use the same selected smooth
font and remain aligned with normal words.
## Preprocess unfamiliar-word timing
Avoid dictionary lookups while reading. The existing chapter sidecars already
carry sparse italic/bold metadata and reserve a timing-modifier field. Extend
the desktop preprocessor to populate that field before books are copied to the
SD card.
1. Store only marked words: delta-encoded word indexes plus a small extra-delay
class (for example, 25%, 50%, or 100%). This keeps metadata sparse rather
than recording timing for every token.
2. Have the preprocessor inspect the whole book to flag first appearances of
uncommon words, likely proper nouns, non-English terms, and user-supplied
vocabulary. Keep language models and dictionaries on the desktop, not the
device.
3. At runtime, compare the current word
index against the next marked index; do not perform SD or dictionary lookups
during word advancement.
4. Combine an extra-delay class with existing punctuation and long-word timing
using the strongest applicable delay, rather than multiplying every rule.

1507
epub_to_chapters.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,19 +2,58 @@
#include <Arduino.h> #include <Arduino.h>
class Arduino_GFX; class TFT_eSPI;
class Board { class Board {
public: public:
bool begin(); bool begin();
bool beginSd(); bool beginSd();
void drawWord(const String& word); void setReadingContext(const String& bookTitle, const String& chapterTitle,
uint8_t progressPercent);
void drawWord(const String& word, uint16_t wpm, uint8_t styleFlags = 0);
void drawChapterTitle(const String& chapter, uint16_t wpm);
void drawTheEnd(uint16_t wpm);
void drawMenu(uint8_t selectedOption);
void drawPicker(const String& title, const String& value);
void drawFontStylePicker(uint8_t style, uint8_t size);
void drawFontSizePicker(uint8_t style, uint8_t size);
void setBrightness(uint8_t level); void setBrightness(uint8_t level);
void setPreferredBrightness(uint8_t level);
void setFont(uint8_t style, uint8_t size);
void displayOff(); void displayOff();
void displayOn(); void displayOn();
private: private:
Arduino_GFX* display_ = nullptr; struct DrawBounds {
uint8_t brightness_ = 0; int16_t x = 0;
int16_t y = 0;
int16_t width = 0;
int16_t height = 0;
bool valid = false;
}; };
int batteryPercent() const;
void setLandscape();
void setReaderFont(uint8_t style, uint8_t size, uint8_t styleFlags = 0);
void drawStatus(uint16_t wpm);
void drawFooter();
String ellipsize(const String& text, int16_t maximumWidth) const;
TFT_eSPI* display_ = nullptr;
uint8_t brightness_ = 0;
uint8_t preferredBrightness_ = 5;
uint8_t fontStyle_ = 0;
uint8_t fontSize_ = 20;
bool landscape_ = false;
bool powerMonitorAvailable_ = false;
bool statusDrawn_ = false;
bool footerDrawn_ = false;
uint16_t displayedWpm_ = 0;
uint8_t displayedProgressPercent_ = 255;
uint32_t lastStatusUpdateMs_ = 0;
String bookTitle_;
String chapterTitle_;
String displayedChapterTitle_;
uint8_t progressPercent_ = 0;
DrawBounds previousWordBounds_;
};

View File

@ -13,6 +13,10 @@ constexpr uint8_t kTftDc = 9;
constexpr uint8_t kTftReset = 47; constexpr uint8_t kTftReset = 47;
constexpr uint8_t kBacklight = 48; constexpr uint8_t kBacklight = 48;
constexpr uint8_t kSdCs = 14; constexpr uint8_t kSdCs = 14;
constexpr uint8_t kI2cSda = 5;
constexpr uint8_t kI2cScl = 6;
constexpr uint8_t kTouchReset = 13;
constexpr uint8_t kTouchInterrupt = 21;
constexpr uint8_t kButtonPlayPause = 0; // BOOT; never hold while pressing RESET. constexpr uint8_t kButtonPlayPause = 0; // BOOT; never hold while pressing RESET.
constexpr uint8_t kButtonPrevious = 12; constexpr uint8_t kButtonPrevious = 12;
constexpr uint8_t kButtonNext = 16; constexpr uint8_t kButtonNext = 16;
@ -23,13 +27,18 @@ constexpr size_t kMaxBookBytes = 1500 * 1024;
constexpr uint16_t kDefaultWpm = 300; constexpr uint16_t kDefaultWpm = 300;
constexpr uint16_t kMinimumWpm = 100; constexpr uint16_t kMinimumWpm = 100;
constexpr uint16_t kMaximumWpm = 800; constexpr uint16_t kMaximumWpm = 800;
constexpr uint16_t kWpmStep = 25; constexpr uint16_t kWpmStep = 50;
constexpr uint32_t kDimAfterMs = 20UL * 1000UL; constexpr uint32_t kDimAfterMs = 20UL * 1000UL;
constexpr uint32_t kDisplayOffAfterMs = 60UL * 1000UL; constexpr uint32_t kDisplayOffAfterMs = 60UL * 1000UL;
constexpr uint32_t kDeepSleepAfterMs = 10UL * 60UL * 1000UL; constexpr uint32_t kDeepSleepAfterMs = 10UL * 60UL * 1000UL;
constexpr uint8_t kNormalBrightness = 5; // 1–16 on v1.1 boards. constexpr uint8_t kNormalBrightness = 5; // 1–16 on v1.1 boards.
constexpr uint8_t kDimBrightness = 1; constexpr uint8_t kDimBrightness = 1;
constexpr uint8_t kDefaultFontStyle = 0; // Sans, Serif, Mono.
// Tenths: 10, 15, 20, 25, 30, 35, and 40 display as 1 through 4.
constexpr uint8_t kDefaultFontSize = 20;
constexpr uint8_t kMinimumFontSize = 10;
constexpr uint8_t kMaximumFontSize = 40;
constexpr uint8_t kFontSizeStep = 5;
} // namespace config } // namespace config

View File

@ -1,13 +1,32 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include <vector>
class Library { class Library {
public: public:
bool loadLastOrFirst(String& path, String& contents) const; bool loadLastOrFirst(String& path, String& contents) const;
bool load(const String& path, String& contents) const;
bool loadSidecar(const String& chapterPath, std::vector<uint8_t>& bytes) const;
bool selectBook(const String& currentPath, int direction, String& chapterPath) const;
bool selectChapter(const String& currentPath, int direction, String& chapterPath) const;
bool previousChapter(const String& currentPath, String& chapterPath) const;
bool nextChapter(const String& currentPath, String& chapterPath) const;
String bookName(const String& chapterPath) const;
String chapterName(const String& chapterPath) const;
private: private:
bool ensureBooks() const;
bool ensureChapters(const String& directoryPath) const;
bool isListedChapter(const String& chapterPath) const;
bool firstChapter(const String& directoryPath, String& chapterPath) const;
bool loadFile(const String& path, String& contents) const; bool loadFile(const String& path, String& contents) const;
bool firstBook(String& path) const; bool firstBook(String& path) const;
};
mutable bool booksCached_ = false;
mutable std::vector<String> books_;
mutable std::vector<String> firstChapters_;
mutable String chapterDirectory_;
mutable std::vector<String> chapters_;
mutable std::vector<String> chapterTitles_;
};

View File

@ -7,23 +7,38 @@ struct Token {
uint32_t offset; uint32_t offset;
uint16_t length; uint16_t length;
uint32_t paragraph; uint32_t paragraph;
uint8_t trailingLineBreaks;
};
struct StyleToken {
uint32_t index;
uint8_t styleFlags;
uint8_t timingModifier;
}; };
class Reader { class Reader {
public: public:
bool load(const String& book); bool load(const String& book, const std::vector<uint8_t>& sidecar);
void restoreAtParagraphStart(uint32_t savedWord); void restoreAtParagraphStart(uint32_t savedWord);
const String currentWord() const; const String currentWord() const;
bool advance(); bool advance();
bool previousParagraph(); bool previousParagraph();
bool nextParagraph(); bool nextParagraph();
bool lastParagraph();
uint32_t currentIndex() const; uint32_t currentIndex() const;
uint8_t progressPercent() const;
uint32_t delayMs(uint16_t wpm) const; uint32_t delayMs(uint16_t wpm) const;
uint8_t currentStyleFlags() const;
bool sidecarValid() const;
private: private:
String book_; String book_;
std::vector<Token> tokens_; std::vector<Token> tokens_;
std::vector<StyleToken> styleTokens_;
std::vector<uint32_t> paragraphStarts_; std::vector<uint32_t> paragraphStarts_;
uint32_t index_ = 0; uint32_t index_ = 0;
}; bool sidecarValid_ = false;
bool loadSidecar(const std::vector<uint8_t>& sidecar);
uint8_t styleFlagsAt(uint32_t wordIndex) const;
};

View File

@ -6,6 +6,9 @@ struct SavedState {
String bookPath; String bookPath;
uint32_t wordIndex = 0; uint32_t wordIndex = 0;
uint16_t wpm = 300; uint16_t wpm = 300;
uint8_t brightness = 5;
uint8_t fontStyle = 0;
uint8_t fontSize = 20;
}; };
class Settings { class Settings {
@ -14,4 +17,3 @@ class Settings {
SavedState load() const; SavedState load() const;
void save(const SavedState& state) const; void save(const SavedState& state) const;
}; };

View File

@ -1,6 +1,5 @@
# Name, Type, SubType, Offset, Size, Flags # Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000, nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xf000, 0x2000, otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x500000, app0, app, ota_0, 0x10000, 0x500000,
spiffs, data, spiffs, 0x510000, 0xAE0000, spiffs, data, spiffs, 0x510000, 0xAE0000,

1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000 0x5000
3 otadata data ota 0xf000 0xe000 0x2000
4 app0 app ota_0 0x10000 0x500000
5 spiffs data spiffs 0x510000 0xAE0000

View File

@ -8,9 +8,9 @@ board = t-display-s3-pro
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
board_build.partitions = partitions.csv board_build.partitions = partitions.csv
lib_extra_dirs = T-Display-S3-Pro/lib
build_flags = build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_CDC_ON_BOOT=1
-DDISABLE_ALL_LIBRARY_WARNINGS -DDISABLE_ALL_LIBRARY_WARNINGS
lib_deps = lib_deps =
moononournation/GFX Library for Arduino @ 1.3.7 TFT_eSPI

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
ebooklib
beautifulsoup4

View File

@ -1,54 +1,440 @@
#include "Board.h" #include "Board.h"
#include <Arduino_GFX_Library.h>
#include <SD.h> #include <SD.h>
#include <SPI.h> #include <SPI.h>
#include <TFT_eSPI.h>
#include <Wire.h>
#include <XPowersLib.h>
#include "Config.h" #include "Config.h"
namespace { namespace {
Arduino_DataBus* bus = new Arduino_ESP32SPI( PowersSY6970 powerMonitor;
config::kTftDc, config::kTftCs, config::kSpiSck, config::kSpiMosi, constexpr int16_t kStatusHeight = 30;
config::kSpiMiso); constexpr int16_t kFooterHeight = 24;
// The panel is natively 222x480. Rotation 1 gives the reader a 480x222 canvas. constexpr uint32_t kStatusRefreshMs = 60UL * 1000UL;
Arduino_GFX* makeDisplay() { constexpr uint8_t kItalicStyle = 0x01;
return new Arduino_ST7796(bus, config::kTftReset, 1, true, 222, 480, 49, 0); constexpr uint8_t kBoldStyle = 0x02;
constexpr uint8_t kKnownStyleFlags = kItalicStyle | kBoldStyle;
// Indexed by user-selected family, point size, then style flags. The sidecar
// uses italic=1 and bold=2, so the combined variant is index 3.
const GFXfont* const kReaderFonts[3][4][4] = {
{
{&FreeSans9pt7b, &FreeSansOblique9pt7b, &FreeSansBold9pt7b,
&FreeSansBoldOblique9pt7b},
{&FreeSans12pt7b, &FreeSansOblique12pt7b, &FreeSansBold12pt7b,
&FreeSansBoldOblique12pt7b},
{&FreeSans18pt7b, &FreeSansOblique18pt7b, &FreeSansBold18pt7b,
&FreeSansBoldOblique18pt7b},
{&FreeSans24pt7b, &FreeSansOblique24pt7b, &FreeSansBold24pt7b,
&FreeSansBoldOblique24pt7b},
},
{
{&FreeSerif9pt7b, &FreeSerifItalic9pt7b, &FreeSerifBold9pt7b,
&FreeSerifBoldItalic9pt7b},
{&FreeSerif12pt7b, &FreeSerifItalic12pt7b, &FreeSerifBold12pt7b,
&FreeSerifBoldItalic12pt7b},
{&FreeSerif18pt7b, &FreeSerifItalic18pt7b, &FreeSerifBold18pt7b,
&FreeSerifBoldItalic18pt7b},
{&FreeSerif24pt7b, &FreeSerifItalic24pt7b, &FreeSerifBold24pt7b,
&FreeSerifBoldItalic24pt7b},
},
{
{&FreeMono9pt7b, &FreeMonoOblique9pt7b, &FreeMonoBold9pt7b,
&FreeMonoBoldOblique9pt7b},
{&FreeMono12pt7b, &FreeMonoOblique12pt7b, &FreeMonoBold12pt7b,
&FreeMonoBoldOblique12pt7b},
{&FreeMono18pt7b, &FreeMonoOblique18pt7b, &FreeMonoBold18pt7b,
&FreeMonoBoldOblique18pt7b},
{&FreeMono24pt7b, &FreeMonoOblique24pt7b, &FreeMonoBold24pt7b,
&FreeMonoBoldOblique24pt7b},
},
};
String fontSizeLabel(uint8_t size) {
const uint8_t whole = size / 10;
return size % 10 == 0 ? String(whole) : String(whole) + ".5";
}
} }
} // namespace
bool Board::begin() { bool Board::begin() {
display_ = makeDisplay(); // The display and SD card share SPI. Keep the SD card deselected while the
if (!display_->begin()) { // ST7796 receives its initialization sequence.
return false; pinMode(config::kSdCs, OUTPUT);
} digitalWrite(config::kSdCs, HIGH);
display_->fillScreen(BLACK);
display_ = new TFT_eSPI();
display_->begin();
setLandscape();
display_->fillScreen(TFT_BLACK);
pinMode(config::kBacklight, OUTPUT); pinMode(config::kBacklight, OUTPUT);
setBrightness(config::kNormalBrightness); setBrightness(config::kNormalBrightness);
// The T-Display-S3-Pro exposes battery voltage through its SY6970 power
// manager on the shared I2C bus.
powerMonitorAvailable_ = powerMonitor.init(
Wire, config::kI2cSda, config::kI2cScl, SY6970_SLAVE_ADDRESS);
if (powerMonitorAvailable_) powerMonitor.enableADCMeasure();
return true; return true;
} }
bool Board::beginSd() { bool Board::beginSd() {
// The TFT and SD card share SPI. Before the TFT is initialized its CS pin
// floats, so hold both devices deselected while probing the card.
pinMode(config::kTftCs, OUTPUT);
digitalWrite(config::kTftCs, HIGH);
pinMode(config::kSdCs, OUTPUT);
digitalWrite(config::kSdCs, HIGH);
SPI.begin(config::kSpiSck, config::kSpiMiso, config::kSpiMosi); SPI.begin(config::kSpiSck, config::kSpiMiso, config::kSpiMosi);
return SD.begin(config::kSdCs, SPI); for (const uint32_t frequency : {20000000UL, 4000000UL, 1000000UL, 400000UL}) {
if (SD.begin(config::kSdCs, SPI, frequency)) return true;
SD.end();
}
return false;
} }
void Board::drawWord(const String& word) { int Board::batteryPercent() const {
if (!powerMonitorAvailable_) return -1;
const int millivolts = powerMonitor.getBattVoltage();
if (millivolts <= 0) return -1;
return constrain((millivolts - 3400) * 100 / (3840 - 3400), 0, 100);
}
void Board::setReadingContext(const String& bookTitle, const String& chapterTitle,
uint8_t progressPercent) {
if (bookTitle_ != bookTitle) {
bookTitle_ = bookTitle;
statusDrawn_ = false;
footerDrawn_ = false;
displayedProgressPercent_ = 255;
previousWordBounds_.valid = false;
}
if (chapterTitle_ != chapterTitle) {
chapterTitle_ = chapterTitle;
footerDrawn_ = false;
}
progressPercent_ = progressPercent;
}
void Board::setLandscape() {
if (landscape_) return;
display_->setRotation(1);
landscape_ = true;
statusDrawn_ = false;
footerDrawn_ = false;
displayedProgressPercent_ = 255;
previousWordBounds_.valid = false;
}
void Board::setReaderFont(uint8_t style, uint8_t size, uint8_t styleFlags) {
uint8_t point = 2;
uint8_t scale = 1;
switch (size) {
case 10:
point = 0;
break;
case 15:
point = 1;
break;
case 20:
point = 2;
break;
case 25:
point = 3;
break;
case 30:
point = 2;
scale = 2;
break;
case 35:
point = 1;
scale = 3;
break;
default:
point = 3;
scale = 2;
break;
}
const uint8_t family = constrain(style, 0, 2);
const uint8_t variant = styleFlags & kKnownStyleFlags;
display_->setFreeFont(kReaderFonts[family][point][variant]);
display_->setTextSize(scale);
}
void Board::drawStatus(uint16_t wpm) {
constexpr int16_t kMargin = 8;
constexpr int16_t kBatteryWidth = 28;
constexpr int16_t kBatteryHeight = 12;
constexpr int16_t kBatteryTipWidth = 3;
display_->setTextFont(2);
display_->setTextSize(1);
display_->setTextColor(TFT_WHITE);
display_->setTextDatum(TL_DATUM);
const String wpmLabel = String(wpm) + " WPM";
display_->drawString(wpmLabel, kMargin, kMargin);
const int16_t batteryX = display_->width() - kMargin - kBatteryTipWidth - kBatteryWidth;
const int16_t batteryY = kMargin + 2;
const int percentage = batteryPercent();
const String label = percentage < 0 ? "--" : String(percentage) + "%";
display_->setTextDatum(TR_DATUM);
display_->drawString(label, batteryX - 5, kMargin);
display_->drawRoundRect(batteryX, batteryY, kBatteryWidth, kBatteryHeight, 2, TFT_WHITE);
display_->fillRect(batteryX + kBatteryWidth, batteryY + 4, kBatteryTipWidth, 4, TFT_WHITE);
if (percentage > 0) {
const int16_t fillWidth = (kBatteryWidth - 4) * percentage / 100;
display_->fillRect(batteryX + 2, batteryY + 2, fillWidth, kBatteryHeight - 4, TFT_WHITE);
}
const int16_t left = kMargin + display_->textWidth(wpmLabel) + 8;
const int16_t right = batteryX - display_->textWidth(label) - 8;
const int16_t centerX = display_->width() / 2;
const int16_t maximumWidth =
2 * min(centerX - left, right - centerX);
if (maximumWidth > 0 && !bookTitle_.isEmpty()) {
display_->setTextDatum(TC_DATUM);
display_->drawString(ellipsize(bookTitle_, maximumWidth), centerX, kMargin);
}
}
String Board::ellipsize(const String& text, int16_t maximumWidth) const {
if (text.isEmpty() || display_->textWidth(text) <= maximumWidth) return text;
const String suffix = "...";
String shortened = text;
while (!shortened.isEmpty() &&
display_->textWidth(shortened + suffix) > maximumWidth) {
shortened.remove(shortened.length() - 1);
}
return shortened.isEmpty() ? String() : shortened + suffix;
}
void Board::drawFooter() {
if (footerDrawn_ && displayedChapterTitle_ == chapterTitle_ &&
displayedProgressPercent_ == progressPercent_) {
return;
}
display_->fillRect(0, display_->height() - kFooterHeight, display_->width(),
kFooterHeight, TFT_BLACK);
display_->setTextFont(2);
display_->setTextSize(1);
display_->setTextColor(TFT_WHITE);
const String progress = String(progressPercent_) + "%";
const int16_t maximumChapterWidth =
display_->width() - display_->textWidth(progress) - 16;
const String chapter = ellipsize(chapterTitle_, maximumChapterWidth);
const String label = chapter.isEmpty() ? progress : chapter + " " + progress;
display_->setTextDatum(TC_DATUM);
display_->drawString(label, display_->width() / 2,
display_->height() - kFooterHeight + 5);
footerDrawn_ = true;
displayedChapterTitle_ = chapterTitle_;
displayedProgressPercent_ = progressPercent_;
}
void Board::drawWord(const String& word, uint16_t wpm, uint8_t styleFlags) {
if (display_ == nullptr) return;
setLandscape();
const bool refreshStatus = !statusDrawn_ || displayedWpm_ != wpm ||
millis() - lastStatusUpdateMs_ >= kStatusRefreshMs;
if (refreshStatus) {
if (statusDrawn_) {
display_->fillRect(0, 0, display_->width(), kStatusHeight, TFT_BLACK);
} else {
display_->fillScreen(TFT_BLACK);
statusDrawn_ = true;
footerDrawn_ = false;
displayedProgressPercent_ = 255;
}
drawStatus(wpm);
displayedWpm_ = wpm;
lastStatusUpdateMs_ = millis();
}
drawFooter();
uint8_t renderedSize = fontSize_;
setReaderFont(fontStyle_, renderedSize, styleFlags);
while (renderedSize > config::kMinimumFontSize &&
display_->textWidth(word) > display_->width() - 8) {
renderedSize -= config::kFontSizeStep;
setReaderFont(fontStyle_, renderedSize, styleFlags);
}
display_->setTextColor(TFT_WHITE);
display_->setTextDatum(MC_DATUM);
const int16_t centerX = display_->width() / 2;
const int16_t centerY =
kStatusHeight +
(display_->height() - kStatusHeight - kFooterHeight) / 2;
const int16_t textWidth = display_->textWidth(word);
const int16_t textHeight = display_->fontHeight();
DrawBounds currentWordBounds;
currentWordBounds.x = static_cast<int16_t>(centerX - textWidth / 2 - 1);
currentWordBounds.y = static_cast<int16_t>(centerY - textHeight / 2 - 1);
currentWordBounds.width = static_cast<int16_t>(textWidth + 2);
currentWordBounds.height = static_cast<int16_t>(textHeight + 2);
currentWordBounds.valid = true;
// Clear only the portion of the word band that either the old or new word
// occupies. The header, footer, and empty bands above and below stay untouched.
if (previousWordBounds_.valid) {
const int16_t left = min(previousWordBounds_.x, currentWordBounds.x);
const int16_t top = min(previousWordBounds_.y, currentWordBounds.y);
const int16_t right = max(previousWordBounds_.x + previousWordBounds_.width,
currentWordBounds.x + currentWordBounds.width);
const int16_t bottom = max(previousWordBounds_.y + previousWordBounds_.height,
currentWordBounds.y + currentWordBounds.height);
display_->fillRect(left, top, right - left, bottom - top, TFT_BLACK);
}
display_->drawString(word, centerX, centerY);
previousWordBounds_ = currentWordBounds;
}
void Board::drawChapterTitle(const String& chapter, uint16_t wpm) {
drawWord(chapter, wpm);
}
void Board::drawTheEnd(uint16_t wpm) { drawWord("The End", wpm); }
void Board::drawMenu(uint8_t selectedOption) {
if (display_ == nullptr) return; if (display_ == nullptr) return;
display_->fillScreen(BLACK); if (landscape_) {
const size_t glyphs = word.length() == 0 ? 1 : word.length(); display_->setRotation(0);
uint16_t calculatedScale = 480 / (glyphs * 6); // MVP font supports printable ASCII text. landscape_ = false;
if (calculatedScale < 2) calculatedScale = 2; }
if (calculatedScale > 12) calculatedScale = 12; display_->fillScreen(TFT_BLACK);
const uint8_t scale = calculatedScale; statusDrawn_ = false;
const int16_t width = glyphs * 6 * scale; footerDrawn_ = false;
const int16_t height = 8 * scale; displayedProgressPercent_ = 255;
int16_t x = (display_->width() - width) / 2; previousWordBounds_.valid = false;
if (x < 0) x = 0; display_->setTextFont(2);
display_->setTextColor(WHITE); display_->setTextSize(1);
display_->setTextColor(TFT_WHITE);
display_->setTextDatum(TC_DATUM);
display_->drawString("MENU", display_->width() / 2, 18);
constexpr int16_t kRowHeight = 58;
constexpr int16_t kFirstRowY = 60;
constexpr int16_t kRowSpacing = 68;
const char* labels[] = {"Book selection", "Chapter selection", "Brightness",
"Font style", "Font size"};
display_->setTextDatum(ML_DATUM);
for (uint8_t index = 0; index < 5; ++index) {
const int16_t y = kFirstRowY + index * kRowSpacing;
if (index == selectedOption) {
display_->fillRoundRect(10, y, display_->width() - 20, kRowHeight, 5, TFT_WHITE);
display_->setTextColor(TFT_BLACK);
} else {
display_->drawRoundRect(10, y, display_->width() - 20, kRowHeight, 5, TFT_WHITE);
display_->setTextColor(TFT_WHITE);
}
display_->drawString(labels[index], 24, y + kRowHeight / 2);
}
display_->setTextColor(TFT_WHITE);
display_->setTextFont(1);
display_->setTextSize(1);
display_->setTextDatum(BC_DATUM);
display_->drawString("BACK / FORWARD to choose", display_->width() / 2,
display_->height() - 30);
display_->drawString("HOME open / BOOT close", display_->width() / 2,
display_->height() - 12);
}
void Board::drawPicker(const String& title, const String& value) {
if (display_ == nullptr) return;
setLandscape();
display_->fillScreen(TFT_BLACK);
statusDrawn_ = false;
previousWordBounds_.valid = false;
display_->setTextFont(2);
display_->setTextSize(1);
display_->setTextColor(TFT_WHITE);
display_->setTextDatum(TC_DATUM);
display_->drawString(title, display_->width() / 2, 16);
display_->setTextFont(1);
const size_t glyphs = value.length() == 0 ? 1 : value.length();
uint8_t scale = display_->width() / (glyphs * 7);
if (scale < 2) scale = 2;
if (scale > 6) scale = 6;
display_->setTextSize(scale); display_->setTextSize(scale);
display_->setCursor(x, (display_->height() - height) / 2); display_->setTextDatum(MC_DATUM);
display_->print(word); display_->drawString(value, display_->width() / 2, display_->height() / 2 - 8);
display_->setTextSize(1);
display_->setTextDatum(BC_DATUM);
display_->drawString("BACK / FORWARD to choose", display_->width() / 2,
display_->height() - 30);
display_->drawString("HOME apply / BOOT cancel", display_->width() / 2,
display_->height() - 12);
}
void Board::drawFontStylePicker(uint8_t style, uint8_t size) {
if (display_ == nullptr) return;
setLandscape();
display_->fillScreen(TFT_BLACK);
statusDrawn_ = false;
previousWordBounds_.valid = false;
display_->setTextFont(2);
display_->setTextSize(1);
display_->setTextColor(TFT_WHITE);
display_->setTextDatum(TC_DATUM);
display_->drawString("FONT STYLE", display_->width() / 2, 16);
const char* names[] = {"Sans-serif", "Serif", "Monospace"};
setReaderFont(style, size);
display_->setTextDatum(MC_DATUM);
display_->drawString("Sample", display_->width() / 2, display_->height() / 2 - 8);
display_->setTextFont(2);
display_->setTextSize(1);
display_->setTextDatum(TC_DATUM);
display_->drawString(names[constrain(style, 0, 2)], display_->width() / 2, 48);
display_->setTextFont(1);
display_->setTextDatum(BC_DATUM);
display_->drawString("BACK / FORWARD to choose", display_->width() / 2,
display_->height() - 30);
display_->drawString("HOME apply / BOOT cancel", display_->width() / 2,
display_->height() - 12);
}
void Board::drawFontSizePicker(uint8_t style, uint8_t size) {
if (display_ == nullptr) return;
setLandscape();
display_->fillScreen(TFT_BLACK);
statusDrawn_ = false;
previousWordBounds_.valid = false;
display_->setTextFont(2);
display_->setTextSize(1);
display_->setTextColor(TFT_WHITE);
display_->setTextDatum(TC_DATUM);
display_->drawString("FONT SIZE", display_->width() / 2, 16);
setReaderFont(style, size);
display_->setTextDatum(MC_DATUM);
display_->drawString(fontSizeLabel(size), display_->width() / 2,
display_->height() / 2 - 8);
display_->setTextFont(1);
display_->setTextSize(1);
display_->setTextDatum(BC_DATUM);
display_->drawString("BACK / FORWARD to choose", display_->width() / 2,
display_->height() - 30);
display_->drawString("HOME apply / BOOT cancel", display_->width() / 2,
display_->height() - 12);
} }
void Board::setBrightness(uint8_t level) { void Board::setBrightness(uint8_t level) {
@ -81,5 +467,16 @@ void Board::setBrightness(uint8_t level) {
#endif #endif
} }
void Board::setPreferredBrightness(uint8_t level) {
preferredBrightness_ = constrain(level, 1, 16);
setBrightness(preferredBrightness_);
}
void Board::setFont(uint8_t style, uint8_t size) {
fontStyle_ = constrain(style, 0, 2);
fontSize_ = constrain(size, config::kMinimumFontSize, config::kMaximumFontSize);
fontSize_ = (fontSize_ / config::kFontSizeStep) * config::kFontSizeStep;
}
void Board::displayOff() { setBrightness(0); } void Board::displayOff() { setBrightness(0); }
void Board::displayOn() { setBrightness(config::kNormalBrightness); } void Board::displayOn() { setBrightness(preferredBrightness_); }

View File

@ -1,21 +1,228 @@
#include "Library.h" #include "Library.h"
#include <algorithm>
#include <SD.h> #include <SD.h>
#include <vector>
#include "Config.h" #include "Config.h"
#include "Settings.h" #include "Settings.h"
namespace {
bool isChapterPath(const String& path) {
const String prefix = String(config::kBooksDirectory) + "/";
if (!path.startsWith(prefix) || !path.endsWith(".txt")) return false;
const String relativePath = path.substring(prefix.length());
const int separator = relativePath.indexOf('/');
return separator > 0 && separator == relativePath.lastIndexOf('/');
}
} // namespace
bool Library::loadLastOrFirst(String& path, String& contents) const { bool Library::loadLastOrFirst(String& path, String& contents) const {
Settings settings; Settings settings;
settings.begin(); settings.begin();
const SavedState state = settings.load(); const SavedState state = settings.load();
if (!state.bookPath.isEmpty() && loadFile(state.bookPath, contents)) { if (isChapterPath(state.bookPath) && loadFile(state.bookPath, contents)) {
path = state.bookPath; path = state.bookPath;
return true; return true;
} }
return firstBook(path) && loadFile(path, contents); return firstBook(path) && loadFile(path, contents);
} }
bool Library::load(const String& path, String& contents) const {
return isChapterPath(path) && isListedChapter(path) && loadFile(path, contents);
}
bool Library::loadSidecar(const String& chapterPath,
std::vector<uint8_t>& bytes) const {
bytes.clear();
if (!isChapterPath(chapterPath)) return false;
String sidecarPath = chapterPath;
sidecarPath.remove(sidecarPath.length() - 4);
sidecarPath += ".rsvp";
File file = SD.open(sidecarPath, FILE_READ);
if (!file || file.isDirectory() || file.size() == 0 ||
static_cast<size_t>(file.size()) > config::kMaxBookBytes) {
file.close();
return false;
}
const size_t size = static_cast<size_t>(file.size());
bytes.resize(size);
const size_t read = file.read(bytes.data(), size);
file.close();
if (read != size) {
bytes.clear();
return false;
}
return true;
}
namespace {
String baseName(const String& path) {
const int slash = path.lastIndexOf('/');
return slash >= 0 ? path.substring(slash + 1) : path;
}
String parentPath(const String& path) {
const int slash = path.lastIndexOf('/');
return slash > 0 ? path.substring(0, slash) : String();
}
String childPath(const String& parent, const String& name) {
return name.startsWith("/") ? name : parent + "/" + name;
}
bool isHiddenPath(const String& path) {
const int slash = path.lastIndexOf('/');
return slash + 1 < static_cast<int>(path.length()) &&
path[slash + 1] == '.';
}
bool listDirectories(const String& path, std::vector<String>& directories) {
File directory = SD.open(path);
if (!directory || !directory.isDirectory()) return false;
for (File entry = directory.openNextFile(); entry;
entry = directory.openNextFile()) {
const String entryPath = childPath(path, entry.name());
if (entry.isDirectory() && !isHiddenPath(entryPath)) {
directories.push_back(entryPath);
}
entry.close();
}
directory.close();
std::sort(directories.begin(), directories.end());
return !directories.empty();
}
bool listChapters(const String& path, std::vector<String>& chapters,
std::vector<String>& titles) {
File toc = SD.open(path + "/TOC.txt", FILE_READ);
if (!toc || toc.isDirectory()) return false;
while (toc.available()) {
String title = toc.readStringUntil('\n');
title.replace("\r", "");
title.trim();
if (title.isEmpty() || title.indexOf('/') >= 0 || title.indexOf('\\') >= 0) continue;
const String chapterPath = path + "/" + title + ".txt";
File chapter = SD.open(chapterPath, FILE_READ);
const bool validChapter = chapter && !chapter.isDirectory();
chapter.close();
if (!validChapter) continue;
chapters.push_back(chapterPath);
titles.push_back(title);
}
toc.close();
return !chapters.empty();
}
bool firstListedChapter(const String& path, String& chapterPath) {
File toc = SD.open(path + "/TOC.txt", FILE_READ);
if (!toc || toc.isDirectory()) return false;
while (toc.available()) {
String title = toc.readStringUntil('\n');
title.replace("\r", "");
title.trim();
if (title.isEmpty() || title.indexOf('/') >= 0 || title.indexOf('\\') >= 0) {
continue;
}
const String candidate = path + "/" + title + ".txt";
File chapter = SD.open(candidate, FILE_READ);
const bool validChapter = chapter && !chapter.isDirectory();
chapter.close();
if (validChapter) {
chapterPath = candidate;
toc.close();
return true;
}
}
toc.close();
return false;
}
bool cycle(const std::vector<String>& choices, const String& current,
int direction, String& choice) {
if (choices.empty()) return false;
size_t index = 0;
for (; index < choices.size(); ++index) {
if (choices[index] == current) break;
}
if (index == choices.size()) {
choice = choices.front();
return true;
}
const int count = static_cast<int>(choices.size());
const int offset = direction < 0 ? -1 : 1;
const int next = (static_cast<int>(index) + offset + count) % count;
choice = choices[next];
return true;
}
} // namespace
bool Library::selectBook(const String& currentPath, int direction,
String& chapterPath) const {
if (!ensureBooks()) return false;
String selectedDirectory;
if (!cycle(books_, parentPath(currentPath), direction, selectedDirectory)) return false;
return firstChapter(selectedDirectory, chapterPath);
}
bool Library::selectChapter(const String& currentPath, int direction,
String& chapterPath) const {
const String directoryPath = parentPath(currentPath);
if (directoryPath.isEmpty() || !ensureChapters(directoryPath)) return false;
return cycle(chapters_, currentPath, direction, chapterPath);
}
bool Library::previousChapter(const String& currentPath, String& chapterPath) const {
const String directoryPath = parentPath(currentPath);
if (directoryPath.isEmpty() || !ensureChapters(directoryPath)) return false;
for (size_t index = 1; index < chapters_.size(); ++index) {
if (chapters_[index] == currentPath) {
chapterPath = chapters_[index - 1];
return true;
}
}
return false;
}
bool Library::nextChapter(const String& currentPath, String& chapterPath) const {
const String directoryPath = parentPath(currentPath);
if (directoryPath.isEmpty() || !ensureChapters(directoryPath)) return false;
for (size_t index = 0; index + 1 < chapters_.size(); ++index) {
if (chapters_[index] == currentPath) {
chapterPath = chapters_[index + 1];
return true;
}
}
return false;
}
String Library::bookName(const String& chapterPath) const {
return baseName(parentPath(chapterPath));
}
String Library::chapterName(const String& chapterPath) const {
const String directoryPath = parentPath(chapterPath);
if (chapterDirectory_ == directoryPath && !chapters_.empty()) {
for (size_t index = 0; index < chapters_.size(); ++index) {
if (chapters_[index] == chapterPath) return chapterTitles_[index];
}
}
String name = baseName(chapterPath);
if (name.endsWith(".txt")) name.remove(name.length() - 4);
return name;
}
bool Library::loadFile(const String& path, String& contents) const { bool Library::loadFile(const String& path, String& contents) const {
File file = SD.open(path, FILE_READ); File file = SD.open(path, FILE_READ);
if (!file || file.isDirectory() || file.size() == 0 || if (!file || file.isDirectory() || file.size() == 0 ||
@ -27,20 +234,52 @@ bool Library::loadFile(const String& path, String& contents) const {
return !contents.isEmpty(); return !contents.isEmpty();
} }
bool Library::firstBook(String& path) const { bool Library::ensureBooks() const {
File directory = SD.open(config::kBooksDirectory); if (booksCached_) return !books_.empty();
if (!directory || !directory.isDirectory()) return false;
for (File entry = directory.openNextFile(); entry; booksCached_ = true;
entry = directory.openNextFile()) { books_.clear();
const String name = entry.name(); firstChapters_.clear();
const bool isText = !entry.isDirectory() && name.endsWith(".txt"); if (!listDirectories(config::kBooksDirectory, books_)) return false;
entry.close(); firstChapters_.resize(books_.size());
if (isText) {
path = name.startsWith("/") ? name : String(config::kBooksDirectory) + "/" + name;
directory.close();
return true; return true;
} }
bool Library::ensureChapters(const String& directoryPath) const {
if (chapterDirectory_ == directoryPath) return !chapters_.empty();
chapterDirectory_ = directoryPath;
chapters_.clear();
chapterTitles_.clear();
return listChapters(chapterDirectory_, chapters_, chapterTitles_);
}
bool Library::isListedChapter(const String& chapterPath) const {
const String directoryPath = parentPath(chapterPath);
if (directoryPath.isEmpty() || !ensureChapters(directoryPath)) return false;
for (const String& path : chapters_) {
if (path == chapterPath) return true;
} }
directory.close();
return false; return false;
} }
bool Library::firstChapter(const String& directoryPath, String& chapterPath) const {
size_t bookIndex = books_.size();
for (size_t index = 0; index < books_.size(); ++index) {
if (books_[index] == directoryPath) {
bookIndex = index;
break;
}
}
if (bookIndex < firstChapters_.size() && !firstChapters_[bookIndex].isEmpty()) {
chapterPath = firstChapters_[bookIndex];
return true;
}
if (!firstListedChapter(directoryPath, chapterPath)) return false;
if (bookIndex < firstChapters_.size()) firstChapters_[bookIndex] = chapterPath;
return true;
}
bool Library::firstBook(String& path) const {
return ensureBooks() && firstChapter(books_.front(), path);
}

View File

@ -1,12 +1,128 @@
#include "Reader.h" #include "Reader.h"
#include <algorithm>
#include <ctype.h> #include <ctype.h>
#include <limits.h>
bool Reader::load(const String& book) { namespace {
book_ = book;
constexpr uint8_t kStyleItalic = 0x01;
constexpr uint8_t kStyleBold = 0x02;
constexpr uint8_t kKnownStyleFlags = kStyleItalic | kStyleBold;
constexpr uint8_t kSidecarVersion = 1;
constexpr size_t kSidecarHeaderBytes = 18;
uint32_t readUint32Le(const std::vector<uint8_t>& bytes, size_t offset) {
return static_cast<uint32_t>(bytes[offset]) |
(static_cast<uint32_t>(bytes[offset + 1]) << 8) |
(static_cast<uint32_t>(bytes[offset + 2]) << 16) |
(static_cast<uint32_t>(bytes[offset + 3]) << 24);
}
uint64_t readUint64Le(const std::vector<uint8_t>& bytes, size_t offset) {
uint64_t value = 0;
for (uint8_t index = 0; index < 8; ++index) {
value |= static_cast<uint64_t>(bytes[offset + index]) << (index * 8);
}
return value;
}
bool readVarint(const std::vector<uint8_t>& bytes, size_t& offset,
uint32_t& value) {
value = 0;
for (uint8_t byteIndex = 0; byteIndex < 5; ++byteIndex) {
if (offset >= bytes.size()) return false;
const uint8_t byte = bytes[offset++];
if (byteIndex == 4 && (byte & 0xF0) != 0) return false;
value |= static_cast<uint32_t>(byte & 0x7F) << (byteIndex * 7);
if ((byte & 0x80) == 0) return true;
}
return false;
}
uint64_t sourceFingerprint(const String& text) {
uint64_t value = 0xCBF29CE484222325ULL;
for (uint32_t index = 0; index < text.length(); ++index) {
value ^= static_cast<uint8_t>(text[index]);
value *= 0x100000001B3ULL;
}
return value;
}
// TFT_eSPI's bundled GFX fonts are ASCII-only. EPUB text commonly uses UTF-8
// “smart” punctuation, so replace the forms that have a clear ASCII equivalent
// before recording token offsets for the reader.
String normalizePunctuation(const String& text) {
String normalized;
normalized.reserve(text.length());
for (uint32_t index = 0; index < text.length();) {
const uint8_t first = static_cast<uint8_t>(text[index]);
if (first == 0xC2 && index + 1 < text.length()) {
const uint8_t second = static_cast<uint8_t>(text[index + 1]);
if (second == 0xA0) { // Non-breaking space.
normalized += ' ';
index += 2;
continue;
}
if (second == 0xAD) { // Soft hyphen.
normalized += '-';
index += 2;
continue;
}
}
if (first == 0xE2 && index + 2 < text.length() &&
static_cast<uint8_t>(text[index + 1]) == 0x80) {
const uint8_t third = static_cast<uint8_t>(text[index + 2]);
if (third == 0x94) { // Em dash: end this RSVP token before the next word.
normalized += "- ";
index += 3;
continue;
}
if ((third >= 0x90 && third <= 0x93) || third == 0x95) {
// Other Unicode hyphens and dashes.
normalized += '-';
index += 3;
continue;
}
if (third == 0x98 || third == 0x99) { // Curly apostrophes.
normalized += '\'';
index += 3;
continue;
}
if (third == 0x9C || third == 0x9D) { // Curly quotation marks.
normalized += '"';
index += 3;
continue;
}
if (third == 0xA6) { // Ellipsis.
normalized += "...";
index += 3;
continue;
}
}
if (first == 0xEF && index + 2 < text.length() &&
static_cast<uint8_t>(text[index + 1]) == 0xBB &&
static_cast<uint8_t>(text[index + 2]) == 0xBF) {
index += 3; // UTF-8 byte-order mark.
continue;
}
normalized += static_cast<char>(first);
++index;
}
return normalized;
}
} // namespace
bool Reader::load(const String& book, const std::vector<uint8_t>& sidecar) {
book_ = normalizePunctuation(book);
tokens_.clear(); tokens_.clear();
styleTokens_.clear();
paragraphStarts_.clear(); paragraphStarts_.clear();
index_ = 0; index_ = 0;
sidecarValid_ = false;
bool inWord = false; bool inWord = false;
uint32_t wordStart = 0; uint32_t wordStart = 0;
@ -15,6 +131,10 @@ bool Reader::load(const String& book) {
const char c = i < book_.length() ? book_[i] : ' '; const char c = i < book_.length() ? book_[i] : ' ';
const bool whitespace = isspace(static_cast<unsigned char>(c)); const bool whitespace = isspace(static_cast<unsigned char>(c));
if (!whitespace && !inWord) { if (!whitespace && !inWord) {
if (!tokens_.empty()) {
tokens_.back().trailingLineBreaks =
lineBreaks > UINT8_MAX ? UINT8_MAX : lineBreaks;
}
const bool paragraphBreak = lineBreaks >= 2 || paragraphStarts_.empty(); const bool paragraphBreak = lineBreaks >= 2 || paragraphStarts_.empty();
wordStart = i; wordStart = i;
inWord = true; inWord = true;
@ -25,14 +145,59 @@ bool Reader::load(const String& book) {
} }
if (whitespace && inWord) { if (whitespace && inWord) {
tokens_.push_back({wordStart, static_cast<uint16_t>(i - wordStart), tokens_.push_back({wordStart, static_cast<uint16_t>(i - wordStart),
static_cast<uint32_t>(paragraphStarts_.size() - 1)}); static_cast<uint32_t>(paragraphStarts_.size() - 1), 0});
inWord = false; inWord = false;
} }
if (c == '\n') { if (c == '\n') {
++lineBreaks; ++lineBreaks;
} }
} }
return !tokens_.empty(); if (tokens_.empty()) return false;
sidecarValid_ = !sidecar.empty() && loadSidecar(sidecar);
return true;
}
bool Reader::loadSidecar(const std::vector<uint8_t>& sidecar) {
if (sidecar.size() < kSidecarHeaderBytes ||
sidecar[0] != 'R' || sidecar[1] != 'S' ||
sidecar[2] != 'V' || sidecar[3] != 'P' ||
sidecar[4] != kSidecarVersion || sidecar[5] != 0 ||
readUint64Le(sidecar, 6) != sourceFingerprint(book_)) {
return false;
}
const uint32_t recordCount = readUint32Le(sidecar, 14);
if (recordCount > tokens_.size()) return false;
std::vector<StyleToken> records;
records.reserve(recordCount);
size_t offset = kSidecarHeaderBytes;
uint32_t previousIndex = 0;
for (uint32_t recordNumber = 0; recordNumber < recordCount; ++recordNumber) {
uint32_t delta = 0;
if (!readVarint(sidecar, offset, delta) || offset + 2 > sidecar.size()) {
return false;
}
uint32_t wordIndex = delta;
if (recordNumber > 0) {
if (delta == 0 || previousIndex > UINT32_MAX - delta) return false;
wordIndex = previousIndex + delta;
}
const uint8_t styleFlags = sidecar[offset++];
const uint8_t timingModifier = sidecar[offset++];
if (wordIndex >= tokens_.size() || styleFlags == 0 ||
(styleFlags & ~kKnownStyleFlags) != 0) {
return false;
}
records.push_back({wordIndex, styleFlags, timingModifier});
previousIndex = wordIndex;
}
if (offset != sidecar.size()) return false;
styleTokens_.swap(records);
return true;
} }
void Reader::restoreAtParagraphStart(uint32_t savedWord) { void Reader::restoreAtParagraphStart(uint32_t savedWord) {
@ -72,15 +237,57 @@ bool Reader::nextParagraph() {
return true; return true;
} }
bool Reader::lastParagraph() {
if (paragraphStarts_.empty()) return false;
index_ = paragraphStarts_.back();
return true;
}
uint32_t Reader::currentIndex() const { return index_; } uint32_t Reader::currentIndex() const { return index_; }
uint8_t Reader::progressPercent() const {
if (tokens_.empty()) return 0;
if (tokens_.size() == 1) return 100;
const uint64_t lastIndex = tokens_.size() - 1;
return static_cast<uint8_t>((static_cast<uint64_t>(index_) * 100 +
lastIndex / 2) /
lastIndex);
}
uint8_t Reader::currentStyleFlags() const { return styleFlagsAt(index_); }
uint8_t Reader::styleFlagsAt(uint32_t wordIndex) const {
const auto record = std::lower_bound(
styleTokens_.begin(), styleTokens_.end(), wordIndex,
[](const StyleToken& token, uint32_t index) {
return token.index < index;
});
return record != styleTokens_.end() && record->index == wordIndex
? record->styleFlags
: 0;
}
bool Reader::sidecarValid() const { return sidecarValid_; }
uint32_t Reader::delayMs(uint16_t wpm) const { uint32_t Reader::delayMs(uint16_t wpm) const {
if (tokens_.empty()) return 1000; if (tokens_.empty()) return 1000;
const String word = currentWord(); const String word = currentWord();
uint32_t delay = 60000UL / wpm; uint32_t delay = 60000UL / wpm;
const char end = word[word.length() - 1]; uint8_t multiplier = 1;
if (end == '.' || end == '!' || end == '?') delay *= 3; char end = word[word.length() - 1];
else if (end == ',' || end == ';' || end == ':') delay *= 2; // Dialogue often ends as word." after EPUB quotes are normalized to ASCII.
else if (word.length() >= 11) delay = delay * 3 / 2; // Preserve the pause class of the punctuation before that closing quote.
return delay; if (end == '"' && word.length() > 1) end = word[word.length() - 2];
if (end == '.' || end == '!' || end == '?') multiplier = 3;
else if (end == ',' || end == ';' || end == ':') multiplier = 2;
else if (word.length() >= 11) multiplier = 2;
const uint8_t trailingLineBreaks = tokens_[index_].trailingLineBreaks;
if (trailingLineBreaks >= 2) {
multiplier = max(multiplier, static_cast<uint8_t>(3));
} else if (trailingLineBreaks == 1) {
multiplier = max(multiplier, static_cast<uint8_t>(2));
}
return delay * multiplier;
} }

View File

@ -15,6 +15,9 @@ SavedState Settings::load() const {
state.bookPath = preferences.getString("book", ""); state.bookPath = preferences.getString("book", "");
state.wordIndex = preferences.getUInt("word", 0); state.wordIndex = preferences.getUInt("word", 0);
state.wpm = preferences.getUShort("wpm", 300); state.wpm = preferences.getUShort("wpm", 300);
state.brightness = preferences.getUChar("brightness", 5);
state.fontStyle = preferences.getUChar("font_style", 0);
state.fontSize = preferences.getUChar("font_size", 20);
preferences.end(); preferences.end();
return state; return state;
} }
@ -25,6 +28,8 @@ void Settings::save(const SavedState& state) const {
preferences.putString("book", state.bookPath); preferences.putString("book", state.bookPath);
preferences.putUInt("word", state.wordIndex); preferences.putUInt("word", state.wordIndex);
preferences.putUShort("wpm", state.wpm); preferences.putUShort("wpm", state.wpm);
preferences.putUChar("brightness", state.brightness);
preferences.putUChar("font_style", state.fontStyle);
preferences.putUChar("font_size", state.fontSize);
preferences.end(); preferences.end();
} }

View File

@ -1,7 +1,10 @@
#include <Arduino.h> #include <Arduino.h>
#include <TouchDrvCSTXXX.hpp>
#include <Wire.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_bt.h> #include <esp_bt.h>
#include <esp_sleep.h> #include <esp_sleep.h>
#include <vector>
#include "Board.h" #include "Board.h"
#include "Config.h" #include "Config.h"
@ -17,17 +20,48 @@ Settings settings;
SavedState state; SavedState state;
bool playing = false; bool playing = false;
bool displayIsOff = false; bool displayIsOff = false;
bool displayIsDimmed = false;
uint32_t lastInteractionMs = 0; uint32_t lastInteractionMs = 0;
uint32_t nextWordMs = 0; uint32_t nextWordMs = 0;
TouchDrvCSTXXX touch;
bool touchAvailable = false;
bool touchPlayPauseRequested = false;
uint32_t lastTouchHomeMs = 0;
enum class Screen {
kReader,
kMenu,
kBookPicker,
kChapterPicker,
kBrightnessPicker,
kFontStylePicker,
kFontSizePicker,
};
enum class ReaderView {
kChapterTitle,
kWord,
kTheEnd,
};
Screen screen = Screen::kReader;
ReaderView readerView = ReaderView::kChapterTitle;
String pendingChapterPath;
uint8_t pendingBrightness = config::kNormalBrightness;
uint8_t pendingFontStyle = config::kDefaultFontStyle;
uint8_t pendingFontSize = config::kDefaultFontSize;
uint8_t menuSelection = 0;
struct Button { struct Button {
explicit Button(uint8_t buttonPin) : pin(buttonPin) {}
uint8_t pin; uint8_t pin;
bool stable = HIGH; bool stable = HIGH;
bool candidate = HIGH; bool candidate = HIGH;
uint32_t changedAt = 0; uint32_t changedAt = 0;
}; };
Button playPause{config::kButtonPlayPause}; Button menu{config::kButtonPlayPause};
Button previous{config::kButtonPrevious}; Button previous{config::kButtonPrevious};
Button next{config::kButtonNext}; Button next{config::kButtonNext};
@ -56,12 +90,283 @@ void persist() {
settings.save(state); settings.save(state);
} }
void showCurrentWord() { board.drawWord(reader.currentWord()); } void setReadingContext(uint8_t progressPercent) {
board.setReadingContext(library.bookName(state.bookPath),
library.chapterName(state.bookPath), progressPercent);
}
void showCurrentWord() {
setReadingContext(reader.progressPercent());
board.drawWord(reader.currentWord(), state.wpm, reader.currentStyleFlags());
}
void wakeDisplay();
void configureTouchLandscape();
void showReaderScreen() {
switch (readerView) {
case ReaderView::kChapterTitle:
setReadingContext(0);
board.drawChapterTitle(library.chapterName(state.bookPath), state.wpm);
break;
case ReaderView::kWord:
showCurrentWord();
break;
case ReaderView::kTheEnd:
setReadingContext(100);
board.drawTheEnd(state.wpm);
break;
}
}
bool loadChapter(const String& path) {
String contents;
std::vector<uint8_t> sidecar;
if (path.isEmpty() || !library.load(path, contents)) {
return false;
}
library.loadSidecar(path, sidecar);
if (!reader.load(contents, sidecar)) return false;
state.bookPath = path;
state.wordIndex = 0;
reader.restoreAtParagraphStart(0);
readerView = ReaderView::kChapterTitle;
playing = false;
return true;
}
void returnToReader() {
screen = Screen::kReader;
playing = false;
configureTouchLandscape();
wakeDisplay();
showReaderScreen();
}
void closeMenuWithoutSaving() {
// Brightness is the only picker that changes hardware before confirmation.
board.setPreferredBrightness(state.brightness);
returnToReader();
}
void drawPicker() {
switch (screen) {
case Screen::kBookPicker:
board.drawPicker("BOOK", library.bookName(pendingChapterPath));
break;
case Screen::kChapterPicker:
board.drawPicker("CHAPTER", library.chapterName(pendingChapterPath));
break;
case Screen::kBrightnessPicker:
board.drawPicker("BRIGHTNESS", String(pendingBrightness) + "/16");
break;
case Screen::kFontStylePicker:
board.drawFontStylePicker(pendingFontStyle, pendingFontSize);
break;
case Screen::kFontSizePicker:
board.drawFontSizePicker(pendingFontStyle, pendingFontSize);
break;
default:
break;
}
}
void openMenu() {
playing = false;
menuSelection = 0;
screen = Screen::kMenu;
wakeDisplay();
board.drawMenu(menuSelection);
}
void openPicker(Screen picker) {
pendingChapterPath = state.bookPath;
pendingBrightness = state.brightness;
pendingFontStyle = state.fontStyle;
pendingFontSize = state.fontSize;
screen = picker;
configureTouchLandscape();
wakeDisplay();
if (screen == Screen::kBrightnessPicker) {
board.setBrightness(pendingBrightness);
}
drawPicker();
}
void changePicker(int direction) {
switch (screen) {
case Screen::kBookPicker:
library.selectBook(pendingChapterPath, direction, pendingChapterPath);
break;
case Screen::kChapterPicker:
library.selectChapter(pendingChapterPath, direction, pendingChapterPath);
break;
case Screen::kBrightnessPicker:
pendingBrightness = constrain(pendingBrightness + direction, 1, 16);
board.setBrightness(pendingBrightness);
break;
case Screen::kFontStylePicker:
pendingFontStyle = (pendingFontStyle + direction + 3) % 3;
break;
case Screen::kFontSizePicker:
pendingFontSize = constrain(
static_cast<int>(pendingFontSize) + direction * config::kFontSizeStep,
config::kMinimumFontSize, config::kMaximumFontSize);
break;
default:
return;
}
drawPicker();
}
void applyPicker() {
if (screen == Screen::kBrightnessPicker) {
state.brightness = pendingBrightness;
board.setPreferredBrightness(state.brightness);
} else if (screen == Screen::kFontStylePicker ||
screen == Screen::kFontSizePicker) {
state.fontStyle = pendingFontStyle;
state.fontSize = pendingFontSize;
board.setFont(state.fontStyle, state.fontSize);
} else {
loadChapter(pendingChapterPath);
}
persist();
returnToReader();
}
void togglePlayback() {
wakeDisplay();
if (readerView == ReaderView::kTheEnd) return;
if (readerView == ReaderView::kChapterTitle) {
reader.restoreAtParagraphStart(0);
readerView = ReaderView::kWord;
playing = true;
showCurrentWord();
} else {
playing = !playing;
}
persist();
if (playing) nextWordMs = millis() + reader.delayMs(state.wpm);
}
void navigatePreviousPaused() {
switch (readerView) {
case ReaderView::kChapterTitle: {
String previousChapterPath;
if (library.previousChapter(state.bookPath, previousChapterPath) &&
loadChapter(previousChapterPath)) {
reader.lastParagraph();
readerView = ReaderView::kWord;
showReaderScreen();
}
break;
}
case ReaderView::kWord:
if (!reader.previousParagraph()) {
readerView = ReaderView::kChapterTitle;
}
showReaderScreen();
break;
case ReaderView::kTheEnd:
// state.bookPath remains the final chapter, so the ending card can be
// stepped back into its last paragraph without reloading the SD card.
if (reader.lastParagraph()) {
readerView = ReaderView::kWord;
showReaderScreen();
}
break;
}
}
void navigateNextPaused() {
switch (readerView) {
case ReaderView::kChapterTitle:
reader.restoreAtParagraphStart(0);
readerView = ReaderView::kWord;
showReaderScreen();
break;
case ReaderView::kWord: {
if (reader.nextParagraph()) {
showReaderScreen();
break;
}
String nextChapterPath;
if (library.nextChapter(state.bookPath, nextChapterPath) &&
loadChapter(nextChapterPath)) {
showReaderScreen();
} else {
readerView = ReaderView::kTheEnd;
showReaderScreen();
}
break;
}
case ReaderView::kTheEnd:
break;
}
}
void onTouchHome(void*) {
constexpr uint32_t kTouchDebounceMs = 200;
if (millis() - lastTouchHomeMs < kTouchDebounceMs) return;
lastTouchHomeMs = millis();
touchPlayPauseRequested = true;
}
void confirmMenuSelection() {
if (screen == Screen::kReader) {
togglePlayback();
return;
}
if (screen == Screen::kMenu) {
switch (menuSelection) {
case 0: openPicker(Screen::kBookPicker); break;
case 1: openPicker(Screen::kChapterPicker); break;
case 2: openPicker(Screen::kBrightnessPicker); break;
case 3: openPicker(Screen::kFontStylePicker); break;
case 4: openPicker(Screen::kFontSizePicker); break;
}
return;
}
applyPicker();
}
void beginTouch() {
touch.setPins(config::kTouchReset, config::kTouchInterrupt);
touchAvailable = touch.begin(Wire, CST226SE_SLAVE_ADDRESS,
config::kI2cSda, config::kI2cScl);
if (!touchAvailable) return;
configureTouchLandscape();
touch.setHomeButtonCallback(onTouchHome);
}
void configureTouchLandscape() {
if (!touchAvailable) return;
// Match LilyGO's coordinate configuration for the rotated 480 x 222 reader.
touch.setMaxCoordinates(480, 222);
touch.setSwapXY(true);
touch.setMirrorXY(false, true);
}
void handleTouch() {
if (!touchAvailable) return;
int16_t x;
int16_t y;
touch.getPoint(&x, &y); // Polling delivers home-button callbacks.
if (touchPlayPauseRequested) {
touchPlayPauseRequested = false;
confirmMenuSelection();
}
}
void wakeDisplay() { void wakeDisplay() {
if (displayIsOff) { if (displayIsOff || displayIsDimmed) {
board.displayOn(); board.displayOn();
displayIsOff = false; displayIsOff = false;
displayIsDimmed = false;
} }
lastInteractionMs = millis(); lastInteractionMs = millis();
} }
@ -76,33 +381,54 @@ void enterDeepSleep() {
} }
void handleInput() { void handleInput() {
if (released(playPause)) { if (released(menu)) {
wakeDisplay(); if (screen == Screen::kReader) {
playing = !playing; openMenu();
persist(); } else {
nextWordMs = millis(); closeMenuWithoutSaving();
} }
return;
}
if (released(previous)) { if (released(previous)) {
wakeDisplay(); wakeDisplay();
if (screen == Screen::kMenu) {
menuSelection = (menuSelection + 4) % 5;
board.drawMenu(menuSelection);
return;
}
if (screen != Screen::kReader) {
changePicker(-1);
return;
}
if (playing) { if (playing) {
state.wpm = state.wpm > config::kMinimumWpm + config::kWpmStep state.wpm = state.wpm > config::kMinimumWpm + config::kWpmStep
? state.wpm - config::kWpmStep ? state.wpm - config::kWpmStep
: config::kMinimumWpm; : config::kMinimumWpm;
} else {
reader.previousParagraph();
showCurrentWord(); showCurrentWord();
} else {
navigatePreviousPaused();
} }
persist(); persist();
} }
if (released(next)) { if (released(next)) {
wakeDisplay(); wakeDisplay();
if (screen == Screen::kMenu) {
menuSelection = (menuSelection + 1) % 5;
board.drawMenu(menuSelection);
return;
}
if (screen != Screen::kReader) {
changePicker(1);
return;
}
if (playing) { if (playing) {
state.wpm = state.wpm + config::kWpmStep < config::kMaximumWpm state.wpm = state.wpm + config::kWpmStep < config::kMaximumWpm
? state.wpm + config::kWpmStep ? state.wpm + config::kWpmStep
: config::kMaximumWpm; : config::kMaximumWpm;
} else {
reader.nextParagraph();
showCurrentWord(); showCurrentWord();
} else {
navigateNextPaused();
} }
persist(); persist();
} }
@ -121,27 +447,61 @@ void setup() {
if (state.wpm < config::kMinimumWpm || state.wpm > config::kMaximumWpm) { if (state.wpm < config::kMinimumWpm || state.wpm > config::kMaximumWpm) {
state.wpm = config::kDefaultWpm; state.wpm = config::kDefaultWpm;
} }
if (state.brightness < 1 || state.brightness > 16) {
state.brightness = config::kNormalBrightness;
}
if (state.fontStyle > 2) {
state.fontStyle = config::kDefaultFontStyle;
}
if (state.fontSize < config::kMinimumFontSize ||
state.fontSize > config::kMaximumFontSize ||
state.fontSize % config::kFontSizeStep != 0) {
state.fontSize = config::kDefaultFontSize;
}
if (!board.begin() || !board.beginSd()) { if (!board.beginSd()) {
while (true) delay(1000); while (true) delay(1000);
} }
if (!board.begin()) {
while (true) delay(1000);
}
board.setPreferredBrightness(state.brightness);
board.setFont(state.fontStyle, state.fontSize);
beginTouch();
String book; String book;
if (!library.loadLastOrFirst(state.bookPath, book) || !reader.load(book)) { std::vector<uint8_t> sidecar;
board.drawWord("NO BOOKS"); if (!library.loadLastOrFirst(state.bookPath, book)) {
board.drawWord("NO BOOKS", state.wpm);
while (true) delay(1000); while (true) delay(1000);
} }
reader.restoreAtParagraphStart(state.wordIndex); library.loadSidecar(state.bookPath, sidecar);
showCurrentWord(); if (!reader.load(book, sidecar)) {
board.drawWord("NO BOOKS", state.wpm);
while (true) delay(1000);
}
state.wordIndex = 0;
reader.restoreAtParagraphStart(0);
readerView = ReaderView::kChapterTitle;
showReaderScreen();
lastInteractionMs = millis(); lastInteractionMs = millis();
} }
void loop() { void loop() {
handleTouch();
handleInput(); handleInput();
if (playing && millis() >= nextWordMs) { if (playing && millis() >= nextWordMs) {
if (!reader.advance()) { if (!reader.advance()) {
String nextChapterPath;
if (library.nextChapter(state.bookPath, nextChapterPath) &&
loadChapter(nextChapterPath)) {
showReaderScreen();
} else {
readerView = ReaderView::kTheEnd;
playing = false; playing = false;
showReaderScreen();
}
persist(); persist();
lastInteractionMs = millis(); lastInteractionMs = millis();
} else { } else {
@ -157,8 +517,10 @@ void loop() {
} else if (!playing && idleMs >= config::kDisplayOffAfterMs && !displayIsOff) { } else if (!playing && idleMs >= config::kDisplayOffAfterMs && !displayIsOff) {
board.displayOff(); board.displayOff();
displayIsOff = true; displayIsOff = true;
} else if (!playing && idleMs >= config::kDimAfterMs && !displayIsOff) { } else if (!playing && idleMs >= config::kDimAfterMs && !displayIsOff &&
!displayIsDimmed) {
board.setBrightness(config::kDimBrightness); board.setBrightness(config::kDimBrightness);
displayIsDimmed = true;
} }
delay(5); delay(5);
} }

View File

@ -0,0 +1,188 @@
import tempfile
import unittest
from pathlib import Path
from bs4 import BeautifulSoup
from epub_to_chapters import (
SIDECAR_HEADER,
SIDECAR_MAGIC,
SIDECAR_VERSION,
STYLE_BOLD,
STYLE_ITALIC,
decode_styled_text,
document_root,
html_to_text,
normalize_display_text,
normalize_text,
output_section_name,
parse_document,
source_fingerprint,
stylesheet_class_styles,
write_style_sidecar,
write_conversion_report,
)
class DisplayTextNormalizationTests(unittest.TestCase):
def test_converts_ligatures_accents_and_supported_symbols_to_ascii(self):
source = (
"\ufb01 \ufb02 \ufb03 caf\u00e9 \u00c5ngstr\u00f6m sm\u00f8rrebr\u00f8d "
"Stra\u00dfe \u0153uvre \u0141\u00f3d\u017a \u00bd \u2260 \u2022 \u2042"
)
self.assertEqual(
normalize_display_text(source),
"fi fl ffi cafe Angstrom smorrebrod Strasse oeuvre Lodz 1/2 != * ***",
)
def test_handles_unicode_whitespace_and_invisible_formatting(self):
source = "one\u00a0two\u200bthree\u200et\u200f four"
self.assertEqual(
normalize_display_text(source),
"one two threet four",
)
def test_em_dash_retains_an_rsvp_token_boundary(self):
self.assertEqual(normalize_text("left\u2014right"), "left- right")
def test_section_names_use_the_same_normalization(self):
used_names = set()
self.assertEqual(
output_section_name("Caf\u00e9 \u2014 Part \u00bd", 1, used_names),
"Cafe - Part 1-2",
)
class ConversionReportTests(unittest.TestCase):
def test_reports_remaining_non_ascii_characters_by_generated_file(self):
with tempfile.TemporaryDirectory() as temporary_directory:
report_file, has_unsupported = write_conversion_report(
Path(temporary_directory),
["Chapter \u03a9"],
[("Chapter \u03a9.txt", "\u3053\u3093\u306b\u3061\u306f")],
)
report = report_file.read_text(encoding="utf-8")
self.assertTrue(has_unsupported)
self.assertIn("U+03A9 GREEK CAPITAL LETTER OMEGA", report)
self.assertIn("U+3053 HIRAGANA LETTER KO", report)
self.assertIn("TOC.txt", report)
def test_reports_an_ascii_safe_conversion(self):
with tempfile.TemporaryDirectory() as temporary_directory:
report_file, has_unsupported = write_conversion_report(
Path(temporary_directory),
["Chapter 1"],
[("Chapter 1.txt", "Plain ASCII text.")],
)
report = report_file.read_text(encoding="utf-8")
self.assertFalse(has_unsupported)
self.assertIn("all generated TOC and chapter text is ASCII-safe", report)
class StyleSidecarTests(unittest.TestCase):
def test_extracts_italic_and_bold_word_styles(self):
soup = BeautifulSoup(
"<p>plain <i>italic</i> <strong>bold</strong> "
"<em><b>both</b></em></p>",
"html.parser",
)
text, records = decode_styled_text(html_to_text(document_root(soup)))
self.assertEqual(text, "plain italic bold both")
self.assertEqual(
records,
[
(1, STYLE_ITALIC, 0),
(2, STYLE_BOLD, 0),
(3, STYLE_ITALIC | STYLE_BOLD, 0),
],
)
def test_writes_a_versioned_sidecar_with_source_fingerprint(self):
text = "plain italic bold both"
records = [
(1, STYLE_ITALIC, 0),
(2, STYLE_BOLD, 0),
(3, STYLE_ITALIC | STYLE_BOLD, 0),
]
with tempfile.TemporaryDirectory() as temporary_directory:
sidecar_file = Path(temporary_directory) / "Chapter 1.rsvp"
write_style_sidecar(sidecar_file, text, records)
data = sidecar_file.read_bytes()
magic, version, reserved, fingerprint, record_count = SIDECAR_HEADER.unpack(
data[:SIDECAR_HEADER.size]
)
self.assertEqual(magic, SIDECAR_MAGIC)
self.assertEqual(version, SIDECAR_VERSION)
self.assertEqual(reserved, 0)
self.assertEqual(fingerprint, source_fingerprint(text))
self.assertEqual(record_count, len(records))
self.assertEqual(
data[SIDECAR_HEADER.size:],
bytes([
1, STYLE_ITALIC, 0,
1, STYLE_BOLD, 0,
1, STYLE_ITALIC | STYLE_BOLD, 0,
]),
)
def test_extracts_styles_from_css_classes_and_inline_css(self):
class FakeItem:
def __init__(self, name, content):
self.file_name = name
self._content = content.encode("utf-8")
def get_name(self):
return self.file_name
def get_content(self):
return self._content
class FakeBook:
def __init__(self, items):
self._items = items
def get_items(self):
return self._items
stylesheet = FakeItem(
"stylesheet.css",
".voice { font-style: italic; }\n"
".both { font: italic 700 1em serif; }",
)
document = FakeItem(
"chapter.xhtml",
"<p>Plain <span class='voice'>italic words</span> "
"<span style='font-weight: bold'>bold words</span> "
"<span class='both'>both</span></p>",
)
class_styles = stylesheet_class_styles(FakeBook([stylesheet, document]))
soup = parse_document(document, class_styles)
text, records = decode_styled_text(html_to_text(document_root(soup)))
self.assertEqual(text, "Plain italic words bold words both")
self.assertEqual(
records,
[
(1, STYLE_ITALIC, 0),
(2, STYLE_ITALIC, 0),
(3, STYLE_BOLD, 0),
(4, STYLE_BOLD, 0),
(5, STYLE_ITALIC | STYLE_BOLD, 0),
],
)
if __name__ == "__main__":
unittest.main()