MVP
This commit is contained in:
parent
40abcd751c
commit
144d95a978
|
|
@ -1,4 +1,24 @@
|
|||
# PlatformIO build output and local editor settings.
|
||||
.pio/
|
||||
.vscode/
|
||||
*.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**.
|
||||
2. Create a directory named `books` at the card's root.
|
||||
3. Copy UTF-8 plain-text files ending in `.txt` into it. Copy
|
||||
[`examples/sample.txt`](examples/sample.txt) for the first test.
|
||||
3. Create one folder per book inside `books`. Each book folder needs a
|
||||
`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:
|
||||
|
||||
```text
|
||||
microSD root/
|
||||
└── books/
|
||||
├── sample.txt
|
||||
└── another-book.txt
|
||||
└── Example Book/
|
||||
├── 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
|
||||
1.5 MiB. The included display font is best for printable ASCII; accented or
|
||||
non-Latin scripts need a future Unicode-font upgrade.
|
||||
For the example above, `TOC.txt` contains:
|
||||
|
||||
```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.
|
||||
|
||||
|
|
@ -94,7 +132,7 @@ pio run
|
|||
```
|
||||
|
||||
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/`.
|
||||
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
|
|
@ -149,11 +187,12 @@ If the board is not detected or an upload repeatedly fails, force download mode:
|
|||
|
||||
## 6. Read
|
||||
|
||||
| Control | Paused | Playing |
|
||||
| Control | Reading view | Menu or picker |
|
||||
| --- | --- | --- |
|
||||
| BOOT / GPIO0 | Play or pause | Play or pause |
|
||||
| Upper-right / GPIO12 | Previous paragraph | Lower WPM |
|
||||
| Lower-right / GPIO16 | Next paragraph | Higher WPM |
|
||||
| BOOT / GPIO0 | Open menu | Close/cancel without saving |
|
||||
| Touch edge Home button | Play or pause | Open highlighted option / apply |
|
||||
| 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
|
||||
the manual download mode used for recovery above.
|
||||
|
|
@ -164,11 +203,12 @@ GPIO12 wakes it.
|
|||
|
||||
## Resume and power behavior
|
||||
|
||||
When the reader advances, pauses, or sleeps, it saves the exact word index and
|
||||
current WPM in the ESP32's nonvolatile storage. On the next boot or wake it
|
||||
opens the same book, moves to the first word of the paragraph containing that
|
||||
saved index, renders it, and remains paused. This is intentional: it gives you
|
||||
paragraph context before reading resumes.
|
||||
The reader saves the selected section, WPM, brightness, and font settings in
|
||||
the ESP32's nonvolatile storage. On the next boot or wake it opens the same
|
||||
section's title card and remains paused; pressing the touch-edge Home button
|
||||
begins at that section's first word. It defers full chapter-list validation
|
||||
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
|
||||
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.
|
||||
- **Permission denied on Linux:** complete the `dialout` group step and log out
|
||||
and back in.
|
||||
- **`NO BOOKS`:** reformat the card FAT32, create `/books`, and copy a `.txt`
|
||||
file directly into that folder. Confirm the card is fully seated.
|
||||
- **`NO BOOKS`:** reformat the card FAT32, create `/books/<book>`, and add a
|
||||
`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
|
||||
reflash and check whether the v1.0 backlight build flag is needed.
|
||||
- **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
|
||||
|
||||
- It reads up to 1.5 MiB per book and discovers `.txt` files directly in
|
||||
`/books` (no nested folders).
|
||||
- It reads sections up to 1.5 MiB from `/books/<book>/<section>.txt`, in the
|
||||
order declared by that book's `TOC.txt`.
|
||||
- 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
|
||||
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>
|
||||
|
||||
class Arduino_GFX;
|
||||
class TFT_eSPI;
|
||||
|
||||
class Board {
|
||||
public:
|
||||
bool begin();
|
||||
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 setPreferredBrightness(uint8_t level);
|
||||
void setFont(uint8_t style, uint8_t size);
|
||||
void displayOff();
|
||||
void displayOn();
|
||||
|
||||
private:
|
||||
Arduino_GFX* display_ = nullptr;
|
||||
uint8_t brightness_ = 0;
|
||||
};
|
||||
struct DrawBounds {
|
||||
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 kBacklight = 48;
|
||||
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 kButtonPrevious = 12;
|
||||
constexpr uint8_t kButtonNext = 16;
|
||||
|
|
@ -23,13 +27,18 @@ constexpr size_t kMaxBookBytes = 1500 * 1024;
|
|||
constexpr uint16_t kDefaultWpm = 300;
|
||||
constexpr uint16_t kMinimumWpm = 100;
|
||||
constexpr uint16_t kMaximumWpm = 800;
|
||||
constexpr uint16_t kWpmStep = 25;
|
||||
constexpr uint16_t kWpmStep = 50;
|
||||
|
||||
constexpr uint32_t kDimAfterMs = 20UL * 1000UL;
|
||||
constexpr uint32_t kDisplayOffAfterMs = 60UL * 1000UL;
|
||||
constexpr uint32_t kDeepSleepAfterMs = 10UL * 60UL * 1000UL;
|
||||
constexpr uint8_t kNormalBrightness = 5; // 1–16 on v1.1 boards.
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,32 @@
|
|||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
|
||||
class Library {
|
||||
public:
|
||||
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:
|
||||
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 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;
|
||||
uint16_t length;
|
||||
uint32_t paragraph;
|
||||
uint8_t trailingLineBreaks;
|
||||
};
|
||||
|
||||
struct StyleToken {
|
||||
uint32_t index;
|
||||
uint8_t styleFlags;
|
||||
uint8_t timingModifier;
|
||||
};
|
||||
|
||||
class Reader {
|
||||
public:
|
||||
bool load(const String& book);
|
||||
bool load(const String& book, const std::vector<uint8_t>& sidecar);
|
||||
void restoreAtParagraphStart(uint32_t savedWord);
|
||||
const String currentWord() const;
|
||||
bool advance();
|
||||
bool previousParagraph();
|
||||
bool nextParagraph();
|
||||
bool lastParagraph();
|
||||
uint32_t currentIndex() const;
|
||||
uint8_t progressPercent() const;
|
||||
uint32_t delayMs(uint16_t wpm) const;
|
||||
uint8_t currentStyleFlags() const;
|
||||
bool sidecarValid() const;
|
||||
|
||||
private:
|
||||
String book_;
|
||||
std::vector<Token> tokens_;
|
||||
std::vector<StyleToken> styleTokens_;
|
||||
std::vector<uint32_t> paragraphStarts_;
|
||||
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;
|
||||
uint32_t wordIndex = 0;
|
||||
uint16_t wpm = 300;
|
||||
uint8_t brightness = 5;
|
||||
uint8_t fontStyle = 0;
|
||||
uint8_t fontSize = 20;
|
||||
};
|
||||
|
||||
class Settings {
|
||||
|
|
@ -14,4 +17,3 @@ class Settings {
|
|||
SavedState load() const;
|
||||
void save(const SavedState& state) const;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x6000,
|
||||
otadata, data, ota, 0xf000, 0x2000,
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x500000,
|
||||
spiffs, data, spiffs, 0x510000, 0xAE0000,
|
||||
|
||||
|
|
|
|||
|
|
|
@ -8,9 +8,9 @@ board = t-display-s3-pro
|
|||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
board_build.partitions = partitions.csv
|
||||
lib_extra_dirs = T-Display-S3-Pro/lib
|
||||
build_flags =
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
-DDISABLE_ALL_LIBRARY_WARNINGS
|
||||
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 <Arduino_GFX_Library.h>
|
||||
#include <SD.h>
|
||||
#include <SPI.h>
|
||||
#include <TFT_eSPI.h>
|
||||
#include <Wire.h>
|
||||
#include <XPowersLib.h>
|
||||
|
||||
#include "Config.h"
|
||||
|
||||
namespace {
|
||||
Arduino_DataBus* bus = new Arduino_ESP32SPI(
|
||||
config::kTftDc, config::kTftCs, config::kSpiSck, config::kSpiMosi,
|
||||
config::kSpiMiso);
|
||||
// The panel is natively 222x480. Rotation 1 gives the reader a 480x222 canvas.
|
||||
Arduino_GFX* makeDisplay() {
|
||||
return new Arduino_ST7796(bus, config::kTftReset, 1, true, 222, 480, 49, 0);
|
||||
PowersSY6970 powerMonitor;
|
||||
constexpr int16_t kStatusHeight = 30;
|
||||
constexpr int16_t kFooterHeight = 24;
|
||||
constexpr uint32_t kStatusRefreshMs = 60UL * 1000UL;
|
||||
constexpr uint8_t kItalicStyle = 0x01;
|
||||
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() {
|
||||
display_ = makeDisplay();
|
||||
if (!display_->begin()) {
|
||||
return false;
|
||||
}
|
||||
display_->fillScreen(BLACK);
|
||||
// The display and SD card share SPI. Keep the SD card deselected while the
|
||||
// ST7796 receives its initialization sequence.
|
||||
pinMode(config::kSdCs, OUTPUT);
|
||||
digitalWrite(config::kSdCs, HIGH);
|
||||
|
||||
display_ = new TFT_eSPI();
|
||||
display_->begin();
|
||||
setLandscape();
|
||||
display_->fillScreen(TFT_BLACK);
|
||||
pinMode(config::kBacklight, OUTPUT);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
|
||||
display_->fillScreen(BLACK);
|
||||
const size_t glyphs = word.length() == 0 ? 1 : word.length();
|
||||
uint16_t calculatedScale = 480 / (glyphs * 6); // MVP font supports printable ASCII text.
|
||||
if (calculatedScale < 2) calculatedScale = 2;
|
||||
if (calculatedScale > 12) calculatedScale = 12;
|
||||
const uint8_t scale = calculatedScale;
|
||||
const int16_t width = glyphs * 6 * scale;
|
||||
const int16_t height = 8 * scale;
|
||||
int16_t x = (display_->width() - width) / 2;
|
||||
if (x < 0) x = 0;
|
||||
display_->setTextColor(WHITE);
|
||||
if (landscape_) {
|
||||
display_->setRotation(0);
|
||||
landscape_ = false;
|
||||
}
|
||||
display_->fillScreen(TFT_BLACK);
|
||||
statusDrawn_ = false;
|
||||
footerDrawn_ = false;
|
||||
displayedProgressPercent_ = 255;
|
||||
previousWordBounds_.valid = false;
|
||||
display_->setTextFont(2);
|
||||
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_->setCursor(x, (display_->height() - height) / 2);
|
||||
display_->print(word);
|
||||
display_->setTextDatum(MC_DATUM);
|
||||
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) {
|
||||
|
|
@ -81,5 +467,16 @@ void Board::setBrightness(uint8_t level) {
|
|||
#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::displayOn() { setBrightness(config::kNormalBrightness); }
|
||||
void Board::displayOn() { setBrightness(preferredBrightness_); }
|
||||
|
|
|
|||
269
src/Library.cpp
269
src/Library.cpp
|
|
@ -1,21 +1,228 @@
|
|||
#include "Library.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
|
||||
#include "Config.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 {
|
||||
Settings settings;
|
||||
settings.begin();
|
||||
const SavedState state = settings.load();
|
||||
if (!state.bookPath.isEmpty() && loadFile(state.bookPath, contents)) {
|
||||
if (isChapterPath(state.bookPath) && loadFile(state.bookPath, contents)) {
|
||||
path = state.bookPath;
|
||||
return true;
|
||||
}
|
||||
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 {
|
||||
File file = SD.open(path, FILE_READ);
|
||||
if (!file || file.isDirectory() || file.size() == 0 ||
|
||||
|
|
@ -27,20 +234,52 @@ bool Library::loadFile(const String& path, String& contents) const {
|
|||
return !contents.isEmpty();
|
||||
}
|
||||
|
||||
bool Library::firstBook(String& path) const {
|
||||
File directory = SD.open(config::kBooksDirectory);
|
||||
if (!directory || !directory.isDirectory()) return false;
|
||||
for (File entry = directory.openNextFile(); entry;
|
||||
entry = directory.openNextFile()) {
|
||||
const String name = entry.name();
|
||||
const bool isText = !entry.isDirectory() && name.endsWith(".txt");
|
||||
entry.close();
|
||||
if (isText) {
|
||||
path = name.startsWith("/") ? name : String(config::kBooksDirectory) + "/" + name;
|
||||
directory.close();
|
||||
return true;
|
||||
}
|
||||
bool Library::ensureBooks() const {
|
||||
if (booksCached_) return !books_.empty();
|
||||
|
||||
booksCached_ = true;
|
||||
books_.clear();
|
||||
firstChapters_.clear();
|
||||
if (!listDirectories(config::kBooksDirectory, books_)) return false;
|
||||
firstChapters_.resize(books_.size());
|
||||
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;
|
||||
}
|
||||
|
||||
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 <algorithm>
|
||||
#include <ctype.h>
|
||||
#include <limits.h>
|
||||
|
||||
bool Reader::load(const String& book) {
|
||||
book_ = book;
|
||||
namespace {
|
||||
|
||||
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();
|
||||
styleTokens_.clear();
|
||||
paragraphStarts_.clear();
|
||||
index_ = 0;
|
||||
sidecarValid_ = false;
|
||||
|
||||
bool inWord = false;
|
||||
uint32_t wordStart = 0;
|
||||
|
|
@ -15,6 +131,10 @@ bool Reader::load(const String& book) {
|
|||
const char c = i < book_.length() ? book_[i] : ' ';
|
||||
const bool whitespace = isspace(static_cast<unsigned char>(c));
|
||||
if (!whitespace && !inWord) {
|
||||
if (!tokens_.empty()) {
|
||||
tokens_.back().trailingLineBreaks =
|
||||
lineBreaks > UINT8_MAX ? UINT8_MAX : lineBreaks;
|
||||
}
|
||||
const bool paragraphBreak = lineBreaks >= 2 || paragraphStarts_.empty();
|
||||
wordStart = i;
|
||||
inWord = true;
|
||||
|
|
@ -25,14 +145,59 @@ bool Reader::load(const String& book) {
|
|||
}
|
||||
if (whitespace && inWord) {
|
||||
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;
|
||||
}
|
||||
if (c == '\n') {
|
||||
++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) {
|
||||
|
|
@ -72,15 +237,57 @@ bool Reader::nextParagraph() {
|
|||
return true;
|
||||
}
|
||||
|
||||
bool Reader::lastParagraph() {
|
||||
if (paragraphStarts_.empty()) return false;
|
||||
index_ = paragraphStarts_.back();
|
||||
return true;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (tokens_.empty()) return 1000;
|
||||
const String word = currentWord();
|
||||
uint32_t delay = 60000UL / wpm;
|
||||
const char end = word[word.length() - 1];
|
||||
if (end == '.' || end == '!' || end == '?') delay *= 3;
|
||||
else if (end == ',' || end == ';' || end == ':') delay *= 2;
|
||||
else if (word.length() >= 11) delay = delay * 3 / 2;
|
||||
return delay;
|
||||
uint8_t multiplier = 1;
|
||||
char end = word[word.length() - 1];
|
||||
// Dialogue often ends as word." after EPUB quotes are normalized to ASCII.
|
||||
// Preserve the pause class of the punctuation before that closing quote.
|
||||
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.wordIndex = preferences.getUInt("word", 0);
|
||||
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();
|
||||
return state;
|
||||
}
|
||||
|
|
@ -25,6 +28,8 @@ void Settings::save(const SavedState& state) const {
|
|||
preferences.putString("book", state.bookPath);
|
||||
preferences.putUInt("word", state.wordIndex);
|
||||
preferences.putUShort("wpm", state.wpm);
|
||||
preferences.putUChar("brightness", state.brightness);
|
||||
preferences.putUChar("font_style", state.fontStyle);
|
||||
preferences.putUChar("font_size", state.fontSize);
|
||||
preferences.end();
|
||||
}
|
||||
|
||||
|
|
|
|||
400
src/main.cpp
400
src/main.cpp
|
|
@ -1,7 +1,10 @@
|
|||
#include <Arduino.h>
|
||||
#include <TouchDrvCSTXXX.hpp>
|
||||
#include <Wire.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_bt.h>
|
||||
#include <esp_sleep.h>
|
||||
#include <vector>
|
||||
|
||||
#include "Board.h"
|
||||
#include "Config.h"
|
||||
|
|
@ -17,17 +20,48 @@ Settings settings;
|
|||
SavedState state;
|
||||
bool playing = false;
|
||||
bool displayIsOff = false;
|
||||
bool displayIsDimmed = false;
|
||||
uint32_t lastInteractionMs = 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 {
|
||||
explicit Button(uint8_t buttonPin) : pin(buttonPin) {}
|
||||
|
||||
uint8_t pin;
|
||||
bool stable = HIGH;
|
||||
bool candidate = HIGH;
|
||||
uint32_t changedAt = 0;
|
||||
};
|
||||
|
||||
Button playPause{config::kButtonPlayPause};
|
||||
Button menu{config::kButtonPlayPause};
|
||||
Button previous{config::kButtonPrevious};
|
||||
Button next{config::kButtonNext};
|
||||
|
||||
|
|
@ -56,12 +90,283 @@ void persist() {
|
|||
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() {
|
||||
if (displayIsOff) {
|
||||
if (displayIsOff || displayIsDimmed) {
|
||||
board.displayOn();
|
||||
displayIsOff = false;
|
||||
displayIsDimmed = false;
|
||||
}
|
||||
lastInteractionMs = millis();
|
||||
}
|
||||
|
|
@ -76,33 +381,54 @@ void enterDeepSleep() {
|
|||
}
|
||||
|
||||
void handleInput() {
|
||||
if (released(playPause)) {
|
||||
wakeDisplay();
|
||||
playing = !playing;
|
||||
persist();
|
||||
nextWordMs = millis();
|
||||
if (released(menu)) {
|
||||
if (screen == Screen::kReader) {
|
||||
openMenu();
|
||||
} else {
|
||||
closeMenuWithoutSaving();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (released(previous)) {
|
||||
wakeDisplay();
|
||||
if (screen == Screen::kMenu) {
|
||||
menuSelection = (menuSelection + 4) % 5;
|
||||
board.drawMenu(menuSelection);
|
||||
return;
|
||||
}
|
||||
if (screen != Screen::kReader) {
|
||||
changePicker(-1);
|
||||
return;
|
||||
}
|
||||
if (playing) {
|
||||
state.wpm = state.wpm > config::kMinimumWpm + config::kWpmStep
|
||||
? state.wpm - config::kWpmStep
|
||||
: config::kMinimumWpm;
|
||||
} else {
|
||||
reader.previousParagraph();
|
||||
showCurrentWord();
|
||||
} else {
|
||||
navigatePreviousPaused();
|
||||
}
|
||||
persist();
|
||||
}
|
||||
if (released(next)) {
|
||||
wakeDisplay();
|
||||
if (screen == Screen::kMenu) {
|
||||
menuSelection = (menuSelection + 1) % 5;
|
||||
board.drawMenu(menuSelection);
|
||||
return;
|
||||
}
|
||||
if (screen != Screen::kReader) {
|
||||
changePicker(1);
|
||||
return;
|
||||
}
|
||||
if (playing) {
|
||||
state.wpm = state.wpm + config::kWpmStep < config::kMaximumWpm
|
||||
? state.wpm + config::kWpmStep
|
||||
: config::kMaximumWpm;
|
||||
} else {
|
||||
reader.nextParagraph();
|
||||
showCurrentWord();
|
||||
} else {
|
||||
navigateNextPaused();
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
|
@ -121,27 +447,61 @@ void setup() {
|
|||
if (state.wpm < config::kMinimumWpm || state.wpm > config::kMaximumWpm) {
|
||||
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);
|
||||
}
|
||||
if (!board.begin()) {
|
||||
while (true) delay(1000);
|
||||
}
|
||||
board.setPreferredBrightness(state.brightness);
|
||||
board.setFont(state.fontStyle, state.fontSize);
|
||||
beginTouch();
|
||||
|
||||
String book;
|
||||
if (!library.loadLastOrFirst(state.bookPath, book) || !reader.load(book)) {
|
||||
board.drawWord("NO BOOKS");
|
||||
std::vector<uint8_t> sidecar;
|
||||
if (!library.loadLastOrFirst(state.bookPath, book)) {
|
||||
board.drawWord("NO BOOKS", state.wpm);
|
||||
while (true) delay(1000);
|
||||
}
|
||||
reader.restoreAtParagraphStart(state.wordIndex);
|
||||
showCurrentWord();
|
||||
library.loadSidecar(state.bookPath, sidecar);
|
||||
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();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
handleTouch();
|
||||
handleInput();
|
||||
|
||||
if (playing && millis() >= nextWordMs) {
|
||||
if (!reader.advance()) {
|
||||
playing = false;
|
||||
String nextChapterPath;
|
||||
if (library.nextChapter(state.bookPath, nextChapterPath) &&
|
||||
loadChapter(nextChapterPath)) {
|
||||
showReaderScreen();
|
||||
} else {
|
||||
readerView = ReaderView::kTheEnd;
|
||||
playing = false;
|
||||
showReaderScreen();
|
||||
}
|
||||
persist();
|
||||
lastInteractionMs = millis();
|
||||
} else {
|
||||
|
|
@ -157,8 +517,10 @@ void loop() {
|
|||
} else if (!playing && idleMs >= config::kDisplayOffAfterMs && !displayIsOff) {
|
||||
board.displayOff();
|
||||
displayIsOff = true;
|
||||
} else if (!playing && idleMs >= config::kDimAfterMs && !displayIsOff) {
|
||||
} else if (!playing && idleMs >= config::kDimAfterMs && !displayIsOff &&
|
||||
!displayIsDimmed) {
|
||||
board.setBrightness(config::kDimBrightness);
|
||||
displayIsDimmed = true;
|
||||
}
|
||||
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