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 = " ";
+ return el;
+ }
+
+ if (name == "path") {
+ // path( [,]? ). 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(
+ ""
+ "a tailb
"
+ "",
+ &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