#include "ChapterHtmlSlimParser.h" #include #include #include #include #include #include #include #include #include #include #include "Epub.h" #include "Epub/Page.h" #include "Epub/converters/ImageDecoderFactory.h" #include "Epub/converters/ImageToFramebufferDecoder.h" #include "Epub/htmlEntities.h" // Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB constexpr size_t PARSE_BUFFER_SIZE = 1024; // 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 // every text fragment (e.g. Kobo KePub spans). The cap prevents unbounded heap growth // on resource-constrained devices (~380KB heap). TOC anchors bypass this cap. constexpr size_t MAX_ANCHORS_PER_CHAPTER = 1024; constexpr const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr const char* BOLD_TAGS[] = {"b", "strong"}; constexpr const char* ITALIC_TAGS[] = {"i", "em"}; constexpr const char* UNDERLINE_TAGS[] = {"u", "ins"}; constexpr const char* IMAGE_TAGS[] = {"img"}; constexpr const char* SKIP_TAGS[] = {"head"}; bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; } bool matches(const char* tag_name, const char* const* possible_tags, size_t count) { for (size_t i = 0; i < count; i++) { if (strcmp(tag_name, possible_tags[i]) == 0) { return true; } } return false; } const char* getAttribute(const XML_Char** atts, const char* attrName) { if (!atts) return nullptr; for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], attrName) == 0) return atts[i + 1]; } return nullptr; } // Returns true if the HTML element is a purely inline, non-navigable wrapper. // IDs on these elements are never meaningful navigation targets in epub content. // Reading-system converters (Kobo KePub, Calibre, etc.) frequently inject thousands // of such IDs for progress tracking or internal bookkeeping, and recording each one // as a navigation anchor exhausts the heap on memory-constrained devices. // Block-level, sectioning, and structural elements are always considered navigable. bool isNonNavigableInlineElement(const char* name) { return strcmp(name, "span") == 0; } bool isInternalEpubLink(const char* href) { if (!href || href[0] == '\0') return false; if (strncmp(href, "http://", 7) == 0 || strncmp(href, "https://", 8) == 0) return false; if (strncmp(href, "mailto:", 7) == 0) return false; if (strncmp(href, "ftp://", 6) == 0) return false; if (strncmp(href, "tel:", 4) == 0) return false; if (strncmp(href, "javascript:", 11) == 0) return false; return true; } bool isHeaderOrBlock(const char* name) { return matches(name, HEADER_TAGS, std::size(HEADER_TAGS)) || matches(name, BLOCK_TAGS, std::size(BLOCK_TAGS)); } bool isTableStructuralTag(const char* name) { return strcmp(name, "table") == 0 || strcmp(name, "tr") == 0 || strcmp(name, "td") == 0 || strcmp(name, "th") == 0; } void ChapterHtmlSlimParser::applyDirectionToEntry(StyleStackEntry& entry, const CssStyle& css) { if (css.hasDirection()) { entry.hasDirection = true; entry.direction = css.direction; } } // Update effective bold/italic/underline based on block style and inline style stack void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { // Start with block-level styles effectiveBold = currentCssStyle.hasFontWeight() && currentCssStyle.fontWeight == CssFontWeight::Bold; effectiveItalic = currentCssStyle.hasFontStyle() && currentCssStyle.fontStyle == CssFontStyle::Italic; effectiveUnderline = currentCssStyle.hasTextDecoration() && currentCssStyle.textDecoration == CssTextDecoration::Underline; effectiveDirectionDefined = currentCssStyle.hasDirection(); effectiveDirection = currentCssStyle.direction; effectiveSup = false; effectiveSub = false; // Apply inline style stack in order for (const auto& entry : inlineStyleStack) { if (entry.hasBold) { effectiveBold = entry.bold; } if (entry.hasItalic) { effectiveItalic = entry.italic; } if (entry.hasUnderline) { effectiveUnderline = entry.underline; } if (entry.hasDirection) { effectiveDirectionDefined = true; effectiveDirection = entry.direction; } if (entry.hasSup) { effectiveSup = entry.sup; if (entry.sup) effectiveSub = false; } if (entry.hasSub) { effectiveSub = entry.sub; if (entry.sub) effectiveSup = false; } } // Keep inherited direction in the active empty text block so upcoming block starts // can inherit from non-block ancestors such as / . if (currentTextBlock && currentTextBlock->isEmpty()) { auto& style = currentTextBlock->getBlockStyle(); if (effectiveDirectionDefined) { style.directionDefined = true; style.isRtl = (effectiveDirection == CssTextDirection::Rtl); } else { style.directionDefined = false; style.isRtl = false; } } } void ChapterHtmlSlimParser::flushPendingAnchor() { if (pendingAnchorId.empty()) return; // If the pending anchor is a TOC chapter boundary, force a page break after the previous // block is flushed so the chapter starts on a fresh page. if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) { if (currentPage && !currentPage->elements.empty()) { completePageFn(std::move(currentPage), xpathParagraphIndex, xpathListItemIndex); completedPageCount++; currentPage.reset(new Page()); currentPageNextY = 0; } } // Record deferred anchor after previous block is flushed (and any TOC page break) anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); } // flush the contents of partWordBuffer to currentTextBlock void ChapterHtmlSlimParser::flushPartWordBuffer() { // Determine font style from depth-based tracking and CSS effective style const bool isBold = boldUntilDepth < depth || effectiveBold; const bool isItalic = italicUntilDepth < depth || effectiveItalic; const bool isUnderline = underlineUntilDepth < depth || effectiveUnderline; // Combine style flags using bitwise OR EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; if (isBold) { fontStyle = static_cast(fontStyle | EpdFontFamily::BOLD); } if (isItalic) { fontStyle = static_cast(fontStyle | EpdFontFamily::ITALIC); } if (isUnderline) { fontStyle = static_cast(fontStyle | EpdFontFamily::UNDERLINE); } if (effectiveSup) { fontStyle = static_cast(fontStyle | EpdFontFamily::SUP); } else if (effectiveSub) { fontStyle = static_cast(fontStyle | EpdFontFamily::SUB); } // flush the buffer partWordBuffer[partWordBufferIndex] = '\0'; currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues); partWordBufferIndex = 0; nextWordContinues = false; } // start a new text block if needed void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { nextWordContinues = false; // New block = new paragraph, no continuation if (currentTextBlock) { // already have a text block running and it is empty - just reuse it if (currentTextBlock->isEmpty()) { // The stack accumulates horizontal margins and text properties from ancestors. // Vertical margins are per-element and not inherited through the stack, but // container elements deposit their vertical margins on the empty block when they // open. Merge those into the new style so the first child in a container inherits // the container's vertical spacing. const auto style = currentTextBlock->getBlockStyle(); currentTextBlock->setBlockStyle(style.getCombinedBlockStyle(blockStyle, BlockStyle::CombineAxis::Vertical)); flushPendingAnchor(); return; } makePages(); } // If the pending anchor is a TOC chapter boundary, force a page break after the previous // block is flushed so the chapter starts on a fresh page. flushPendingAnchor(); currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle)); wordsExtractedInBlock = 0; } void ChapterHtmlSlimParser::emitHorizontalRule(const BlockStyle& blockStyle) { if (partWordBufferIndex > 0) { flushPartWordBuffer(); } if (currentTextBlock) { const BlockStyle parentBlockStyle = currentTextBlock->getBlockStyle(); startNewTextBlock(parentBlockStyle); } if (!currentPage) { currentPage.reset(new (std::nothrow) Page()); if (!currentPage) { LOG_ERR("EHP", "Failed to create page for horizontal rule"); return; } currentPageNextY = 0; } const int16_t lineHeight = static_cast(renderer.getLineHeight(fontId) * lineCompression + 0.5f); const int16_t defaultVerticalSpacing = static_cast(lineHeight / 2); const int16_t topSpacing = static_cast((blockStyle.marginTop > 0 ? blockStyle.marginTop : defaultVerticalSpacing) + (blockStyle.paddingTop > 0 ? blockStyle.paddingTop : 0)); const int16_t bottomSpacing = static_cast((blockStyle.marginBottom > 0 ? blockStyle.marginBottom : defaultVerticalSpacing) + (blockStyle.paddingBottom > 0 ? blockStyle.paddingBottom : 0)); constexpr uint8_t ruleThickness = 2; const int16_t availableWidth = std::max(1, static_cast(viewportWidth - blockStyle.totalHorizontalInset())); const int16_t width = std::max(1, static_cast(availableWidth / 4)); const int16_t xPos = static_cast(blockStyle.leftInset() + ((availableWidth - width) / 2)); const int16_t totalHeight = static_cast(topSpacing + ruleThickness + bottomSpacing); if (!currentPage->elements.empty() && currentPageNextY + totalHeight > viewportHeight) { completePageFn(std::move(currentPage), xpathParagraphIndex, xpathListItemIndex); completedPageCount++; currentPage.reset(new (std::nothrow) Page()); if (!currentPage) { LOG_ERR("EHP", "Failed to create page after horizontal-rule page break"); return; } currentPageNextY = 0; } currentPageNextY += topSpacing; auto pageRule = std::shared_ptr( new (std::nothrow) PageHorizontalRule(width, ruleThickness, xPos, currentPageNextY)); if (!pageRule) { LOG_ERR("EHP", "Failed to create PageHorizontalRule"); return; } currentPage->elements.push_back(pageRule); currentPageNextY = static_cast(currentPageNextY + ruleThickness + bottomSpacing); if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); } } void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { auto* self = static_cast(userData); // Middle of skip if (self->skipUntilDepth < self->depth) { self->depth += 1; return; } if (strcmp(name, "p") == 0) { self->xpathParagraphIndex++; } if (strcmp(name, "li") == 0) { self->xpathListItemIndex++; } // Extract class, style, id, and dir attributes for CSS/RTL processing std::string classAttr; std::string styleAttr; std::string dirAttr; if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "class") == 0) { classAttr = atts[i + 1]; } else if (strcmp(atts[i], "style") == 0) { styleAttr = atts[i + 1]; } else if (strcmp(atts[i], "id") == 0) { // Defer both anchor recording and TOC page breaks until startNewTextBlock, // after the previous block is flushed to pages via makePages(). // // Skip IDs on non-navigable inline elements (e.g. ): these are never // link targets in epub content, but reading-system converters can inject tens // of thousands of them per chapter, exhausting the heap. TOC anchors are // always recorded regardless of element type, since they drive page breaks. const char* idValue = atts[i + 1]; const bool isTocAnchor = std::find(self->tocAnchors.begin(), self->tocAnchors.end(), idValue) != self->tocAnchors.end(); if (isTocAnchor || (!isNonNavigableInlineElement(name) && self->anchorData.size() < MAX_ANCHORS_PER_CHAPTER)) { // Flush a displaced anchor before overwriting. Consecutive non-block elements // (e.g.