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 8ad591489..d2e679c36 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 74dfaef19..69bc3ec24 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 bac858e06..7b129eafd 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 8e014c071..368a4c60d 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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 literal 0 HcmV?d00001