fix: Flatten TextBlock word storage into single allocation (#2547)
This commit is contained in:
+20
-10
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user