diff --git a/.codebuddy/skills/pagx/spec/html_subset.md b/.codebuddy/skills/pagx/spec/html_subset.md index 49ac2863fb..7d3d80f198 100644 --- a/.codebuddy/skills/pagx/spec/html_subset.md +++ b/.codebuddy/skills/pagx/spec/html_subset.md @@ -179,6 +179,7 @@ also a no-op (the offsets are ignored alongside the dropped position). | `background-image: conic-gradient(from angle, …)` | inline `` (CSS 0° = top, PAGX 0° = right; angle shifted by −90°) | | `background-clip: text` (alias `-webkit-background-clip: text`) | combined with a gradient `background-image`: routes the gradient onto descendant text fills (`` / `` get a `` carrying the gradient) and suppresses the rectangle that would otherwise paint behind the text. Without a gradient `background-image`, the property is a no-op. | | `background-image: url(...)` | recovered as an `` fill on the background rectangle (the inverse of `HTMLWriter`'s url-background emission). `background-size` / `background-repeat` / `background-position` drive the pattern's `scaleMode` / tile modes / matrix | +| `background-blend-mode: ` | sets `Fill.blendMode` on the gradient / image fill so it composites against the `background-color`, which is kept as a solid `` underneath (the backdrop the blend needs). `normal` (the default) is a no-op and the opaque gradient/image keeps hiding the colour | | `mask-image: url(data:image/svg+xml,...)` (+ `mask-mode` / `mask-size` / `mask-position` / `mask-repeat`) | the referenced SVG becomes a PAGX mask layer; `mask-mode` selects Alpha vs Luminance, `mask-size` / `mask-position` drive its scale / offset | | `clip-path: url(#id)` | resolves the referenced hidden `` into a contour mask layer. Geometric forms (`inset()`/`circle()`/`ellipse()`/`polygon()`/`path()`) have no PAGX primitive and are dropped with a warning | | `border-radius: N` (px), `N%` (resolved against `min(width, height)`; a fixed-size element with `border-radius: 50%` becomes an `Ellipse`), or the 1–4 value shorthand (`T`, `T R`, `T R B`, `T R B L`) | `Rectangle.roundness = N` (or an `Ellipse` for `50%`). Elliptical `W / H` two-radius forms are warned and ignored | diff --git a/spec/html_subset.md b/spec/html_subset.md index 49ac2863fb..7d3d80f198 100644 --- a/spec/html_subset.md +++ b/spec/html_subset.md @@ -179,6 +179,7 @@ also a no-op (the offsets are ignored alongside the dropped position). | `background-image: conic-gradient(from angle, …)` | inline `` (CSS 0° = top, PAGX 0° = right; angle shifted by −90°) | | `background-clip: text` (alias `-webkit-background-clip: text`) | combined with a gradient `background-image`: routes the gradient onto descendant text fills (`` / `` get a `` carrying the gradient) and suppresses the rectangle that would otherwise paint behind the text. Without a gradient `background-image`, the property is a no-op. | | `background-image: url(...)` | recovered as an `` fill on the background rectangle (the inverse of `HTMLWriter`'s url-background emission). `background-size` / `background-repeat` / `background-position` drive the pattern's `scaleMode` / tile modes / matrix | +| `background-blend-mode: ` | sets `Fill.blendMode` on the gradient / image fill so it composites against the `background-color`, which is kept as a solid `` underneath (the backdrop the blend needs). `normal` (the default) is a no-op and the opaque gradient/image keeps hiding the colour | | `mask-image: url(data:image/svg+xml,...)` (+ `mask-mode` / `mask-size` / `mask-position` / `mask-repeat`) | the referenced SVG becomes a PAGX mask layer; `mask-mode` selects Alpha vs Luminance, `mask-size` / `mask-position` drive its scale / offset | | `clip-path: url(#id)` | resolves the referenced hidden `` into a contour mask layer. Geometric forms (`inset()`/`circle()`/`ellipse()`/`polygon()`/`path()`) have no PAGX primitive and are dropped with a warning | | `border-radius: N` (px), `N%` (resolved against `min(width, height)`; a fixed-size element with `border-radius: 50%` becomes an `Ellipse`), or the 1–4 value shorthand (`T`, `T R`, `T R B`, `T R B L`) | `Rectangle.roundness = N` (or an `Ellipse` for `50%`). Elliptical `W / H` two-radius forms are warned and ignored | diff --git a/spec/html_subset.zh_CN.md b/spec/html_subset.zh_CN.md index 965c3161de..6acd2c8832 100644 --- a/spec/html_subset.zh_CN.md +++ b/spec/html_subset.zh_CN.md @@ -166,6 +166,7 @@ | `background-image: conic-gradient(from angle, …)` | 内嵌 ``(CSS 0° = 顶,PAGX 0° = 右,自动 −90°) | | `background-clip: text`(别名 `-webkit-background-clip: text`) | 与 gradient `background-image` 同时设置时,渐变下沉到后代文字节点(`` / `` 的 `` 内嵌渐变),本元素的矩形背景被抑制。无 gradient `background-image` 时该属性为无操作。 | | `background-image: url(...)` | 还原为背景矩形上的 `` 填充;`background-size` / `background-repeat` / `background-position` 决定该 pattern 的 `scaleMode` / 平铺模式 / 矩阵 | +| `background-blend-mode: ` | 在渐变 / 图像填充上设置 `Fill.blendMode`,使其与 `background-color` 混合;此时会保留底部的纯色 `` 作为混合所需的背景。`normal`(默认)为空操作,不透明的渐变 / 图像仍会覆盖底色 | | `mask-image: url(data:image/svg+xml,...)`(+ `mask-mode` / `mask-size` / `mask-position` / `mask-repeat`) | 引用的 SVG 变成一层 PAGX mask;`mask-mode` 选择 Alpha 还是 Luminance,`mask-size` / `mask-position` 决定其缩放/偏移 | | `clip-path: url(#id)` | 解析引用的隐藏 `` 为一层轮廓 mask。几何形式(`inset()`/`circle()`/`ellipse()`/`polygon()`/`path()`)在 PAGX 无对应原语,告警丢弃 | | `border-radius: N`(px)、`N%`(按 `min(width, height)` 解析;固定 px 宽高且 `border-radius: 50%` 的元素会变成 `Ellipse`),或 1–4 值缩写(`T`、`T R`、`T R B`、`T R B L`) | `Rectangle.roundness = N`(`50%` 输出 `Ellipse`)。椭圆 `W / H` 双半径形式告警并忽略 | diff --git a/src/pagx/PAGXExporter.cpp b/src/pagx/PAGXExporter.cpp index 6940a5737b..616e211fa4 100644 --- a/src/pagx/PAGXExporter.cpp +++ b/src/pagx/PAGXExporter.cpp @@ -1396,6 +1396,13 @@ static void WriteResource(XMLBuilder& xml, const Node* node, const Options& opti auto size = image->data->size(); xml.addAttribute("source", std::string("data:") + DetectImageMimeOrPNG(bytes, size) + ";base64," + Base64Encode(bytes, size)); + } else { + // `source` is a required attribute (see pagx.xsd). An unresolved image — e.g. an `` + // whose `src` was missing/invalid at HTML import time, preserved so the element is not + // dropped — carries neither a file path nor inline data. `addAttribute` skips empty + // values, so emit the empty source explicitly to keep the document schema-valid (and to + // round-trip back to the same unresolved Image). + xml.addRequiredAttribute("source", ""); } WriteCustomData(xml, node); xml.closeElementSelfClosing(); diff --git a/src/pagx/PAGXOptimizer.cpp b/src/pagx/PAGXOptimizer.cpp index adae776e78..a5ad783776 100644 --- a/src/pagx/PAGXOptimizer.cpp +++ b/src/pagx/PAGXOptimizer.cpp @@ -101,6 +101,16 @@ bool DowngradeShellChildren(PAGXDocument* doc, std::vector& layers, const std::unordered_set& maskRefs); bool IsMergeableShellLayer(const Layer* layer, const std::unordered_set& maskRefs); + +bool ChildFillsParent(const Layer* parent, const Layer* child); +bool ChildHasNoPaintAffectingEffect(const Layer* child); +bool CanAbsorbFillingChild(const Layer* parent, const Layer* child, + const std::unordered_set& maskRefs); +void AbsorbFillingChild(Layer* parent, Layer* child); +bool CanDropShellParent(const Layer* parent, const Layer* child, + const std::unordered_set& maskRefs); +bool CollapseSingleChildLayersInList(std::vector& layers, + const std::unordered_set& maskRefs); template void BeginMutation(std::vector& source, std::vector& result, size_t upTo, bool& changed); bool MergeAdjacentShellLayersInList(PAGXDocument* doc, std::vector& layers, @@ -1222,6 +1232,197 @@ bool MergeAdjacentGroupsInLayer(Layer* layer) { return changed; } +// ---------------------------------------------------------------------------- +// Collapse redundant single-child Layer nesting. +// +// Importers wrap many leaves in a double Layer: an outer box that positions / sizes / clips, and an +// inner Layer that only carries the payload (an icon's resolved , a divider's , a text +// run, ...). When the inner child sits at the outer's content-box origin with the same size, every +// role the outer plays over it (layout, alignment, arrangement, clip) is a no-op, so the two Layers +// render identically whether nested or flattened. Two shapes are collapsed: +// +// * absorb-child : the child fills the parent and carries no effect of its own -> move the child's +// payload up into the parent; the parent keeps its frame / clip / effects. +// * drop-shell : the parent is a plain shell (every attribute default, no contents) -> replace +// the parent with its only child, which already carries the real attributes. +// +// Both only move existing nodes; neither is applied to a Layer referenced as a mask, and neither +// drops a referenced `id`. Runs bottom-up to a fixed point via the optimizer's iteration loop. +// ---------------------------------------------------------------------------- + +// The child occupies the parent's whole content box with no offset / transform of its own, so +// flattening it leaves its payload in exactly the same place. A content-sized (NaN) axis is not +// proof of equality: parent and child may measure from different contents, and percentage-sized +// descendants would then resolve against a different box after reparenting. Require each axis to +// be either an exact concrete match or an explicit 100% fill. +bool IsZeroOrUnset(float value) { + return std::isnan(value) || value == 0.0f; +} + +bool AxisFills(float childSize, float parentSize, float childPercent) { + // Percentage sizing takes precedence over the absolute field in constraint layout, so a + // programmatically-built node carrying both must be judged by its percentage. + if (!std::isnan(childPercent)) { + return childPercent == 100.0f; + } + return !std::isnan(childSize) && !std::isnan(parentSize) && childSize == parentSize; +} + +bool ChildFillsParent(const Layer* parent, const Layer* child) { + if (child->x != 0.0f || child->y != 0.0f) { + return false; + } + if (!child->matrix.isIdentity() || !child->matrix3D.isIdentity()) { + return false; + } + if (!IsZeroOrUnset(child->left) || !IsZeroOrUnset(child->top)) { + return false; + } + if (!std::isnan(child->right) || !std::isnan(child->bottom) || !std::isnan(child->centerX) || + !std::isnan(child->centerY)) { + return false; + } + // Padding on the parent would inset the child's frame, so only a zero-padding parent qualifies. + if (!parent->padding.isZero()) { + return false; + } + return AxisFills(child->width, parent->width, child->percentWidth) && + AxisFills(child->height, parent->height, child->percentHeight); +} + +// The child applies nothing that would change how its payload is painted once it is reparented into +// the (identically-framed) parent. Attributes that describe the child's own frame / layout role +// (size, constraints, flex, includeInLayout) are intentionally not checked here — those are covered +// by ChildFillsParent or are meaningless once the child is dissolved. `layout` must be None because +// a container layout would reposition child layers, and the child keeps none after the merge. +bool ChildHasNoPaintAffectingEffect(const Layer* child) { + return child->visible && child->alpha == 1.0f && child->blendMode == BlendMode::Normal && + child->matrix3D.isIdentity() && !child->preserve3D && child->antiAlias && + child->groupOpacity && child->passThroughBackground && !child->hasScrollRect && + !child->clipToBounds && child->mask == nullptr && child->composition == nullptr && + child->compositionFilePath.empty() && child->externalDoc == nullptr && + child->timelines.empty() && child->styles.empty() && child->filters.empty() && + child->customData.empty() && child->vmContext.empty() && child->padding.isZero() && + child->layout == LayoutMode::None; +} + +bool CanAbsorbFillingChild(const Layer* parent, const Layer* child, + const std::unordered_set& maskRefs) { + if (!child->children.empty()) { + return false; // only leaf payload wrappers; child layers would need layout re-parenting + } + if (parent->composition != nullptr || !parent->compositionFilePath.empty() || + parent->externalDoc != nullptr) { + return false; // a composition-backed layer renders its comp, not its own contents/children + } + if (LayerNeedsKeeping(child, maskRefs)) { + return false; // child is referenced as a mask elsewhere + } + // An identified/named layer is externally observable even when no current document node points + // at it: animations, data binds, host lookups, and later edits may target it. Moving its identity + // onto the parent changes the scope of those operations (and assigning an id directly would also + // leave PAGXDocument's id index pointing at the detached child), so only anonymous wrappers may + // be dissolved. + if (!child->id.empty() || !child->name.empty()) { + return false; + } + if (!ChildHasNoPaintAffectingEffect(child)) { + return false; + } + if (!ChildFillsParent(parent, child)) { + return false; + } + // An import directive resolves into `contents`, so mixing it with pre-existing content on either + // layer is unsafe (it can reorder paints), and two imports cannot share one layer. + bool parentImport = HasUnresolvedImport(parent); + bool childImport = HasUnresolvedImport(child); + if (parentImport && childImport) { + return false; + } + if (parentImport && !child->contents.empty()) { + return false; + } + if (childImport && !parent->contents.empty()) { + return false; + } + return true; +} + +// Move the filling leaf child's payload into the parent, preserving paint order (any existing +// parent content stays behind the child's). The parent survives with its own frame / clip / effects; +// only the child's now-meaningless container-layout role is reset. +void AbsorbFillingChild(Layer* parent, Layer* child) { + for (auto* element : child->contents) { + parent->contents.push_back(element); + } + child->contents.clear(); + if (HasUnresolvedImport(child)) { + parent->importDirective = child->importDirective; + } + parent->children.clear(); + parent->layout = LayoutMode::None; + parent->gap = 0.0f; + parent->alignment = Alignment::Stretch; + parent->arrangement = Arrangement::Start; +} + +// The parent is a pure shell (all attributes default, no contents), so it contributes nothing and +// can be replaced by its only child. The child must keep the exact flex-item role the shell had +// (flex 0, participating in layout) so a surrounding container's layout is unaffected. +bool CanDropShellParent(const Layer* parent, const Layer* child, + const std::unordered_set& maskRefs) { + if (!parent->contents.empty()) { + return false; + } + if (!IsLayerShell(parent)) { + return false; + } + // IsLayerShell intentionally ignores these semantic-but-non-geometric fields; dropping the parent + // would discard them, so a shell carrying any of them is not a pure structural wrapper here. + if (!parent->compositionFilePath.empty() || parent->externalDoc != nullptr || + !parent->timelines.empty() || !parent->vmContext.empty() || !parent->customData.empty()) { + return false; + } + if (LayerNeedsKeeping(parent, maskRefs)) { + return false; + } + if (HasUnresolvedImport(parent)) { + return false; + } + if (child->flex != 0.0f || !child->includeInLayout) { + return false; + } + return true; +} + +// Recursively collapse single-child nests in `layers`. Children are processed first (bottom-up), so +// a fully-collapsed child is seen when its parent is considered; the per-layer while-loop then +// collapses multi-level nests in one pass. +bool CollapseSingleChildLayersInList(std::vector& layers, + const std::unordered_set& maskRefs) { + bool changed = false; + for (size_t i = 0; i < layers.size(); i++) { + Layer* layer = layers[i]; + changed |= CollapseSingleChildLayersInList(layer->children, maskRefs); + while (layer->children.size() == 1) { + Layer* child = layer->children[0]; + if (CanDropShellParent(layer, child, maskRefs)) { + layers[i] = child; + layer = child; // child's subtree is already collapsed + changed = true; + continue; + } + if (CanAbsorbFillingChild(layer, child, maskRefs)) { + AbsorbFillingChild(layer, child); // clears layer->children -> loop exits next check + changed = true; + continue; + } + break; + } + } + return changed; +} + // ============================================================================ // Driver // ============================================================================ @@ -1539,6 +1740,9 @@ int OptimizeLayerList(PAGXDocument* doc, std::vector& layers, if (options.mergeAdjacentShellLayers) { changed |= MergeAdjacentShellLayers(doc, layers, maskRefs); } + if (options.collapseSingleChildLayers) { + changed |= CollapseSingleChildLayersInList(layers, maskRefs); + } if (!changed) { return iter + 1; } diff --git a/src/pagx/PAGXOptimizerOptions.h b/src/pagx/PAGXOptimizerOptions.h index e04ba32e7b..9f82613b60 100644 --- a/src/pagx/PAGXOptimizerOptions.h +++ b/src/pagx/PAGXOptimizerOptions.h @@ -31,6 +31,13 @@ struct PAGXOptimizerOptions { bool downgradeShellChildren = true; /** Merge runs of adjacent shell Layers into a single Layer with one Group per source. */ bool mergeAdjacentShellLayers = true; + /** + * Collapse a redundant single-child Layer nest: absorb a child that exactly fills its parent up + * into the parent (keeping the parent's frame / clip / effects), or drop a shell parent in favour + * of its only child. Removes the double-Layer nesting importers emit for icons, dividers and + * other wrapped leaves without changing the rendered result. + */ + bool collapseSingleChildLayers = true; /** Unwrap a leading Group child when it has default transform and no painter isolation needed. */ bool unwrapRedundantFirstGroup = true; /** Collapse consecutive Groups with identical painters into a single Group. */ diff --git a/src/pagx/html/importer/HTMLBoxAttributes.h b/src/pagx/html/importer/HTMLBoxAttributes.h index e1f98ccbad..a100c06fc7 100644 --- a/src/pagx/html/importer/HTMLBoxAttributes.h +++ b/src/pagx/html/importer/HTMLBoxAttributes.h @@ -38,6 +38,19 @@ static constexpr float HTML_DEFAULT_FONT_SIZE = 14.0f; */ static constexpr const char* HTML_DEFAULT_FONT_FAMILY = "Arial"; +/** + * Default font style/variant name written to every imported `Text` node. The synthesis in + * `ResolveFontStyleSynthesis` leaves the style label empty for the plain base face (bold / italic + * are carried by `fauxBold` / `fauxItalic`); this constant substitutes the canonical "Regular" + * name so every HTML-imported `Text` node always carries a concrete `fontStyle`. + */ +static constexpr const char* HTML_DEFAULT_FONT_STYLE = "Regular"; + +/** Returns a concrete PAGX font style name for a resolved HTML face. */ +inline std::string ResolveHTMLFontStyleName(const std::string& fontStyleName) { + return fontStyleName.empty() ? HTML_DEFAULT_FONT_STYLE : fontStyleName; +} + /** * Default text colour used when no `color` is inherited; matches the CSS default for * in `ElementDefaults()`. @@ -235,6 +248,13 @@ struct HTMLBoxAttributes { std::string backgroundRepeat = {}; std::string backgroundPosition = {}; + // CSS `background-blend-mode`, lower-cased and trimmed. Blends the element's background + // layers (gradient / image over background-color) among themselves. A non-empty, non-`normal` + // value drives the gradient/image Fill's `blendMode`; the importer then also keeps the + // background-color Fill underneath so the blend composites against a backdrop. Empty (or + // `normal`) means "no blending" and the background paints with its default opaque stacking. + std::string backgroundBlendMode = {}; + // CSS `border-radius` expanded to four corners (TL, TR, BR, BL) in pixels, after applying the // CSS "edge overlap" scaling clamp (radii are shrunk uniformly so adjacent corner pairs never // exceed the box's edge length). When the input was uniform — or all four resolved values are @@ -273,9 +293,10 @@ struct HTMLBoxAttributes { std::string maskSize = {}; std::string maskPosition = {}; - // CSS `clip-path: url(#id)` reference (raw, including the `url(...)` wrapper and any quotes). - // The importer resolves the referenced hidden `` def into a contour mask layer (the - // inverse of `HTMLWriter::writeClipDef`). Empty means "no clip-path authored". + // CSS `clip-path` value, kept raw. Either a `url(#id)` reference to a hidden `` def + // (including the `url(...)` wrapper and any quotes) or a CSS basic shape (`polygon()` / `path()` + // / `circle()` / `ellipse()` / `inset()`). The importer rebuilds both into a contour mask layer + // (the `url` form is the inverse of `HTMLWriter::writeClipDef`). Empty means "no clip-path". std::string clipPathRef = {}; float opacity = 1.0f; diff --git a/src/pagx/html/importer/HTMLDetail.cpp b/src/pagx/html/importer/HTMLDetail.cpp index ee03ebd402..9bdc8cb758 100644 --- a/src/pagx/html/importer/HTMLDetail.cpp +++ b/src/pagx/html/importer/HTMLDetail.cpp @@ -132,10 +132,163 @@ std::vector> ParseStyleDeclarations(const st return out; } +namespace { + +bool IsBackgroundImageToken(const std::string& lower) { + return lower.rfind("url(", 0) == 0 || lower.find("gradient(") != std::string::npos; +} + +bool IsBackgroundRepeatToken(const std::string& lower) { + return lower == "repeat" || lower == "no-repeat" || lower == "repeat-x" || lower == "repeat-y" || + lower == "space" || lower == "round"; +} + +bool IsBackgroundBoxOrAttachmentToken(const std::string& lower) { + return lower == "scroll" || lower == "fixed" || lower == "local" || lower == "border-box" || + lower == "padding-box" || lower == "content-box"; +} + +bool IsBackgroundColorToken(const std::string& token) { + std::string lower = ToLower(Trim(token)); + if (lower.empty()) return false; + if (lower[0] == '#') return true; + static const char* kColorFns[] = {"rgb(", "rgba(", "hsl(", "hsla(", "hwb(", + "lab(", "lch(", "oklab(", "oklch(", "color("}; + for (const char* fn : kColorFns) { + if (lower.rfind(fn, 0) == 0) return true; + } + if (lower == "currentcolor") return true; + return NamedColors().find(lower) != NamedColors().end(); +} + +std::vector SplitBackgroundLayerTokens(const std::string& layer) { + std::string spaced; + spaced.reserve(layer.size() + 8); + int depth = 0; + for (char c : layer) { + if (c == '(') { + depth++; + } else if (c == ')' && depth > 0) { + depth--; + } + if (c == '/' && depth == 0) { + spaced += " / "; + } else { + spaced += c; + } + } + return SplitTopLevelWhitespace(spaced); +} + +std::string JoinStyleTokens(const std::vector& tokens) { + std::string out; + for (const auto& token : tokens) { + if (!out.empty()) out.push_back(' '); + out += token; + } + return out; +} + +// Applies one `background` declaration at its exact position in a declaration list. Empty values +// are intentional cascade tombstones: they override lower-priority/earlier longhands but are +// omitted by the property filter and style emitter, thereby representing the shorthand's initial +// values without serialising noise such as `background-image:none`. +void ApplyBackgroundShorthand(const std::string& value, + std::unordered_map& out) { + static const char* kResetProperties[] = { + "background-color", "background-image", "background-position", + "background-size", "background-repeat", "background-clip", + }; + for (const char* property : kResetProperties) { + out[property].clear(); + } + + auto layers = SplitTopLevelCommas(Trim(value)); + std::vector imageLayers; + bool hasURLImage = false; + std::string colorToken; + std::string positionValue; + std::string sizeValue; + std::string repeatValue; + + for (size_t layerIndex = 0; layerIndex < layers.size(); ++layerIndex) { + bool isLastLayer = layerIndex + 1 == layers.size(); + std::string imageToken; + std::vector positionTokens; + std::vector sizeTokens; + std::vector repeatTokens; + bool afterSlash = false; + for (const auto& token : SplitBackgroundLayerTokens(layers[layerIndex])) { + if (token == "/") { + afterSlash = true; + continue; + } + std::string lower = ToLower(token); + if (IsBackgroundImageToken(lower)) { + imageToken = token; + } else if (lower == "none") { + // The reset above already represents `background-image:none`. + } else if (isLastLayer && colorToken.empty() && IsBackgroundColorToken(token)) { + colorToken = token; + } else if (IsBackgroundRepeatToken(lower)) { + repeatTokens.push_back(lower); + } else if (IsBackgroundBoxOrAttachmentToken(lower)) { + // These reset correctly above but have no PAGX visual analogue. + } else if (afterSlash) { + sizeTokens.push_back(token); + } else { + positionTokens.push_back(token); + } + } + if (!imageToken.empty()) { + bool isURLImage = ToLower(imageToken).rfind("url(", 0) == 0; + imageLayers.push_back(std::move(imageToken)); + hasURLImage |= isURLImage; + // PAGX can stack gradients, but its ImagePattern path consumes only one image layer. Keep + // the fitting values from the CSS-topmost image so they stay paired with the same layer if + // the shorthand has to be reduced to one image below. + if (imageLayers.size() == 1) { + positionValue = JoinStyleTokens(positionTokens); + sizeValue = JoinStyleTokens(sizeTokens); + repeatValue = JoinStyleTokens(repeatTokens); + } + } + } + + if (!imageLayers.empty()) { + std::string joinedImages; + if (hasURLImage) { + // Mixed/url stacks cannot be represented by one PAGX ImagePattern. CSS paints the first + // listed image on top, so retain that layer and its position/size/repeat values rather than + // pairing a multi-layer image list with unrelated fitting data. + joinedImages = imageLayers.front(); + } else { + for (const auto& image : imageLayers) { + if (!joinedImages.empty()) joinedImages += ", "; + joinedImages += image; + } + } + out["background-image"] = std::move(joinedImages); + out["background-position"] = std::move(positionValue); + out["background-size"] = std::move(sizeValue); + out["background-repeat"] = std::move(repeatValue); + } + if (!colorToken.empty()) { + out["background-color"] = std::move(colorToken); + } +} + +} // namespace + void ParseStyleString(const std::string& styleStr, std::unordered_map& out) { for (auto& decl : ParseStyleDeclarations(styleStr)) { - out[ToLower(decl.first)] = std::move(decl.second); + std::string property = ToLower(decl.first); + if (property == "background") { + ApplyBackgroundShorthand(decl.second, out); + } else { + out[property] = std::move(decl.second); + } } } diff --git a/src/pagx/html/importer/HTMLDetail.h b/src/pagx/html/importer/HTMLDetail.h index e8c2be280c..b3c3c21a7a 100644 --- a/src/pagx/html/importer/HTMLDetail.h +++ b/src/pagx/html/importer/HTMLDetail.h @@ -85,6 +85,8 @@ std::vector> ParseStyleDeclarations(const st // Parse a CSS style string into a property map. Later properties override earlier ones. // Property names are lower-cased before insertion so callers can use canonical CSS names. +// `background` is expanded in source order into the supported longhands; empty internal values +// act as cascade resets and are omitted when the final inline style is emitted. // Thin wrapper over `ParseStyleDeclarations` — see it for syntactic guarantees. void ParseStyleString(const std::string& styleStr, std::unordered_map& out); diff --git a/src/pagx/html/importer/HTMLElementEmitter.cpp b/src/pagx/html/importer/HTMLElementEmitter.cpp index 5ea9811286..7fdebb89c8 100644 --- a/src/pagx/html/importer/HTMLElementEmitter.cpp +++ b/src/pagx/html/importer/HTMLElementEmitter.cpp @@ -165,7 +165,17 @@ std::string ExtractCssUrl(const std::string& value) { inner.back() == inner.front()) { inner = inner.substr(1, inner.size() - 2); } - return Trim(inner); + std::string unescaped; + unescaped.reserve(inner.size()); + for (size_t i = 0; i < inner.size(); ++i) { + if (inner[i] == '\\' && i + 1 < inner.size() && + (inner[i + 1] == '\\' || inner[i + 1] == '\'' || inner[i + 1] == '"')) { + unescaped.push_back(inner[++i]); + } else { + unescaped.push_back(inner[i]); + } + } + return Trim(unescaped); } // Decodes a single hex digit to its 0-15 value, or -1 when not a hex digit. @@ -233,6 +243,226 @@ void ResolveBackgroundRepeat(const std::string& repeat, TileMode& outX, TileMode } } +// Reads a DOM element attribute by (case-insensitive) name, or empty when absent. `name` must be +// supplied lowercased. Inline SVG attribute names keep their authored casing (`clipPathUnits`), so +// the comparison lowercases the stored name before matching. +std::string GetDomAttribute(const std::shared_ptr& node, const std::string& name) { + if (node == nullptr) return {}; + for (const auto& attr : node->attributes) { + if (ToLower(attr.name) == name) return attr.value; + } + return {}; +} + +// Resolves a CSS `` clip coordinate against `axis` (the box width or height in +// px). Percentages resolve against the axis; unitless (only `0` is valid unitless in CSS, but any +// number is accepted leniently) and `px` lengths are taken verbatim. Returns NaN for anything else +// (calc(), unsupported units) so the caller can drop the clip-path with a diagnostic. +float ResolveClipCoord(const std::string& token, float axis) { + std::string t = Trim(token); + if (t.empty()) return NAN; + float fraction = 0; + if (ParseCssPercentage(t, fraction)) return fraction * axis; + const char* c = t.c_str(); + char* end = nullptr; + float num = std::strtof(c, &end); + if (end == c) return NAN; + std::string suffix = ToLower(Trim(std::string(end))); + if (suffix.empty() || suffix == "px") return num; + return NAN; +} + +// Resolves the horizontal (`horizontal=true`) or vertical component of a clip-shape position token +// (`left/right/top/bottom/center` keyword or a ``) into a pixel coordinate in +// the masked box's local space. +float ResolveClipPositionAxis(const std::string& token, float axis, bool horizontal) { + std::string t = ToLower(Trim(token)); + if (t == "center") return axis / 2.0f; + if (horizontal) { + if (t == "left") return 0.0f; + if (t == "right") return axis; + } else { + if (t == "top") return 0.0f; + if (t == "bottom") return axis; + } + return ResolveClipCoord(token, axis); +} + +// Splits `fn(args)` into a lowercased function name and the raw argument string between the +// outermost parentheses. Returns false when `value` is not a function form. +bool SplitCssFunction(const std::string& value, std::string& outName, std::string& outArgs) { + std::string t = Trim(value); + auto open = t.find('('); + auto close = t.rfind(')'); + if (open == std::string::npos || close == std::string::npos || close <= open) return false; + outName = ToLower(Trim(t.substr(0, open))); + outArgs = Trim(t.substr(open + 1, close - open - 1)); + return true; +} + +// Resolves one ellipse radius against its box axis. CSS permits the closest-side / farthest-side +// keywords independently on each axis; an omitted radius defaults to closest-side. +float ResolveEllipseRadius(const std::string& token, float axis, float center) { + std::string value = ToLower(Trim(token)); + if (value.empty() || value == "closest-side") return std::min(center, axis - center); + if (value == "farthest-side") return std::max(center, axis - center); + return ResolveClipCoord(token, axis); +} + +// Converts a CSS `clip-path` basic shape into SVG geometry expressed in the masked box's local +// pixel space (0..w, 0..h). The emitted shape(s) are painted opaque white by the framing and +// parsed through SVGImporter into a contour mask, exactly like a resolved `url(#id)` . +// Supports `polygon()`, `path()`, `circle()`, `ellipse()` and `inset()`; returns empty when the +// shape is malformed or uses an out-of-subset feature so the caller can drop it with a diagnostic. +std::string BuildClipShapeGeometry(const std::string& value, float w, float h) { + std::string name; + std::string args; + if (!SplitCssFunction(value, name, args)) return {}; + + if (name == "polygon") { + auto items = SplitTopLevelCommas(args); + if (items.empty()) return {}; + // An optional leading `` (`nonzero` | `evenodd`) is its own comma-separated token. + std::string fillRule; + size_t start = 0; + std::string firstLc = ToLower(Trim(items[0])); + if (firstLc == "evenodd" || firstLc == "nonzero") { + if (firstLc == "evenodd") fillRule = "evenodd"; + start = 1; + } + std::string points; + for (size_t i = start; i < items.size(); ++i) { + auto xy = SplitTopLevelWhitespace(items[i]); + if (xy.size() < 2) return {}; + float x = ResolveClipCoord(xy[0], w); + float y = ResolveClipCoord(xy[1], h); + if (std::isnan(x) || std::isnan(y)) return {}; + if (!points.empty()) points += ' '; + points += CssFloatToString(x) + "," + CssFloatToString(y); + } + if (points.empty()) return {}; + std::string el = ",]? ). A `` prefix is only present when the value does + // not start with the quoted path string (whose `d` data itself contains commas). + std::string rest = Trim(args); + if (!rest.empty() && rest.front() != '"' && rest.front() != '\'') { + auto comma = rest.find(','); + if (comma != std::string::npos) rest = Trim(rest.substr(comma + 1)); + } + if (rest.size() >= 2 && (rest.front() == '"' || rest.front() == '\'') && + rest.back() == rest.front()) { + rest = rest.substr(1, rest.size() - 2); + } + if (rest.empty()) return {}; + return ""; + } + + if (name == "circle") { + // circle( []? [at ]? ) + auto tokens = SplitTopLevelWhitespace(args); + std::string radiusTok; + std::vector posTokens; + size_t i = 0; + if (i < tokens.size() && ToLower(tokens[i]) != "at") { + radiusTok = tokens[i]; + ++i; + } + if (i < tokens.size() && ToLower(tokens[i]) == "at") { + for (++i; i < tokens.size(); ++i) posTokens.push_back(tokens[i]); + } + float cx = posTokens.size() >= 1 ? ResolveClipPositionAxis(posTokens[0], w, true) : w / 2.0f; + float cy = posTokens.size() >= 2 ? ResolveClipPositionAxis(posTokens[1], h, false) : h / 2.0f; + if (std::isnan(cx) || std::isnan(cy)) return {}; + float r = NAN; + std::string rl = ToLower(Trim(radiusTok)); + if (rl.empty() || rl == "closest-side") { + r = std::min(std::min(cx, w - cx), std::min(cy, h - cy)); + } else if (rl == "farthest-side") { + r = std::max(std::max(cx, w - cx), std::max(cy, h - cy)); + } else { + float fraction = 0; + // A percentage radius resolves against `sqrt(w^2 + h^2) / sqrt(2)` per the CSS shapes spec. + if (ParseCssPercentage(radiusTok, fraction)) { + r = fraction * std::sqrt(w * w + h * h) / std::sqrt(2.0f); + } else { + r = ResolveClipCoord(radiusTok, w); + } + } + if (std::isnan(r) || r <= 0) return {}; + return ""; + } + + if (name == "ellipse") { + // ellipse( [ ]? [at ]? ) + auto tokens = SplitTopLevelWhitespace(args); + std::vector radii; + std::vector posTokens; + size_t i = 0; + for (; i < tokens.size() && ToLower(tokens[i]) != "at"; ++i) radii.push_back(tokens[i]); + if (i < tokens.size() && ToLower(tokens[i]) == "at") { + for (++i; i < tokens.size(); ++i) posTokens.push_back(tokens[i]); + } + float cx = posTokens.size() >= 1 ? ResolveClipPositionAxis(posTokens[0], w, true) : w / 2.0f; + float cy = posTokens.size() >= 2 ? ResolveClipPositionAxis(posTokens[1], h, false) : h / 2.0f; + if (std::isnan(cx) || std::isnan(cy)) return {}; + float rx = ResolveEllipseRadius(radii.size() > 0 ? radii[0] : "", w, cx); + float ry = ResolveEllipseRadius(radii.size() > 1 ? radii[1] : "", h, cy); + if (std::isnan(rx) || std::isnan(ry) || rx <= 0 || ry <= 0) return {}; + return ""; + } + + if (name == "inset") { + // inset( ? ? ? [round ]? ) + auto tokens = SplitTopLevelWhitespace(args); + std::vector offs; + std::vector roundToks; + bool afterRound = false; + for (auto& tk : tokens) { + if (!afterRound && ToLower(tk) == "round") { + afterRound = true; + continue; + } + (afterRound ? roundToks : offs).push_back(tk); + } + if (offs.empty() || offs.size() > 4) return {}; + // CSS edge shorthand: 1 -> all, 2 -> (top/bottom, left/right), 3 -> (top, left/right, bottom), + // 4 -> (top, right, bottom, left). + std::string tS = offs[0]; + std::string rS = offs.size() >= 2 ? offs[1] : offs[0]; + std::string bS = offs.size() >= 3 ? offs[2] : tS; + std::string lS = offs.size() >= 4 ? offs[3] : rS; + float top = ResolveClipCoord(tS, h); + float right = ResolveClipCoord(rS, w); + float bottom = ResolveClipCoord(bS, h); + float left = ResolveClipCoord(lS, w); + if (std::isnan(top) || std::isnan(right) || std::isnan(bottom) || std::isnan(left)) return {}; + float rw = w - left - right; + float rh = h - top - bottom; + if (rw <= 0 || rh <= 0) return {}; + std::string rect = "` form is modelled; the elliptical / per-corner border-radius + // grammar collapses to the first radius token applied to both axes. + if (!roundToks.empty()) { + float rr = ResolveClipCoord(roundToks[0], w); + if (!std::isnan(rr) && rr > 0) rect += " rx=\"" + CssFloatToString(rr) + "\""; + } + rect += "/>"; + return rect; + } + + return {}; +} + } // namespace bool HTMLParserContext::foldRoundedImageWrapper(const std::shared_ptr& element, @@ -286,6 +516,12 @@ bool HTMLParserContext::foldRoundedImageWrapper(const std::shared_ptr& pattern->scaleMode = ResolveImageScaleMode(imgBox.objectFit); fill->color = pattern; layer->contents.push_back(fill); + + // The fold collapses the wrapper + inner `` into a single layer. The wrapper's own + // `data-*` were already forwarded when its layer attributes were applied; forward the inner + // ``'s too so image-carried custom data (e.g. `data-id`) is not lost to the fold. + _layerBuilder->forwardDataAttributes(layer, img); + _idAllocator->assign(layer, element); return true; } @@ -293,11 +529,7 @@ bool HTMLParserContext::foldRoundedImageWrapper(const std::shared_ptr& Layer* HTMLParserContext::convertImage(const std::shared_ptr& element, const HTMLBoxAttributes& box) { auto* srcAttr = element->findAttribute("src"); - if (!srcAttr || srcAttr->empty()) { - warn("html: missing src; skipped"); - return nullptr; - } - const std::string& src = *srcAttr; + const std::string src = (srcAttr != nullptr) ? *srcAttr : std::string(); if (IsExternalSvgSrc(src)) { auto layer = _document->makeNode(); _layerBuilder->applySizeAndPosition(layer, box); @@ -308,8 +540,16 @@ Layer* HTMLParserContext::convertImage(const std::shared_ptr& element, return layer; } - auto* imageNode = registerImageResource(resolveImageSource(src)); - if (!imageNode) return nullptr; + // A missing / empty `src` is not a reason to drop the element: the `` still occupies a + // box in the layout and carries its own visuals (size, border-radius, data-*). Preserve the + // layer and back its fill with a placeholder `Image` whose source is left unresolved, so the + // image slot survives the import instead of vanishing from the PAGX. A non-empty `src` always + // registers an `Image` (even a broken path decodes to an unloadable-but-present resource). + Image* imageNode = src.empty() ? nullptr : registerImageResource(resolveImageSource(src)); + if (imageNode == nullptr) { + warn("html: missing src; image preserved with unresolved source"); + imageNode = _imageResources->createPlaceholder(); + } auto layer = _document->makeNode(); _layerBuilder->applySizeAndPosition(layer, box); @@ -398,31 +638,50 @@ void HTMLParserContext::applyMaskOrClip(Layer* layer, const HTMLBoxAttributes& b } maskType = (box.maskMode == "luminance") ? MaskType::Luminance : MaskType::Alpha; } else { - std::string id = ExtractCssUrl(box.clipPathRef); - if (!id.empty() && id.front() == '#') { - id = id.substr(1); - } - auto clipNode = id.empty() ? nullptr : _svgEmitter->lookupSharedDef(id); - if (clipNode == nullptr) { - warn("html: clip-path references an unknown id; ignored"); - return; - } - // Frame the children inside an sized to the masked box so the contour lands - // in the masked layer's local coordinate space. The clip shapes are painted opaque white; + // CSS `clip-path`: either a `url(#id)` reference to a hidden def, or a basic shape + // (`polygon()/path()/circle()/ellipse()/inset()`). Both are rebuilt into a contour mask framed + // to the masked box's local coordinate space; the clip shapes are painted opaque white because // contour masking only reads their coverage, not their colour. float w = std::isnan(box.widthPx) ? _canvasWidth : box.widthPx; float h = std::isnan(box.heightPx) ? _canvasHeight : box.heightPx; + std::string trimmedClip = Trim(box.clipPathRef); + bool isUrl = ToLower(trimmedClip).compare(0, 4, "url(") == 0; std::string inner; - for (auto child = clipNode->getFirstChild(); child; child = child->getNextSibling()) { - inner += _svgEmitter->serialize(child); - } - if (inner.empty()) { - warn("html: clip-path has no geometry; ignored"); - return; + // The framing viewBox maps geometry coordinates onto the pixel box. Basic shapes and + // `userSpaceOnUse` clipPaths carry pixel coordinates (0..w / 0..h); `objectBoundingBox` + // clipPaths carry 0..1 fractions, mapped by a unit-square viewBox at the box's pixel size. + std::string viewBox = "0 0 " + CssFloatToString(w) + " " + CssFloatToString(h); + if (isUrl) { + std::string id = ExtractCssUrl(box.clipPathRef); + if (!id.empty() && id.front() == '#') { + id = id.substr(1); + } + auto clipNode = id.empty() ? nullptr : _svgEmitter->lookupSharedDef(id); + if (clipNode == nullptr) { + warn("html: clip-path references an unknown id; ignored"); + return; + } + for (auto child = clipNode->getFirstChild(); child; child = child->getNextSibling()) { + inner += _svgEmitter->serialize(child); + } + if (inner.empty()) { + warn("html: clip-path has no geometry; ignored"); + return; + } + std::string units = ToLower(Trim(GetDomAttribute(clipNode, "clippathunits"))); + if (units == "objectboundingbox") { + viewBox = "0 0 1 1"; + } + } else { + inner = BuildClipShapeGeometry(trimmedClip, w, h); + if (inner.empty()) { + warn("html: unsupported clip-path basic shape; ignored"); + return; + } } svgContent = "" + inner + ""; + "\" height=\"" + CssFloatToString(h) + "\" viewBox=\"" + viewBox + + "\" fill=\"white\">" + inner + ""; maskType = MaskType::Contour; } @@ -614,6 +873,13 @@ bool HTMLParserContext::applyBackgroundImageFill(const HTMLBoxAttributes& box, L auto fill = _document->makeNode(); fill->color = pattern; + // A `background-blend-mode` blends this url() image against the background-color that + // applyBackgroundFill emits underneath it, mirroring the gradient path. With no colour the + // image is the bottom-most background layer and has nothing to blend against, so it draws + // Normal (matching CSS, where a lone layer's blend mode is a no-op). + if (box.backgroundColorSet) { + fill->blendMode = HTMLLayerBuilder::resolveBackgroundBlendMode(box.backgroundBlendMode); + } layer->contents.push_back(fill); return true; } diff --git a/src/pagx/html/importer/HTMLImageResources.cpp b/src/pagx/html/importer/HTMLImageResources.cpp index cbba408664..016f8877aa 100644 --- a/src/pagx/html/importer/HTMLImageResources.cpp +++ b/src/pagx/html/importer/HTMLImageResources.cpp @@ -51,4 +51,10 @@ Image* HTMLImageResources::registerResource(const std::string& imageSource) { return imageNode; } +Image* HTMLImageResources::createPlaceholder() { + auto imageNode = _document->makeNode(); + imageNode->id = _idAllocator.generateUnique("image"); + return imageNode; +} + } // namespace pagx diff --git a/src/pagx/html/importer/HTMLImageResources.h b/src/pagx/html/importer/HTMLImageResources.h index a62b7078e0..15ed8bd6c5 100644 --- a/src/pagx/html/importer/HTMLImageResources.h +++ b/src/pagx/html/importer/HTMLImageResources.h @@ -59,6 +59,11 @@ class HTMLImageResources { * underlying resource. Returns nullptr when `imageSource` is empty. */ Image* registerResource(const std::string& imageSource); + /** Creates a fresh, unresolved `Image` node (empty `filePath`) with a unique id. Used to + * preserve the image slot of an `` whose `src` is missing/empty/invalid so the element + * is not silently dropped from the PAGX. Never deduplicated — each placeholder is distinct. */ + Image* createPlaceholder(); + private: HTMLIdAllocator& _idAllocator; PAGXDocument* _document = nullptr; diff --git a/src/pagx/html/importer/HTMLLayerBuilder.cpp b/src/pagx/html/importer/HTMLLayerBuilder.cpp index 7e96d94a95..8a48327160 100644 --- a/src/pagx/html/importer/HTMLLayerBuilder.cpp +++ b/src/pagx/html/importer/HTMLLayerBuilder.cpp @@ -30,6 +30,7 @@ #include "pagx/html/importer/HTMLValueParser.h" #include "pagx/nodes/BackgroundBlurStyle.h" #include "pagx/nodes/BlurFilter.h" +#include "pagx/nodes/ColorMatrixFilter.h" #include "pagx/nodes/ConicGradient.h" #include "pagx/nodes/DropShadowFilter.h" #include "pagx/nodes/DropShadowStyle.h" @@ -122,6 +123,17 @@ void ResetLayoutAnchors(Layer* inner) { } // namespace +BlendMode HTMLLayerBuilder::resolveBackgroundBlendMode(const std::string& value) { + if (value.empty()) return BlendMode::Normal; + // `background-blend-mode` may list one keyword per background layer. We apply a single blend + // to every gradient/image Fill, so key off the first top-level token (the common single-value + // case is unaffected). `SVGBlendModeFromString` maps the CSS keyword directly and returns + // Normal for `normal` / any unrecognised value. + auto tokens = SplitTopLevelCommas(value); + std::string first = tokens.empty() ? value : Trim(tokens.front()); + return SVGBlendModeFromString(first); +} + HTMLLayerBuilder::HTMLLayerBuilder(HTMLDiagnosticSink& sink, HTMLValueParser& valueParser, HTMLInlineSvgEmitter& svgEmitter) : _diagnostics(sink), _valueParser(valueParser), _filterDecoder(sink, svgEmitter, valueParser) { @@ -322,17 +334,22 @@ bool HTMLLayerBuilder::applyBackgroundVisuals(Layer* layer, const HTMLBoxAttribu void HTMLLayerBuilder::applyBackgroundFill(Layer* layer, const HTMLBoxAttributes& box, Element* geometry, bool& emitted) { if (!geometry) return; - if (box.backgroundColorSet && box.backgroundImage.empty()) { - layer->contents.push_back(buildSolidFill(box.backgroundColor)); - emitted = true; - return; - } - if (box.backgroundImage.empty()) return; // A `url(...)` background is recovered as an `ImagePattern` fill by `HTMLParserContext` - // (it owns the image-resource registry and native-size decoding). The geometry is already - // on the layer; leave the fill to the caller and emit nothing here. - if (ToLower(box.backgroundImage).find("url(") != std::string::npos) return; + // (it owns the image-resource registry and native-size decoding) and pushed onto the layer + // right after this call, so it paints on top. CSS layers the image over the + // background-color, and decorative url() backgrounds are frequently partly transparent + // (e.g. a low-opacity noise overlay), so the colour must still be emitted underneath here + // rather than dropped. + bool urlImage = !box.backgroundImage.empty() && + ToLower(box.backgroundImage).find("url(") != std::string::npos; + if (box.backgroundImage.empty() || urlImage) { + if (box.backgroundColorSet) { + layer->contents.push_back(buildSolidFill(box.backgroundColor)); + emitted = true; + } + return; + } // CSS allows stacking multiple gradients in `background-image` separated by top-level // commas, with the first listed gradient painted on top. PAGX paints Fills in the order @@ -358,10 +375,28 @@ void HTMLLayerBuilder::applyBackgroundFill(Layer* layer, const HTMLBoxAttributes } if (!colors.empty()) { + // `background-blend-mode` blends each background layer against the layers *below* it, with + // the background-color as the bottom-most layer. Emit the solid colour first so the blended + // gradient Fill has a backdrop to composite against; without a blend mode an opaque gradient + // fully hides the colour, so the no-blend path keeps dropping it to avoid an inert extra Fill. + BlendMode blend = resolveBackgroundBlendMode(box.backgroundBlendMode); + // Tracks whether a background layer has already been painted beneath the current Fill. The + // bottom-most layer has nothing below it in the element's own background, so it must draw + // with Normal (matching CSS, where a lone/bottom layer's blend mode is a no-op) rather than + // blending against whatever sits behind the whole layer. + bool hasBackdrop = false; + if (blend != BlendMode::Normal && box.backgroundColorSet) { + layer->contents.push_back(buildSolidFill(box.backgroundColor)); + hasBackdrop = true; + } + // Gradients are pushed in reverse CSS order (the CSS-last layer first), so the first Fill + // emitted here is the bottom-most background layer. for (auto it = colors.rbegin(); it != colors.rend(); ++it) { auto fill = _document->makeNode(); fill->color = *it; + fill->blendMode = hasBackdrop ? blend : BlendMode::Normal; layer->contents.push_back(fill); + hasBackdrop = true; } emitted = true; return; @@ -516,6 +551,10 @@ void HTMLLayerBuilder::applyLayerAttributes(Layer* layer, const std::shared_ptr< drop->blurY = step.shadow.blur; drop->color = step.shadow.color; layer->filters.push_back(drop); + } else if (step.kind == HTMLValueParser::FilterStep::Kind::ColorMatrix) { + auto cm = _document->makeNode(); + cm->matrix = step.matrix; + layer->filters.push_back(cm); } else if (step.kind == HTMLValueParser::FilterStep::Kind::SvgRef) { // `plus-darker` has no CSS mix-blend-mode; the exporter bakes the backdrop into a PNG and // applies it through a `pagx_pd_*` feImage+feComposite(arithmetic) filter. That baked filter @@ -536,16 +575,7 @@ void HTMLLayerBuilder::applyLayerAttributes(Layer* layer, const std::shared_ptr< } // data-* attributes -> customData - for (const auto& attr : element->attributes) { - if (attr.name.compare(0, 5, "data-") == 0) { - std::string key = attr.name.substr(5); - if (IsValidCustomDataKey(key)) { - layer->customData[key] = attr.value; - } else { - _diagnostics.warn("html: invalid data-* attribute name '" + attr.name + "'"); - } - } - } + forwardDataAttributes(layer, element); // href on if (element->name == "a") { auto* href = element->findAttribute("href"); @@ -555,6 +585,25 @@ void HTMLLayerBuilder::applyLayerAttributes(Layer* layer, const std::shared_ptr< } } +void HTMLLayerBuilder::forwardDataAttributes(Layer* layer, + const std::shared_ptr& element) { + if (layer == nullptr || element == nullptr) return; + for (const auto& attr : element->attributes) { + if (attr.name.compare(0, 5, "data-") != 0) continue; + std::string key = attr.name.substr(5); + if (IsValidCustomDataKey(key)) { + auto existing = layer->customData.find(key); + if (existing != layer->customData.end() && existing->second != attr.value) { + _diagnostics.warn("html: data-* attribute '" + attr.name + + "' overrides an existing custom data value"); + } + layer->customData[key] = attr.value; + } else { + _diagnostics.warn("html: invalid data-* attribute name '" + attr.name + "'"); + } + } +} + Layer* HTMLLayerBuilder::maybeSplitBoxShadowFromClip(Layer* inner) { if (inner == nullptr || !inner->clipToBounds || inner->styles.empty()) { return inner; diff --git a/src/pagx/html/importer/HTMLLayerBuilder.h b/src/pagx/html/importer/HTMLLayerBuilder.h index ca2e6288ce..b0fe08fa11 100644 --- a/src/pagx/html/importer/HTMLLayerBuilder.h +++ b/src/pagx/html/importer/HTMLLayerBuilder.h @@ -22,6 +22,7 @@ #include #include "pagx/html/importer/HTMLBoxAttributes.h" #include "pagx/html/importer/HTMLSvgFilterDecoder.h" +#include "pagx/types/BlendMode.h" namespace pagx { @@ -80,9 +81,20 @@ class HTMLLayerBuilder { void applyLayerAttributes(Layer* layer, const std::shared_ptr& element, const HTMLBoxAttributes& box); + /** Copies the element's `data-*` attributes into `layer->customData` (invalid keys and + * conflicting overwrites are warned). Split out from `applyLayerAttributes` so the + * folded-image path — which reuses a parent layer and skips the rest of the layer-attribute + * chain — can still forward the inner ``'s custom data. */ + void forwardDataAttributes(Layer* layer, const std::shared_ptr& element); + /** Builds a `Fill` chain whose colour is a single solid `color`. */ Fill* buildSolidFill(const Color& color); + /** Maps a CSS `background-blend-mode` value onto a PAGX `BlendMode` (the first top-level + * keyword when a per-layer list is given). Returns `BlendMode::Normal` for an empty, + * `normal`, or unrecognised value. */ + static BlendMode resolveBackgroundBlendMode(const std::string& value); + /** Resolves a CSS gradient string into a registered gradient node. `boxWidth` / `boxHeight` * are the painted box size in px, used to normalise radial-gradient size/position descriptors; * pass NaN when unknown. */ diff --git a/src/pagx/html/importer/HTMLParserContext.cpp b/src/pagx/html/importer/HTMLParserContext.cpp index b5b1ae8a0e..3154c6db2e 100644 --- a/src/pagx/html/importer/HTMLParserContext.cpp +++ b/src/pagx/html/importer/HTMLParserContext.cpp @@ -355,6 +355,9 @@ Layer* HTMLParserContext::convertElement(const std::shared_ptr& element text->text = "\n"; text->fontFamily = inherited.primaryFontFamily.empty() ? HTML_DEFAULT_FONT_FAMILY : inherited.primaryFontFamily; + text->fontStyle = ResolveHTMLFontStyleName(inherited.fontStyleName); + text->fauxBold = inherited.fauxBold; + text->fauxItalic = inherited.fauxItalic; text->fontSize = HTML_DEFAULT_FONT_SIZE; layer->contents.push_back(text); return layer; diff --git a/src/pagx/html/importer/HTMLParserContext.h b/src/pagx/html/importer/HTMLParserContext.h index 0719402e58..be0ab34e06 100644 --- a/src/pagx/html/importer/HTMLParserContext.h +++ b/src/pagx/html/importer/HTMLParserContext.h @@ -119,7 +119,9 @@ class HTMLParserContext { const HTMLInheritedStyle& inherited); // Rebuilds a PAGX mask layer from the element's CSS `mask-image` (alpha / luminance) or - // `clip-path: url(#id)` (contour) and attaches it to `layer` as `layer->mask` / `maskType` + // `clip-path` (contour) — the latter from a `url(#id)` reference or a CSS basic + // shape (`polygon()` / `path()` / `circle()` / `ellipse()` / `inset()`) synthesised into + // equivalent SVG geometry — and attaches it to `layer` as `layer->mask` / `maskType` // (the inverse of `HTMLWriter::writeMaskCSS` / `writeClipDef`). The mask geometry SVG is parsed // through `SVGImporter`, and its nodes are transplanted into `_document`. The mask layer is added // as an invisible, layout-excluded child of `layer` so it shares the masked layer's local diff --git a/src/pagx/html/importer/HTMLStyleCascade.cpp b/src/pagx/html/importer/HTMLStyleCascade.cpp index 90d55025b7..17b6736903 100644 --- a/src/pagx/html/importer/HTMLStyleCascade.cpp +++ b/src/pagx/html/importer/HTMLStyleCascade.cpp @@ -808,6 +808,25 @@ void HTMLStyleCascade::parseBoxVisuals(HTMLBoxAttributes& box, const PropertyMap box.backgroundRepeat = LookupLowerTrimmed(props, "background-repeat"); box.backgroundPosition = LookupLowerTrimmed(props, "background-position"); } + // `background-blend-mode` blends the element's background layers among themselves. Kept + // lower-cased so the keyword comparison in applyBackgroundFill is case-insensitive; `normal` + // is treated as "no blending" there. + box.backgroundBlendMode = LookupLowerTrimmed(props, "background-blend-mode"); + auto backgroundBlendModes = SplitTopLevelCommas(box.backgroundBlendMode); + if (backgroundBlendModes.size() > 1) { + std::string firstMode = ToLower(Trim(backgroundBlendModes.front())); + bool hasDifferentMode = false; + for (size_t i = 1; i < backgroundBlendModes.size(); ++i) { + if (ToLower(Trim(backgroundBlendModes[i])) != firstMode) { + hasDifferentMode = true; + break; + } + } + if (hasDifferentMode) { + _diagnostics.warn("html: per-layer background-blend-mode values are not supported; using '" + + firstMode + "' for every background layer"); + } + } // `background-clip: text` is the only clip value the importer models. The subset transformer // already normalises every other keyword to empty, so a non-empty value here equals `text`. std::string bgClip = LookupLowerTrimmed(props, "background-clip"); diff --git a/src/pagx/html/importer/HTMLSubsetPropertyTable.cpp b/src/pagx/html/importer/HTMLSubsetPropertyTable.cpp index 125e98034f..87a7c1a165 100644 --- a/src/pagx/html/importer/HTMLSubsetPropertyTable.cpp +++ b/src/pagx/html/importer/HTMLSubsetPropertyTable.cpp @@ -207,16 +207,21 @@ std::string TransformBackgroundClip(const std::string& value, const PropertyCont return DropProperty("background-clip", value, "is not a supported value", diags); } -// `clip-path` is modelled only as a reference to a `` def (`url(#id)`), which the -// importer rebuilds into a contour mask layer. Geometric forms (`inset()` / `circle()` / -// `ellipse()` / `polygon()` / `path()`) have no PAGX geometry primitive and are dropped with a -// diagnostic (the strict-mode trigger), matching the snapshot's own `normalizeClipPath` filter. +// `clip-path` is rebuilt by the importer into a contour mask layer, either from a `url(#id)` +// reference to a hidden `` def or from a CSS basic shape (`polygon()` / `path()` / +// `circle()` / `ellipse()` / `inset()`), whose geometry is synthesised into an equivalent SVG in +// the masked box's local space. Both forms are kept; anything else (e.g. `shape()`, unknown +// functions) is dropped with a diagnostic. Mirrors the snapshot's own `normalizeClipPath` filter. std::string TransformClipPath(const std::string& value, const PropertyContext&, HTMLTransformContext& diags) { std::string lc = ToLower(Trim(value)); if (lc.empty() || lc == "none") return std::string(); if (lc.compare(0, 4, "url(") == 0) return Trim(value); - return DropProperty("clip-path", value, "only url(#id) references are supported", diags); + static const char* kSupportedShapes[] = {"polygon(", "path(", "circle(", "ellipse(", "inset("}; + for (const char* shape : kSupportedShapes) { + if (lc.compare(0, std::strlen(shape), shape) == 0) return Trim(value); + } + return DropProperty("clip-path", value, "only url(#id) and basic shapes are supported", diags); } std::string TransformBorder(const std::string& value, const PropertyContext&, @@ -375,6 +380,10 @@ const PropertyEntry SubsetPropertyEntries[] = { {"background-color", PropAction::Keep, nullptr, nullptr}, {"background-image", PropAction::Transform, &TransformBackgroundImage, nullptr}, {"background-clip", PropAction::Transform, &TransformBackgroundClip, nullptr}, + // `background-blend-mode` blends an element's background layers among themselves; the + // importer maps it onto the gradient/image Fill's blendMode. Kept verbatim (a CSS blend + // keyword) and consumed in HTMLStyleCascade::parseBoxVisuals. + {"background-blend-mode", PropAction::Keep, nullptr, nullptr}, // `background-size` / `background-repeat` / `background-position` are kept verbatim and // consumed only when paired with a `url(...)` background, where they drive the recovered // ImagePattern's scaleMode / tile modes / matrix. @@ -391,8 +400,8 @@ const PropertyEntry SubsetPropertyEntries[] = { {"mask-position", PropAction::Keep, nullptr, nullptr}, {"mask-repeat", PropAction::Keep, nullptr, nullptr}, // `clip-path: url(#id)` references a hidden def; the importer resolves it into a - // contour mask layer (the inverse of HTMLWriter::writeClipDef). Geometric clip-path forms - // (inset/circle/ellipse/polygon/path) have no PAGX primitive and are dropped with a diagnostic. + // contour mask layer (the inverse of HTMLWriter::writeClipDef). CSS basic shapes + // (polygon/path/circle/ellipse/inset) are also kept and synthesised into the same contour mask. {"clip-path", PropAction::Transform, &TransformClipPath, nullptr}, {"border", PropAction::Transform, &TransformBorder, nullptr}, // border-radius accepts 1-4 lengths or percentages; ResolveLengthShorthand handles both. diff --git a/src/pagx/html/importer/HTMLTextFragmentBuilder.cpp b/src/pagx/html/importer/HTMLTextFragmentBuilder.cpp index fbfd2fcd6e..317d43b129 100644 --- a/src/pagx/html/importer/HTMLTextFragmentBuilder.cpp +++ b/src/pagx/html/importer/HTMLTextFragmentBuilder.cpp @@ -72,7 +72,7 @@ Text* HTMLTextFragmentBuilder::buildTextElement(const TextFragment& fragment) { auto t = _document->makeNode(); t->text = fragment.text; t->fontFamily = fragment.fontFamily; - t->fontStyle = fragment.fontStyleName; + t->fontStyle = ResolveHTMLFontStyleName(fragment.fontStyleName); t->fauxBold = fragment.fauxBold; t->fauxItalic = fragment.fauxItalic; t->fontSize = fragment.fontSize; @@ -309,9 +309,27 @@ Layer* HTMLTextFragmentBuilder::convertTextLeaf(const std::shared_ptr& // host them, but the unified Layer.matrix path makes the wrapping Layer transform every // descendant uniformly — including the bare `+` pair the no-TextBox branch // emits — so the extra TextBox is no longer needed. - bool needsTextBox = hasMultipleFragments || !inherited.textAlign.empty() || - !inherited.lineHeight.empty() || box.clipOverflow || hasNoWrap || - isVertical || fragmentHasExplicitLineHeight; + // + // `hasNoWrap` (CSS `white-space: nowrap` / `pre`) is deliberately NOT a trigger: a bare + // `` is inherently single-line and never soft-wraps, so it already reproduces the + // no-wrap behaviour without a `TextBox wordWrap="false"` wrapper. The html-snapshot pipeline + // stamps `white-space: nowrap` onto every flattened text span, so keying off it here forced a + // TextBox around essentially every label. Hard line breaks (`pre` / `
` `\n`) are also fine + // on a bare Text — per the spec a source newline triggers a line break with the font-metric + // default line height, which is exactly what a `TextBox` with `lineHeight="0"` (auto) does. + // + // Only a *real* (pixel-resolved) line height forces a TextBox so it can carry `lineHeight`. + // A `normal` / unset line height resolves to NaN and must NOT force one — the html-snapshot + // pipeline stamps `line-height: normal` onto every flattened span, so keying off the raw + // (non-empty) string would wrap every label in a TextBox. Check both the leaf's own resolved + // value and every fragment's (`fragmentHasExplicitLineHeight`), so a real value inherited by + // the leaf is not lost when the only run overrides its own line height back to `normal`. + float leafLineHeightPx = + _valueParser.resolveLineHeightPx(inherited.lineHeight, inherited.fontSizePx); + bool hasRealLineHeight = + (!std::isnan(leafLineHeightPx) && leafLineHeightPx > 0) || fragmentHasExplicitLineHeight; + bool needsTextBox = hasMultipleFragments || !inherited.textAlign.empty() || box.clipOverflow || + isVertical || hasRealLineHeight; // CSS shrink-to-fit for inline text. The html-snapshot pipeline bakes the *browser's* // measured px width/height onto every text `` (it flattens each run into an diff --git a/src/pagx/html/importer/HTMLValueParser.cpp b/src/pagx/html/importer/HTMLValueParser.cpp index 42991414ec..4b27fb1058 100644 --- a/src/pagx/html/importer/HTMLValueParser.cpp +++ b/src/pagx/html/importer/HTMLValueParser.cpp @@ -48,6 +48,126 @@ bool ExtractGradientParts(const std::string& value, std::vector& ou return outParts.size() >= 2; } +// Parses a CSS filter amount, which is a number or a percentage (e.g. `1.4` or `140%`). An empty +// argument selects the function's default. Returns false when the token is present but malformed. +bool ParseFilterAmount(const std::string& raw, float defaultValue, float& out) { + std::string token = Trim(raw); + if (token.empty()) { + out = defaultValue; + return true; + } + float fraction = 0.0f; + if (ParseCssPercentage(token, fraction)) { + out = fraction; + return true; + } + char* end = nullptr; + float v = std::strtof(token.c_str(), &end); + if (end == token.c_str() || static_cast(end - token.c_str()) != token.size()) { + return false; + } + out = v; + return true; +} + +// The 20-element identity matrix (4x5, row-major, normalised colour space) used as the starting +// point for the colour-adjustment filter functions. +std::array IdentityColorMatrix() { + return {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}; +} + +// CSS `brightness(a)` / `contrast(a)`: per-channel linear ramp `out = a * in + bias`. brightness +// uses bias 0; contrast uses bias `0.5 - 0.5a` so the transform pivots around mid-grey. +std::array BrightnessContrastMatrix(float amount, float bias) { + auto m = IdentityColorMatrix(); + m[0] = amount; + m[6] = amount; + m[12] = amount; + m[4] = bias; + m[9] = bias; + m[14] = bias; + return m; +} + +// CSS `saturate(s)` matrix (Rec.709 luma coefficients), per the Filter Effects spec. `s == 1` is +// the identity; `s == 0` collapses to greyscale. +std::array SaturateMatrix(float s) { + auto m = IdentityColorMatrix(); + m[0] = 0.213f + 0.787f * s; + m[1] = 0.715f - 0.715f * s; + m[2] = 0.072f - 0.072f * s; + m[5] = 0.213f - 0.213f * s; + m[6] = 0.715f + 0.285f * s; + m[7] = 0.072f - 0.072f * s; + m[10] = 0.213f - 0.213f * s; + m[11] = 0.715f - 0.715f * s; + m[12] = 0.072f + 0.928f * s; + return m; +} + +// CSS `grayscale(a)` matrix, interpolating from identity (a == 0) to full luma greyscale (a == 1). +// The caller clamps `a` to [0, 1]. +std::array GrayscaleMatrix(float a) { + return SaturateMatrix(1.0f - a); +} + +// CSS `sepia(a)` matrix, per the Filter Effects spec, interpolating from identity to full sepia. +// The caller clamps `a` to [0, 1]. +std::array SepiaMatrix(float a) { + float inv = 1.0f - a; + auto m = IdentityColorMatrix(); + m[0] = 0.393f + 0.607f * inv; + m[1] = 0.769f - 0.769f * inv; + m[2] = 0.189f - 0.189f * inv; + m[5] = 0.349f - 0.349f * inv; + m[6] = 0.686f + 0.314f * inv; + m[7] = 0.168f - 0.168f * inv; + m[10] = 0.272f - 0.272f * inv; + m[11] = 0.534f - 0.534f * inv; + m[12] = 0.131f + 0.869f * inv; + return m; +} + +// CSS `invert(a)`: per-channel `out = a + (1 - 2a) * in`, so `a == 1` fully inverts each channel. +std::array InvertMatrix(float a) { + auto m = IdentityColorMatrix(); + float diag = 1.0f - 2.0f * a; + m[0] = diag; + m[6] = diag; + m[12] = diag; + m[4] = a; + m[9] = a; + m[14] = a; + return m; +} + +// CSS `opacity(a)`: scales only the alpha channel. +std::array OpacityMatrix(float a) { + auto m = IdentityColorMatrix(); + m[18] = a; + return m; +} + +// CSS `hue-rotate(angle)` matrix, per the Filter Effects spec (Rec.709 luma with the standard +// rotation coefficients). `angleDeg` rotates the hue around the colour wheel. +std::array HueRotateMatrix(float angleDeg) { + float rad = angleDeg * 3.14159265358979323846f / 180.0f; + float c = std::cos(rad); + float s = std::sin(rad); + auto m = IdentityColorMatrix(); + m[0] = 0.213f + c * 0.787f - s * 0.213f; + m[1] = 0.715f - c * 0.715f - s * 0.715f; + m[2] = 0.072f - c * 0.072f + s * 0.928f; + m[5] = 0.213f - c * 0.213f + s * 0.143f; + m[6] = 0.715f + c * 0.285f + s * 0.140f; + m[7] = 0.072f - c * 0.072f - s * 0.283f; + m[10] = 0.213f - c * 0.213f - s * 0.787f; + m[11] = 0.715f - c * 0.715f + s * 0.715f; + m[12] = 0.072f + c * 0.928f + s * 0.072f; + return m; +} + } // namespace HTMLValueParser::HTMLValueParser(HTMLDiagnosticSink& sink, const float& canvasWidth, @@ -343,6 +463,46 @@ std::vector HTMLValueParser::parseFilterChain( } step.kind = ref.empty() ? FilterStep::Kind::Unsupported : FilterStep::Kind::SvgRef; step.refId = ref; + } else if (name == "brightness" || name == "contrast" || name == "saturate" || + name == "grayscale" || name == "sepia" || name == "invert" || name == "opacity") { + // Colour-adjustment functions taking a | amount. brightness / contrast / + // saturate / opacity default to 1 (identity); grayscale / sepia / invert default to 0. + float defaultValue = + (name == "brightness" || name == "contrast" || name == "saturate" || name == "opacity") + ? 1.0f + : 0.0f; + float amount = defaultValue; + if (!ParseFilterAmount(args, defaultValue, amount)) { + _diagnostics.warn("html: invalid amount in filter '" + step.raw + "'; ignored"); + step.kind = FilterStep::Kind::Unsupported; + } else { + // Match the clamping browsers apply: grayscale / sepia / invert / opacity saturate at 1, + // while brightness / contrast / saturate only forbid negatives (no upper bound). + if (name == "grayscale" || name == "sepia" || name == "invert" || name == "opacity") { + amount = std::clamp(amount, 0.0f, 1.0f); + } else { + amount = std::max(amount, 0.0f); + } + step.kind = FilterStep::Kind::ColorMatrix; + if (name == "brightness") { + step.matrix = BrightnessContrastMatrix(amount, 0.0f); + } else if (name == "contrast") { + step.matrix = BrightnessContrastMatrix(amount, 0.5f - 0.5f * amount); + } else if (name == "saturate") { + step.matrix = SaturateMatrix(amount); + } else if (name == "grayscale") { + step.matrix = GrayscaleMatrix(amount); + } else if (name == "sepia") { + step.matrix = SepiaMatrix(amount); + } else if (name == "invert") { + step.matrix = InvertMatrix(amount); + } else { // opacity + step.matrix = OpacityMatrix(amount); + } + } + } else if (name == "hue-rotate") { + step.kind = FilterStep::Kind::ColorMatrix; + step.matrix = HueRotateMatrix(ParseAngle(args)); } else { step.kind = FilterStep::Kind::Unsupported; } diff --git a/src/pagx/html/importer/HTMLValueParser.h b/src/pagx/html/importer/HTMLValueParser.h index 2a335ab724..15cbe6388a 100644 --- a/src/pagx/html/importer/HTMLValueParser.h +++ b/src/pagx/html/importer/HTMLValueParser.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include #include @@ -61,16 +62,20 @@ class HTMLValueParser { /** * One step of a CSS `filter` / `backdrop-filter` chain. `SvgRef` carries a `url(#id)` * reference to an SVG `` def, whose `refId` the caller resolves through the - * shared-defs table. `Unsupported` is emitted for anything outside the - * {`blur`, `drop-shadow`, `url(#…)`} subset; the caller can surface a diagnostic using `raw`. + * shared-defs table. `ColorMatrix` carries the 4x5 (20-element, row-major, normalised colour + * space) transform that the colour-adjustment functions — `brightness`, `contrast`, `saturate`, + * `grayscale`, `sepia`, `invert`, `opacity`, `hue-rotate` — resolve to. `Unsupported` is emitted + * for anything outside the supported subset; the caller can surface a diagnostic using `raw`. */ struct FilterStep { - enum class Kind { Blur, DropShadow, SvgRef, Unsupported }; + enum class Kind { Blur, DropShadow, SvgRef, ColorMatrix, Unsupported }; Kind kind = Kind::Unsupported; float blurX = 0; float blurY = 0; ShadowSpec shadow = {}; std::string refId = {}; + std::array matrix = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}; std::string raw = {}; }; diff --git a/test/src/PAGXHTMLImporterTest.cpp b/test/src/PAGXHTMLImporterTest.cpp index 513e162c61..74a514366f 100644 --- a/test/src/PAGXHTMLImporterTest.cpp +++ b/test/src/PAGXHTMLImporterTest.cpp @@ -36,6 +36,7 @@ #include "pagx/html/importer/HTMLSubsetTransformer.h" #include "pagx/html/importer/HTMLTransformContext.h" #include "pagx/html/importer/HTMLTransformPassUtils.h" +#include "pagx/html/importer/HTMLTransformPasses.h" #include "pagx/nodes/BackgroundBlurStyle.h" #include "pagx/nodes/BlendFilter.h" #include "pagx/nodes/BlurFilter.h" @@ -175,6 +176,20 @@ inline std::shared_ptr ParseFromString(const std::string& ht return pagx::HTMLImporter::ParseString(html); } +pagx::ColorMatrixFilter* ParseColorMatrixFilter(const std::string& filter, + std::shared_ptr& document) { + document = ParseFromString( + "
"); + if (!document || document->layers.empty() || document->layers.front()->children.empty()) { + return nullptr; + } + auto* layer = document->layers.front()->children.back(); + if (layer->filters.size() != 1) return nullptr; + return As(layer->filters.front()); +} + inline pagx::Color SolidFillColorOf(pagx::Layer* layer) { auto* fill = FindElementOfType(layer); if (!fill) return {}; @@ -285,6 +300,275 @@ PAG_TEST(PAGXHTMLImporterTest, BackgroundColorBecomesRectangleAndFill) { EXPECT_FLOAT_EQ(div->height, 40.0f); } +// Regression: a colour-only `background` shorthand (e.g. `background:#FF0000`) must paint the +// same fill as the `background-color` longhand. The subset property table only lists the longhand, +// so declaration parsing expands the shorthand before PropertyFilterPass would otherwise drop it. +PAG_TEST(PAGXHTMLImporterTest, BackgroundShorthandColorBecomesFill) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + auto* rect = FindElementOfType(div); + auto* fill = FindElementOfType(div); + ASSERT_NE(rect, nullptr); + ASSERT_NE(fill, nullptr); + EXPECT_FLOAT_EQ(rect->roundness, 8.0f); + auto* solid = As(fill->color); + ASSERT_NE(solid, nullptr); + EXPECT_TRUE(ColorNear(solid->color, HexColor(0xFF0000))); +} + +// A gradient carried by the `background` shorthand routes to `background-image` during expansion +// and paints a gradient fill, matching the `background-image` longhand. +PAG_TEST(PAGXHTMLImporterTest, BackgroundShorthandGradientBecomesGradientFill) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + auto* fill = FindElementOfType(div); + ASSERT_NE(fill, nullptr); + auto* lg = As(fill->color); + ASSERT_NE(lg, nullptr); + ASSERT_EQ(lg->colorStops.size(), 2u); + EXPECT_TRUE(ColorNear(lg->colorStops.front()->color, HexColor(0xFF0000))); + EXPECT_TRUE(ColorNear(lg->colorStops.back()->color, HexColor(0x0000FF))); +} + +// A functional-notation colour (`rgba(...)`) carried by the `background` shorthand must be +// recognised as a colour — not mistaken for an image — and paint a solid fill. +PAG_TEST(PAGXHTMLImporterTest, BackgroundShorthandFunctionalColorBecomesFill) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + auto* fill = FindElementOfType(div); + ASSERT_NE(fill, nullptr); + auto* solid = As(fill->color); + ASSERT_NE(solid, nullptr); + EXPECT_NEAR(solid->color.alpha, 0.5f, 0.02f); +} + +// A combined `background` shorthand (colour + gradient overlay) decomposes into both a +// `background-color` and a `background-image`; the gradient paints over the colour, so the fill +// is the gradient. +PAG_TEST(PAGXHTMLImporterTest, BackgroundShorthandColorPlusGradient) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + auto* fill = FindElementOfType(div); + ASSERT_NE(fill, nullptr); + auto* lg = As(fill->color); + ASSERT_NE(lg, nullptr); + ASSERT_EQ(lg->colorStops.size(), 2u); + EXPECT_TRUE(ColorNear(lg->colorStops.front()->color, HexColor(0xFF0000))); +} + +// PAGX represents a URL background with one ImagePattern. When a shorthand contains multiple +// image layers, retain the CSS-topmost layer and the fitting properties declared on that same +// layer instead of pairing the image with a lower layer's size/repeat/position. +PAG_TEST(PAGXHTMLImporterTest, BackgroundShorthandKeepsTopImageFittingTogether) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* layer = doc->layers.front()->children.front(); + auto* fill = FindElementOfType(layer); + ASSERT_NE(fill, nullptr); + auto* pattern = As(fill->color); + ASSERT_NE(pattern, nullptr); + ASSERT_NE(pattern->image, nullptr); + EXPECT_EQ(pattern->image->filePath, "top.png"); + EXPECT_EQ(pattern->scaleMode, pagx::ScaleMode::LetterBox); +} + +// CSS paints a `url(...)` background image on top of the `background-color`, and such images are +// frequently partly transparent (e.g. a low-opacity noise overlay), so the colour must show +// through. Emit the solid colour fill underneath the ImagePattern rather than dropping it. +PAG_TEST(PAGXHTMLImporterTest, BackgroundColorPlusUrlImageKeepsColorUnderneath) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + + // Two fills: the solid colour first (painted underneath) then the image pattern on top. + std::vector fills; + for (auto* e : div->contents) { + if (auto* f = As(e)) fills.push_back(f); + } + ASSERT_EQ(fills.size(), 2u); + auto* solid = As(fills[0]->color); + ASSERT_NE(solid, nullptr); + EXPECT_TRUE(ColorNear(solid->color, HexColor(0xDFDDDA))); + auto* pattern = As(fills[1]->color); + ASSERT_NE(pattern, nullptr); + ASSERT_NE(pattern->image, nullptr); + EXPECT_EQ(pattern->image->filePath, "noise.png"); +} + +// `background-blend-mode` composites the gradient against the background-color beneath it. +// Unlike the plain (opaque) gradient path — which drops the now-hidden colour — the blended +// path keeps the solid colour fill underneath and tags the gradient Fill with the blend mode +// so the multiply has a backdrop to composite against. +PAG_TEST(PAGXHTMLImporterTest, BackgroundColorPlusGradientBlendMode) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + + // Two fills: the solid colour first (painted underneath) then the multiply-blended gradient. + std::vector fills; + for (auto* e : div->contents) { + if (auto* f = As(e)) fills.push_back(f); + } + ASSERT_EQ(fills.size(), 2u); + auto* solid = As(fills[0]->color); + ASSERT_NE(solid, nullptr); + EXPECT_TRUE(ColorNear(solid->color, HexColor(0x6366F1))); + EXPECT_EQ(fills[0]->blendMode, pagx::BlendMode::Normal); + auto* lg = As(fills[1]->color); + ASSERT_NE(lg, nullptr); + EXPECT_EQ(fills[1]->blendMode, pagx::BlendMode::Multiply); +} + +// Without a `background-blend-mode`, an opaque gradient over a background-color fully hides the +// colour, so the importer drops the colour and emits only the gradient Fill (no inert extra +// Fill, no blend mode). Guards the gating that keeps the blend-mode path from churning the +// common no-blend case. +PAG_TEST(PAGXHTMLImporterTest, BackgroundColorPlusGradientNoBlendDropsColor) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + std::vector fills; + for (auto* e : div->contents) { + if (auto* f = As(e)) fills.push_back(f); + } + ASSERT_EQ(fills.size(), 1u); + auto* lg = As(fills[0]->color); + ASSERT_NE(lg, nullptr); + EXPECT_EQ(fills[0]->blendMode, pagx::BlendMode::Normal); +} + +// `background-blend-mode` blends a layer only against the background layers below it. A lone +// gradient with no background-color is the bottom-most layer, so it has nothing to blend against +// and must draw Normal — otherwise it would wrongly blend against content behind the whole layer +// (CSS treats a lone/bottom layer's blend mode as a no-op). +PAG_TEST(PAGXHTMLImporterTest, BackgroundBlendModeNoColorLoneGradientStaysNormal) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.front(); + std::vector fills; + for (auto* e : div->contents) { + if (auto* f = As(e)) fills.push_back(f); + } + ASSERT_EQ(fills.size(), 1u); + ASSERT_NE(As(fills[0]->color), nullptr); + EXPECT_EQ(fills[0]->blendMode, pagx::BlendMode::Normal); +} + +// PAGX has one blend mode per Fill rather than CSS's independently repeated per-background list. +// Equal repeated values are lossless; differing values use the first mode and report the +// approximation instead of silently producing a potentially different composite. +PAG_TEST(PAGXHTMLImporterTest, DifferentPerLayerBackgroundBlendModesWarn) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + bool warned = false; + for (const auto& message : doc->errors) { + if (message.find("per-layer background-blend-mode") != std::string::npos) warned = true; + } + EXPECT_TRUE(warned); +} + +// A higher-priority shorthand resets every background longhand supplied by a lower-priority rule. +// In particular, an inline colour-only background must clear a class-provided image. +PAG_TEST(PAGXHTMLImporterTest, BackgroundShorthandResetsLowerPriorityLonghands) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* fill = FindElementOfType(doc->layers.front()->children.front()); + ASSERT_NE(fill, nullptr); + auto* solid = As(fill->color); + ASSERT_NE(solid, nullptr); + EXPECT_TRUE(ColorNear(solid->color, HexColor(0x00FF00))); +} + +// Shorthand/longhand precedence within one declaration block follows source order in both +// directions: a later shorthand resets an earlier image, while a later longhand overrides the +// corresponding component produced by an earlier shorthand. +PAG_TEST(PAGXHTMLImporterTest, BackgroundShorthandAndLonghandHonorSourceOrder) { + auto earlierImage = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(earlierImage, nullptr); + auto* firstFill = FindElementOfType(earlierImage->layers.front()->children.front()); + ASSERT_NE(firstFill, nullptr); + auto* solid = As(firstFill->color); + ASSERT_NE(solid, nullptr); + EXPECT_TRUE(ColorNear(solid->color, HexColor(0x00FF00))); + + auto laterImage = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(laterImage, nullptr); + auto* secondFill = FindElementOfType(laterImage->layers.front()->children.front()); + ASSERT_NE(secondFill, nullptr); + EXPECT_NE(As(secondFill->color), nullptr); +} + PAG_TEST(PAGXHTMLImporterTest, BorderRadiusMapsToRoundness) { auto doc = ParseFromString(R"HTML( @@ -1142,6 +1426,9 @@ PAG_TEST(PAGXHTMLImporterTest, WhiteSpacePreLineKeepsNewlineCollapsesSpaces) { } // `white-space: pre` keeps source spaces and newlines verbatim (no folding, no per-fragment trim). +// A single-fragment `pre` run needs no TextBox — a bare reproduces both the verbatim spaces +// and the hard line break — so the importer emits bare Text; only when another paragraph feature is +// present is a TextBox synthesised, and then its wrapping is disabled. PAG_TEST(PAGXHTMLImporterTest, WhiteSpacePreKeepsSpacesVerbatim) { auto doc = ParseFromString( "" @@ -1149,12 +1436,10 @@ PAG_TEST(PAGXHTMLImporterTest, WhiteSpacePreKeepsSpacesVerbatim) { ""); ASSERT_NE(doc, nullptr); auto* leaf = doc->layers.front()->children.front(); - auto* tb = FindElementOfType(leaf); - ASSERT_NE(tb, nullptr); - EXPECT_FALSE(tb->wordWrap); + EXPECT_EQ(FindElementOfType(leaf), nullptr); std::vector texts; std::vector fills; - GatherTextRuns(tb->elements, &texts, &fills); + GatherTextRuns(leaf->contents, &texts, &fills); ASSERT_FALSE(texts.empty()); const std::string& t = texts.front()->text; EXPECT_NE(t.find("A B"), std::string::npos); @@ -1886,7 +2171,7 @@ PAG_TEST(PAGXHTMLImporterTest, DisallowedLayoutPropertiesEmitWarnings) { PAG_TEST(PAGXHTMLImporterTest, DisallowedVisualPropertiesEmitWarnings) { auto doc = ParseFromString(R"HTML( -
)HTML"); @@ -2542,7 +2827,8 @@ PAG_TEST(PAGXHTMLImporterTest, HeadingDefaultFontSizes) { EXPECT_FLOAT_EQ(text->fontSize, r.size) << r.tag; // Headings default to font-weight:bold, which is baked as faux bold (synthesised on a base // face) rather than a "Bold" style label so the authored weight survives a missing styled face. - EXPECT_EQ(text->fontStyle, "") << r.tag; + // The base-face label surfaces as the canonical "Regular" so every Text node carries a style. + EXPECT_EQ(text->fontStyle, "Regular") << r.tag; EXPECT_TRUE(text->fauxBold) << r.tag; EXPECT_FALSE(text->fauxItalic) << r.tag; } @@ -2557,8 +2843,9 @@ PAG_TEST(PAGXHTMLImporterTest, FontWeightNumericMapsToFauxBold) { ASSERT_NE(doc, nullptr); auto* text = FindElementOfType(doc->layers.front()->children.front()); ASSERT_NE(text, nullptr); - // Bold (weight >= 600) is synthesised via faux bold rather than locking onto a "Bold" face. - EXPECT_EQ(text->fontStyle, ""); + // Bold (weight >= 600) is synthesised via faux bold rather than locking onto a "Bold" face; the + // base-face label surfaces as the canonical "Regular". + EXPECT_EQ(text->fontStyle, "Regular"); EXPECT_TRUE(text->fauxBold); EXPECT_FALSE(text->fauxItalic); } @@ -2587,9 +2874,9 @@ PAG_TEST(PAGXHTMLImporterTest, BoldItalicCombined) { ASSERT_NE(doc, nullptr); auto* text = FindElementOfType(doc->layers.front()->children.front()); ASSERT_NE(text, nullptr); - // Both axes are synthesised: the style label drops to empty and both faux flags are set so the - // run renders bold + italic even when the styled face is unavailable. - EXPECT_EQ(text->fontStyle, ""); + // Both axes are synthesised: the style label drops to the canonical "Regular" base face and both + // faux flags are set so the run renders bold + italic even when the styled face is unavailable. + EXPECT_EQ(text->fontStyle, "Regular"); EXPECT_TRUE(text->fauxBold); EXPECT_TRUE(text->fauxItalic); } @@ -2782,6 +3069,9 @@ PAG_TEST(PAGXHTMLImporterTest, TextAlignJustifyMapped) { EXPECT_EQ(tb->textAlign, pagx::TextAlign::Justify); } +// `white-space: nowrap` disables soft wrapping. A bare never soft-wraps, so a run that has +// no other paragraph feature is emitted as bare Text (no TextBox needed); when a TextBox is present +// for another reason its wordWrap must be false. Either shape reproduces the no-wrap behaviour. PAG_TEST(PAGXHTMLImporterTest, WhiteSpaceNowrapDisablesWrap) { auto doc = ParseFromString(R"HTML( @@ -2789,9 +3079,15 @@ PAG_TEST(PAGXHTMLImporterTest, WhiteSpaceNowrapDisablesWrap) { )HTML"); ASSERT_NE(doc, nullptr); - auto* tb = FindElementOfType(doc->layers.front()->children.front()); - ASSERT_NE(tb, nullptr); - EXPECT_FALSE(tb->wordWrap); + auto* leaf = doc->layers.front()->children.front(); + if (auto* tb = FindElementOfType(leaf)) { + EXPECT_FALSE(tb->wordWrap); + } else { + std::vector texts; + std::vector fills; + GatherTextRuns(leaf->contents, &texts, &fills); + EXPECT_FALSE(texts.empty()); + } } PAG_TEST(PAGXHTMLImporterTest, OverflowHiddenOnTextContainerHidesText) { @@ -2971,6 +3267,64 @@ PAG_TEST(PAGXHTMLImporterTest, DataStarAttributesPropagate) { EXPECT_EQ(div->customData["label"], "hi"); } +// `data-*` attributes on an `` forward onto the image layer's customData, matching the +// behaviour on plain containers. html-snapshot preserves these on the emitted ``. +PAG_TEST(PAGXHTMLImporterTest, DataStarAttributesPropagateOnImage) { + auto doc = ParseFromString(R"HTML( + + + + )HTML"); + ASSERT_NE(doc, nullptr); + auto* img = doc->layers.front()->children.front(); + auto* pattern = As(FindElementOfType(img)->color); + ASSERT_NE(pattern, nullptr); + EXPECT_EQ(img->customData["id"], "hero"); + EXPECT_EQ(img->customData["role"], "cover"); +} + +// The rounded-image fold collapses the wrapper + inner `` into one layer; the inner +// ``'s `data-*` must still reach the folded layer's customData. +PAG_TEST(PAGXHTMLImporterTest, DataStarAttributesPropagateOnFoldedRoundedImage) { + auto doc = ParseFromString(R"HTML( + +
+ +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* wrapper = doc->layers.front()->children.front(); + EXPECT_TRUE(wrapper->children.empty()); + auto* pattern = As(FindElementOfType(wrapper)->color); + ASSERT_NE(pattern, nullptr); + EXPECT_EQ(wrapper->customData["id"], "avatar"); +} + +// The folded is the more specific payload node, so its data value wins over the structural +// wrapper's value. The lossy collision is surfaced as a diagnostic rather than silently ignored. +PAG_TEST(PAGXHTMLImporterTest, FoldedImageDataConflictWarnsAndImageWins) { + auto doc = ParseFromString(R"HTML( + +
+ +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* wrapper = doc->layers.front()->children.front(); + EXPECT_EQ(wrapper->customData["id"], "image"); + bool foundConflict = false; + for (const auto& error : doc->errors) { + if (error.find("data-id") != std::string::npos && + error.find("overrides") != std::string::npos) { + foundConflict = true; + break; + } + } + EXPECT_TRUE(foundConflict); +} + PAG_TEST(PAGXHTMLImporterTest, IdAttributePropagatesToLayer) { auto doc = ParseFromString(R"HTML( @@ -3149,6 +3503,23 @@ PAG_TEST(PAGXHTMLImporterTest, BackgroundImageSizeContainMapsToLetterBox) { EXPECT_EQ(pattern->scaleMode, pagx::ScaleMode::LetterBox); } +// html-snapshot escapes apostrophes inside its single-quoted CSS url(). The importer removes that +// simple CSS escape so local paths keep their actual filename. +PAG_TEST(PAGXHTMLImporterTest, BackgroundImageUnescapesQuotedURL) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* fill = FindElementOfType(doc->layers.front()->children.front()); + ASSERT_NE(fill, nullptr); + auto* pattern = As(fill->color); + ASSERT_NE(pattern, nullptr); + ASSERT_NE(pattern->image, nullptr); + EXPECT_EQ(pattern->image->filePath, "/tmp/b's.png"); +} + // `background-size: cover` → Zoom. PAG_TEST(PAGXHTMLImporterTest, BackgroundImageSizeCoverMapsToZoom) { auto doc = ParseFromString(R"HTML( @@ -4066,20 +4437,156 @@ PAG_TEST(PAGXHTMLImporterTest, RawUnsupportedFilterWarns) { opts.autoNormalize = false; auto doc = pagx::HTMLImporter::ParseString(R"HTML( -
+
)HTML", opts); ASSERT_NE(doc, nullptr); bool warned = false; for (const auto& msg : doc->errors) { - if (msg.find("filter") != std::string::npos && msg.find("grayscale") != std::string::npos) { + if (msg.find("filter") != std::string::npos && msg.find("not-a-filter") != std::string::npos) { warned = true; } } EXPECT_TRUE(warned); } +// CSS `filter: brightness(x)` maps to a ColorMatrixFilter scaling the RGB channels by `x` while +// leaving alpha untouched. +PAG_TEST(PAGXHTMLImporterTest, FilterBrightnessBecomesColorMatrix) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.back(); + ASSERT_EQ(div->filters.size(), 1u); + auto* cm = As(div->filters[0]); + ASSERT_NE(cm, nullptr); + EXPECT_NEAR(cm->matrix[0], 1.4f, 0.001f); + EXPECT_NEAR(cm->matrix[6], 1.4f, 0.001f); + EXPECT_NEAR(cm->matrix[12], 1.4f, 0.001f); + EXPECT_NEAR(cm->matrix[18], 1.0f, 0.001f); + EXPECT_NEAR(cm->matrix[4], 0.0f, 0.001f); +} + +// `filter: grayscale(100%)` collapses to the Rec.709 luma greyscale ColorMatrixFilter (a fully +// desaturated `saturate(0)`), accepting the percentage form of the amount. +PAG_TEST(PAGXHTMLImporterTest, FilterGrayscaleBecomesColorMatrix) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.back(); + ASSERT_EQ(div->filters.size(), 1u); + auto* cm = As(div->filters[0]); + ASSERT_NE(cm, nullptr); + EXPECT_NEAR(cm->matrix[0], 0.213f, 0.001f); + EXPECT_NEAR(cm->matrix[1], 0.715f, 0.001f); + EXPECT_NEAR(cm->matrix[2], 0.072f, 0.001f); +} + +// Out-of-range amounts are clamped to match browsers: invert / opacity saturate at 1, so +// `invert(150%)` == `invert(100%)` (full inversion: diagonal -1, bias 1). +PAG_TEST(PAGXHTMLImporterTest, FilterAmountClampedToBrowserRange) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* div = doc->layers.front()->children.back(); + ASSERT_EQ(div->filters.size(), 1u); + auto* cm = As(div->filters[0]); + ASSERT_NE(cm, nullptr); + EXPECT_NEAR(cm->matrix[0], -1.0f, 0.001f); + EXPECT_NEAR(cm->matrix[4], 1.0f, 0.001f); +} + +// Cover the remaining scalar colour-adjustment functions and their distinguishing coefficients: +// contrast pivots around 0.5, saturate uses the Rec.709 matrix, sepia mixes RGB, and opacity only +// scales alpha. +PAG_TEST(PAGXHTMLImporterTest, RemainingColorAdjustmentFiltersBecomeColorMatrices) { + std::shared_ptr contrastDoc; + auto* contrast = ParseColorMatrixFilter("contrast(150%)", contrastDoc); + ASSERT_NE(contrast, nullptr); + EXPECT_NEAR(contrast->matrix[0], 1.5f, kEps); + EXPECT_NEAR(contrast->matrix[4], -0.25f, kEps); + + std::shared_ptr saturateDoc; + auto* saturate = ParseColorMatrixFilter("saturate(25%)", saturateDoc); + ASSERT_NE(saturate, nullptr); + EXPECT_NEAR(saturate->matrix[0], 0.40975f, kEps); + EXPECT_NEAR(saturate->matrix[1], 0.53625f, kEps); + EXPECT_NEAR(saturate->matrix[12], 0.304f, kEps); + + std::shared_ptr sepiaDoc; + auto* sepia = ParseColorMatrixFilter("sepia(100%)", sepiaDoc); + ASSERT_NE(sepia, nullptr); + EXPECT_NEAR(sepia->matrix[0], 0.393f, kEps); + EXPECT_NEAR(sepia->matrix[1], 0.769f, kEps); + EXPECT_NEAR(sepia->matrix[12], 0.131f, kEps); + + std::shared_ptr opacityDoc; + auto* opacity = ParseColorMatrixFilter("opacity(25%)", opacityDoc); + ASSERT_NE(opacity, nullptr); + EXPECT_NEAR(opacity->matrix[0], 1.0f, kEps); + EXPECT_NEAR(opacity->matrix[18], 0.25f, kEps); +} + +// hue-rotate accepts all three CSS angle units. A quarter turn must produce the same matrix as +// 90deg and pi/2 radians, including the non-trivial off-diagonal coefficients. +PAG_TEST(PAGXHTMLImporterTest, FilterHueRotateSupportsDegTurnAndRad) { + std::shared_ptr degreesDoc; + auto* degrees = ParseColorMatrixFilter("hue-rotate(90deg)", degreesDoc); + ASSERT_NE(degrees, nullptr); + EXPECT_NEAR(degrees->matrix[0], 0.0f, kEps); + EXPECT_NEAR(degrees->matrix[2], 1.0f, kEps); + EXPECT_NEAR(degrees->matrix[11], 1.43f, kEps); + + std::shared_ptr turnsDoc; + auto* turns = ParseColorMatrixFilter("hue-rotate(0.25turn)", turnsDoc); + ASSERT_NE(turns, nullptr); + std::shared_ptr radiansDoc; + auto* radians = ParseColorMatrixFilter("hue-rotate(1.57079632679rad)", radiansDoc); + ASSERT_NE(radians, nullptr); + for (size_t i = 0; i < degrees->matrix.size(); ++i) { + EXPECT_NEAR(turns->matrix[i], degrees->matrix[i], kEps); + EXPECT_NEAR(radians->matrix[i], degrees->matrix[i], kEps); + } +} + +// Unlike the [0,1]-bounded filters, contrast and saturate retain values above 1. Negative values +// still clamp to zero, so neither function can invert its adjustment matrix. +PAG_TEST(PAGXHTMLImporterTest, FilterContrastAndSaturateClampOnlyAtLowerBound) { + std::shared_ptr highContrastDoc; + auto* highContrast = ParseColorMatrixFilter("contrast(250%)", highContrastDoc); + ASSERT_NE(highContrast, nullptr); + EXPECT_NEAR(highContrast->matrix[0], 2.5f, kEps); + EXPECT_NEAR(highContrast->matrix[4], -0.75f, kEps); + + std::shared_ptr highSaturateDoc; + auto* highSaturate = ParseColorMatrixFilter("saturate(200%)", highSaturateDoc); + ASSERT_NE(highSaturate, nullptr); + EXPECT_NEAR(highSaturate->matrix[0], 1.787f, kEps); + EXPECT_NEAR(highSaturate->matrix[1], -0.715f, kEps); + + std::shared_ptr lowContrastDoc; + auto* lowContrast = ParseColorMatrixFilter("contrast(-1)", lowContrastDoc); + ASSERT_NE(lowContrast, nullptr); + EXPECT_NEAR(lowContrast->matrix[0], 0.0f, kEps); + EXPECT_NEAR(lowContrast->matrix[4], 0.5f, kEps); + + std::shared_ptr lowSaturateDoc; + auto* lowSaturate = ParseColorMatrixFilter("saturate(-1)", lowSaturateDoc); + ASSERT_NE(lowSaturate, nullptr); + EXPECT_NEAR(lowSaturate->matrix[0], 0.213f, kEps); + EXPECT_NEAR(lowSaturate->matrix[1], 0.715f, kEps); +} + PAG_TEST(PAGXHTMLImporterTest, RawBackdropFilterDropShadowUnsupportedWarns) { // `backdrop-filter` only models blur(); other functions emit a warning. pagx::HTMLImporter::Options opts; @@ -4356,7 +4863,8 @@ PAG_TEST(PAGXHTMLImporterTest, GradientThreeStopsImplicitMiddleInterpolated) { } PAG_TEST(PAGXHTMLImporterTest, FontStyleItalicOnlyProducesFauxItalic) { - // Pure italic without bold is synthesised via faux italic, leaving the style label empty. + // Pure italic without bold is synthesised via faux italic; the base-face label surfaces as the + // canonical "Regular". auto doc = ParseFromString(R"HTML( Hi @@ -4365,12 +4873,16 @@ PAG_TEST(PAGXHTMLImporterTest, FontStyleItalicOnlyProducesFauxItalic) { ASSERT_NE(doc, nullptr); auto* text = FindElementOfType(doc->layers.front()->children.front()); ASSERT_NE(text, nullptr); - EXPECT_EQ(text->fontStyle, ""); + EXPECT_EQ(text->fontStyle, "Regular"); EXPECT_FALSE(text->fauxBold); EXPECT_TRUE(text->fauxItalic); } -PAG_TEST(PAGXHTMLImporterTest, ImageMissingSrcWarnsAndIsSkipped) { +// A missing / empty `src` warns but no longer drops the ``: the element still occupies a +// box, so the importer preserves the layer and backs it with an ImagePattern fill whose Image +// resource is left unresolved (empty filePath). This keeps the image slot in the PAGX instead of +// vanishing. +PAG_TEST(PAGXHTMLImporterTest, ImageMissingSrcWarnsButIsPreserved) { auto doc = ParseFromString(R"HTML( @@ -4382,6 +4894,38 @@ PAG_TEST(PAGXHTMLImporterTest, ImageMissingSrcWarnsAndIsSkipped) { if (msg.find("missing src") != std::string::npos) warned = true; } EXPECT_TRUE(warned); + + ASSERT_FALSE(doc->layers.front()->children.empty()); + auto* leaf = doc->layers.front()->children.front(); + auto* fill = FindElementOfType(leaf); + ASSERT_NE(fill, nullptr); + auto* pattern = As(fill->color); + ASSERT_NE(pattern, nullptr); + ASSERT_NE(pattern->image, nullptr); + EXPECT_TRUE(pattern->image->filePath.empty()); +} + +// The preserved placeholder Image (empty filePath, no data) must still serialise a `source` +// attribute — it is `use="required"` in pagx.xsd — so the exported document stays schema-valid +// and round-trips back to the same unresolved Image instead of tripping validation. +PAG_TEST(PAGXHTMLImporterTest, ImageMissingSrcExportsEmptySourceAttribute) { + auto doc = ParseFromString(R"HTML( + + + + )HTML"); + ASSERT_NE(doc, nullptr); + auto xml = pagx::PAGXExporter::ToXML(*doc); + EXPECT_NE(xml.find("source=\"\""), std::string::npos); + + auto reimported = pagx::PAGXImporter::FromXML(xml); + ASSERT_NE(reimported, nullptr); + ASSERT_FALSE(reimported->layers.front()->children.empty()); + auto* leaf = reimported->layers.front()->children.front(); + auto* pattern = As(FindElementOfType(leaf)->color); + ASSERT_NE(pattern, nullptr); + ASSERT_NE(pattern->image, nullptr); + EXPECT_TRUE(pattern->image->filePath.empty()); } PAG_TEST(PAGXHTMLImporterTest, TransparentColorIsTreatedAsNoFill) { @@ -5249,7 +5793,7 @@ PAG_TEST(PAGXHTMLImporterTest, RawExcessivelyNestedTreeTripsRecursionLimit) { // HTMLElementEmitter — `` / inline `` rejection branches //================================================================================================== -PAG_TEST(PAGXHTMLImporterTest, ImgWithoutSrcSkippedWithDiagnostic) { +PAG_TEST(PAGXHTMLImporterTest, ImgWithoutSrcPreservedWithDiagnostic) { auto doc = ParseRaw(R"HTML( @@ -5257,6 +5801,11 @@ PAG_TEST(PAGXHTMLImporterTest, ImgWithoutSrcSkippedWithDiagnostic) { )HTML"); ASSERT_NE(doc, nullptr); EXPECT_TRUE(HasDiagnosticContaining(doc, " missing src")); + ASSERT_FALSE(doc->layers.front()->children.empty()); + auto* leaf = doc->layers.front()->children.front(); + auto* pattern = As(FindElementOfType(leaf)->color); + ASSERT_NE(pattern, nullptr); + ASSERT_NE(pattern->image, nullptr); } PAG_TEST(PAGXHTMLImporterTest, ImgExternalSvgRoutesAsImportDirective) { @@ -5785,12 +6334,12 @@ PAG_TEST(PAGXHTMLSubsetTransformerTest, StrictModeFailsOnUnsupportedProperty) { pagx::HTMLSubsetTransformer::Options opts = {}; opts.strict = true; std::shared_ptr root; - // `clip-path` stays on the explicit-drop list (PAGX has no equivalent geometry primitive), - // so it remains a clean trigger for strict-mode failures even after `margin` was promoted - // out of the drop set in MarginIsResolvedThroughPaddingWrapper. + // A geometry-box-only `clip-path` (`margin-box`) has no url(#id) reference and is not a supported + // basic shape, so it stays on the explicit-drop list (PAGX has no equivalent primitive) and + // remains a clean trigger for strict-mode failures. auto result = RunTransform( R"HTML( -
)HTML", +
)HTML", &root, opts); EXPECT_FALSE(result.ok); EXPECT_FALSE(result.diagnostics.empty()); @@ -7326,6 +7875,108 @@ PAG_TEST(PAGXHTMLImporterTest, ClipPathRebuildsContourMask) { EXPECT_TRUE(hasGeometry); } +// A CSS `clip-path` basic shape (`polygon()`) with no def is synthesised into contour +// mask geometry framed to the masked box, with percentage coordinates resolved against the box. +PAG_TEST(PAGXHTMLImporterTest, ClipPathPolygonShapeRebuildsContourMask) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + EXPECT_FALSE(HasDiagnosticContaining(doc, "clip-path")); + auto* masked = doc->layers.front()->children.back(); + ASSERT_NE(masked->mask, nullptr); + EXPECT_EQ(masked->maskType, pagx::MaskType::Contour); + auto* path = FindElementOfType(masked->mask); + ASSERT_NE(path, nullptr); + ASSERT_NE(path->data, nullptr); + // The triangle's `%` coordinates resolve to these exact vertices in the 100x100 box. + const auto& verbs = path->data->verbs(); + const auto& points = path->data->points(); + ASSERT_EQ(verbs.size(), 4u); + EXPECT_EQ(verbs[0], pagx::PathVerb::Move); + EXPECT_EQ(verbs[1], pagx::PathVerb::Line); + EXPECT_EQ(verbs[2], pagx::PathVerb::Line); + EXPECT_EQ(verbs[3], pagx::PathVerb::Close); + ASSERT_EQ(points.size(), 3u); + EXPECT_NEAR(points[0].x, 50.0f, kEps); + EXPECT_NEAR(points[0].y, 0.0f, kEps); + EXPECT_NEAR(points[1].x, 100.0f, kEps); + EXPECT_NEAR(points[1].y, 100.0f, kEps); + EXPECT_NEAR(points[2].x, 0.0f, kEps); + EXPECT_NEAR(points[2].y, 100.0f, kEps); +} + +// A `clip-path: circle()` basic shape is synthesised into an SVG in box space, imported +// as an Ellipse contour geometry; the radius resolves against the box (default `closest-side`). +PAG_TEST(PAGXHTMLImporterTest, ClipPathCircleShapeRebuildsContourMask) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + EXPECT_FALSE(HasDiagnosticContaining(doc, "clip-path")); + auto* masked = doc->layers.front()->children.back(); + ASSERT_NE(masked->mask, nullptr); + EXPECT_EQ(masked->maskType, pagx::MaskType::Contour); + auto* ellipse = FindElementOfType(masked->mask); + ASSERT_NE(ellipse, nullptr); + EXPECT_NEAR(ellipse->size.width, 60.0f, kEps); + EXPECT_NEAR(ellipse->size.height, 60.0f, kEps); + EXPECT_NEAR(ellipse->position.x, 50.0f, kEps); + EXPECT_NEAR(ellipse->position.y, 40.0f, kEps); +} + +// A CSS `clip-path: inset()` basic shape is synthesised into an SVG in box space; the four +// edge offsets carve an inner rectangle (left/right against width, top/bottom against height). +PAG_TEST(PAGXHTMLImporterTest, ClipPathInsetShapeRebuildsContourMask) { + auto doc = ParseFromString(R"HTML( + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + EXPECT_FALSE(HasDiagnosticContaining(doc, "clip-path")); + auto* masked = doc->layers.front()->children.back(); + ASSERT_NE(masked->mask, nullptr); + EXPECT_EQ(masked->maskType, pagx::MaskType::Contour); + auto* rectangle = FindElementOfType(masked->mask); + ASSERT_NE(rectangle, nullptr); + EXPECT_NEAR(rectangle->size.width, 60.0f, kEps); + EXPECT_NEAR(rectangle->size.height, 80.0f, kEps); + EXPECT_NEAR(rectangle->position.x, 50.0f, kEps); + EXPECT_NEAR(rectangle->position.y, 50.0f, kEps); +} + +// A `clip-path: url(#id)` whose uses `clipPathUnits="objectBoundingBox"` carries 0..1 +// fractional coordinates; the importer maps that unit square onto the masked box so the contour +// fills it (rather than collapsing to a ~1px region at the origin). +PAG_TEST(PAGXHTMLImporterTest, ClipPathObjectBoundingBoxScalesToBox) { + auto doc = ParseFromString(R"HTML( + + + + + + + + +
+ + )HTML"); + ASSERT_NE(doc, nullptr); + auto* masked = doc->layers.front()->children.back(); + ASSERT_NE(masked->mask, nullptr); + EXPECT_EQ(masked->maskType, pagx::MaskType::Contour); + // The unit-square (0..1) clip geometry is mapped onto the 200x200 box, so the mask carries a + // `scale(200, 200)` transform. Without the objectBoundingBox handling the geometry would stay in + // 0..1 pixel space and collapse to a ~1px region at the origin (no scale emitted). + std::string xml = pagx::PAGXExporter::ToXML(*doc); + EXPECT_NE(xml.find("scale=\"200,200\""), std::string::npos); +} + // The rebuilt mask layer carries a generated id so the `mask="@id"` reference survives a PAGX // export / re-import round-trip — including the forward reference (the mask is a descendant of // the masked layer, parsed after the owning layer's `mask` attribute is read). @@ -8516,4 +9167,125 @@ PAG_TEST(PAGXHTMLPassUtilsTest, ClearChildMainMarginColumnPreservesCrossAxis) { EXPECT_EQ(pagx::html::LookupProperty(props, "margin-right"), "8px"); } +//================================================================================================== +// HTMLTransformPasses — uncovered document and normalization edge cases +//================================================================================================== + +PAG_TEST(PAGXHTMLSubsetTransformerTest, NullRootAndMissingBodyAreRejected) { + pagx::HTMLSubsetTransformer::Options options; + pagx::HTMLTransformContext context(options); + pagx::html::DocumentSkeletonPass pass; + pass.apply(nullptr, context); + ASSERT_TRUE(context.hasFatal()); + ASSERT_FALSE(context.diagnostics().empty()); + EXPECT_EQ(context.diagnostics().back().code, "subset:no-root"); + + std::shared_ptr root; + auto result = RunTransform("x", &root); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(HasDiagnostic(result, "subset:no-body")); +} + +PAG_TEST(PAGXHTMLSubsetTransformerTest, SkeletonMergesDuplicatesAndSanitizesRootAndHead) { + std::shared_ptr root; + auto result = RunTransform( + "a" + "" + "" + "
" + "x", + &root); + ASSERT_TRUE(result.ok); + auto head = root->getFirstChild("head"); + auto body = root->getFirstChild("body"); + ASSERT_NE(head, nullptr); + ASSERT_NE(body, nullptr); + EXPECT_NE(head->getFirstChild("title"), nullptr); + EXPECT_NE(head->getFirstChild("meta"), nullptr); + EXPECT_EQ(head->getFirstChild("link"), nullptr); + EXPECT_EQ(CountElementChildren(body), 2u); + EXPECT_TRUE(HasDiagnostic(result, "subset:external-stylesheet")); + EXPECT_TRUE(HasDiagnostic(result, "subset:unsupported-tag")); +} + +PAG_TEST(PAGXHTMLSubsetTransformerTest, StrayTextAfterElementIsWrappedAndBlankTextDropped) { + std::shared_ptr root; + auto result = RunTransform( + "" + "
atailb
" + "", + &root); + ASSERT_TRUE(result.ok); + auto div = FirstBodyChild(root, "div"); + ASSERT_NE(div, nullptr); + auto first = div->getFirstChild("span"); + ASSERT_NE(first, nullptr); + auto wrapped = first->nextSibling; + ASSERT_NE(wrapped, nullptr); + EXPECT_EQ(wrapped->name, "p"); + EXPECT_TRUE(HasDiagnostic(result, "subset:text-wrapped")); + for (auto child = div->firstChild; child; child = child->nextSibling) { + EXPECT_EQ(child->type, pagx::DOMNodeType::Element); + } +} + +PAG_TEST(PAGXHTMLSubsetTransformerTest, FilterKeepsDataPropertyAndEmitterRemovesEmptyStyle) { + auto root = ParseHtml("
"); + ASSERT_NE(root, nullptr); + auto body = root->getFirstChild("body"); + auto empty = body->getFirstChild("div"); + auto data = empty->nextSibling; + ASSERT_NE(data, nullptr); + + pagx::HTMLSubsetTransformer::Options options; + pagx::HTMLTransformContext context(options); + context.setResolved(empty.get(), {}); + context.setResolved(data.get(), {{"data-test", "kept"}}); + pagx::html::PropertyFilterPass filter; + filter.apply(root, context); + pagx::html::InlineStyleEmitterPass emitter; + emitter.apply(root, context); + + EXPECT_EQ(empty->findAttribute("style"), nullptr); + EXPECT_TRUE(StyleContains(data, "data-test: kept")); +} + +PAG_TEST(PAGXHTMLSubsetTransformerTest, MarginToGapRejectsMismatchedOuterMargins) { + std::shared_ptr root; + auto trailing = RunTransform( + "" + "
" + "
" + "
" + "
" + "
", + &root); + ASSERT_TRUE(trailing.ok); + EXPECT_FALSE(HasDiagnostic(trailing, "subset:margin-promoted-to-gap")); + + auto leading = RunTransform( + "" + "
" + "
" + "
" + "
" + "
", + &root); + ASSERT_TRUE(leading.ok); + EXPECT_FALSE(HasDiagnostic(leading, "subset:margin-promoted-to-gap")); +} + +PAG_TEST(PAGXHTMLSubsetTransformerTest, SpaceJustifyUsesPercentageChildrenAndGap) { + std::shared_ptr root; + auto result = RunTransform( + "" + "
" + "
" + "
", + &root); + ASSERT_TRUE(result.ok); + EXPECT_TRUE(HasDiagnostic(result, "subset:space-justify-collapsed-on-overflow")); + EXPECT_TRUE(StyleContains(FirstBodyChild(root, "div"), "justify-content: flex-start")); +} + } // namespace pag diff --git a/test/src/PAGXOptimizerTest.cpp b/test/src/PAGXOptimizerTest.cpp index b97b6e4834..4b016727f2 100644 --- a/test/src/PAGXOptimizerTest.cpp +++ b/test/src/PAGXOptimizerTest.cpp @@ -29,13 +29,18 @@ #include "pagx/nodes/DropShadowStyle.h" #include "pagx/nodes/Ellipse.h" #include "pagx/nodes/Fill.h" +#include "pagx/nodes/Font.h" +#include "pagx/nodes/GlyphRun.h" #include "pagx/nodes/Group.h" +#include "pagx/nodes/Image.h" +#include "pagx/nodes/ImagePattern.h" #include "pagx/nodes/Layer.h" #include "pagx/nodes/Path.h" #include "pagx/nodes/PathData.h" #include "pagx/nodes/Rectangle.h" #include "pagx/nodes/SolidColor.h" #include "pagx/nodes/Stroke.h" +#include "pagx/nodes/Text.h" #include "pagx/utils/VerifyUtils.h" #include "utils/ProjectPath.h" @@ -51,9 +56,14 @@ using pagx::DropShadowStyle; using pagx::Element; using pagx::Ellipse; using pagx::Fill; +using pagx::Font; +using pagx::Glyph; +using pagx::GlyphRun; using pagx::Group; using pagx::HasLayerOnlyFeatures; using pagx::HasPainter; +using pagx::Image; +using pagx::ImagePattern; using pagx::IsLayerShell; using pagx::IsPainter; using pagx::Layer; @@ -74,6 +84,7 @@ using pagx::PathData; using pagx::Rectangle; using pagx::SolidColor; using pagx::Stroke; +using pagx::Text; // --------------------------------------------------------------------------- // Builders kept tiny so each test stays focused on the rule under inspection. @@ -103,6 +114,21 @@ static Layer* AddTopLayer(PAGXDocument* doc) { return layer; } +static PAGXOptimizerOptions OnlyRuleOptions() { + PAGXOptimizerOptions options; + options.pruneEmpty = false; + options.downgradeShellChildren = false; + options.mergeAdjacentShellLayers = false; + options.collapseSingleChildLayers = false; + options.unwrapRedundantFirstGroup = false; + options.mergeAdjacentGroups = false; + options.canonicalizePaths = false; + options.rectMaskToScrollRect = false; + options.dedupPathData = false; + options.pruneUnreferencedResources = false; + return options; +} + // --------------------------------------------------------------------------- // PruneEmpty // --------------------------------------------------------------------------- @@ -891,6 +917,267 @@ CLI_TEST(PAGXOptimizerTest, OptimizeResultSignalsNonConvergenceAtIterationCap) { EXPECT_TRUE(warnedAboutOptimizer); } +// --------------------------------------------------------------------------- +// Coverage-focused guards for canonicalization, mask conversion, and resources. +// --------------------------------------------------------------------------- + +CLI_TEST(PAGXOptimizerTest, OptimizeNullDocumentIsANoOp) { + auto result = OptimizeWithOptions(nullptr, OnlyRuleOptions()); + EXPECT_TRUE(result.converged); + EXPECT_EQ(result.iterationsUsed, 0); +} + +CLI_TEST(PAGXOptimizerTest, CanonicalizesEllipseAndPreservesIneligiblePaths) { + auto doc = PAGXDocument::Make(120, 120); + auto* layer = AddTopLayer(doc.get()); + + auto* ellipseData = doc->makeNode(); + ellipseData->moveTo(50, 0); + ellipseData->cubicTo(77.614235f, 0, 100, 22.385765f, 100, 50); + ellipseData->cubicTo(100, 77.614235f, 77.614235f, 100, 50, 100); + ellipseData->cubicTo(22.385765f, 100, 0, 77.614235f, 0, 50); + ellipseData->cubicTo(0, 22.385765f, 22.385765f, 0, 50, 0); + ellipseData->close(); + auto* ellipsePath = doc->makeNode(); + ellipsePath->data = ellipseData; + ellipsePath->position = {3, 4}; + layer->contents.push_back(ellipsePath); + + auto* emptyPath = doc->makeNode(); + emptyPath->data = doc->makeNode(); + layer->contents.push_back(emptyPath); + + auto* rectData = doc->makeNode(); + rectData->moveTo(0, 0); + rectData->lineTo(10, 0); + rectData->lineTo(10, 10); + rectData->lineTo(0, 10); + rectData->close(); + auto* reversedPath = doc->makeNode(); + reversedPath->data = rectData; + reversedPath->reversed = true; + layer->contents.push_back(reversedPath); + auto* constrainedPath = doc->makeNode(); + constrainedPath->data = rectData; + constrainedPath->right = 0; + layer->contents.push_back(constrainedPath); + auto* annotatedPath = doc->makeNode(); + annotatedPath->data = rectData; + annotatedPath->customData["data-role"] = "shape"; + layer->contents.push_back(annotatedPath); + + auto options = OnlyRuleOptions(); + options.canonicalizePaths = true; + OptimizeWithOptions(doc.get(), options); + + ASSERT_EQ(layer->contents.size(), 5u); + ASSERT_EQ(layer->contents[0]->nodeType(), NodeType::Ellipse); + auto* ellipse = static_cast(layer->contents[0]); + EXPECT_FLOAT_EQ(ellipse->position.x, 53.0f); + EXPECT_FLOAT_EQ(ellipse->position.y, 54.0f); + EXPECT_FLOAT_EQ(ellipse->size.width, 100.0f); + EXPECT_FLOAT_EQ(ellipse->size.height, 100.0f); + for (size_t i = 1; i < layer->contents.size(); i++) { + EXPECT_EQ(layer->contents[i]->nodeType(), NodeType::Path); + } +} + +CLI_TEST(PAGXOptimizerTest, NonDefaultGroupsKeepTheirTransformAndConstraintFrame) { + auto options = OnlyRuleOptions(); + options.unwrapRedundantFirstGroup = true; + for (int guard = 0; guard < 11; guard++) { + auto doc = PAGXDocument::Make(100, 100); + auto* layer = AddTopLayer(doc.get()); + auto* group = doc->makeNode(); + group->elements.push_back(doc->makeNode()); + switch (guard) { + case 0: + group->alpha = 0.5f; + break; + case 1: + group->position.x = 1; + break; + case 2: + group->anchor.y = 1; + break; + case 3: + group->rotation = 5; + break; + case 4: + group->scale.x = 2; + break; + case 5: + group->skew = 3; + break; + case 6: + group->skewAxis = 15; + break; + case 7: + group->width = 20; + break; + case 8: + group->percentHeight = 100; + break; + case 9: + group->padding.left = 2; + break; + case 10: + group->right = 0; + break; + } + layer->contents.push_back(group); + OptimizeWithOptions(doc.get(), options); + ASSERT_EQ(layer->contents.size(), 1u) << "guard=" << guard; + EXPECT_EQ(layer->contents[0], group) << "guard=" << guard; + } +} + +CLI_TEST(PAGXOptimizerTest, RectMaskConversionRejectsSemanticAndGeometryChanges) { + struct Fixture { + std::shared_ptr doc; + Layer* mask = nullptr; + Layer* user = nullptr; + Rectangle* rect = nullptr; + Fill* fill = nullptr; + }; + auto makeFixture = []() { + Fixture f; + f.doc = PAGXDocument::Make(100, 100); + f.mask = AddTopLayer(f.doc.get()); + f.rect = f.doc->makeNode(); + f.rect->position = {25, 25}; + f.rect->size = {50, 50}; + f.fill = MakeSolidFill(f.doc.get(), 1, 1, 1); + f.mask->contents = {f.rect, f.fill}; + f.user = AddTopLayer(f.doc.get()); + f.user->mask = f.mask; + f.user->maskType = MaskType::Alpha; + f.user->contents.push_back(MakeFilledRectGroup(f.doc.get(), 100, 100, 1, 0, 0)); + return f; + }; + + auto options = OnlyRuleOptions(); + options.rectMaskToScrollRect = true; + for (int guard = 0; guard < 22; guard++) { + auto f = makeFixture(); + switch (guard) { + case 0: + f.user->hasScrollRect = true; + break; + case 1: + f.mask->x = 1; + break; + case 2: + f.mask->matrix = Matrix::Scale(2, 2); + break; + case 3: + f.mask->matrix3D.setRowColumn(0, 3, 1); + break; + case 4: + f.mask->alpha = 0.5f; + break; + case 5: + f.mask->blendMode = BlendMode::Multiply; + break; + case 6: + f.mask->styles.push_back(f.doc->makeNode()); + break; + case 7: + f.mask->customData["data-mask"] = "kept"; + break; + case 8: + f.mask->contents.pop_back(); + break; + case 9: + f.mask->contents[0] = f.doc->makeNode(); + break; + case 10: + f.mask->contents[1] = f.doc->makeNode(); + break; + case 11: + f.rect->reversed = true; + break; + case 12: + f.rect->right = 0; + break; + case 13: + f.rect->customData["data-rect"] = "kept"; + break; + case 14: + f.fill->alpha = 0.5f; + break; + case 15: + f.fill->blendMode = BlendMode::Multiply; + break; + case 16: + f.fill->color = f.doc->makeNode(); + break; + case 17: + static_cast(f.fill->color)->color.alpha = 0.5f; + break; + case 18: + f.user->matrix = Matrix::Scale(2, 1); + break; + case 19: + f.user->matrix3D.setRowColumn(1, 3, 2); + break; + case 20: + f.user->right = 0; + break; + case 21: + f.user->width = 100; + break; + } + OptimizeWithOptions(f.doc.get(), options); + EXPECT_EQ(f.user->mask, f.mask) << "guard=" << guard; + } +} + +CLI_TEST(PAGXOptimizerTest, PruneResourcesFollowsPatternsTextAndFontGlyphs) { + auto doc = PAGXDocument::Make(100, 100); + auto* layer = AddTopLayer(doc.get()); + + auto* fillImage = doc->makeNode("fill-image"); + auto* fillPattern = doc->makeNode("fill-pattern"); + fillPattern->image = fillImage; + auto* fill = doc->makeNode(); + fill->color = fillPattern; + layer->contents.push_back(fill); + + auto* strokeImage = doc->makeNode("stroke-image"); + auto* strokePattern = doc->makeNode("stroke-pattern"); + strokePattern->image = strokeImage; + auto* stroke = doc->makeNode(); + stroke->color = strokePattern; + layer->contents.push_back(stroke); + + auto* glyphPath = doc->makeNode("glyph-path"); + auto* glyphImage = doc->makeNode("glyph-image"); + auto* pathGlyph = doc->makeNode(); + pathGlyph->path = glyphPath; + auto* imageGlyph = doc->makeNode(); + imageGlyph->image = glyphImage; + auto* font = doc->makeNode("embedded-font"); + font->glyphs = {nullptr, pathGlyph, imageGlyph}; + auto* run = doc->makeNode(); + run->font = font; + auto* text = doc->makeNode(); + text->glyphRuns = {nullptr, run}; + layer->contents.push_back(text); + + auto options = OnlyRuleOptions(); + options.pruneUnreferencedResources = true; + OptimizeWithOptions(doc.get(), options); + + EXPECT_EQ(fillPattern->id, "fill-pattern"); + EXPECT_EQ(fillImage->id, "fill-image"); + EXPECT_EQ(strokePattern->id, "stroke-pattern"); + EXPECT_EQ(strokeImage->id, "stroke-image"); + EXPECT_EQ(font->id, "embedded-font"); + EXPECT_EQ(glyphPath->id, "glyph-path"); + EXPECT_EQ(glyphImage->id, "glyph-image"); +} + // --------------------------------------------------------------------------- // VerifyUtils: HasLayerOnlyFeatures — a freshly created Layer with all defaults // must return false. Every subsequent test mutates exactly one attribute to @@ -1244,4 +1531,295 @@ CLI_TEST(VerifyUtilsTest, HasPainterPainterAsFirstElement) { EXPECT_TRUE(HasPainter(elements)); } +// --------------------------------------------------------------------------- +// CollapseSingleChildLayers +// --------------------------------------------------------------------------- + +// Only the collapse rule enabled, so a test observes it in isolation from the other structural +// passes (which could otherwise downgrade/merge the same layers). +static PAGXOptimizerOptions CollapseOnly() { + PAGXOptimizerOptions options; + options.pruneEmpty = false; + options.downgradeShellChildren = false; + options.mergeAdjacentShellLayers = false; + options.unwrapRedundantFirstGroup = false; + options.mergeAdjacentGroups = false; + options.canonicalizePaths = false; + options.rectMaskToScrollRect = false; + options.dedupPathData = false; + options.pruneUnreferencedResources = false; + options.collapseSingleChildLayers = true; + return options; +} + +static void AddRectFill(PAGXDocument* doc, Layer* layer, float w, float h) { + auto* rect = doc->makeNode(); + rect->position = {w / 2.0f, h / 2.0f}; + rect->size = {w, h}; + layer->contents.push_back(rect); + layer->contents.push_back(MakeSolidFill(doc, 1, 0, 0)); +} + +// A clipping/sizing parent whose single child exactly fills it and only carries paint content: +// the child's content is absorbed and the parent keeps its frame + clip. +CLI_TEST(PAGXOptimizerTest, CollapseAbsorbsFillingChild) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 16; + parent->height = 16; + parent->clipToBounds = true; + auto* child = doc->makeNode(); + child->width = 16; + child->height = 16; + AddRectFill(doc.get(), child, 16, 16); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(doc->layers.size(), 1u); + EXPECT_EQ(doc->layers[0], parent); + EXPECT_TRUE(parent->children.empty()); + EXPECT_TRUE(parent->clipToBounds); + EXPECT_FLOAT_EQ(parent->width, 16.0f); + ASSERT_EQ(parent->contents.size(), 2u); + EXPECT_EQ(parent->contents[0]->nodeType(), NodeType::Rectangle); + EXPECT_EQ(parent->contents[1]->nodeType(), NodeType::Fill); +} + +// A shell parent (all attributes default, no contents) is dropped in favour of its only child, +// which carries the real frame. +CLI_TEST(PAGXOptimizerTest, CollapseDropsShellParent) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + auto* child = doc->makeNode(); + child->width = 40; + child->height = 20; + child->left = 5; + child->top = 5; + AddRectFill(doc.get(), child, 40, 20); + parent->children.push_back(child); + ASSERT_TRUE(IsLayerShell(parent)); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(doc->layers.size(), 1u); + EXPECT_EQ(doc->layers[0], child); + EXPECT_FLOAT_EQ(child->width, 40.0f); + EXPECT_FLOAT_EQ(child->left, 5.0f); +} + +// Bottom-up recursion plus the local fixed-point loop collapses an arbitrarily nested shell chain +// in one optimizer pass, leaving the painted leaf as the sole top-level layer. +CLI_TEST(PAGXOptimizerTest, CollapseDropsNestedShellChainInOnePass) { + auto doc = PAGXDocument::Make(100, 100); + auto* outer = AddTopLayer(doc.get()); + auto* middle = doc->makeNode(); + auto* inner = doc->makeNode(); + auto* leaf = doc->makeNode(); + leaf->width = 40; + leaf->height = 20; + AddRectFill(doc.get(), leaf, 40, 20); + inner->children.push_back(leaf); + middle->children.push_back(inner); + outer->children.push_back(middle); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(doc->layers.size(), 1u); + EXPECT_EQ(doc->layers.front(), leaf); + EXPECT_TRUE(leaf->children.empty()); + ASSERT_EQ(leaf->contents.size(), 2u); +} + +// A child that applies its own opacity must NOT be absorbed — the alpha would be lost. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsChildWithAlpha) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 16; + parent->height = 16; + parent->clipToBounds = true; + auto* child = doc->makeNode(); + child->width = 16; + child->height = 16; + child->alpha = 0.5f; + AddRectFill(doc.get(), child, 16, 16); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); + EXPECT_TRUE(parent->contents.empty()); +} + +// Padding changes the constraint frame used to lay out the child's contents. Dissolving this layer +// would make percentage/constrained elements resolve against the parent's unpadded frame. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsChildWithPadding) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 100; + parent->height = 100; + parent->clipToBounds = true; + auto* child = doc->makeNode(); + child->width = 100; + child->height = 100; + child->padding = {10, 10, 10, 10}; + auto* rect = doc->makeNode(); + rect->percentWidth = 100; + rect->percentHeight = 100; + child->contents.push_back(rect); + child->contents.push_back(MakeSolidFill(doc.get(), 1, 0, 0)); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); + EXPECT_TRUE(parent->contents.empty()); +} + +// An id makes the child externally observable through animations, data binds, host lookups, and +// future edits. Moving that id to a parent that may have its own contents changes the target scope. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsIdentifiedChild) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 100; + parent->height = 100; + AddRectFill(doc.get(), parent, 100, 100); + auto* child = doc->makeNode("animated"); + child->width = 100; + child->height = 100; + AddRectFill(doc.get(), child, 50, 50); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); + EXPECT_EQ(doc->findNode("animated"), child); + EXPECT_EQ(parent->contents.size(), 2u); +} + +// Two auto/content-sized axes are not evidence that the resolved boxes match: parent and child can +// measure from different payloads, and reparenting percentage descendants would change their frame. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsUnprovenAutoSizedChild) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->clipToBounds = true; + auto* child = doc->makeNode(); + AddRectFill(doc.get(), child, 16, 16); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); +} + +// Fill proof is per-axis: an exact concrete width plus a 100%-height constraint is as safe as two +// concrete matches and should retain the useful optimisation. +CLI_TEST(PAGXOptimizerTest, CollapseAbsorbsMixedConcreteAndPercentFill) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 16; + parent->height = 16; + parent->clipToBounds = true; + auto* child = doc->makeNode(); + child->width = 16; + child->percentHeight = 100; + AddRectFill(doc.get(), child, 16, 16); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + EXPECT_TRUE(parent->children.empty()); + ASSERT_EQ(parent->contents.size(), 2u); +} + +// Percentage constraints take precedence over absolute dimensions. A malformed/programmatically +// built node can carry both, so matching absolute dimensions must not hide a non-filling percent. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsNonFillingPercentDespiteMatchingAbsoluteSize) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 16; + parent->height = 16; + parent->clipToBounds = true; + auto* child = doc->makeNode(); + child->width = 16; + child->height = 16; + child->percentWidth = 50; + AddRectFill(doc.get(), child, 16, 16); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); + EXPECT_TRUE(parent->contents.empty()); +} + +// A filling child that carries non-geometric semantics (an external composition, a databind +// context, ...) must NOT be absorbed — dropping the child layer would discard that content/binding. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsChildWithComposition) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 16; + parent->height = 16; + parent->clipToBounds = true; + auto* child = doc->makeNode(); + child->width = 16; + child->height = 16; + child->compositionFilePath = "icon.pagx"; + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); + EXPECT_EQ(child->compositionFilePath, "icon.pagx"); +} + +// A shell parent that carries a databind context is not a pure structural wrapper: dropping it +// would discard the vmContext, so it must be kept even though its geometry looks like a shell. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsShellParentWithVmContext) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->vmContext = "$vm.item"; + auto* child = doc->makeNode(); + child->width = 40; + child->height = 20; + AddRectFill(doc.get(), child, 40, 20); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(doc->layers.size(), 1u); + EXPECT_EQ(doc->layers[0], parent); + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); + EXPECT_EQ(parent->vmContext, "$vm.item"); +} + +// A child that does not fill its parent (smaller + offset) is left alone: the parent's frame/clip +// is doing real work positioning it. +CLI_TEST(PAGXOptimizerTest, CollapseKeepsNonFillingChild) { + auto doc = PAGXDocument::Make(100, 100); + auto* parent = AddTopLayer(doc.get()); + parent->width = 20; + parent->height = 20; + parent->clipToBounds = true; + auto* child = doc->makeNode(); + child->width = 10; + child->height = 10; + child->left = 5; + child->top = 5; + AddRectFill(doc.get(), child, 10, 10); + parent->children.push_back(child); + + OptimizeWithOptions(doc.get(), CollapseOnly()); + + ASSERT_EQ(parent->children.size(), 1u); + EXPECT_EQ(parent->children[0], child); +} + } // namespace pag diff --git a/tools/html-snapshot/eval/README.md b/tools/html-snapshot/eval/README.md index 8355ee1838..4a178ec62d 100644 --- a/tools/html-snapshot/eval/README.md +++ b/tools/html-snapshot/eval/README.md @@ -4,11 +4,23 @@ Quantify the fidelity of the `snapshot.js → pagx import → pagx render` pipeline against the source HTML corpus. Used to drive iteration on the snapshot's layout-preserving rewrites. +The eval harness supports Puppeteer 23.0.0 or newer only. This matches the +tested minimum declared by `tools/html-snapshot/package.json`; ad-hoc installs +must stay at or above that floor because baseline capture passes the +`captureBeyondViewport` screenshot option. + ## Layout | File | Role | |------|------| | `baseline.js` | Render an original HTML in headless Chromium, save a PNG cropped to the body's measured rect — this is the ground truth. | + +> Baseline and snapshot share `lib/page-loader.ts`, which walks the viewport +> top-to-bottom before capturing so lazy-loaded content (IntersectionObserver / +> scroll listeners / native `loading="lazy"`, and infinite-scroll feeds) is +> realised instead of captured as skeleton placeholders. The pass is bounded +> (pass count / height / time) so infinite feeds settle at a sane length. Set +> `HTML_SNAPSHOT_NO_AUTOSCROLL=1` to disable it. | `compare.js` | Pixel metrics (pixelmatch + mean RGB delta + luma SSIM), Chromium-based flex container counter (same method for live and subset), importer-warning parser. Importable as a module or runnable per-case. | | `run.js` | Per-case driver: baseline → snapshot → import → resolve → render → compare. Emits `report.csv`, `report.md`, and `index.html`. | | `run.sh` | Wrapper around `run.js`. | diff --git a/tools/html-snapshot/eval/baseline.js b/tools/html-snapshot/eval/baseline.js index 7cc739412b..8abc89260c 100755 --- a/tools/html-snapshot/eval/baseline.js +++ b/tools/html-snapshot/eval/baseline.js @@ -11,8 +11,9 @@ const fs = require('fs'); const path = require('path'); const { openAndSettlePage } = require('../dist/lib/page-loader'); -const { makeFail, parseNumber } = require('../dist/lib/common'); -const { launchBrowser, resolveEngine, setViewport } = require('../dist/lib/browser-engine'); +const { makeFail, parseNumber, MAX_CAPTURE_HEIGHT_PX } = require('../dist/lib/common'); +const { launchBrowser, resolveEngine } = require('../dist/lib/browser-engine'); +const { applyCanvasViewport } = require('../dist/lib/canvas-viewport'); const fail = makeFail('baseline'); @@ -66,23 +67,56 @@ function parseArgs(argv) { return opts; } -async function captureBodyRect(page) { +async function captureBodyRect(page, maxHeightPx) { // Force layout flush and read the body rect that snapshot.js will use as the // canvas. Anything outside this rect is not addressable in the subset output, // so cropping the baseline screenshot to the same rect keeps the diff fair. - return page.evaluate(() => { + return page.evaluate((maxHeight) => { const body = document.body; body.style.margin = '0'; body.style.padding = '0'; + // Pin to the viewport origin. The snapshot's `` carries + // `padding:32px 0` plus `display:flex; justify-content:center` purely so a + // browser preview of the subset has breathing room, but `pagx render` roots + // at `` and ignores the `` box — the subset PNG therefore places + // body content at (0,0). Left as-is, Chromium would honour the `` + // padding/centring and push the baseline's body down 32px (and, for a body + // narrower than the viewport, right by the centring gap), so every diff + // would be offset. Neutralising the `` box here reproduces pagx's + // body-rooted framing and — unlike clipping from the body's own origin — + // keeps the screenshot clip inside the viewport, so it behaves identically + // on both the puppeteer and playwright engines. + const de = document.documentElement; + if (de && de.style) { + de.style.margin = '0'; + de.style.padding = '0'; + de.style.display = 'block'; + } + // Mirror prepareBodyForSnapshot (browser-snapshot.ts): force the scroll + // reset instant so `html { scroll-behavior: smooth }` can't leave the + // scrollTo(0, 0) below animating. Kept symmetric with the subset so the two + // "prepare" steps stay mirror images even though this one's outputs (body + // dimensions + a captureBeyondViewport clip at the document origin) are + // already scroll-independent. + if (de && de.style) de.style.setProperty('scroll-behavior', 'auto', 'important'); + if (body && body.style) body.style.setProperty('scroll-behavior', 'auto', 'important'); if (typeof window.scrollTo === 'function') { try { window.scrollTo(0, 0); } catch (_) { /* ignore */ } } void body.offsetHeight; const rect = body.getBoundingClientRect(); const width = Math.max(body.scrollWidth, Math.round(rect.width)); - const height = Math.max(body.scrollHeight, Math.round(rect.height)); + // Clamp to the same renderable maximum snapshot.js applies to the subset + // canvas. Infinite-scroll feeds inflate the body past what a single GL + // render surface can hold; without this the baseline PNG (ground truth) + // would be taller than the subset and the diff would compare mismatched + // canvases. Clamping both to MAX_CAPTURE_HEIGHT_PX keeps them aligned. + const height = Math.min( + maxHeight, + Math.max(body.scrollHeight, Math.round(rect.height)), + ); return { width, height }; - }); + }, maxHeightPx); } async function main() { @@ -103,18 +137,40 @@ async function main() { console.error(`page exception: ${err.message}`); }, }); - const { width, height } = await captureBodyRect(page); - // Re-size the viewport to the captured body rect so the screenshot is - // taken at the canvas dimensions (matches `pagx render --scale 1` output - // size). `setViewport` is engine-aware: puppeteer keeps the legacy - // per-page setter, playwright forwards to setViewportSize. - await setViewport(page, engine, { width, height, deviceScaleFactor: 1 }); - await new Promise((r) => setTimeout(r, 50)); + const { width, height } = await captureBodyRect(page, MAX_CAPTURE_HEIGHT_PX); + // Resize the viewport to the captured body rect so the screenshot is taken + // at the canvas dimensions (matches `pagx render --scale 1` output size), + // then keep it only if it was safe. `applyCanvasViewport` is the SAME guard + // the subset uses (lib/canvas-viewport.ts): it reverts to the settle + // viewport for fluid / viewport-driven pages whose `vh` sections balloon or + // whose scroll-driven content resets under a full-canvas viewport. Sharing + // it keeps the baseline and subset in lock-step — both resize, or both + // revert, so the diff compares the same page state. + await applyCanvasViewport( + page, + engine, + { width, height }, + { width: opts.viewportWidth, height: opts.viewportHeight }, + (msg) => console.error(msg), + ); + // captureBodyRect neutralised the `` box, so sits at the + // viewport origin — the same body-rooted framing `pagx render` produces for + // the subset. Clip at (0,0) to the canvas rect measured before any resize: + // it equals the subset's emitted canvas in both branches (kept: viewport == + // canvas; reverted: the subset re-measures at the settle viewport, which is + // where this rect was measured). `captureBeyondViewport` lets the reverted + // case screenshot the full canvas height at the shorter settle viewport + // WITHOUT resizing the layout back up (which is exactly what would re-balloon + // a `vh` page). await page.screenshot({ path: opts.output, type: 'png', clip: { x: 0, y: 0, width, height }, omitBackground: false, + // The eval harness supports Puppeteer >= 23.0.0 only (the minimum declared + // in tools/html-snapshot/package.json). Keep ad-hoc installs at or above + // that tested floor when using this full-page clipping option. + captureBeyondViewport: true, }); console.log(`baseline: wrote ${opts.output} (${width}x${height})`); } finally { diff --git a/tools/html-snapshot/eval/run.js b/tools/html-snapshot/eval/run.js index 0871189269..f6f08e5bd1 100755 --- a/tools/html-snapshot/eval/run.js +++ b/tools/html-snapshot/eval/run.js @@ -203,6 +203,50 @@ function findCorpusFiles(corpusDir, only, recursive) { return out; } +// Optional per-case source URL manifest so the report's "source" column can +// point its ` + : `
source
n/a
`; return `

${r.name} SSIM ${ssim}

@@ -561,9 +624,10 @@ function writeIndexHtml(rows, outPath, label) { ${status}

-
baseline
-
subset
-
diff
+ ${srcCell} +
baseline
+
subset
+
diff
`; }).join('\n'); @@ -601,16 +665,32 @@ function writeIndexHtml(rows, outPath, label) { .flag.dropped { background: #e0e7ff; color: #3730a3; } .err { color: #b00; font-weight: 600; } .muted { color: #94a3b8; font-size: 12px; } - .row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } + .controls { position: sticky; top: 0; z-index: 10; display: flex; flex-wrap: wrap; align-items: center; gap: 14px; padding: 10px 0; margin: 0 0 12px; background: #fff; border-bottom: 1px solid #e5e7eb; font-size: 13px; } + .controls strong { color: #0f172a; font-weight: 600; } + .controls label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; color: #334155; } + .row { display: grid; grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); gap: 12px; } + body.hide-source .col-source, body.hide-baseline .col-baseline, body.hide-subset .col-subset, body.hide-diff .col-diff { display: none; } .row figure { margin: 0; background: #f1f5f9; padding: 6px; border-radius: 6px; } .row figcaption { font-size: 12px; color: #64748b; margin-bottom: 4px; } + .row figcaption a { color: #2563eb; text-decoration: none; } + .row figcaption a:hover { text-decoration: underline; } .row img { width: 100%; height: auto; display: block; image-rendering: pixelated; } + .frame-wrap { position: relative; width: 100%; overflow: hidden; background: #fff; } + .frame { border: 0; transform-origin: top left; background: #fff; } + .frame-missing { display: flex; align-items: center; justify-content: center; height: 120px; background: #fff; border-radius: 4px; } p { color: #475569; font-size: 13px; }

html-snapshot eval — ${label}

${rows.length} cases (${s.ok} ok, ${s.errored} errored). Generated ${new Date().toISOString()}.

+
+ Columns: + + + + +

Corpus summary

    @@ -623,6 +703,49 @@ function writeIndexHtml(rows, outPath, label) {
${cards} + `; fs.writeFileSync(outPath, html, 'utf8'); @@ -637,6 +760,10 @@ async function main() { if (!opts.outDir) opts.outDir = path.join(SCRIPT_DIR, 'out', opts.label); ensureDir(opts.outDir); + // Optional slug->live-URL manifest from the corpus root; drives the report's + // "source"