Agent identified fixes

This commit is contained in:
John 2026-09-23 00:38:30 -05:00
parent 710620da8c
commit c6be185f4c
11 changed files with 259 additions and 175 deletions

27
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,27 @@
name: CI
on:
push:
pull_request:
jobs:
converter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install -r requirements.txt
- run: python -m unittest discover -v
- run: python epub_to_chapters.py examples/rsvp-timing-sample.epub /tmp/timing-sample
firmware:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install platformio
- run: pio run

View File

@ -99,18 +99,19 @@ Chapter 2
Generate this layout from an EPUB with the included converter: Generate this layout from an EPUB with the included converter:
```bash ```bash
python3 -m pip install -r requirements.txt ./.venv/bin/python -m pip install -r requirements.txt
python3 epub_to_chapters.py book.epub "Example Book" ./.venv/bin/python epub_to_chapters.py book.epub "Example Book"
``` ```
For a quick end-to-end check, the repository includes a two-chapter sample For a quick end-to-end check, the repository includes a two-chapter sample
with EPUB styles, punctuation, and repeated invented names: with EPUB styles, punctuation, and repeated invented names:
```bash ```bash
python3 epub_to_chapters.py examples/rsvp-timing-sample.epub "Timing Sample" ./.venv/bin/python epub_to_chapters.py examples/rsvp-timing-sample.epub "Timing Sample"
``` ```
Copy the resulting `Example Book` directory into `/books` on the card. A The output directory must be empty unless `--overwrite` is supplied. 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 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 the book directory. It records any unsupported non-ASCII characters left after
normalizing common punctuation, ligatures, accents, whitespace, fractions, and normalizing common punctuation, ligatures, accents, whitespace, fractions, and
@ -220,12 +221,12 @@ GPIO12 wakes it.
## Resume and power behavior ## Resume and power behavior
The reader saves the selected section, WPM, brightness, and font settings in The reader saves the selected section, paragraph-level position, WPM,
the ESP32's nonvolatile storage. On the next boot or wake it opens the same brightness, and font settings in the ESP32's nonvolatile storage. On the next
section's title card and remains paused; pressing the touch-edge Home button boot or wake it opens the same section's title card and remains paused;
begins at that section's first word. It defers full chapter-list validation pressing the touch-edge Home button begins at the saved paragraph. It validates
until chapter browsing is needed, so normal startup opens only the selected the saved section against its `TOC.txt` before opening it, then falls back to
chapter and its sidecar. the first available book if the card has changed.
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,

24
TODO.md
View File

@ -1,5 +1,29 @@
# TODO # TODO
## Bookmarks and per-book progress
Paragraph-level resume restores the last reading position. Add named bookmarks
and a compact recent-books/progress list so readers can return to several
places in a book without overwriting their normal resume point.
## Reading profiles
Add selectable reading profiles for punctuation and paragraph pause lengths,
long-word delay, unfamiliar-word timing, and WPM presets. Keep a plain/default
profile that preserves the current cadence.
## Converter preflight and safe section splitting
Add a `--check` mode that reports section and sidecar sizes, filename issues,
and unsupported characters without writing output. Offer optional splitting at
paragraph boundaries for sections that exceed the device's 1.5 MiB file limit.
## Memory-limit hardware validation
Stress-test the advertised maximum section and sidecar sizes on real hardware,
record PSRAM headroom, and lower the supported limit if token indexing or
sidecar metadata leaves insufficient memory for reliable reading.
## Add anti-aliased reader fonts ## Add anti-aliased reader fonts
The current reader uses TFT_eSPI's bitmap FreeSans, FreeSerif, and FreeMono The current reader uses TFT_eSPI's bitmap FreeSans, FreeSerif, and FreeMono

View File

@ -122,6 +122,8 @@ SIDECAR_HEADER = struct.Struct("<4sBBQI")
CSS_RULE = re.compile(r"([^{}]+)\{([^{}]*)\}") CSS_RULE = re.compile(r"([^{}]+)\{([^{}]*)\}")
CSS_CLASS = re.compile(r"\.([A-Za-z_-][A-Za-z0-9_-]*)") CSS_CLASS = re.compile(r"\.([A-Za-z_-][A-Za-z0-9_-]*)")
WORD_LOOKUP_KEY = re.compile(r"^[^A-Za-z]*([A-Za-z]+(?:['-][A-Za-z]+)*)[^A-Za-z]*$") WORD_LOOKUP_KEY = re.compile(r"^[^A-Za-z]*([A-Za-z]+(?:['-][A-Za-z]+)*)[^A-Za-z]*$")
MAX_SECTION_BYTES = 1500 * 1024
MAX_SECTION_NAME_BYTES = 200
TIMING_NONE = 0 TIMING_NONE = 0
TIMING_DOUBLE = 2 TIMING_DOUBLE = 2
@ -412,8 +414,8 @@ def encode_varint(value):
return bytes(encoded) return bytes(encoded)
def write_style_sidecar(output_file, text, style_records): def style_sidecar_bytes(text, style_records):
"""Write validated sparse style and timing metadata beside a chapter.""" """Return validated sparse style and timing metadata for a chapter."""
payload = bytearray(SIDECAR_HEADER.pack( payload = bytearray(SIDECAR_HEADER.pack(
SIDECAR_MAGIC, SIDECAR_MAGIC,
@ -437,7 +439,13 @@ def write_style_sidecar(output_file, text, style_records):
payload.extend((style_flags, timing_modifier)) payload.extend((style_flags, timing_modifier))
previous_index = word_index previous_index = word_index
output_file.write_bytes(payload) return bytes(payload)
def write_style_sidecar(output_file, text, style_records):
"""Write validated sparse style and timing metadata beside a chapter."""
output_file.write_bytes(style_sidecar_bytes(text, style_records))
def output_section_name(title, number, used_names): def output_section_name(title, number, used_names):
@ -453,10 +461,19 @@ def output_section_name(title, number, used_names):
if not title: if not title:
title = f"Section {number}" title = f"Section {number}"
encoded_title = title.encode("utf-8")
if len(encoded_title) > MAX_SECTION_NAME_BYTES:
title = encoded_title[:MAX_SECTION_NAME_BYTES].decode(
"utf-8", errors="ignore"
).rstrip(". ")
candidate = title candidate = title
duplicate = 2 duplicate = 2
while candidate.casefold() in used_names: while candidate.casefold() in used_names:
candidate = f"{title} ({duplicate})" suffix = f" ({duplicate})"
available = MAX_SECTION_NAME_BYTES - len(suffix.encode("utf-8"))
stem = title.encode("utf-8")[:available].decode("utf-8", errors="ignore")
candidate = f"{stem.rstrip('. ')}{suffix}"
duplicate += 1 duplicate += 1
used_names.add(candidate.casefold()) used_names.add(candidate.casefold())
@ -995,103 +1012,6 @@ def spine_documents(book):
return result return result
# ---------------------------------------------------------------------------
# Chapter extraction using TOC
# ---------------------------------------------------------------------------
def extract_toc_chapter(entries, index, class_styles=None):
"""
Extract one chapter based on its TOC position.
This handles:
chapter.xhtml#chapter1
chapter.xhtml#chapter2
correctly by stopping at the next TOC anchor.
It also handles chapters spanning multiple XHTML files.
"""
current = entries[index]
next_entry = (
entries[index + 1]
if index + 1 < len(entries)
else None
)
current_item = current["item"]
current_soup = parse_document(current_item, class_styles)
current_root = document_root(current_soup)
start = find_fragment(
current_soup,
current["fragment"],
)
# ---------------------------------------------------------------
# Find next chapter boundary if it is in this same document.
# ---------------------------------------------------------------
end = None
if (
next_entry is not None
and same_document(current_item, next_entry["item"])
):
end = find_fragment(
current_soup,
next_entry["fragment"],
)
# ---------------------------------------------------------------
# Same XHTML file.
# ---------------------------------------------------------------
if next_entry is not None and same_document(
current_item,
next_entry["item"],
):
return extract_range(
current_root,
start,
end,
)
# ---------------------------------------------------------------
# Different XHTML file.
#
# Everything from the current starting point through the end
# of the current document belongs to this chapter.
# ---------------------------------------------------------------
parts = []
parts.append(
extract_range(
current_root,
start,
None,
)
)
# If there is no next chapter, we stop here.
if next_entry is None:
return normalize_text("\n\n".join(
part for part in parts if part
))
# Add any spine documents between current and next document.
spine = spine_documents(book=None) if False else []
return normalize_text("\n\n".join(
part for part in parts if part
))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Improved multi-document chapter extraction # Improved multi-document chapter extraction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1170,10 +1090,11 @@ def extract_chapter(book, entries, index, doc_positions, class_styles=None):
pieces = [] pieces = []
# Process documents from current through the document before # Process documents from current through the document before the next
# the next chapter. # chapter. A fragment in the current document is a special case: include
# that document once and stop at the next fragment.
end_pos = ( end_pos = (
next_pos next_pos + 1 if next_pos == current_pos and next_entry is not None else next_pos
if next_pos is not None if next_pos is not None
else len(docs) else len(docs)
) )
@ -1321,7 +1242,7 @@ def remove_duplicate_chapters(chapters):
# Main conversion # Main conversion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def convert_epub(epub_path, output_dir): def convert_epub(epub_path, output_dir, overwrite=False):
epub_path = Path(epub_path) epub_path = Path(epub_path)
output_dir = Path(output_dir) output_dir = Path(output_dir)
@ -1333,10 +1254,21 @@ def convert_epub(epub_path, output_dir):
) )
sys.exit(1) sys.exit(1)
output_dir.mkdir( if output_dir.exists() and any(output_dir.iterdir()):
parents=True, if not overwrite:
exist_ok=True, print(
f"Error: output directory is not empty: {output_dir}. "
"Choose an empty directory or pass --overwrite."
) )
sys.exit(1)
for output_file in output_dir.iterdir():
if output_file.is_file() and (
output_file.suffix in {".txt", ".rsvp"} or
output_file.name == "CONVERSION_REPORT.txt"
):
output_file.unlink()
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Reading: {epub_path}") print(f"Reading: {epub_path}")
@ -1453,7 +1385,7 @@ def convert_epub(epub_path, output_dir):
# --------------------------------------------------------------- # ---------------------------------------------------------------
toc_entries = [] toc_entries = []
written_sections = [] prepared_sections = []
used_names = set() used_names = set()
for number, chapter in enumerate( for number, chapter in enumerate(
@ -1467,29 +1399,30 @@ def convert_epub(epub_path, output_dir):
used_names, used_names,
) )
output_file = (
output_dir /
f"{title}.txt"
)
output_file.write_text(
chapter["text"],
encoding="utf-8",
)
sidecar_file = output_dir / f"{title}.rsvp"
write_style_sidecar(
sidecar_file,
chapter["text"],
chapter["style_records"],
)
toc_entries.append(title) toc_entries.append(title)
prepared_sections.append((title, chapter))
for title, chapter in prepared_sections:
text_bytes = chapter["text"].encode("utf-8")
sidecar_bytes = style_sidecar_bytes(
chapter["text"], chapter["style_records"]
)
if len(text_bytes) > MAX_SECTION_BYTES or len(sidecar_bytes) > MAX_SECTION_BYTES:
print(
f"Error: {title!r} exceeds the device's {MAX_SECTION_BYTES} byte "
"per-file limit. Split the section before converting."
)
sys.exit(1)
written_sections = []
for number, (title, chapter) in enumerate(prepared_sections, start=1):
output_file = output_dir / f"{title}.txt"
sidecar_file = output_dir / f"{title}.rsvp"
output_file.write_text(chapter["text"], encoding="utf-8")
write_style_sidecar(sidecar_file, chapter["text"], chapter["style_records"])
written_sections.append((output_file.name, chapter["text"])) written_sections.append((output_file.name, chapter["text"]))
print( print(
f"{number:4}. " f"{number:4}. {title} -> {output_file.name}, {sidecar_file.name}"
f"{title} "
f"-> {output_file.name}, {sidecar_file.name}"
) )
toc_file = output_dir / "TOC.txt" toc_file = output_dir / "TOC.txt"
@ -1529,20 +1462,24 @@ def convert_epub(epub_path, output_dir):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def main(): def main():
arguments = sys.argv[1:]
overwrite = "--overwrite" in arguments
if overwrite:
arguments.remove("--overwrite")
if len(sys.argv) < 2: if not arguments or len(arguments) > 2:
print( print(
"Usage:\n" "Usage:\n"
" python epub_to_chapters.py book.epub\n" " python epub_to_chapters.py book.epub\n"
" python epub_to_chapters.py " " python epub_to_chapters.py book.epub output_directory\n"
"book.epub output_directory" " python epub_to_chapters.py book.epub output_directory --overwrite"
) )
sys.exit(1) sys.exit(1)
epub_path = Path( epub_path = Path(
sys.argv[1] arguments[0]
) )
if not epub_path.exists(): if not epub_path.exists():
@ -1563,10 +1500,10 @@ def main():
sys.exit(1) sys.exit(1)
if len(sys.argv) >= 3: if len(arguments) >= 2:
output_dir = Path( output_dir = Path(
sys.argv[2] arguments[1]
) )
else: else:
@ -1575,10 +1512,7 @@ def main():
"chapters" "chapters"
) )
convert_epub( convert_epub(epub_path, output_dir, overwrite)
epub_path,
output_dir,
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -8,11 +8,10 @@ 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 =
TFT_eSPI TFT_eSPI@2.5.43
lewisxhe/SensorLib@0.4.0 lewisxhe/SensorLib@0.4.0
lewisxhe/XPowersLib@0.2.9 lewisxhe/XPowersLib@0.2.9

View File

@ -1,3 +1,3 @@
ebooklib ebooklib==0.20
beautifulsoup4 beautifulsoup4==4.15.0
english-words==2.0.2 english-words==2.0.2

View File

@ -22,7 +22,8 @@ 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 (isChapterPath(state.bookPath) && loadFile(state.bookPath, contents)) { if (isChapterPath(state.bookPath) && isListedChapter(state.bookPath) &&
loadFile(state.bookPath, contents)) {
path = state.bookPath; path = state.bookPath;
return true; return true;
} }

View File

@ -25,11 +25,25 @@ SavedState Settings::load() const {
void Settings::save(const SavedState& state) const { void Settings::save(const SavedState& state) const {
Preferences preferences; Preferences preferences;
preferences.begin(kNamespace, false); preferences.begin(kNamespace, false);
if (!preferences.isKey("book") || preferences.getString("book") != state.bookPath) {
preferences.putString("book", state.bookPath); preferences.putString("book", state.bookPath);
}
if (!preferences.isKey("word") || preferences.getUInt("word") != state.wordIndex) {
preferences.putUInt("word", state.wordIndex); preferences.putUInt("word", state.wordIndex);
}
if (!preferences.isKey("wpm") || preferences.getUShort("wpm") != state.wpm) {
preferences.putUShort("wpm", state.wpm); preferences.putUShort("wpm", state.wpm);
}
if (!preferences.isKey("brightness") ||
preferences.getUChar("brightness") != state.brightness) {
preferences.putUChar("brightness", state.brightness); preferences.putUChar("brightness", state.brightness);
}
if (!preferences.isKey("font_style") ||
preferences.getUChar("font_style") != state.fontStyle) {
preferences.putUChar("font_style", state.fontStyle); preferences.putUChar("font_style", state.fontStyle);
}
if (!preferences.isKey("font_size") || preferences.getUChar("font_size") != state.fontSize) {
preferences.putUChar("font_size", state.fontSize); preferences.putUChar("font_size", state.fontSize);
}
preferences.end(); preferences.end();
} }

View File

@ -23,6 +23,8 @@ bool displayIsOff = false;
bool displayIsDimmed = false; bool displayIsDimmed = false;
uint32_t lastInteractionMs = 0; uint32_t lastInteractionMs = 0;
uint32_t nextWordMs = 0; uint32_t nextWordMs = 0;
uint32_t lastPersistMs = 0;
bool stateDirty = false;
TouchDrvCSTXXX touch; TouchDrvCSTXXX touch;
bool touchAvailable = false; bool touchAvailable = false;
bool touchPlayPauseRequested = false; bool touchPlayPauseRequested = false;
@ -85,9 +87,21 @@ bool released(Button& button) {
return wasPressed && button.stable == HIGH; return wasPressed && button.stable == HIGH;
} }
void persist() { void markStateDirty() { stateDirty = true; }
state.wordIndex = reader.currentIndex();
void persist(bool force = false) {
const uint32_t currentWord = reader.currentIndex();
if (state.wordIndex != currentWord) {
state.wordIndex = currentWord;
stateDirty = true;
}
constexpr uint32_t kProgressCheckpointMs = 30UL * 1000UL;
if (!stateDirty || (!force && millis() - lastPersistMs < kProgressCheckpointMs)) {
return;
}
settings.save(state); settings.save(state);
stateDirty = false;
lastPersistMs = millis();
} }
void setReadingContext(uint8_t progressPercent) { void setReadingContext(uint8_t progressPercent) {
@ -130,6 +144,7 @@ bool loadChapter(const String& path) {
state.bookPath = path; state.bookPath = path;
state.wordIndex = 0; state.wordIndex = 0;
reader.restoreAtParagraphStart(0); reader.restoreAtParagraphStart(0);
markStateDirty();
readerView = ReaderView::kChapterTitle; readerView = ReaderView::kChapterTitle;
playing = false; playing = false;
return true; return true;
@ -231,7 +246,8 @@ void applyPicker() {
} else { } else {
loadChapter(pendingChapterPath); loadChapter(pendingChapterPath);
} }
persist(); markStateDirty();
persist(true);
returnToReader(); returnToReader();
} }
@ -240,14 +256,14 @@ void togglePlayback() {
if (readerView == ReaderView::kTheEnd) return; if (readerView == ReaderView::kTheEnd) return;
if (readerView == ReaderView::kChapterTitle) { if (readerView == ReaderView::kChapterTitle) {
reader.restoreAtParagraphStart(0); reader.restoreAtParagraphStart(state.wordIndex);
readerView = ReaderView::kWord; readerView = ReaderView::kWord;
playing = true; playing = true;
showCurrentWord(); showCurrentWord();
} else { } else {
playing = !playing; playing = !playing;
} }
persist(); persist(true);
if (playing) nextWordMs = millis() + reader.delayMs(state.wpm); if (playing) nextWordMs = millis() + reader.delayMs(state.wpm);
} }
@ -283,7 +299,7 @@ void navigatePreviousPaused() {
void navigateNextPaused() { void navigateNextPaused() {
switch (readerView) { switch (readerView) {
case ReaderView::kChapterTitle: case ReaderView::kChapterTitle:
reader.restoreAtParagraphStart(0); reader.restoreAtParagraphStart(state.wordIndex);
readerView = ReaderView::kWord; readerView = ReaderView::kWord;
showReaderScreen(); showReaderScreen();
break; break;
@ -372,7 +388,7 @@ void wakeDisplay() {
} }
void enterDeepSleep() { void enterDeepSleep() {
persist(); persist(true);
board.displayOff(); board.displayOff();
// GPIO12 is RTC-capable on ESP32-S3. A press (active low) wakes the reader. // GPIO12 is RTC-capable on ESP32-S3. A press (active low) wakes the reader.
esp_sleep_enable_ext0_wakeup(static_cast<gpio_num_t>(config::kButtonPrevious), 0); esp_sleep_enable_ext0_wakeup(static_cast<gpio_num_t>(config::kButtonPrevious), 0);
@ -405,11 +421,12 @@ void handleInput() {
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;
markStateDirty();
showCurrentWord(); showCurrentWord();
} else { } else {
navigatePreviousPaused(); navigatePreviousPaused();
} }
persist(); persist(true);
} }
if (released(next)) { if (released(next)) {
wakeDisplay(); wakeDisplay();
@ -426,11 +443,12 @@ void handleInput() {
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;
markStateDirty();
showCurrentWord(); showCurrentWord();
} else { } else {
navigateNextPaused(); navigateNextPaused();
} }
persist(); persist(true);
} }
} }
} // namespace } // namespace
@ -444,24 +462,27 @@ void setup() {
pinMode(config::kButtonNext, INPUT_PULLUP); pinMode(config::kButtonNext, INPUT_PULLUP);
settings.begin(); settings.begin();
state = settings.load(); state = settings.load();
bool sanitizedSettings = false;
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;
sanitizedSettings = true;
} }
if (state.brightness < 1 || state.brightness > 16) { if (state.brightness < 1 || state.brightness > 16) {
state.brightness = config::kNormalBrightness; state.brightness = config::kNormalBrightness;
sanitizedSettings = true;
} }
if (state.fontStyle > 2) { if (state.fontStyle > 2) {
state.fontStyle = config::kDefaultFontStyle; state.fontStyle = config::kDefaultFontStyle;
sanitizedSettings = true;
} }
if (state.fontSize < config::kMinimumFontSize || if (state.fontSize < config::kMinimumFontSize ||
state.fontSize > config::kMaximumFontSize || state.fontSize > config::kMaximumFontSize ||
state.fontSize % config::kFontSizeStep != 0) { state.fontSize % config::kFontSizeStep != 0) {
state.fontSize = config::kDefaultFontSize; state.fontSize = config::kDefaultFontSize;
sanitizedSettings = true;
} }
if (!board.beginSd()) { const bool sdAvailable = board.beginSd();
while (true) delay(1000);
}
if (!board.begin()) { if (!board.begin()) {
while (true) delay(1000); while (true) delay(1000);
} }
@ -469,6 +490,11 @@ void setup() {
board.setFont(state.fontStyle, state.fontSize); board.setFont(state.fontStyle, state.fontSize);
beginTouch(); beginTouch();
if (!sdAvailable) {
board.drawWord("SD CARD ERROR", state.wpm);
while (true) delay(1000);
}
String book; String book;
std::vector<uint8_t> sidecar; std::vector<uint8_t> sidecar;
if (!library.loadLastOrFirst(state.bookPath, book)) { if (!library.loadLastOrFirst(state.bookPath, book)) {
@ -480,8 +506,11 @@ void setup() {
board.drawWord("NO BOOKS", state.wpm); board.drawWord("NO BOOKS", state.wpm);
while (true) delay(1000); while (true) delay(1000);
} }
state.wordIndex = 0; const uint32_t savedWord = state.wordIndex;
reader.restoreAtParagraphStart(0); reader.restoreAtParagraphStart(savedWord);
state.wordIndex = reader.currentIndex();
stateDirty = sanitizedSettings || state.wordIndex != savedWord;
persist(true);
readerView = ReaderView::kChapterTitle; readerView = ReaderView::kChapterTitle;
showReaderScreen(); showReaderScreen();
lastInteractionMs = millis(); lastInteractionMs = millis();

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Test package for unittest discovery."""

View File

@ -15,7 +15,9 @@ from epub_to_chapters import (
add_unfamiliar_word_timing, add_unfamiliar_word_timing,
decode_styled_text, decode_styled_text,
document_root, document_root,
extract_chapter,
html_to_text, html_to_text,
item_index_map,
normalize_display_text, normalize_display_text,
normalize_text, normalize_text,
output_section_name, output_section_name,
@ -58,6 +60,11 @@ class DisplayTextNormalizationTests(unittest.TestCase):
"Cafe - Part 1-2", "Cafe - Part 1-2",
) )
def test_section_names_fit_within_the_device_filename_budget(self):
name = output_section_name("A" * 300, 1, set())
self.assertLessEqual(len(name.encode("utf-8")), 200)
class ConversionReportTests(unittest.TestCase): class ConversionReportTests(unittest.TestCase):
def test_reports_remaining_non_ascii_characters_by_generated_file(self): def test_reports_remaining_non_ascii_characters_by_generated_file(self):
@ -234,5 +241,52 @@ class UnfamiliarWordTimingTests(unittest.TestCase):
self.assertEqual(chapters[0]["style_records"], []) self.assertEqual(chapters[0]["style_records"], [])
class SameDocumentTocTests(unittest.TestCase):
def test_extracts_each_fragment_when_two_toc_entries_share_a_document(self):
class FakeItem:
def __init__(self, name, content):
self.name = name
self.content = content
def get_content(self):
return self.content.encode("utf-8")
def get_name(self):
return self.name
def get_id(self):
return self.name
def get_type(self):
from epub_to_chapters import ITEM_DOCUMENT
return ITEM_DOCUMENT
class FakeBook:
def __init__(self, item):
self.item = item
self.spine = [(item.get_id(),)]
def get_items(self):
return [self.item]
item = FakeItem(
"chapter.xhtml",
"<body><h1 id='one'>One</h1><p>First text.</p>"
"<h1 id='two'>Two</h1><p>Second text.</p></body>",
)
book = FakeBook(item)
entries = [
{"title": "One", "item": item, "fragment": "one"},
{"title": "Two", "item": item, "fragment": "two"},
]
positions = item_index_map(book)
first, _ = decode_styled_text(extract_chapter(book, entries, 0, positions))
second, _ = decode_styled_text(extract_chapter(book, entries, 1, positions))
self.assertEqual(first, "One\nFirst text.")
self.assertEqual(second, "Two\nSecond text.")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()