diff --git a/docs/file-formats.md b/docs/file-formats.md index eec9474f..4289f7e1 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -90,13 +90,13 @@ if (parsedSize != fileSize) { ## `section.bin` -### Version 28 +### Version 29 Each file in `sections/*.bin` stores one laid-out spine section. The header is also the cache-busting key: if any layout-affecting setting differs from the current reader settings, the section is discarded and rebuilt. -Version 28 includes: +Version 29 includes: - cache-busting fields for paragraph alignment, hyphenation, embedded CSS, image rendering mode, and Focus Reading @@ -107,6 +107,10 @@ Version 28 includes: - per-page footnote entries - serialized word style bits for underline, strikethrough, superscript, and subscript +- flat TextBlock word storage (v29): per-word arrays plus one shared + NUL-terminated text blob, replacing v28's length-prefixed word strings. The + on-disk order mirrors the in-RAM arena so the firmware reads a whole block + payload with a single allocation and a single SD read ImHex pattern: @@ -115,7 +119,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 28 +#define EXPECTED_VERSION 29 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 96 @@ -176,14 +180,20 @@ struct BlockStyle { struct TextBlock { u16 wordCount; - String words[wordCount]; - s16 wordXPos[wordCount]; - WordStyle wordStyle[wordCount]; - u8 hasFocus; - if (hasFocus != 0) { - u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; - u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; + u16 textBytes [[comment("Total size of text[], including one NUL per word")]]; + + if (wordCount > 0) { + u16 textOff[wordCount] [[comment("Byte offset of word i's text within text[]")]]; + s16 wordXPos[wordCount]; + if (hasFocus != 0) { + u16 wordFocusSuffixX[wordCount] [[comment("Suffix x offset from word start")]]; + } + WordStyle wordStyle[wordCount]; + if (hasFocus != 0) { + u8 wordFocusBoundary[wordCount] [[comment("UTF-8 byte boundary between bold prefix and suffix")]]; + } + char text[textBytes] [[comment("All words back to back, each NUL-terminated")]]; } BlockStyle blockStyle; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 5032056c..25c1f512 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -39,7 +39,17 @@ std::unique_ptr PageLine::deserialize(HalFile& file) { serialization::readPod(file, yPos); auto tb = TextBlock::deserialize(file); - return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); + if (!tb) { + LOG_ERR("PGE", "Deserialization failed: null TextBlock"); + return nullptr; + } + + auto* line = new (std::nothrow) PageLine(std::move(tb), xPos, yPos); + if (!line) { + LOG_ERR("PGE", "Deserialization failed: could not allocate PageLine"); + return nullptr; + } + return std::unique_ptr(line); } void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { @@ -155,9 +165,15 @@ std::unique_ptr Page::deserialize(HalFile& file) { if (tag == TAG_PageLine) { auto pl = PageLine::deserialize(file); + if (!pl) { + return nullptr; + } page->elements.push_back(std::move(pl)); } else if (tag == TAG_PageImage) { auto pi = PageImage::deserialize(file); + if (!pi) { + return nullptr; + } page->elements.push_back(std::move(pi)); } else if (tag == TAG_PageHorizontalRule) { auto rule = PageHorizontalRule::deserialize(file); diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index ebe758db..2ae204a4 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -1133,8 +1134,14 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } if (!lineHasFocusSplit) { - processLine(std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), - std::vector{}, std::vector{}, blockStyle)); + // TextBlock flattens the vectors into its arena; they stay owned here and die at return. + auto block = std::make_shared(lineWords, lineXPos, lineWordStyles, std::vector{}, + std::vector{}, blockStyle); + if (!block->valid()) { + LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); + return; + } + processLine(std::move(block)); return; } @@ -1179,6 +1186,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const } } - processLine(std::make_shared(std::move(outWords), std::move(outXPos), std::move(outStyles), - std::move(outBoundaries), std::move(outSuffixX), blockStyle)); + auto block = std::make_shared(outWords, outXPos, outStyles, outBoundaries, outSuffixX, blockStyle); + if (!block->valid()) { + LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed"); + return; + } + processLine(std::move(block)); } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 2de11f58..663d086b 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -11,8 +11,9 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -// v28: text decoration bits now include line-through in serialized wordStyles. -constexpr uint8_t SECTION_FILE_VERSION = 28; +// v29: TextBlock word data stored as one flat arena (offset table + NUL-terminated +// text blob) instead of length-prefixed strings and per-field arrays. +constexpr uint8_t SECTION_FILE_VERSION = 29; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects @@ -25,7 +26,11 @@ constexpr uint8_t SECTION_FILE_INCOMPLETE_VERSION = 0; // rebuilding in the background. Uses the same header layout as SECTION_FILE_VERSION, // so finalized files are untouched by this feature; older firmware treats the sentinel // as an unknown version and rebuilds, which is a safe downgrade. -constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE; +// MUST change in lockstep with SECTION_FILE_VERSION: the sentinel IS the partial's +// format version, so a stale-format partial otherwise passes the header check and +// only fails (noisily, via the block-decode error path) when a page is loaded. +// Derived so the pairing can't be forgotten: 0xFE for v28, 0xFD for v29, ... +constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE - (SECTION_FILE_VERSION - 28); constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + @@ -733,10 +738,10 @@ std::string Section::getTextFromSectionFile() { if (el->getTag() == TAG_PageLine) { const auto& line = static_cast(*el); if (line.getBlock()) { - const auto& words = line.getBlock()->getWords(); - for (const auto& w : words) { + const auto& block = *line.getBlock(); + for (uint16_t i = 0; i < block.wordCount(); i++) { if (!fullText.empty()) fullText += " "; - fullText += w; + fullText += block.wordText(i); } } } diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index 3d5f920b..c90467d6 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -3,19 +3,114 @@ #include #include #include +#include #include #include -void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { +size_t TextBlock::arenaSize(const uint16_t wordCount, const bool hasFocus, const uint16_t textBytes) { + // Layout documented in TextBlock.h: 16-bit arrays first, then 8-bit arrays, then text. + size_t size = static_cast(wordCount) * (sizeof(uint16_t) + sizeof(int16_t) + sizeof(uint8_t)); + if (hasFocus) { + size += static_cast(wordCount) * (sizeof(uint16_t) + sizeof(uint8_t)); + } + return size + textBytes; +} + +void TextBlock::bindArenaPointers() { + uint8_t* base = arena.get(); + const size_t wc = numWords; + textOffArr = reinterpret_cast(base); + xposArr = reinterpret_cast(base + wc * 2); + size_t off = wc * 4; + if (focusPresent) { + focusSuffixXArr = reinterpret_cast(base + off); + off += wc * 2; + } + stylesArr = base + off; + off += wc; + if (focusPresent) { + focusBoundaryArr = base + off; + off += wc; + } + textArr = reinterpret_cast(base + off); +} + +TextBlock::TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& focusBoundary, + const std::vector& focusSuffixX, const BlockStyle& blockStyle) + : blockStyle(blockStyle) { // Focus annotations are optional: empty vectors mean no word in this block has a split. // When present, they must be sized in lockstep with words[]. - const bool hasFocus = !wordFocusBoundary.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { - LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", - (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size(), - (uint32_t)wordFocusBoundary.size(), (uint32_t)wordFocusSuffixX.size()); + const bool hasFocus = !focusBoundary.empty(); + if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || words.size() > 10000 || + (hasFocus && (words.size() != focusBoundary.size() || words.size() != focusSuffixX.size()))) { + LOG_ERR("TXB", "Construction failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)", + static_cast(words.size()), static_cast(wordXpos.size()), + static_cast(wordStyles.size()), static_cast(focusBoundary.size()), + static_cast(focusSuffixX.size())); + isValid = false; + return; + } + + numWords = static_cast(words.size()); + focusPresent = hasFocus; + if (numWords == 0) { + return; // valid empty block, no arena + } + + // Pass 1: total text size, one NUL per word. A line is at most a physical + // row of the page, so uint16_t offsets are ample; reject anything larger. + size_t totalText = 0; + for (const auto& w : words) totalText += w.size() + 1; + if (totalText > UINT16_MAX) { + LOG_ERR("TXB", "Construction failed: text size %u exceeds arena limit", static_cast(totalText)); + numWords = 0; + focusPresent = false; + isValid = false; + return; + } + textBytes = static_cast(totalText); + + const size_t size = arenaSize(numWords, focusPresent, textBytes); + arena = makeUniqueNoThrow(size); + if (!arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + numWords = 0; + textBytes = 0; + focusPresent = false; + isValid = false; + return; + } + bindArenaPointers(); + + // Pass 2: fill. Mutable aliases of the const views bound above. + auto* textOff = const_cast(textOffArr); + auto* xpos = const_cast(xposArr); + auto* styles = const_cast(stylesArr); + auto* text = const_cast(textArr); + uint16_t off = 0; + for (uint16_t i = 0; i < numWords; i++) { + textOff[i] = off; + xpos[i] = wordXpos[i]; + styles[i] = static_cast(wordStyles[i]); + memcpy(text + off, words[i].data(), words[i].size()); + off += static_cast(words[i].size()); + text[off++] = '\0'; + } + if (focusPresent) { + auto* suffixX = const_cast(focusSuffixXArr); + auto* boundary = const_cast(focusBoundaryArr); + for (uint16_t i = 0; i < numWords; i++) { + suffixX[i] = focusSuffixX[i]; + boundary[i] = focusBoundary[i]; + } + } +} + +void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { + if (!isValid) { + LOG_ERR("TXB", "Render skipped: invalid block"); return; } @@ -54,12 +149,13 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } }; - for (size_t i = 0; i < words.size(); i++) { - const int wordX = wordXpos[i] + x; - const EpdFontFamily::Style currentStyle = wordStyles[i]; - const auto baseDir = static_cast( - BidiUtils::detectParagraphLevel(words[i].c_str(), blockStyle.isRtl ? 1 : 0)); - const uint8_t boundary = hasFocus ? wordFocusBoundary[i] : 0; + for (uint16_t i = 0; i < numWords; i++) { + const char* word = wordText(i); + const int wordX = xposArr[i] + x; + const EpdFontFamily::Style currentStyle = wordStyle(i); + const auto baseDir = + static_cast(BidiUtils::detectParagraphLevel(word, blockStyle.isRtl ? 1 : 0)); + const uint8_t boundary = focusBoundary(i); // SUP/SUB shift the baseline passed to drawText; the glyph is also scaled 50% inside // drawText, so these offsets are chosen relative to the full-size ascender: @@ -82,14 +178,15 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int static_assert(sizeof(boldBuf) >= MAX_FOCUS_PREFIX_BYTES, "boldBuf too small for max focus prefix (9 codepoints * 4 UTF-8 bytes + null)"); const auto boldStyle = static_cast(currentStyle | EpdFontFamily::BOLD); - const size_t boldLen = std::min({static_cast(boundary), words[i].size(), sizeof(boldBuf) - 1}); - memcpy(boldBuf, words[i].c_str(), boldLen); + const size_t boldLen = + std::min({static_cast(boundary), static_cast(wordTextLen(i)), sizeof(boldBuf) - 1}); + memcpy(boldBuf, word, boldLen); boldBuf[boldLen] = '\0'; renderer.drawText(fontId, wordX, wordY, boldBuf, true, boldStyle, baseDir); - const int suffixX = wordX + wordFocusSuffixX[i]; - renderer.drawText(fontId, suffixX, wordY, words[i].c_str() + boldLen, true, currentStyle, baseDir); + const int suffixX = wordX + focusSuffixXArr[i]; + renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir); } else { - renderer.drawText(fontId, wordX, wordY, words[i].c_str(), true, currentStyle, baseDir); + renderer.drawText(fontId, wordX, wordY, word, true, currentStyle, baseDir); } if (scanning) { @@ -97,18 +194,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } if (EpdFontFamily::hasTextDecoration(currentStyle)) { - const std::string& w = words[i]; int lineStartX = wordX; - int lineWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle, baseDir); + int lineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir); if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { lineWidth = (lineWidth + 1) / 2; } // Do not decorate the synthetic em-space used for paragraph indentation. - if (w.size() >= 3 && static_cast(w[0]) == 0xE2 && static_cast(w[1]) == 0x80 && - static_cast(w[2]) == 0x83) { - const char* visibleText = w.c_str() + 3; + if (wordTextLen(i) >= 3 && static_cast(word[0]) == 0xE2 && static_cast(word[1]) == 0x80 && + static_cast(word[2]) == 0x83) { + const char* visibleText = word + 3; lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir); if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { @@ -140,29 +236,23 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } bool TextBlock::serialize(HalFile& file) const { - // Focus annotations are optional; vectors are either empty (no splits in this block) - // or sized in lockstep with words[]. - const bool hasFocus = !wordFocusBoundary.empty(); - if (words.size() != wordXpos.size() || words.size() != wordStyles.size() || - (hasFocus && (words.size() != wordFocusBoundary.size() || words.size() != wordFocusSuffixX.size()))) { - LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u, boundary=%u, suffixX=%u)\n", - static_cast(words.size()), static_cast(wordXpos.size()), - static_cast(wordStyles.size()), static_cast(wordFocusBoundary.size()), - static_cast(wordFocusSuffixX.size())); + if (!isValid) { + LOG_ERR("TXB", "Serialization failed: invalid block"); return false; } - // Word data - serialization::writePod(file, static_cast(words.size())); - for (const auto& w : words) serialization::writeString(file, w); - for (auto x : wordXpos) serialization::writePod(file, x); - for (auto s : wordStyles) serialization::writePod(file, s); - // Focus block: 1-byte presence flag, followed by per-word vectors only when present. - // Saves 3 bytes/word when focus reading is disabled or no word on this line was split. - serialization::writePod(file, static_cast(hasFocus ? 1 : 0)); - if (hasFocus) { - for (auto b : wordFocusBoundary) serialization::writePod(file, b); - for (auto sx : wordFocusSuffixX) serialization::writePod(file, sx); + // Word data: scalars, then the arena verbatim -- its in-memory layout is + // exactly the on-disk layout (see TextBlock.h), so one write covers all + // per-word arrays and the text blob. + serialization::writePod(file, numWords); + serialization::writePod(file, static_cast(focusPresent ? 1 : 0)); + serialization::writePod(file, textBytes); + if (numWords > 0) { + const size_t size = arenaSize(numWords, focusPresent, textBytes); + if (file.write(arena.get(), size) != size) { + LOG_ERR("TXB", "Serialization failed: arena write (%u bytes)", static_cast(size)); + return false; + } } // Style (alignment + margins/padding/indent) @@ -186,41 +276,64 @@ bool TextBlock::serialize(HalFile& file) const { std::unique_ptr TextBlock::deserialize(HalFile& file) { uint16_t wc; - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - std::vector wordFocusBoundary; - std::vector wordFocusSuffixX; - BlockStyle blockStyle; - - // Word count + uint8_t hasFocus; + uint16_t textBytes; serialization::readPod(file, wc); + serialization::readPod(file, hasFocus); + serialization::readPod(file, textBytes); - // Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block) + // Sanity checks: cap the arena allocation and reject impossible geometry + // (every word carries at least its NUL terminator). if (wc > 10000) { LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc); return nullptr; } + if ((wc == 0 && textBytes != 0) || (wc > 0 && textBytes < wc)) { + LOG_ERR("TXB", "Deserialization failed: bad text size %u for %u words", textBytes, wc); + return nullptr; + } - // Word data - words.resize(wc); - wordXpos.resize(wc); - wordStyles.resize(wc); - for (auto& w : words) serialization::readString(file, w); - for (auto& x : wordXpos) serialization::readPod(file, x); - for (auto& s : wordStyles) serialization::readPod(file, s); - // Focus block: presence flag, then vectors only if present. Empty vectors when absent - // signal "no splits in this block" to render() (zero per-word RAM cost). - uint8_t hasFocus; - serialization::readPod(file, hasFocus); - if (hasFocus) { - wordFocusBoundary.resize(wc); - wordFocusSuffixX.resize(wc); - for (auto& b : wordFocusBoundary) serialization::readPod(file, b); - for (auto& sx : wordFocusSuffixX) serialization::readPod(file, sx); + std::unique_ptr block(new (std::nothrow) TextBlock()); + if (!block) { + LOG_ERR("TXB", "OOM: TextBlock"); + return nullptr; + } + block->numWords = wc; + block->textBytes = textBytes; + block->focusPresent = hasFocus != 0; + + if (wc > 0) { + const size_t size = arenaSize(wc, block->focusPresent, textBytes); + block->arena = makeUniqueNoThrow(size); + if (!block->arena) { + LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); + return nullptr; + } + if (file.read(block->arena.get(), size) != size) { + LOG_ERR("TXB", "Deserialization failed: arena read (%u bytes)", static_cast(size)); + return nullptr; + } + block->bindArenaPointers(); + + // Validate offsets before anything dereferences wordText(): offset 0 first, + // strictly increasing, in bounds, and every word NUL-terminated (word i ends + // at the byte before offset i+1; the last word at the last text byte). + const uint16_t* textOff = block->textOffArr; + const char* text = block->textArr; + if (textOff[0] != 0 || text[textBytes - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt text layout"); + return nullptr; + } + for (uint16_t i = 1; i < wc; i++) { + if (textOff[i] <= textOff[i - 1] || textOff[i] >= textBytes || text[textOff[i] - 1] != '\0') { + LOG_ERR("TXB", "Deserialization failed: corrupt word offset %u", i); + return nullptr; + } + } } // Style (alignment + margins/padding/indent) + BlockStyle& blockStyle = block->blockStyle; serialization::readPod(file, blockStyle.alignment); serialization::readPod(file, blockStyle.textAlignDefined); serialization::readPod(file, blockStyle.marginTop); @@ -236,7 +349,5 @@ std::unique_ptr TextBlock::deserialize(HalFile& file) { serialization::readPod(file, blockStyle.isRtl); serialization::readPod(file, blockStyle.directionDefined); - return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), - std::move(wordFocusBoundary), std::move(wordFocusSuffixX), - blockStyle)); + return block; } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 5f4bf80e..38f24f53 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -9,42 +9,83 @@ #include "Block.h" #include "BlockStyle.h" -// Represents a line of text on a page +// Represents a line of text on a page. +// +// All per-word data lives in ONE flat heap allocation (the arena) instead of +// six parallel vectors: a resident page holds ~25-30 of these blocks, and the +// vector-of-string layout cost ~250 throwing allocations per page load, which +// was the primary driver of heap fragmentation on the ESP32-C3. +// +// Arena layout, in order (2-byte alignment holds by construction: all 16-bit +// arrays come first and the arena base is allocator-aligned; RISC-V faults on +// unaligned multi-byte access): +// uint16_t textOff[wordCount] byte offset of word i's text in text[] +// int16_t xpos[wordCount] +// uint16_t focusSuffixX[wordCount] present only when focusPresent +// uint8_t styles[wordCount] +// uint8_t focusBoundary[wordCount] present only when focusPresent +// char text[textBytes] all words back to back, NUL-terminated +// +// Each word is stored NUL-terminated so render() can hand `text + textOff[i]` +// straight to C APIs (drawText) with no std::string materialization. +// +// Focus split semantics (unchanged from the vector layout): boundary N > 0 +// means the first N bytes of word i render bold, the remainder in the base +// style. N is bounded to 9 codepoints (<= 36 UTF-8 bytes) by the clamp in +// ParsedText::addWord. focusSuffixX is the pre-computed pixel offset from the +// word start to the regular suffix. Both arrays are omitted from the arena +// entirely when no word on the line has a split (zero per-word RAM cost when +// focus reading is disabled). class TextBlock final : public Block { private: - std::vector words; - std::vector wordXpos; - std::vector wordStyles; - // Per-word focus boundary: N > 0 means the first N bytes of words[i] are rendered bold, - // the remainder in the base style. 0 means no split (whole word uses wordStyles[i]). - // N encodes the bold PREFIX length only — bounded to 9 codepoints (≤36 UTF-8 bytes) by - // FOCUS_READING_PERCENT's 1..9 clamp in ParsedText::addWord, so it always fits in uint8_t. - // Vector is empty when no focus splits exist anywhere in the block (zero per-word RAM cost - // when focus reading is disabled, or on lines that happen to contain no splittable words). - std::vector wordFocusBoundary; - // Pre-computed pixel offset from word start to the regular suffix, stored when boundary > 0. - // Eliminates getTextAdvanceX from the render path. 0 when boundary == 0. - // Empty in lockstep with wordFocusBoundary. - std::vector wordFocusSuffixX; BlockStyle blockStyle; + uint16_t numWords = 0; + uint16_t textBytes = 0; // total size of the text region, including NULs + bool focusPresent = false; + bool isValid = true; + // The ONLY allocation: makeUniqueNoThrow, so OOM yields an invalid block + // instead of abort() (bare new is not nothrow with -fno-exceptions). + std::unique_ptr arena; + // Typed views into the arena, bound once after the arena is filled. All + // 16-bit bases sit at even offsets, so direct dereference is alignment-safe. + const uint16_t* textOffArr = nullptr; + const int16_t* xposArr = nullptr; + const uint16_t* focusSuffixXArr = nullptr; // null when !focusPresent + const uint8_t* stylesArr = nullptr; + const uint8_t* focusBoundaryArr = nullptr; // null when !focusPresent + const char* textArr = nullptr; + + TextBlock() = default; // deserialize() fills the fields directly + static size_t arenaSize(uint16_t wordCount, bool hasFocus, uint16_t textBytes); + void bindArenaPointers(); public: - explicit TextBlock(std::vector words, std::vector word_xpos, - std::vector word_styles, std::vector focus_boundary, - std::vector focus_suffix_x, const BlockStyle& blockStyle = BlockStyle()) - : words(std::move(words)), - wordXpos(std::move(word_xpos)), - wordStyles(std::move(word_styles)), - wordFocusBoundary(std::move(focus_boundary)), - wordFocusSuffixX(std::move(focus_suffix_x)), - blockStyle(blockStyle) {} + // Flatten-on-construct: copies the layout-time vectors into the arena; the + // vectors die with the caller. On arena OOM the block is empty and valid() + // is false -- callers must check and fail the line instead of using it. + explicit TextBlock(const std::vector& words, const std::vector& wordXpos, + const std::vector& wordStyles, const std::vector& focusBoundary, + const std::vector& focusSuffixX, const BlockStyle& blockStyle = BlockStyle()); ~TextBlock() override = default; + TextBlock(const TextBlock&) = delete; + TextBlock& operator=(const TextBlock&) = delete; + void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } const BlockStyle& getBlockStyle() const { return blockStyle; } - const std::vector& getWords() const { return words; } - bool isEmpty() override { return words.empty(); } - size_t wordCount() const { return words.size(); } - // given a renderer works out where to break the words into lines + bool isEmpty() override { return numWords == 0; } + bool valid() const { return isValid; } + uint16_t wordCount() const { return numWords; } + // NUL-terminated by construction; safe to pass to C APIs directly. + const char* wordText(const uint16_t i) const { return textArr + textOffArr[i]; } + uint16_t wordTextLen(const uint16_t i) const { + const uint16_t end = (i + 1 < numWords) ? textOffArr[i + 1] : textBytes; + return end - textOffArr[i] - 1; // exclude the NUL + } + int16_t wordXpos(const uint16_t i) const { return xposArr[i]; } + EpdFontFamily::Style wordStyle(const uint16_t i) const { return static_cast(stylesArr[i]); } + uint8_t focusBoundary(const uint16_t i) const { return focusPresent ? focusBoundaryArr[i] : 0; } + uint16_t focusSuffixX(const uint16_t i) const { return focusPresent ? focusSuffixXArr[i] : 0; } + void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } bool serialize(HalFile& file) const;