From fdffc2e5d9d17d5c811a3b1067739e1560126ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20B=C3=ACnh=20An?= <111893501+brianhuster@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:21:08 +0700 Subject: [PATCH] fix: reduce CSS parse-time OOM risk in chapter layout (#2606) --- lib/Epub/Epub/ParsedText.cpp | 48 ++++++++++--------- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 25 +++++++--- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 2ae204a4..bf6ff8d7 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -287,7 +287,30 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, 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()) { + // 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; size_t tokenStart = 0; for (const size_t breakOffset : breakOffsets) { @@ -326,29 +349,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // --- FOCUS READING LOGIC BELOW --- - // Pre-reserve capacity to prevent mid-word heap reallocations. - size_t maxPossibleNewTokens = 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); - } + // Worst case: a segment boundary on each byte (highly punctuated UTF-8 text). + ensureTokenCapacity(word.length()); // Lambda helper to process and push individual sub-segments of the string // Use std::string_view to avoid heap allocations when slicing diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 76a6901d..2ec1d2c2 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -22,6 +22,17 @@ constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB 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 // 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 @@ -1155,12 +1166,14 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char self->partWordBuffer[self->partWordBufferIndex++] = s[i]; } - // 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. - if (self->currentTextBlock->size() > 750) { - LOG_DBG("EHP", "Text block too long, splitting into multiple pages"); + // Keep token growth bounded: CSS-heavy spans can fragment text into many tiny + // words, so flush earlier when embedded CSS is active. We still keep the + // "exclude last line" behavior to preserve paragraph flow across chunks. + const size_t blockWordCount = self->currentTextBlock->size(); + const size_t softFlushThreshold = + 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(blockWordCount)); const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth) ? static_cast(self->viewportWidth - horizontalInset)