MVP
This commit is contained in:
parent
40abcd751c
commit
144d95a978
|
|
@ -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/
|
||||||
|
|
|
||||||
87
README.md
87
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit bef8db74e847f1e89a10cb3877f3b3a8544857d6
|
||||||
|
|
@ -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.
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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_;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
ebooklib
|
||||||
|
beautifulsoup4
|
||||||
455
src/Board.cpp
455
src/Board.cpp
|
|
@ -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_); }
|
||||||
|
|
|
||||||
265
src/Library.cpp
265
src/Library.cpp
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
225
src/Reader.cpp
225
src/Reader.cpp
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
398
src/main.cpp
398
src/main.cpp
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue