#include "ChapterHtmlSlimParser.h" #include #include #include #include #include #include #include #include #include "../../Epub.h" #include "../Page.h" #include "../converters/ImageDecoderFactory.h" #include "../converters/ImageToFramebufferDecoder.h" #include "../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; 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; } 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; } // 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; // 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; } } } // 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); } // 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)); if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); } return; } makePages(); } // Record deferred anchor after previous block is flushed if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); } currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, blockStyle)); wordsExtractedInBlock = 0; } 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, and id attributes std::string classAttr; std::string styleAttr; 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 recording until startNewTextBlock, after previous block is flushed to pages self->pendingAnchorId = atts[i + 1]; } } } auto centeredBlockStyle = BlockStyle(); centeredBlockStyle.textAlignDefined = true; centeredBlockStyle.alignment = CssTextAlign::Center; // Compute CSS style for this element early so display:none can short-circuit // before tag-specific branches emit any content or metadata. CssStyle cssStyle; if (self->cssParser) { cssStyle = self->cssParser->resolveStyle(name, classAttr); if (!styleAttr.empty()) { CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr); cssStyle.applyOver(inlineStyle); } } // Skip elements with display:none before all fast paths (tables, links, etc.). if (cssStyle.hasDisplay() && cssStyle.display == CssDisplay::None) { self->skipUntilDepth = self->depth; self->depth += 1; return; } // Special handling for tables/cells: flatten into per-cell paragraphs with a prefixed header. if (strcmp(name, "table") == 0) { // skip nested tables if (self->tableDepth > 0) { self->tableDepth += 1; return; } if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); } self->tableDepth += 1; self->tableRowIndex = 0; self->tableColIndex = 0; self->depth += 1; return; } if (self->tableDepth == 1 && strcmp(name, "tr") == 0) { self->tableRowIndex += 1; self->tableColIndex = 0; self->depth += 1; return; } if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) { if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); } self->tableColIndex += 1; auto tableCellBlockStyle = BlockStyle(); tableCellBlockStyle.textAlignDefined = true; const auto align = (self->paragraphAlignment == static_cast(CssTextAlign::None)) ? CssTextAlign::Justify : static_cast(self->paragraphAlignment); tableCellBlockStyle.alignment = align; self->startNewTextBlock(tableCellBlockStyle); const std::string headerText = "Tab Row " + std::to_string(self->tableRowIndex) + ", Cell " + std::to_string(self->tableColIndex) + ":"; StyleStackEntry headerStyle; headerStyle.depth = self->depth; headerStyle.hasBold = true; headerStyle.bold = false; headerStyle.hasItalic = true; headerStyle.italic = true; headerStyle.hasUnderline = true; headerStyle.underline = false; self->inlineStyleStack.push_back(headerStyle); self->updateEffectiveInlineStyle(); self->characterData(userData, headerText.c_str(), static_cast(headerText.length())); if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); } self->nextWordContinues = false; self->inlineStyleStack.pop_back(); self->updateEffectiveInlineStyle(); self->depth += 1; return; } if (matches(name, IMAGE_TAGS, std::size(IMAGE_TAGS))) { std::string src; std::string alt; if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "src") == 0) { src = atts[i + 1]; } else if (strcmp(atts[i], "alt") == 0) { alt = atts[i + 1]; } } // imageRendering: 0=display, 1=placeholder (alt text only), 2=suppress entirely if (self->imageRendering == 2) { self->skipUntilDepth = self->depth; self->depth += 1; return; } // Skip image if CSS display:none if (self->cssParser) { CssStyle imgDisplayStyle = self->cssParser->resolveStyle("img", classAttr); if (!styleAttr.empty()) { imgDisplayStyle.applyOver(CssParser::parseInlineStyle(styleAttr)); } if (imgDisplayStyle.hasDisplay() && imgDisplayStyle.display == CssDisplay::None) { self->skipUntilDepth = self->depth; self->depth += 1; return; } } if (!src.empty() && self->imageRendering != 1) { LOG_DBG("EHP", "Found image: src=%s", src.c_str()); { // Resolve the image path relative to the HTML file std::string resolvedPath = FsHelpers::normalisePath(self->contentBase + src); if (ImageDecoderFactory::isFormatSupported(resolvedPath)) { // Create a unique filename for the cached image std::string ext; size_t extPos = resolvedPath.rfind('.'); if (extPos != std::string::npos) { ext = resolvedPath.substr(extPos); } std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext; // Extract image to cache file FsFile cachedImageFile; bool extractSuccess = false; if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) { extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096); cachedImageFile.flush(); cachedImageFile.close(); delay(50); // Give SD card time to sync } if (extractSuccess) { // Get image dimensions ImageDimensions dims = {0, 0}; ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath); if (decoder && decoder->getDimensions(cachedImagePath, dims)) { LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height); int displayWidth = 0; int displayHeight = 0; const float emSize = static_cast(self->renderer.getFontAscenderSize(self->fontId)); CssStyle imgStyle = self->cssParser ? self->cssParser->resolveStyle("img", classAttr) : CssStyle{}; // Merge inline style (e.g. style="height: 2em") so it overrides stylesheet rules if (!styleAttr.empty()) { imgStyle.applyOver(CssParser::parseInlineStyle(styleAttr)); } const bool hasCssHeight = imgStyle.hasImageHeight(); const bool hasCssWidth = imgStyle.hasImageWidth(); // Compute effective container width for percentage-based image sizes. // If the image is inside a block with horizontal margins/padding (e.g. //
), percentage widths like width:100% // should resolve against the container width, not the full viewport. int containerWidth = self->viewportWidth; if (self->currentTextBlock) { const int inset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); if (inset > 0 && inset < self->viewportWidth) { containerWidth = self->viewportWidth - inset; } } if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) { // Both CSS height and width set: resolve both, then clamp to viewport preserving requested ratio displayHeight = static_cast( imgStyle.imageHeight.toPixels(emSize, static_cast(self->viewportHeight)) + 0.5f); displayWidth = static_cast(imgStyle.imageWidth.toPixels(emSize, static_cast(containerWidth)) + 0.5f); if (displayHeight < 1) displayHeight = 1; if (displayWidth < 1) displayWidth = 1; if (displayWidth > containerWidth || displayHeight > self->viewportHeight) { float scaleX = (displayWidth > containerWidth) ? static_cast(containerWidth) / displayWidth : 1.0f; float scaleY = (displayHeight > self->viewportHeight) ? static_cast(self->viewportHeight) / displayHeight : 1.0f; float scale = (scaleX < scaleY) ? scaleX : scaleY; displayWidth = static_cast(displayWidth * scale + 0.5f); displayHeight = static_cast(displayHeight * scale + 0.5f); if (displayWidth < 1) displayWidth = 1; if (displayHeight < 1) displayHeight = 1; } LOG_DBG("EHP", "Display size from CSS height+width: %dx%d", displayWidth, displayHeight); } else if (hasCssHeight && !hasCssWidth && dims.width > 0 && dims.height > 0) { // Use CSS height (resolve % against viewport height) and derive width from aspect ratio displayHeight = static_cast( imgStyle.imageHeight.toPixels(emSize, static_cast(self->viewportHeight)) + 0.5f); if (displayHeight < 1) displayHeight = 1; displayWidth = static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); if (displayHeight > self->viewportHeight) { displayHeight = self->viewportHeight; // Rescale width to preserve aspect ratio when height is clamped displayWidth = static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); if (displayWidth < 1) displayWidth = 1; } if (displayWidth > containerWidth) { displayWidth = containerWidth; // Rescale height to preserve aspect ratio when width is clamped displayHeight = static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); if (displayHeight < 1) displayHeight = 1; } if (displayWidth < 1) displayWidth = 1; LOG_DBG("EHP", "Display size from CSS height: %dx%d", displayWidth, displayHeight); } else if (hasCssWidth && !hasCssHeight && dims.width > 0 && dims.height > 0) { // Use CSS width (resolve % against container width) and derive height from aspect ratio displayWidth = static_cast(imgStyle.imageWidth.toPixels(emSize, static_cast(containerWidth)) + 0.5f); if (displayWidth > containerWidth) displayWidth = containerWidth; if (displayWidth < 1) displayWidth = 1; displayHeight = static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); if (displayHeight > self->viewportHeight) { displayHeight = self->viewportHeight; // Rescale width to preserve aspect ratio when height is clamped displayWidth = static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); if (displayWidth < 1) displayWidth = 1; } if (displayHeight < 1) displayHeight = 1; LOG_DBG("EHP", "Display size from CSS width: %dx%d", displayWidth, displayHeight); } else { // Scale to fit container while maintaining aspect ratio int maxWidth = containerWidth; int maxHeight = self->viewportHeight; float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f; float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f; float scale = (scaleX < scaleY) ? scaleX : scaleY; if (scale > 1.0f) scale = 1.0f; displayWidth = (int)(dims.width * scale); displayHeight = (int)(dims.height * scale); LOG_DBG("EHP", "Display size: %dx%d (scale %.2f)", displayWidth, displayHeight, scale); } // Flush any pending text block so it appears before the image if (self->partWordBufferIndex > 0) { self->flushPartWordBuffer(); } if (self->currentTextBlock && !self->currentTextBlock->isEmpty()) { const BlockStyle parentBlockStyle = self->currentTextBlock->getBlockStyle(); self->startNewTextBlock(parentBlockStyle); } // Apply vertical margins from the container to the image. // Top margin lives on the empty text block (deposited via vertical merge // in startNewTextBlock). Bottom margin was stripped by withoutBottom() for // deferred application at element close, so read it from the stack. int16_t imageMarginTop = 0; int16_t imageMarginBottom = 0; if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { const auto& bs = self->currentTextBlock->getBlockStyle(); imageMarginTop = bs.topInset(); if (self->blockStyleStack.size() > 1) { imageMarginBottom = self->blockStyleStack.back().bottomInset(); } } // Create page for image - only break if image won't fit remaining space if (self->currentPage && !self->currentPage->elements.empty() && (self->currentPageNextY + imageMarginTop + displayHeight + imageMarginBottom > self->viewportHeight)) { self->completePageFn(std::move(self->currentPage), self->xpathParagraphIndex, self->xpathListItemIndex); self->completedPageCount++; self->currentPage.reset(new Page()); if (!self->currentPage) { LOG_ERR("EHP", "Failed to create new page"); return; } self->currentPageNextY = 0; } else if (!self->currentPage) { self->currentPage.reset(new Page()); if (!self->currentPage) { LOG_ERR("EHP", "Failed to create initial page"); return; } self->currentPageNextY = 0; } // Apply top margin from container block self->currentPageNextY += imageMarginTop; // Create ImageBlock and add to page auto imageBlock = std::make_shared(cachedImagePath, displayWidth, displayHeight); if (!imageBlock) { LOG_ERR("EHP", "Failed to create ImageBlock"); return; } int xPos = (self->viewportWidth - displayWidth) / 2; auto pageImage = std::make_shared(imageBlock, xPos, self->currentPageNextY); if (!pageImage) { LOG_ERR("EHP", "Failed to create PageImage"); return; } self->currentPage->elements.push_back(pageImage); self->currentPageNextY += displayHeight + imageMarginBottom; // The image consumed the empty block's accumulated vertical spacing. // Reset the block so the Vertical merge in startNewTextBlock doesn't // re-apply the same margins to the next text paragraph. if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { BlockStyle resetStyle; resetStyle.alignment = (self->paragraphAlignment == static_cast(CssTextAlign::None)) ? CssTextAlign::Justify : static_cast(self->paragraphAlignment); self->currentTextBlock->setBlockStyle(resetStyle); } self->depth += 1; return; } else { LOG_ERR("EHP", "Failed to get image dimensions"); Storage.remove(cachedImagePath.c_str()); } } else { LOG_ERR("EHP", "Failed to extract image"); } } // isFormatSupported } } // Fallback to alt text if image processing fails if (!alt.empty()) { alt = "[Image: " + alt + "]"; self->startNewTextBlock(self->blockStyleStack.back() .getCombinedBlockStyle(centeredBlockStyle, BlockStyle::CombineAxis::Horizontal) .withoutBottom()); self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); self->depth += 1; self->characterData(userData, alt.c_str(), alt.length()); // Skip any child content (skip until parent as we pre-advanced depth above) self->skipUntilDepth = self->depth - 1; return; } // No alt text, skip self->skipUntilDepth = self->depth; self->depth += 1; return; } } if (matches(name, SKIP_TAGS, std::size(SKIP_TAGS))) { // start skip self->skipUntilDepth = self->depth; self->depth += 1; return; } // Skip blocks with role="doc-pagebreak" and epub:type="pagebreak" if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "role") == 0 && strcmp(atts[i + 1], "doc-pagebreak") == 0 || strcmp(atts[i], "epub:type") == 0 && strcmp(atts[i + 1], "pagebreak") == 0) { self->skipUntilDepth = self->depth; self->depth += 1; return; } } } // Detect internal links (footnotes, cross-references) // Note: