fix: Flatten TextBlock word storage into single allocation (#2547)

This commit is contained in:
Justin Mitchell
2026-07-06 23:41:00 +03:00
committed by GitHub
parent f2b00f6b93
commit 6f5c5a0900
6 changed files with 314 additions and 120 deletions
+20 -10
View File
@@ -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;
+17 -1
View File
@@ -39,7 +39,17 @@ std::unique_ptr<PageLine> PageLine::deserialize(HalFile& file) {
serialization::readPod(file, yPos);
auto tb = TextBlock::deserialize(file);
return std::unique_ptr<PageLine>(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<PageLine>(line);
}
void PageImage::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
@@ -155,9 +165,15 @@ std::unique_ptr<Page> 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);
+15 -4
View File
@@ -2,6 +2,7 @@
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Utf8.h>
#include <algorithm>
@@ -1133,8 +1134,14 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
}
if (!lineHasFocusSplit) {
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles),
std::vector<uint8_t>{}, std::vector<uint16_t>{}, blockStyle));
// TextBlock flattens the vectors into its arena; they stay owned here and die at return.
auto block = std::make_shared<TextBlock>(lineWords, lineXPos, lineWordStyles, std::vector<uint8_t>{},
std::vector<uint16_t>{}, 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<TextBlock>(std::move(outWords), std::move(outXPos), std::move(outStyles),
std::move(outBoundaries), std::move(outSuffixX), blockStyle));
auto block = std::make_shared<TextBlock>(outWords, outXPos, outStyles, outBoundaries, outSuffixX, blockStyle);
if (!block->valid()) {
LOG_ERR("PTX", "Dropping line: TextBlock arena allocation failed");
return;
}
processLine(std::move(block));
}
+11 -6
View File
@@ -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<const PageLine&>(*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);
}
}
}
+182 -71
View File
@@ -3,19 +3,114 @@
#include <BidiUtils.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Memory.h>
#include <Serialization.h>
#include <cstring>
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<size_t>(wordCount) * (sizeof(uint16_t) + sizeof(int16_t) + sizeof(uint8_t));
if (hasFocus) {
size += static_cast<size_t>(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<const uint16_t*>(base);
xposArr = reinterpret_cast<const int16_t*>(base + wc * 2);
size_t off = wc * 4;
if (focusPresent) {
focusSuffixXArr = reinterpret_cast<const uint16_t*>(base + off);
off += wc * 2;
}
stylesArr = base + off;
off += wc;
if (focusPresent) {
focusBoundaryArr = base + off;
off += wc;
}
textArr = reinterpret_cast<const char*>(base + off);
}
TextBlock::TextBlock(const std::vector<std::string>& words, const std::vector<int16_t>& wordXpos,
const std::vector<EpdFontFamily::Style>& wordStyles, const std::vector<uint8_t>& focusBoundary,
const std::vector<uint16_t>& 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<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(focusBoundary.size()),
static_cast<uint32_t>(focusSuffixX.size()));
isValid = false;
return;
}
numWords = static_cast<uint16_t>(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<uint32_t>(totalText));
numWords = 0;
focusPresent = false;
isValid = false;
return;
}
textBytes = static_cast<uint16_t>(totalText);
const size_t size = arenaSize(numWords, focusPresent, textBytes);
arena = makeUniqueNoThrow<uint8_t[]>(size);
if (!arena) {
LOG_ERR("TXB", "OOM: arena %u bytes", static_cast<uint32_t>(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<uint16_t*>(textOffArr);
auto* xpos = const_cast<int16_t*>(xposArr);
auto* styles = const_cast<uint8_t*>(stylesArr);
auto* text = const_cast<char*>(textArr);
uint16_t off = 0;
for (uint16_t i = 0; i < numWords; i++) {
textOff[i] = off;
xpos[i] = wordXpos[i];
styles[i] = static_cast<uint8_t>(wordStyles[i]);
memcpy(text + off, words[i].data(), words[i].size());
off += static_cast<uint16_t>(words[i].size());
text[off++] = '\0';
}
if (focusPresent) {
auto* suffixX = const_cast<uint16_t*>(focusSuffixXArr);
auto* boundary = const_cast<uint8_t*>(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::BidiBaseDir>(
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::BidiBaseDir>(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<EpdFontFamily::Style>(currentStyle | EpdFontFamily::BOLD);
const size_t boldLen = std::min<size_t>({static_cast<size_t>(boundary), words[i].size(), sizeof(boldBuf) - 1});
memcpy(boldBuf, words[i].c_str(), boldLen);
const size_t boldLen =
std::min<size_t>({static_cast<size_t>(boundary), static_cast<size_t>(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<uint8_t>(w[0]) == 0xE2 && static_cast<uint8_t>(w[1]) == 0x80 &&
static_cast<uint8_t>(w[2]) == 0x83) {
const char* visibleText = w.c_str() + 3;
if (wordTextLen(i) >= 3 && static_cast<uint8_t>(word[0]) == 0xE2 && static_cast<uint8_t>(word[1]) == 0x80 &&
static_cast<uint8_t>(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<uint32_t>(words.size()), static_cast<uint32_t>(wordXpos.size()),
static_cast<uint32_t>(wordStyles.size()), static_cast<uint32_t>(wordFocusBoundary.size()),
static_cast<uint32_t>(wordFocusSuffixX.size()));
if (!isValid) {
LOG_ERR("TXB", "Serialization failed: invalid block");
return false;
}
// Word data
serialization::writePod(file, static_cast<uint16_t>(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<uint8_t>(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<uint8_t>(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<uint32_t>(size));
return false;
}
}
// Style (alignment + margins/padding/indent)
@@ -186,41 +276,64 @@ bool TextBlock::serialize(HalFile& file) const {
std::unique_ptr<TextBlock> TextBlock::deserialize(HalFile& file) {
uint16_t wc;
std::vector<std::string> words;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> wordStyles;
std::vector<uint8_t> wordFocusBoundary;
std::vector<uint16_t> 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<TextBlock> 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<uint8_t[]>(size);
if (!block->arena) {
LOG_ERR("TXB", "OOM: arena %u bytes", static_cast<uint32_t>(size));
return nullptr;
}
if (file.read(block->arena.get(), size) != size) {
LOG_ERR("TXB", "Deserialization failed: arena read (%u bytes)", static_cast<uint32_t>(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> TextBlock::deserialize(HalFile& file) {
serialization::readPod(file, blockStyle.isRtl);
serialization::readPod(file, blockStyle.directionDefined);
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles),
std::move(wordFocusBoundary), std::move(wordFocusSuffixX),
blockStyle));
return block;
}
+69 -28
View File
@@ -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<std::string> words;
std::vector<int16_t> wordXpos;
std::vector<EpdFontFamily::Style> 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<uint8_t> 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<uint16_t> 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<uint8_t[]> 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<std::string> words, std::vector<int16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, std::vector<uint8_t> focus_boundary,
std::vector<uint16_t> 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<std::string>& words, const std::vector<int16_t>& wordXpos,
const std::vector<EpdFontFamily::Style>& wordStyles, const std::vector<uint8_t>& focusBoundary,
const std::vector<uint16_t>& 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<std::string>& 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<EpdFontFamily::Style>(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;