From ceb6acc8d760b9460c49a47fc296903b6540c623 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 23 Mar 2026 19:51:02 +0100 Subject: [PATCH] fix: Fix img layout issue / support CSS display:none for elements and images (#1443) ## Summary - Add CSS `display: none` support to the EPUB rendering pipeline (fixes #1431) - Parse `display` property in stylesheets and inline styles, with full cascade resolution (element, class, element.class, inline) - Skip hidden elements and all their descendants in `ChapterHtmlSlimParser` - Separate display:none check for `` tags (image code path is independent of the general element handler) - Flush pending text blocks before placing images to fix layout ordering (text preceding an image now correctly renders above it) - Bump CSS cache version to 4 to invalidate stale caches - Add test EPUB (`test_display_none.epub`) covering class selectors, element selectors, combined selectors, inline styles, nested hidden content, hidden images, style priority/override, and realistic use cases --- lib/Epub/Epub/css/CssParser.cpp | 74 ++++++++++++++++++ lib/Epub/Epub/css/CssParser.h | 2 +- lib/Epub/Epub/css/CssStyle.h | 18 ++++- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 52 +++++++++--- test/epubs/test_display_none.epub | Bin 0 -> 11018 bytes 5 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 test/epubs/test_display_none.epub diff --git a/lib/Epub/Epub/css/CssParser.cpp b/lib/Epub/Epub/css/CssParser.cpp index 8ad59148..d2e679c3 100644 --- a/lib/Epub/Epub/css/CssParser.cpp +++ b/lib/Epub/Epub/css/CssParser.cpp @@ -52,6 +52,29 @@ constexpr size_t MAX_SELECTOR_LENGTH = 256; // Check if character is CSS whitespace bool isCssWhitespace(const char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; } +std::string_view stripTrailingImportant(std::string_view value) { + constexpr std::string_view IMPORTANT = "!important"; + + while (!value.empty() && isCssWhitespace(value.back())) { + value.remove_suffix(1); + } + + if (value.size() < IMPORTANT.size()) { + return value; + } + + const size_t suffixPos = value.size() - IMPORTANT.size(); + if (value.substr(suffixPos) != IMPORTANT) { + return value; + } + + value.remove_suffix(IMPORTANT.size()); + while (!value.empty() && isCssWhitespace(value.back())) { + value.remove_suffix(1); + } + return value; +} + } // anonymous namespace // String utilities implementation @@ -317,6 +340,10 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty style.imageWidth = len; style.defined.imageWidth = 1; } + } else if (propNameBuf == "display") { + const std::string_view displayValue = stripTrailingImportant(propValueBuf); + style.display = (displayValue == "none") ? CssDisplay::None : CssDisplay::Block; + style.defined.display = 1; } } @@ -692,6 +719,7 @@ bool CssParser::saveToCache() const { writeLength(style.paddingRight); writeLength(style.imageHeight); writeLength(style.imageWidth); + file.write(static_cast(style.display)); // Write defined flags as uint16_t uint16_t definedBits = 0; @@ -710,6 +738,7 @@ bool CssParser::saveToCache() const { if (style.defined.paddingRight) definedBits |= 1 << 12; if (style.defined.imageHeight) definedBits |= 1 << 13; if (style.defined.imageWidth) definedBits |= 1 << 14; + if (style.defined.display) definedBits |= 1 << 15; file.write(reinterpret_cast(&definedBits), sizeof(definedBits)); } @@ -748,16 +777,44 @@ bool CssParser::loadFromCache() { return false; } + if (ruleCount > MAX_RULES) { + LOG_DBG("CSS", "Invalid cache rule count (%u > %zu)", ruleCount, MAX_RULES); + rulesBySelector_.clear(); + file.close(); + return false; + } + + auto hasRemainingBytes = [&file](const size_t neededBytes) -> bool { + return static_cast(file.available()) >= neededBytes; + }; + + constexpr size_t CSS_LENGTH_FIELD_COUNT = 11; + constexpr size_t CSS_LENGTH_BYTES = sizeof(float) + sizeof(uint8_t); + constexpr size_t CSS_FIXED_STYLE_BYTES = + 4 * sizeof(uint8_t) + (CSS_LENGTH_FIELD_COUNT * CSS_LENGTH_BYTES) + sizeof(uint8_t) + sizeof(uint16_t); + // Read each rule for (uint16_t i = 0; i < ruleCount; ++i) { // Read selector string uint16_t selectorLen = 0; + if (!hasRemainingBytes(sizeof(selectorLen))) { + rulesBySelector_.clear(); + file.close(); + return false; + } if (file.read(&selectorLen, sizeof(selectorLen)) != sizeof(selectorLen)) { rulesBySelector_.clear(); file.close(); return false; } + if (selectorLen == 0 || selectorLen > MAX_SELECTOR_LENGTH || !hasRemainingBytes(selectorLen)) { + LOG_DBG("CSS", "Invalid selector length in cache: %u", selectorLen); + rulesBySelector_.clear(); + file.close(); + return false; + } + std::string selector; selector.resize(selectorLen); if (file.read(&selector[0], selectorLen) != selectorLen) { @@ -766,6 +823,13 @@ bool CssParser::loadFromCache() { return false; } + if (!hasRemainingBytes(CSS_FIXED_STYLE_BYTES)) { + LOG_DBG("CSS", "Truncated CSS cache while reading style payload"); + rulesBySelector_.clear(); + file.close(); + return false; + } + // Read CssStyle fields CssStyle style; uint8_t enumVal; @@ -820,6 +884,15 @@ bool CssParser::loadFromCache() { return false; } + // Read display value + uint8_t displayVal; + if (file.read(&displayVal, 1) != 1) { + rulesBySelector_.clear(); + file.close(); + return false; + } + style.display = static_cast(displayVal); + // Read defined flags uint16_t definedBits = 0; if (file.read(&definedBits, sizeof(definedBits)) != sizeof(definedBits)) { @@ -842,6 +915,7 @@ bool CssParser::loadFromCache() { style.defined.paddingRight = (definedBits & 1 << 12) != 0; style.defined.imageHeight = (definedBits & 1 << 13) != 0; style.defined.imageWidth = (definedBits & 1 << 14) != 0; + style.defined.display = (definedBits & 1 << 15) != 0; rulesBySelector_[selector] = style; } diff --git a/lib/Epub/Epub/css/CssParser.h b/lib/Epub/Epub/css/CssParser.h index 74dfaef1..69bc3ec2 100644 --- a/lib/Epub/Epub/css/CssParser.h +++ b/lib/Epub/Epub/css/CssParser.h @@ -31,7 +31,7 @@ class CssParser { public: // Bump when CSS cache format or rules change; section caches are invalidated when this changes - static constexpr uint8_t CSS_CACHE_VERSION = 3; + static constexpr uint8_t CSS_CACHE_VERSION = 4; explicit CssParser(std::string cachePath) : cachePath(std::move(cachePath)) {} ~CssParser() = default; diff --git a/lib/Epub/Epub/css/CssStyle.h b/lib/Epub/Epub/css/CssStyle.h index bac858e0..7b129eaf 100644 --- a/lib/Epub/Epub/css/CssStyle.h +++ b/lib/Epub/Epub/css/CssStyle.h @@ -54,6 +54,9 @@ enum class CssFontWeight : uint8_t { Normal = 0, Bold = 1 }; // Text decoration options enum class CssTextDecoration : uint8_t { None = 0, Underline = 1 }; +// Display options - only None and Block are relevant for e-ink rendering +enum class CssDisplay : uint8_t { Block = 0, None = 1 }; + // Bitmask for tracking which properties have been explicitly set struct CssPropertyFlags { uint16_t textAlign : 1; @@ -71,6 +74,7 @@ struct CssPropertyFlags { uint16_t paddingRight : 1; uint16_t imageHeight : 1; uint16_t imageWidth : 1; + uint16_t display : 1; CssPropertyFlags() : textAlign(0), @@ -87,19 +91,20 @@ struct CssPropertyFlags { paddingLeft(0), paddingRight(0), imageHeight(0), - imageWidth(0) {} + imageWidth(0), + display(0) {} [[nodiscard]] bool anySet() const { return textAlign || fontStyle || fontWeight || textDecoration || textIndent || marginTop || marginBottom || marginLeft || marginRight || paddingTop || paddingBottom || paddingLeft || paddingRight || imageHeight || - imageWidth; + imageWidth || display; } void clearAll() { textAlign = fontStyle = fontWeight = textDecoration = textIndent = 0; marginTop = marginBottom = marginLeft = marginRight = 0; paddingTop = paddingBottom = paddingLeft = paddingRight = 0; - imageHeight = imageWidth = 0; + imageHeight = imageWidth = display = 0; } }; @@ -123,6 +128,7 @@ struct CssStyle { CssLength paddingRight; // Padding right CssLength imageHeight; // Height for img (e.g. 2em) – width derived from aspect ratio when only height set CssLength imageWidth; // Width for img when both or only width set + CssDisplay display = CssDisplay::Block; // display property (Block or None) CssPropertyFlags defined; // Tracks which properties were explicitly set @@ -189,6 +195,10 @@ struct CssStyle { imageWidth = base.imageWidth; defined.imageWidth = 1; } + if (base.hasDisplay()) { + display = base.display; + defined.display = 1; + } } [[nodiscard]] bool hasTextAlign() const { return defined.textAlign; } @@ -206,6 +216,7 @@ struct CssStyle { [[nodiscard]] bool hasPaddingRight() const { return defined.paddingRight; } [[nodiscard]] bool hasImageHeight() const { return defined.imageHeight; } [[nodiscard]] bool hasImageWidth() const { return defined.imageWidth; } + [[nodiscard]] bool hasDisplay() const { return defined.display; } void reset() { textAlign = CssTextAlign::Left; @@ -216,6 +227,7 @@ struct CssStyle { marginTop = marginBottom = marginLeft = marginRight = CssLength{}; paddingTop = paddingBottom = paddingLeft = paddingRight = CssLength{}; imageHeight = imageWidth = CssLength{}; + display = CssDisplay::Block; defined.clearAll(); } }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 8e014c07..368a4c60 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -182,6 +182,24 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* 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 @@ -264,6 +282,19 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* 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()); @@ -384,6 +415,15 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* 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); + } + // 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)) { @@ -514,18 +554,6 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } } - // Compute CSS style for this element - CssStyle cssStyle; - if (self->cssParser) { - // Get combined tag + class styles - cssStyle = self->cssParser->resolveStyle(name, classAttr); - // Merge inline style (highest priority) - if (!styleAttr.empty()) { - CssStyle inlineStyle = CssParser::parseInlineStyle(styleAttr); - cssStyle.applyOver(inlineStyle); - } - } - const float emSize = static_cast(self->renderer.getFontAscenderSize(self->fontId)); const auto userAlignmentBlockStyle = BlockStyle::fromCssStyle( cssStyle, emSize, static_cast(self->paragraphAlignment), self->viewportWidth); diff --git a/test/epubs/test_display_none.epub b/test/epubs/test_display_none.epub new file mode 100644 index 0000000000000000000000000000000000000000..64ba69ace0fe46ef21d10d0b63b6bba10b3d2b2f GIT binary patch literal 11018 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