(self->paragraphAlignment), self->viewportWidth);
// Block/header boundaries must flush any buffered trailing word first.
// Otherwise tags like ..."item?" can carry the final word into the next paragraph.
if (self->partWordBufferIndex > 0 && ((matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) ||
(matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) && strcmp(name, "br") != 0))) {
self->flushPartWordBuffer();
}
if (matches(name, HEADER_TAGS, NUM_HEADER_TAGS)) {
self->currentCssStyle = cssStyle;
auto headerBlockStyle = BlockStyle::fromCssStyle(cssStyle, emSize, CssTextAlign::Center, self->viewportWidth);
headerBlockStyle.textAlignDefined = true;
if (self->embeddedStyle && cssStyle.hasTextAlign()) {
headerBlockStyle.alignment = cssStyle.textAlign;
}
self->startNewTextBlock(headerBlockStyle);
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
self->updateEffectiveInlineStyle();
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
if (isZeroHeightSpacerParagraph(name, styleAttr)) {
// Preserve paragraph break semantics for this
, but skip its inner text payload.
self->currentCssStyle = cssStyle;
auto blockStyle = userAlignmentBlockStyle;
if (self->embeddedStyle && cssStyle.hasTextAlign()) {
blockStyle.alignment = cssStyle.textAlign;
blockStyle.textAlignDefined = true;
}
self->startNewTextBlock(blockStyle);
self->updateEffectiveInlineStyle();
self->skipTextUntilDepth = self->depth;
self->depth += 1;
return;
}
if (strcmp(name, "br") == 0) {
if (self->partWordBufferIndex > 0) {
// flush word preceding
to currentTextBlock before calling startNewTextBlock
self->flushPartWordBuffer();
}
// Tag the new block so startNewTextBlock can inject a full line-height gap if
// the block remains empty (i.e.
is a section separator between paragraphs).
// If the block gets text added before the next block opens it becomes non-empty,
// goes through makePages() normally, and the flag has no effect (inline
case).
// Build a neutral
style that keeps inline alignment/indent context but avoids
// carrying cumulative margins from previous empty blocks (which can force spurious page breaks).
const BlockStyle& currentStyle = self->currentTextBlock->getBlockStyle();
BlockStyle brStyle;
brStyle.alignment = currentStyle.alignment;
brStyle.textAlignDefined = currentStyle.textAlignDefined;
brStyle.textIndent = currentStyle.textIndent;
brStyle.textIndentDefined = currentStyle.textIndentDefined;
brStyle.fromBrElement = true;
self->startNewTextBlock(brStyle);
} else {
self->currentCssStyle = cssStyle;
auto blockStyle = userAlignmentBlockStyle;
if (self->embeddedStyle && cssStyle.hasTextAlign()) {
blockStyle.alignment = cssStyle.textAlign;
blockStyle.textAlignDefined = true;
}
self->startNewTextBlock(blockStyle);
self->updateEffectiveInlineStyle();
if (strcmp(name, "li") == 0) {
char marker[12];
if (!self->listStack.empty() && self->listStack.back().isOrdered) {
self->listStack.back().counter += 1;
snprintf(marker, sizeof(marker), "%d.", self->listStack.back().counter);
} else {
strcpy(marker, "\xe2\x80\xa2");
}
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
} else if (strcmp(name, "pre") == 0) {
// Record depth so characterData can treat \n as a hard line break inside
.
// depth has not been incremented yet here; it will be after startElement returns.
self->preUntilDepth = std::min(self->preUntilDepth, self->depth);
}
}
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
// Push inline style entry for underline tag
StyleStackEntry entry;
entry.depth = self->depth; // Track depth for matching pop
entry.hasUnderline = true;
entry.underline = true;
if (cssStyle.hasFontWeight()) {
entry.hasBold = true;
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
}
if (cssStyle.hasFontStyle()) {
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
// Push inline style entry for bold tag
StyleStackEntry entry;
entry.depth = self->depth; // Track depth for matching pop
entry.hasBold = true;
entry.bold = true;
if (cssStyle.hasFontStyle()) {
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
// Push inline style entry for italic tag
StyleStackEntry entry;
entry.depth = self->depth; // Track depth for matching pop
entry.hasItalic = true;
entry.italic = true;
if (cssStyle.hasFontWeight()) {
entry.hasBold = true;
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
} else if (strcmp(name, "span") == 0 || !isHeaderOrBlock(name)) {
// Handle span and other inline elements for CSS styling
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
// Flush buffer before style change so preceding text gets current style
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
self->nextWordContinues = true;
}
StyleStackEntry entry;
entry.depth = self->depth; // Track depth for matching pop
if (cssStyle.hasFontWeight()) {
entry.hasBold = true;
entry.bold = cssStyle.fontWeight == CssFontWeight::Bold;
}
if (cssStyle.hasFontStyle()) {
entry.hasItalic = true;
entry.italic = cssStyle.fontStyle == CssFontStyle::Italic;
}
if (cssStyle.hasTextDecoration()) {
entry.hasUnderline = true;
entry.underline = cssStyle.textDecoration == CssTextDecoration::Underline;
}
self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle();
}
}
// Unprocessed tag, just increasing depth and continue forward
self->depth += 1;
}
void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast(userData);
// Skip content of nested table
if (self->tableDepth > 1) {
return;
}
// Middle of skip
if (self->skipUntilDepth < self->depth) {
return;
}
// Ignore character data inside synthetic zero-height spacer tags.
if (self->skipTextUntilDepth < self->depth) {
return;
}
// Collect footnote link display text (for the number label)
// Skip whitespace and brackets to normalize noterefs like "[1]" → "1"
if (self->insideFootnoteLink) {
for (int i = 0; i < len; i++) {
unsigned char c = static_cast(s[i]);
if (isWhitespace(c) || c == '[' || c == ']') continue;
if (self->currentFootnoteLinkTextLen < static_cast(sizeof(self->currentFootnoteLinkText)) - 1) {
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen++] = c;
self->currentFootnoteLinkText[self->currentFootnoteLinkTextLen] = '\0';
}
}
}
for (int i = 0; i < len; i++) {
const unsigned char c = static_cast(s[i]);
// Fast path for plain ASCII word characters (> 0x20 and < 0x80).
// This covers the vast majority of characters in Latin-script text.
// All multi-byte UTF-8 sequences start with a byte >= 0x80, so this
// path is safe to take without any further multi-byte checks.
if (c > 0x20 && c < 0x80) {
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
// Buffer is full — flush before appending. Pure ASCII means no
// partial multi-byte sequence can be at the boundary.
self->flushPartWordBuffer();
}
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
continue;
}
if (isWhitespace(s[i])) {
// Inside : treat \n as a hard line break.
if (s[i] == '\n' && self->preUntilDepth < self->depth) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
// Blank line: the current block is empty, but we still need to emit a visible
// empty line. Add a single space so the block is non-empty and makePages()
// will produce a line of the correct height instead of reusing the empty block.
if (self->currentTextBlock->isEmpty()) {
self->currentTextBlock->addWord(" ", EpdFontFamily::REGULAR);
}
self->startNewTextBlock(self->currentTextBlock->getBlockStyle());
self->nextWordContinues = false;
continue;
}
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
// Whitespace is a real word boundary — reset continuation state
self->nextWordContinues = false;
// Skip the whitespace char
continue;
}
// Detect U+00A0 (non-breaking space, UTF-8: 0xC2 0xA0) or
// U+202F (narrow no-break space, UTF-8: 0xE2 0x80 0xAF).
//
// Both are rendered as a visible space but must never allow a line break around them.
// We split the no-break space into its own word token and link the surrounding words
// with continuation flags so the layout engine treats them as an indivisible group.
//
// Example: "200 Quadratkilometer" or "200 Quadratkilometer"
// Input bytes: "200\xC2\xA0Quadratkilometer" (or 0xE2 0x80 0xAF for U+202F)
// Tokens produced:
// [0] "200" continues=false
// [1] " " continues=true (attaches to "200", no gap)
// [2] "Quadratkilometer" continues=true (attaches to " ", no gap)
//
// The continuation flags prevent the line-breaker from inserting a line break
// between "200" and "Quadratkilometer". However, "Quadratkilometer" is now a
// standalone word for hyphenation purposes, so Liang patterns can produce
// "200 Quadrat-" / "kilometer" instead of the unusable "200" / "Quadratkilometer".
if (static_cast(s[i]) == 0xC2 && i + 1 < len && static_cast(s[i + 1]) == 0xA0) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
self->partWordBuffer[0] = ' ';
self->partWordBuffer[1] = '\0';
self->partWordBufferIndex = 1;
self->nextWordContinues = true; // Attach space to previous word (no break).
self->flushPartWordBuffer();
self->nextWordContinues = true; // Next real word attaches to this space (no break).
i++; // Skip the second byte (0xA0)
continue;
}
// U+202F (narrow no-break space) — identical logic to U+00A0 above.
if (static_cast(s[i]) == 0xE2 && i + 2 < len && static_cast(s[i + 1]) == 0x80 &&
static_cast(s[i + 2]) == 0xAF) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
self->partWordBuffer[0] = ' ';
self->partWordBuffer[1] = '\0';
self->partWordBufferIndex = 1;
self->nextWordContinues = true;
self->flushPartWordBuffer();
self->nextWordContinues = true;
i += 2; // Skip the remaining two bytes (0x80 0xAF)
continue;
}
// Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF
const XML_Char FEFF_BYTE_1 = static_cast(0xEF);
const XML_Char FEFF_BYTE_2 = static_cast(0xBB);
const XML_Char FEFF_BYTE_3 = static_cast(0xBF);
if (s[i] == FEFF_BYTE_1) {
// Check if the next two bytes complete the 3-byte sequence
if ((i + 2 < len) && (s[i + 1] == FEFF_BYTE_2) && (s[i + 2] == FEFF_BYTE_3)) {
// Sequence 0xEF 0xBB 0xBF found!
i += 2; // Skip the next two bytes
continue; // Move to the next iteration
}
}
// If we're about to run out of space, then cut the word off and start a new one.
// For CJK text (no spaces), this is the primary word-breaking mechanism.
// We must avoid splitting multi-byte UTF-8 sequences across word boundaries,
// otherwise the trailing bytes become orphaned continuation bytes that the
// decoder can't interpret.
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
int safeLen = utf8SafeTruncateBuffer(self->partWordBuffer, self->partWordBufferIndex);
if (safeLen < self->partWordBufferIndex && safeLen > 0) {
// Incomplete UTF-8 sequence at the end — save it before flushing
int overflow = self->partWordBufferIndex - safeLen;
char saved[4];
for (int j = 0; j < overflow; j++) {
saved[j] = self->partWordBuffer[safeLen + j];
}
self->partWordBufferIndex = safeLen;
self->flushPartWordBuffer();
for (int j = 0; j < overflow; j++) {
self->partWordBuffer[j] = saved[j];
}
self->partWordBufferIndex = overflow;
} else {
self->flushPartWordBuffer();
}
}
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");
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
? static_cast(self->viewportWidth - horizontalInset)
: self->viewportWidth;
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, effectiveWidth,
[self](const std::shared_ptr& textBlock, const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
return self->addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry);
},
false);
}
}
void XMLCALL ChapterHtmlSlimParser::defaultHandlerExpand(void* userData, const XML_Char* s, const int len) {
// Check if this looks like an entity reference (&...;)
if (len >= 3 && s[0] == '&' && s[len - 1] == ';') {
const char* utf8Value = lookupHtmlEntity(s, static_cast(len));
if (utf8Value != nullptr) {
// Known entity: expand to its UTF-8 value
characterData(userData, utf8Value, strlen(utf8Value));
return;
}
// Unknown entity: preserve original &...; sequence
characterData(userData, s, len);
return;
}
// Not an entity we recognize - skip it
}
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast(userData);
// Check if any style state will change after we decrement depth
// If so, we MUST flush the partWordBuffer with the CURRENT style first
// Note: depth hasn't been decremented yet, so we check against (depth - 1)
const bool willPopStyleStack =
!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth - 1;
const bool willClearBold = self->boldUntilDepth == self->depth - 1;
const bool willClearItalic = self->italicUntilDepth == self->depth - 1;
const bool willClearUnderline = self->underlineUntilDepth == self->depth - 1;
const bool styleWillChange = willPopStyleStack || willClearBold || willClearItalic || willClearUnderline;
const bool headerOrBlockTag = isHeaderOrBlock(name);
const bool tableStructuralTag = isTableStructuralTag(name);
if (self->tableDepth > 1 && strcmp(name, "table") == 0) {
// get rid of all text inside the nested table
self->partWordBufferIndex = 0;
self->tableDepth -= 1;
LOG_DBG("EHP", "nested table detected, get rid of its content");
return;
}
// Flush buffer with current style BEFORE any style changes
if (self->partWordBufferIndex > 0) {
// Flush if style will change OR if we're closing a block/structural element
const bool isInlineTag =
!headerOrBlockTag && !tableStructuralTag && !matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) && self->depth != 1;
const bool shouldFlush = styleWillChange || headerOrBlockTag || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || tableStructuralTag ||
matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS) || self->depth == 1;
if (shouldFlush) {
self->flushPartWordBuffer();
// If closing an inline element, the next word fragment continues the same visual word
if (isInlineTag) {
self->nextWordContinues = true;
}
}
}
self->depth -= 1;
// Pop list entries whose ul/ol is now out of scope
while (!self->listStack.empty() && self->listStack.back().depth >= self->depth) {
self->listStack.pop_back();
}
// Closing a footnote link — create entry from collected text and href
if (self->insideFootnoteLink && self->depth == self->footnoteLinkDepth) {
if (self->currentFootnoteLinkText[0] != '\0' && self->currentFootnoteLinkHref[0] != '\0') {
FootnoteEntry entry;
strncpy(entry.number, self->currentFootnoteLinkText, sizeof(entry.number) - 1);
entry.number[sizeof(entry.number) - 1] = '\0';
strncpy(entry.href, self->currentFootnoteLinkHref, sizeof(entry.href) - 1);
entry.href[sizeof(entry.href) - 1] = '\0';
int wordIndex =
self->wordsExtractedInBlock + (self->currentTextBlock ? static_cast(self->currentTextBlock->size()) : 0);
self->pendingFootnotes.push_back({wordIndex, entry});
}
self->insideFootnoteLink = false;
}
// Leaving skip
if (self->skipUntilDepth == self->depth) {
self->skipUntilDepth = INT_MAX;
}
// Leaving zero-height spacer paragraph text-skip scope
if (self->skipTextUntilDepth == self->depth) {
self->skipTextUntilDepth = INT_MAX;
}
if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
self->nextWordContinues = false;
}
if (self->tableDepth == 1 && (strcmp(name, "tr") == 0)) {
self->nextWordContinues = false;
}
if (self->tableDepth == 1 && strcmp(name, "table") == 0) {
self->tableDepth -= 1;
self->tableRowIndex = 0;
self->tableColIndex = 0;
self->nextWordContinues = false;
}
// Leaving bold tag
if (self->boldUntilDepth == self->depth) {
self->boldUntilDepth = INT_MAX;
}
// Leaving italic tag
if (self->italicUntilDepth == self->depth) {
self->italicUntilDepth = INT_MAX;
}
// Leaving underline tag
if (self->underlineUntilDepth == self->depth) {
self->underlineUntilDepth = INT_MAX;
}
// Leaving pre tag
if (self->preUntilDepth == self->depth) {
self->preUntilDepth = INT_MAX;
}
// Pop from inline style stack if we pushed an entry at this depth
// This handles all inline elements: b, i, u, span, etc.
if (!self->inlineStyleStack.empty() && self->inlineStyleStack.back().depth == self->depth) {
self->inlineStyleStack.pop_back();
self->updateEffectiveInlineStyle();
}
// Clear block style when leaving header or block elements
if (headerOrBlockTag) {
self->currentCssStyle.reset();
self->updateEffectiveInlineStyle();
// Reset alignment on empty text blocks to prevent stale alignment from bleeding
// into the next sibling element. This fixes issue #1026 where an empty (default
// Center) followed by an image-only
causes Center to persist through the chain
// of empty block reuse into subsequent text paragraphs.
// Margins/padding are preserved so parent element spacing still accumulates correctly.
if (self->currentTextBlock && self->currentTextBlock->isEmpty()) {
auto style = self->currentTextBlock->getBlockStyle();
// Keep alignment only when closing the
separator itself so subsequent text
// within the same block container stays aligned. Reset alignment when closing
// other block tags (e.g. div/p) to avoid leaking centered/right alignment globally.
const bool preserveForBrClose = style.fromBrElement && strcmp(name, "br") == 0;
if (!preserveForBrClose) {
style.textAlignDefined = false;
style.alignment = (self->paragraphAlignment == static_cast(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast(self->paragraphAlignment);
self->currentTextBlock->setBlockStyle(style);
}
}
}
}
bool ChapterHtmlSlimParser::parseAndBuildPages() {
auto paragraphAlignmentBlockStyle = BlockStyle();
paragraphAlignmentBlockStyle.textAlignDefined = true;
// Resolve None sentinel to Justify for initial block (no CSS context yet)
const auto align = (this->paragraphAlignment == static_cast(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast(this->paragraphAlignment);
paragraphAlignmentBlockStyle.alignment = align;
startNewTextBlock(paragraphAlignmentBlockStyle);
const XML_Parser parser = XML_ParserCreate(nullptr);
int done;
if (!parser) {
LOG_ERR("EHP", "Couldn't allocate memory for parser");
return false;
}
// Handle HTML entities (like ) that aren't in XML spec or DTD
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
FsFile file;
if (!Storage.openFileForRead("EHP", filepath, file)) {
XML_ParserFree(parser);
return false;
}
const size_t totalFileSize = file.size();
size_t bytesRead = 0;
int lastReportedProgress = -1;
// Show initial progress popup for files above threshold.
if (progressFn && totalFileSize >= MIN_SIZE_FOR_POPUP) {
progressFn(0);
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
// Compute the time taken to parse and build pages
const uint32_t chapterStartTime = millis();
do {
void* const buf = XML_GetBuffer(parser, PARSE_BUFFER_SIZE);
if (!buf) {
LOG_ERR("EHP", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
return false;
}
const size_t len = file.read(buf, PARSE_BUFFER_SIZE);
bytesRead += len;
// Report progress in 5% increments to limit e-ink refreshes.
if (progressFn && totalFileSize >= MIN_SIZE_FOR_POPUP) {
const int progress = static_cast(bytesRead * 100 / totalFileSize);
if (progress / 5 > lastReportedProgress / 5) {
lastReportedProgress = progress;
progressFn(progress);
}
}
if (len == 0 && file.available() > 0) {
LOG_ERR("EHP", "File read error");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
return false;
}
done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) {
LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
return false;
}
} while (!done);
const uint32_t totalTimeMs = millis() - chapterStartTime;
LOG_DBG("EHP", "Time to parse and build pages: %lu ms", totalTimeMs);
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
file.close();
// Process last page if there is still text
if (currentTextBlock) {
makePages();
if (!pendingAnchorId.empty()) {
anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)});
pendingAnchorId.clear();
}
paragraphIndexPerPage.push_back(xpathParagraphIndex);
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset();
currentTextBlock.reset();
}
return true;
}
ParsedText::LineProcessResult ChapterHtmlSlimParser::addLineToPage(std::shared_ptr line,
const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
if (currentPageNextY + lineHeight > viewportHeight) {
paragraphIndexPerPage.push_back(xpathParagraphIndex);
completePageFn(std::move(currentPage));
completedPageCount++;
currentPage.reset(new Page());
currentPageNextY = 0;
}
const bool noRoomForAnotherLine =
currentPageNextY + lineHeight <= viewportHeight && currentPageNextY + (lineHeight * 2) > viewportHeight;
if (lineEndsWithHyphenatedWord && !suppressHyphenationRetry && noRoomForAnotherLine) {
const std::string linePreview = buildTextBlockPreview(line);
LOG_DBG("EHP", "Requesting line rerender without hyphenation to avoid page-break split word: %s",
linePreview.c_str());
return ParsedText::LineProcessResult::RetryWithoutHyphenation;
}
// Track cumulative words to assign footnotes to the page containing their anchor
wordsExtractedInBlock += line->wordCount();
auto footnoteIt = pendingFootnotes.begin();
while (footnoteIt != pendingFootnotes.end() && footnoteIt->first <= wordsExtractedInBlock) {
currentPage->addFootnote(footnoteIt->second.number, footnoteIt->second.href);
++footnoteIt;
}
pendingFootnotes.erase(pendingFootnotes.begin(), footnoteIt);
// Apply horizontal left inset (margin + padding) as x position offset
const int16_t xOffset = line->getBlockStyle().leftInset();
currentPage->elements.push_back(std::make_shared(line, xOffset, currentPageNextY));
currentPageNextY += lineHeight;
return ParsedText::LineProcessResult::Accepted;
}
void ChapterHtmlSlimParser::makePages() {
if (!currentTextBlock) {
LOG_ERR("EHP", "!! No text block to make pages for !!");
return;
}
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
const int lineHeight = renderer.getLineHeight(fontId) * lineCompression;
// Apply top spacing before the paragraph (stored in pixels)
const BlockStyle& blockStyle = currentTextBlock->getBlockStyle();
if (blockStyle.marginTop > 0) {
currentPageNextY += blockStyle.marginTop;
}
if (blockStyle.paddingTop > 0) {
currentPageNextY += blockStyle.paddingTop;
}
// Calculate effective width accounting for horizontal margins/padding
const int horizontalInset = blockStyle.totalHorizontalInset();
const uint16_t effectiveWidth =
(horizontalInset < viewportWidth) ? static_cast(viewportWidth - horizontalInset) : viewportWidth;
currentTextBlock->layoutAndExtractLines(
renderer, fontId, effectiveWidth,
[this](const std::shared_ptr& textBlock, const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
return addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry);
});
// Fallback: transfer any remaining pending footnotes to current page.
// Normally addLineToPage handles this via word-index tracking, but this catches
// edge cases where a footnote's word index equals the exact block size.
if (!pendingFootnotes.empty() && currentPage) {
for (const auto& [idx, fn] : pendingFootnotes) {
currentPage->addFootnote(fn.number, fn.href);
}
pendingFootnotes.clear();
}
// Apply bottom spacing after the paragraph (stored in pixels)
if (blockStyle.marginBottom > 0) {
currentPageNextY += blockStyle.marginBottom;
}
if (blockStyle.paddingBottom > 0) {
currentPageNextY += blockStyle.paddingBottom;
}
// Extra paragraph spacing if enabled (default behavior).
// Suppressed between lines within a block so code/preformatted text is not
// double-spaced; the last line of the block is flushed after
is closed and
// preUntilDepth has already been reset, so it still receives normal paragraph spacing.
if (extraParagraphSpacing && preUntilDepth == INT_MAX) {
currentPageNextY += lineHeight / 2;
}
}