Shrink token from 16b to 8b, improving memory usage
This commit is contained in:
parent
ce14c9d53b
commit
b977f07297
10
README.md
10
README.md
|
|
@ -111,8 +111,9 @@ with EPUB styles, punctuation, and repeated invented names:
|
||||||
```
|
```
|
||||||
|
|
||||||
The output directory must be empty unless `--overwrite` is supplied. Copy the
|
The output directory must be empty unless `--overwrite` is supplied. Copy the
|
||||||
resulting `Example Book` directory into `/books` on the card. A
|
resulting `Example Book` directory into `/books` on the card. A section is
|
||||||
section and its `.rsvp` sidecar may each be up to 1 MiB. The converter writes
|
limited to 150,000 words; its source text and `.rsvp` sidecar may each be up
|
||||||
|
to 1 MiB. The converter writes
|
||||||
`CONVERSION_REPORT.txt` in the book directory. It records any unsupported
|
`CONVERSION_REPORT.txt` in the book directory. It records any unsupported
|
||||||
non-ASCII characters left after
|
non-ASCII characters left after
|
||||||
normalizing common punctuation, ligatures, accents, whitespace, fractions, and
|
normalizing common punctuation, ligatures, accents, whitespace, fractions, and
|
||||||
|
|
@ -255,8 +256,9 @@ cloud account, or synchronization feature.
|
||||||
|
|
||||||
## Limits of the first MVP
|
## Limits of the first MVP
|
||||||
|
|
||||||
- It reads sections up to 1 MiB from `/books/<book>/<section>.txt`, in the
|
- It reads sections up to 150,000 words and 1 MiB from
|
||||||
order declared by that book's `TOC.txt`.
|
`/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
|
- The built-in display font is best for printable ASCII text. UTF-8 text files
|
||||||
are accepted, but accented/non-Latin characters need a Unicode font addition
|
are accepted, but accented/non-Latin characters need a Unicode font addition
|
||||||
before they will render correctly.
|
before they will render correctly.
|
||||||
|
|
|
||||||
15
TODO.md
15
TODO.md
|
|
@ -14,16 +14,17 @@ profile that preserves the current cadence.
|
||||||
|
|
||||||
## Converter preflight and safe section splitting
|
## Converter preflight and safe section splitting
|
||||||
|
|
||||||
Add a `--check` mode that reports section and sidecar sizes, filename issues,
|
Add a `--check` mode that reports section word counts, section and sidecar
|
||||||
and unsupported characters without writing output. Offer optional splitting at
|
sizes, filename issues, and unsupported characters without writing output.
|
||||||
paragraph boundaries for sections that exceed the device's 1 MiB file limit.
|
Offer optional splitting at paragraph boundaries for sections that exceed the
|
||||||
|
device's 150,000-word or 1 MiB file limits.
|
||||||
|
|
||||||
## Memory-limit hardware validation
|
## Memory-limit hardware validation
|
||||||
|
|
||||||
Stress-test the 1 MiB section and sidecar limits on real hardware, record PSRAM
|
Stress-test the 150,000-word and 1 MiB section/sidecar limits on real hardware,
|
||||||
headroom, and lower the supported limit if token indexing or sidecar metadata
|
record PSRAM headroom, and lower the supported limit if token indexing or
|
||||||
leaves insufficient memory for reliable reading. Include adversarial short-word
|
sidecar metadata leaves insufficient memory for reliable reading. Include
|
||||||
and dense-sidecar inputs, not just ordinary prose.
|
adversarial short-word and dense-sidecar inputs, not just ordinary prose.
|
||||||
|
|
||||||
## Reading continuity preference
|
## Reading continuity preference
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,24 +91,6 @@ BLOCK_TAGS = {
|
||||||
"h6",
|
"h6",
|
||||||
}
|
}
|
||||||
|
|
||||||
# These are useful TOC entries but generally aren't chapters.
|
|
||||||
FRONT_MATTER = {
|
|
||||||
"contents",
|
|
||||||
"table of contents",
|
|
||||||
"toc",
|
|
||||||
"cover",
|
|
||||||
"title page",
|
|
||||||
"copyright",
|
|
||||||
"copyright page",
|
|
||||||
"dedication",
|
|
||||||
"epigraph",
|
|
||||||
"acknowledgments",
|
|
||||||
"acknowledgements",
|
|
||||||
"about the author",
|
|
||||||
"also by",
|
|
||||||
"colophon",
|
|
||||||
}
|
|
||||||
|
|
||||||
FAT_FORBIDDEN_FILENAME_CHARACTERS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
FAT_FORBIDDEN_FILENAME_CHARACTERS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
STYLE_ITALIC = 0x01
|
STYLE_ITALIC = 0x01
|
||||||
|
|
@ -123,8 +105,10 @@ 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 = 1024 * 1024
|
MAX_SECTION_BYTES = 1024 * 1024
|
||||||
MAX_TOKEN_BYTES = 256 * 1024
|
MAX_SECTION_WORDS = 150_000
|
||||||
|
MAX_TOKEN_BYTES = 65_535
|
||||||
MAX_SECTION_NAME_BYTES = 200
|
MAX_SECTION_NAME_BYTES = 200
|
||||||
|
RESERVED_SECTION_NAMES = {"toc", "conversion_report"}
|
||||||
|
|
||||||
TIMING_NONE = 0
|
TIMING_NONE = 0
|
||||||
TIMING_DOUBLE = 2
|
TIMING_DOUBLE = 2
|
||||||
|
|
@ -335,9 +319,13 @@ def lookup_word_key(token):
|
||||||
|
|
||||||
|
|
||||||
def validate_token_lengths(text):
|
def validate_token_lengths(text):
|
||||||
"""Reject a malformed section containing an impractically long token."""
|
"""Reject sections whose token index would exceed the device memory budget."""
|
||||||
|
|
||||||
for word_number, match in enumerate(re.finditer(r"\S+", text), start=1):
|
for word_number, match in enumerate(re.finditer(r"\S+", text), start=1):
|
||||||
|
if word_number > MAX_SECTION_WORDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"section exceeds the {MAX_SECTION_WORDS} word limit"
|
||||||
|
)
|
||||||
if len(match.group().encode("utf-8")) > MAX_TOKEN_BYTES:
|
if len(match.group().encode("utf-8")) > MAX_TOKEN_BYTES:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"token {word_number} exceeds the {MAX_TOKEN_BYTES} byte limit"
|
f"token {word_number} exceeds the {MAX_TOKEN_BYTES} byte limit"
|
||||||
|
|
@ -1397,7 +1385,7 @@ def convert_epub(epub_path, output_dir, overwrite=False):
|
||||||
|
|
||||||
toc_entries = []
|
toc_entries = []
|
||||||
prepared_sections = []
|
prepared_sections = []
|
||||||
used_names = set()
|
used_names = set(RESERVED_SECTION_NAMES)
|
||||||
|
|
||||||
for number, chapter in enumerate(
|
for number, chapter in enumerate(
|
||||||
chapters,
|
chapters,
|
||||||
|
|
@ -1418,7 +1406,7 @@ def convert_epub(epub_path, output_dir, overwrite=False):
|
||||||
try:
|
try:
|
||||||
validate_token_lengths(chapter["text"])
|
validate_token_lengths(chapter["text"])
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
print(f"Error: {title!r} contains an invalid token: {exc}")
|
print(f"Error: {title!r} exceeds reader limits: {exc}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
sidecar_bytes = style_sidecar_bytes(
|
sidecar_bytes = style_sidecar_bytes(
|
||||||
chapter["text"], chapter["style_records"]
|
chapter["text"], chapter["style_records"]
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,13 @@ constexpr uint8_t kButtonNext = 16;
|
||||||
constexpr char kBooksDirectory[] = "/books";
|
constexpr char kBooksDirectory[] = "/books";
|
||||||
// Source sections and their optional RSVP sidecars are loaded into memory.
|
// Source sections and their optional RSVP sidecars are loaded into memory.
|
||||||
constexpr size_t kMaxSectionBytes = 1024 * 1024;
|
constexpr size_t kMaxSectionBytes = 1024 * 1024;
|
||||||
// A single token can be a long URL or identifier, but should never consume a
|
// Each token occupies eight bytes. Limiting a section to 150,000 tokens keeps
|
||||||
// material fraction of the reader's working memory.
|
// its token index near 1.2 MiB, leaving PSRAM headroom for source text,
|
||||||
constexpr uint32_t kMaxTokenBytes = 256 * 1024;
|
// sidecar metadata, and the display runtime.
|
||||||
|
constexpr uint32_t kMaxSectionWords = 150000;
|
||||||
|
// Token lengths are stored in uint16_t. A 64 KiB token is already much longer
|
||||||
|
// than the display can usefully render.
|
||||||
|
constexpr uint16_t kMaxTokenBytes = UINT16_MAX;
|
||||||
|
|
||||||
constexpr uint16_t kDefaultWpm = 300;
|
constexpr uint16_t kDefaultWpm = 300;
|
||||||
constexpr uint16_t kMinimumWpm = 100;
|
constexpr uint16_t kMinimumWpm = 100;
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
|
|
||||||
struct Token {
|
struct Token {
|
||||||
uint32_t offset;
|
uint32_t offset;
|
||||||
uint32_t length;
|
uint16_t length;
|
||||||
uint32_t paragraph;
|
|
||||||
uint8_t trailingLineBreaks;
|
uint8_t trailingLineBreaks;
|
||||||
};
|
};
|
||||||
|
static_assert(sizeof(Token) == 8, "Token storage must remain memory-budgeted");
|
||||||
|
|
||||||
struct StyleToken {
|
struct StyleToken {
|
||||||
uint32_t index;
|
uint32_t index;
|
||||||
|
|
@ -42,6 +42,7 @@ class Reader {
|
||||||
uint64_t sourceFingerprint_ = 0;
|
uint64_t sourceFingerprint_ = 0;
|
||||||
|
|
||||||
bool loadSidecar(const std::vector<uint8_t>& sidecar);
|
bool loadSidecar(const std::vector<uint8_t>& sidecar);
|
||||||
|
uint32_t paragraphStartFor(uint32_t wordIndex) const;
|
||||||
void seekStyleToken();
|
void seekStyleToken();
|
||||||
const StyleToken* currentStyleToken() const;
|
const StyleToken* currentStyleToken() const;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -154,13 +154,14 @@ bool Reader::load(const String& book, const std::vector<uint8_t>& sidecar) {
|
||||||
}
|
}
|
||||||
if (whitespace && inWord) {
|
if (whitespace && inWord) {
|
||||||
const uint32_t wordLength = i - wordStart;
|
const uint32_t wordLength = i - wordStart;
|
||||||
if (wordLength > config::kMaxTokenBytes) {
|
if (wordLength > config::kMaxTokenBytes ||
|
||||||
|
tokens_.size() >= config::kMaxSectionWords) {
|
||||||
tokens_.clear();
|
tokens_.clear();
|
||||||
paragraphStarts_.clear();
|
paragraphStarts_.clear();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
tokens_.push_back({wordStart, wordLength,
|
tokens_.push_back(
|
||||||
static_cast<uint32_t>(paragraphStarts_.size() - 1), 0});
|
{wordStart, static_cast<uint16_t>(wordLength), 0});
|
||||||
inWord = false;
|
inWord = false;
|
||||||
}
|
}
|
||||||
if (c == '\n') {
|
if (c == '\n') {
|
||||||
|
|
@ -225,7 +226,7 @@ bool Reader::loadSidecar(const std::vector<uint8_t>& sidecar) {
|
||||||
void Reader::restoreAtParagraphStart(uint32_t savedWord) {
|
void Reader::restoreAtParagraphStart(uint32_t savedWord) {
|
||||||
if (tokens_.empty()) return;
|
if (tokens_.empty()) return;
|
||||||
index_ = savedWord < tokens_.size() ? savedWord : tokens_.size() - 1;
|
index_ = savedWord < tokens_.size() ? savedWord : tokens_.size() - 1;
|
||||||
index_ = paragraphStarts_[tokens_[index_].paragraph];
|
index_ = paragraphStartFor(index_);
|
||||||
seekStyleToken();
|
seekStyleToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,22 +248,25 @@ bool Reader::advance() {
|
||||||
|
|
||||||
bool Reader::previousParagraph() {
|
bool Reader::previousParagraph() {
|
||||||
if (tokens_.empty()) return false;
|
if (tokens_.empty()) return false;
|
||||||
const uint32_t paragraph = tokens_[index_].paragraph;
|
const auto nextParagraph = std::upper_bound(
|
||||||
if (paragraph == 0) {
|
paragraphStarts_.begin(), paragraphStarts_.end(), index_);
|
||||||
|
const auto currentParagraph = std::prev(nextParagraph);
|
||||||
|
if (currentParagraph == paragraphStarts_.begin()) {
|
||||||
index_ = 0;
|
index_ = 0;
|
||||||
seekStyleToken();
|
seekStyleToken();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
index_ = paragraphStarts_[paragraph - 1];
|
index_ = *std::prev(currentParagraph);
|
||||||
seekStyleToken();
|
seekStyleToken();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Reader::nextParagraph() {
|
bool Reader::nextParagraph() {
|
||||||
if (tokens_.empty()) return false;
|
if (tokens_.empty()) return false;
|
||||||
const uint32_t paragraph = tokens_[index_].paragraph;
|
const auto nextParagraph = std::upper_bound(
|
||||||
if (paragraph + 1 >= paragraphStarts_.size()) return false;
|
paragraphStarts_.begin(), paragraphStarts_.end(), index_);
|
||||||
index_ = paragraphStarts_[paragraph + 1];
|
if (nextParagraph == paragraphStarts_.end()) return false;
|
||||||
|
index_ = *nextParagraph;
|
||||||
seekStyleToken();
|
seekStyleToken();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -286,6 +290,12 @@ uint8_t Reader::progressPercent() const {
|
||||||
lastIndex);
|
lastIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t Reader::paragraphStartFor(uint32_t wordIndex) const {
|
||||||
|
const auto nextParagraph = std::upper_bound(
|
||||||
|
paragraphStarts_.begin(), paragraphStarts_.end(), wordIndex);
|
||||||
|
return *std::prev(nextParagraph);
|
||||||
|
}
|
||||||
|
|
||||||
void Reader::seekStyleToken() {
|
void Reader::seekStyleToken() {
|
||||||
const auto record = std::lower_bound(
|
const auto record = std::lower_bound(
|
||||||
styleTokens_.begin(), styleTokens_.end(), index_,
|
styleTokens_.begin(), styleTokens_.end(), index_,
|
||||||
|
|
|
||||||
|
|
@ -503,7 +503,7 @@ void loop() {
|
||||||
handleTouch();
|
handleTouch();
|
||||||
handleInput();
|
handleInput();
|
||||||
|
|
||||||
if (playing && millis() >= nextWordMs) {
|
if (playing && static_cast<int32_t>(millis() - nextWordMs) >= 0) {
|
||||||
if (!reader.advance()) {
|
if (!reader.advance()) {
|
||||||
String nextChapterPath;
|
String nextChapterPath;
|
||||||
if (library.nextChapter(state.bookPath, nextChapterPath) &&
|
if (library.nextChapter(state.bookPath, nextChapterPath) &&
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from epub_to_chapters import (
|
||||||
TIMING_DOUBLE,
|
TIMING_DOUBLE,
|
||||||
TIMING_TRIPLE,
|
TIMING_TRIPLE,
|
||||||
MAX_TOKEN_BYTES,
|
MAX_TOKEN_BYTES,
|
||||||
|
MAX_SECTION_WORDS,
|
||||||
add_unfamiliar_word_timing,
|
add_unfamiliar_word_timing,
|
||||||
decode_styled_text,
|
decode_styled_text,
|
||||||
document_root,
|
document_root,
|
||||||
|
|
@ -67,6 +68,15 @@ class DisplayTextNormalizationTests(unittest.TestCase):
|
||||||
|
|
||||||
self.assertLessEqual(len(name.encode("utf-8")), 200)
|
self.assertLessEqual(len(name.encode("utf-8")), 200)
|
||||||
|
|
||||||
|
def test_section_names_do_not_collide_with_generated_files(self):
|
||||||
|
used_names = {"toc", "conversion_report"}
|
||||||
|
|
||||||
|
self.assertEqual(output_section_name("TOC", 1, used_names), "TOC (2)")
|
||||||
|
self.assertEqual(
|
||||||
|
output_section_name("CONVERSION_REPORT", 2, used_names),
|
||||||
|
"CONVERSION_REPORT (2)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TokenValidationTests(unittest.TestCase):
|
class TokenValidationTests(unittest.TestCase):
|
||||||
def test_allows_a_token_at_the_configured_byte_limit(self):
|
def test_allows_a_token_at_the_configured_byte_limit(self):
|
||||||
|
|
@ -76,6 +86,10 @@ class TokenValidationTests(unittest.TestCase):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
validate_token_lengths("a" * (MAX_TOKEN_BYTES + 1))
|
validate_token_lengths("a" * (MAX_TOKEN_BYTES + 1))
|
||||||
|
|
||||||
|
def test_rejects_sections_with_too_many_words(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_token_lengths("a " * (MAX_SECTION_WORDS + 1))
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue