fix: reduce CSS parse-time OOM risk in chapter layout (#2606)

This commit is contained in:
Phạm Bình An
2026-07-16 07:21:08 +03:00
committed by GitHub
parent a2db43d235
commit fdffc2e5d9
2 changed files with 44 additions and 29 deletions
+25 -23
View File
@@ -287,7 +287,30 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
effectiveNoSpaceBefore = true; effectiveNoSpaceBefore = true;
} }
const auto ensureTokenCapacity = [&](const size_t additionalTokens) {
if (additionalTokens == 0) return;
const size_t requiredSize = words.size() + additionalTokens;
if (words.capacity() >= requiredSize) return;
size_t newCapacity = words.capacity();
if (newCapacity < 16) {
newCapacity = 16;
}
while (newCapacity < requiredSize) {
newCapacity *= 2;
}
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
};
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) { if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
// CJK-heavy paragraphs can push hundreds of tiny tokens quickly when CSS toggles
// inline styles. Reserve once up front to avoid repeated vector growth reallocations.
ensureTokenCapacity(breakOffsets.size() + 1);
bool firstToken = true; bool firstToken = true;
size_t tokenStart = 0; size_t tokenStart = 0;
for (const size_t breakOffset : breakOffsets) { for (const size_t breakOffset : breakOffsets) {
@@ -326,29 +349,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
// --- FOCUS READING LOGIC BELOW --- // --- FOCUS READING LOGIC BELOW ---
// Pre-reserve capacity to prevent mid-word heap reallocations. // Worst case: a segment boundary on each byte (highly punctuated UTF-8 text).
size_t maxPossibleNewTokens = word.length(); ensureTokenCapacity(word.length());
size_t requiredSize = words.size() + maxPossibleNewTokens;
if (words.capacity() < requiredSize) {
// Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
size_t newCapacity = words.capacity() * 2;
// Ensure the doubled capacity is actually enough for this specific word
if (newCapacity < requiredSize) {
newCapacity = requiredSize;
}
// Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
if (newCapacity < 16) {
newCapacity = 16;
}
words.reserve(newCapacity);
wordStyles.reserve(newCapacity);
wordContinues.reserve(newCapacity);
wordNoSpaceBefore.reserve(newCapacity);
wordIsFocusSuffix.reserve(newCapacity);
}
// Lambda helper to process and push individual sub-segments of the string // Lambda helper to process and push individual sub-segments of the string
// Use std::string_view to avoid heap allocations when slicing // Use std::string_view to avoid heap allocations when slicing
@@ -22,6 +22,17 @@
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
constexpr size_t PARSE_BUFFER_SIZE = 1024; constexpr size_t PARSE_BUFFER_SIZE = 1024;
// This number comes from PR #73
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
// memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there.
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS = 750;
// When CSS is enabled, flush earlier to save RAM. 320 is still more than enough to build a CJK
// page at font size 14
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS = 320;
// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation // Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per // anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
// chapter. A runaway count usually means a converter injected machine-generated IDs on // chapter. A runaway count usually means a converter injected machine-generated IDs on
@@ -1155,12 +1166,14 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
self->partWordBuffer[self->partWordBufferIndex++] = s[i]; self->partWordBuffer[self->partWordBufferIndex++] = s[i];
} }
// If we have > 750 words buffered up, perform the layout and consume out all but the last line // Keep token growth bounded: CSS-heavy spans can fragment text into many tiny
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of // words, so flush earlier when embedded CSS is active. We still keep the
// memory. // "exclude last line" behavior to preserve paragraph flow across chunks.
// Spotted when reading Intermezzo, there are some really long text blocks in there. const size_t blockWordCount = self->currentTextBlock->size();
if (self->currentTextBlock->size() > 750) { const size_t softFlushThreshold =
LOG_DBG("EHP", "Text block too long, splitting into multiple pages"); self->embeddedStyle ? TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS : TEXT_BLOCK_SOFT_FLUSH_WORDS;
if (blockWordCount > softFlushThreshold) {
LOG_DBG("EHP", "Text block soft flush (%u words)", static_cast<unsigned>(blockWordCount));
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth) const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
? static_cast<uint16_t>(self->viewportWidth - horizontalInset) ? static_cast<uint16_t>(self->viewportWidth - horizontalInset)