#include "ChapterHtmlSlimParser.h" #include #include #include #include #include #include "../../Epub.h" #include "../Page.h" #include "../converters/ImageDecoderFactory.h" #include "../converters/ImageToFramebufferDecoder.h" #include "../htmlEntities.h" const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); // 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; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); const char* BOLD_TAGS[] = {"b", "strong"}; constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); const char* ITALIC_TAGS[] = {"i", "em"}; constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); const char* UNDERLINE_TAGS[] = {"u", "ins"}; constexpr int NUM_UNDERLINE_TAGS = sizeof(UNDERLINE_TAGS) / sizeof(UNDERLINE_TAGS[0]); const char* IMAGE_TAGS[] = {"img"}; constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]); const char* SKIP_TAGS[] = {"head"}; constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]); bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; } // given the start and end of a tag, check to see if it matches a known tag bool matches(const char* tag_name, const char* possible_tags[], const int possible_tag_count) { for (int i = 0; i < possible_tag_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, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_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()) { // Merge with existing block style to accumulate CSS styling from parent block elements. // This handles cases like

text

where the // div's margin should be preserved, even though it has no direct text content. currentTextBlock->setBlockStyle(currentTextBlock->getBlockStyle().getCombinedBlockStyle(blockStyle)); return; } makePages(); } 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; } // Extract class and style attributes for CSS processing 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]; } } } auto centeredBlockStyle = BlockStyle(); centeredBlockStyle.textAlignDefined = true; centeredBlockStyle.alignment = CssTextAlign::Center; // 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, NUM_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]; } } if (!src.empty()) { 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.getLineHeight(self->fontId)) * self->lineCompression; 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(); 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(self->viewportWidth)) + 0.5f); if (displayHeight < 1) displayHeight = 1; if (displayWidth < 1) displayWidth = 1; if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) { float scaleX = (displayWidth > self->viewportWidth) ? static_cast(self->viewportWidth) / 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 > self->viewportWidth) { displayWidth = self->viewportWidth; // 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 viewport width) and derive height from aspect ratio displayWidth = static_cast( imgStyle.imageWidth.toPixels(emSize, static_cast(self->viewportWidth)) + 0.5f); if (displayWidth > self->viewportWidth) displayWidth = self->viewportWidth; 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 viewport while maintaining aspect ratio int maxWidth = self->viewportWidth; 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); } // Create page for image - only break if image won't fit remaining space if (self->currentPage && !self->currentPage->elements.empty() && (self->currentPageNextY + displayHeight > self->viewportHeight)) { self->completePageFn(std::move(self->currentPage)); 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; } // 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; 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(centeredBlockStyle); 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, NUM_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: