Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3ef815a
Fix html-snapshot eval baseline vertical offset by neutralizing the <…
OnionsYu Jul 15, 2026
ce9dc7f
Add a live source iframe column and toggleable columns to the html-sn…
OnionsYu Jul 16, 2026
9a5a49e
Realize lazy-loaded content in html-snapshot by auto-scrolling before…
OnionsYu Jul 16, 2026
66668db
Fix html-snapshot viewport-relative positioning by resizing the viewp…
OnionsYu Jul 16, 2026
97714d4
Fix html-snapshot flex containers dropping per-child align-self by ke…
OnionsYu Jul 16, 2026
48484a7
Preserve dashed and dotted per-side borders in html-snapshot by strok…
OnionsYu Jul 16, 2026
0382147
Preserve <img> data-* attributes through html-snapshot and the HTML i…
OnionsYu Jul 17, 2026
254efea
Preserve <img> elements with missing or invalid src during HTML impor…
OnionsYu Jul 17, 2026
6bfd8a1
Expand the CSS background shorthand into its longhand properties duri…
OnionsYu Jul 17, 2026
a87637a
Forward <img> data-* attributes onto the backing Image resource's cus…
OnionsYu Jul 17, 2026
3f08897
Fix HTML import failing to parse pages whose background-image url() r…
OnionsYu Jul 17, 2026
4d8ef0c
Shrink HTML-imported PAGX by emitting bare Text for single-line spans…
OnionsYu Jul 17, 2026
04e1894
Merge remote-tracking branch 'origin/main' into feature/onionsyu_impo…
OnionsYu Jul 17, 2026
7a86626
Revert "Forward <img> data-* attributes onto the backing Image resour…
OnionsYu Jul 20, 2026
0f8f584
Ensure every HTML-imported PAGX Text node carries a concrete fontStyl…
OnionsYu Jul 20, 2026
896a6fa
Apply clang-format line reflow to HTML importer and optimizer sources.
OnionsYu Jul 20, 2026
b3753f0
Honor CSS background shorthand source order and preserve identified o…
OnionsYu Jul 20, 2026
08b05c4
Add coverage tests for HTML subset transformer edge cases and PAGX op…
OnionsYu Jul 20, 2026
62293fc
Merge branch 'main' into feature/onionsyu_import_html3
OnionsYu Jul 20, 2026
bc045c7
Apply clang-format include and using-declaration ordering to HTML imp…
OnionsYu Jul 20, 2026
ea47ec0
Keep the topmost CSS background image with its fitting properties une…
OnionsYu Jul 20, 2026
d3b2f07
Paint background-color beneath a url() background image instead of dr…
OnionsYu Jul 20, 2026
337e966
Reset scroll instantly before snapshotting so smooth-scroll pages are…
OnionsYu Jul 21, 2026
c003286
Map CSS filter color-adjustment functions (brightness, contrast, satu…
OnionsYu Jul 21, 2026
101437e
Map CSS background-blend-mode onto the gradient/image Fill's blendMod…
OnionsYu Jul 21, 2026
6b2e715
Rebuild CSS clip-path basic shapes (polygon/path/circle/ellipse/inset…
OnionsYu Jul 21, 2026
fe75a4e
Reformat HTML importer filter and clip-shape code to match the projec…
OnionsYu Jul 21, 2026
5709879
Merge remote-tracking branch 'origin/main' into feature/onionsyu_impo…
OnionsYu Jul 21, 2026
9429d9c
Warn during HTML import when per-layer background-blend-mode values d…
OnionsYu Jul 21, 2026
2d07c75
Add unit tests for the HTML snapshot canvas viewport fit-and-revert l…
OnionsYu Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .codebuddy/skills/pagx/spec/html_subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ also a no-op (the offsets are ignored alongside the dropped position).
| `background-image: conic-gradient(from angle, …)` | inline `<ConicGradient>` (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 (`<TextBox>` / `<Text>` get a `<Fill>` 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 `<ImagePattern>` 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: <mode>` | sets `Fill.blendMode` on the gradient / image fill so it composites against the `background-color`, which is kept as a solid `<Fill>` 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 `<clipPath>` 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 |
Expand Down
1 change: 1 addition & 0 deletions spec/html_subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ also a no-op (the offsets are ignored alongside the dropped position).
| `background-image: conic-gradient(from angle, …)` | inline `<ConicGradient>` (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 (`<TextBox>` / `<Text>` get a `<Fill>` 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 `<ImagePattern>` 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: <mode>` | sets `Fill.blendMode` on the gradient / image fill so it composites against the `background-color`, which is kept as a solid `<Fill>` 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 `<clipPath>` 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 |
Expand Down
1 change: 1 addition & 0 deletions spec/html_subset.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
| `background-image: conic-gradient(from angle, …)` | 内嵌 `<ConicGradient>`(CSS 0° = 顶,PAGX 0° = 右,自动 −90°) |
| `background-clip: text`(别名 `-webkit-background-clip: text`) | 与 gradient `background-image` 同时设置时,渐变下沉到后代文字节点(`<TextBox>` / `<Text>` 的 `<Fill>` 内嵌渐变),本元素的矩形背景被抑制。无 gradient `background-image` 时该属性为无操作。 |
| `background-image: url(...)` | 还原为背景矩形上的 `<ImagePattern>` 填充;`background-size` / `background-repeat` / `background-position` 决定该 pattern 的 `scaleMode` / 平铺模式 / 矩阵 |
| `background-blend-mode: <mode>` | 在渐变 / 图像填充上设置 `Fill.blendMode`,使其与 `background-color` 混合;此时会保留底部的纯色 `<Fill>` 作为混合所需的背景。`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)` | 解析引用的隐藏 `<clipPath>` 为一层轮廓 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` 双半径形式告警并忽略 |
Expand Down
7 changes: 7 additions & 0 deletions src/pagx/PAGXExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>`
// 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();
Expand Down
204 changes: 204 additions & 0 deletions src/pagx/PAGXOptimizer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ bool DowngradeShellChildren(PAGXDocument* doc, std::vector<Layer*>& layers,
const std::unordered_set<const Layer*>& maskRefs);

bool IsMergeableShellLayer(const Layer* layer, const std::unordered_set<const Layer*>& 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<const Layer*>& maskRefs);
void AbsorbFillingChild(Layer* parent, Layer* child);
bool CanDropShellParent(const Layer* parent, const Layer* child,
const std::unordered_set<const Layer*>& maskRefs);
bool CollapseSingleChildLayersInList(std::vector<Layer*>& layers,
const std::unordered_set<const Layer*>& maskRefs);
template <class T>
void BeginMutation(std::vector<T>& source, std::vector<T>& result, size_t upTo, bool& changed);
bool MergeAdjacentShellLayersInList(PAGXDocument* doc, std::vector<Layer*>& layers,
Expand Down Expand Up @@ -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 <svg>, a divider's <Path>, 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<const Layer*>& 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<const Layer*>& 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<Layer*>& layers,
const std::unordered_set<const Layer*>& 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
// ============================================================================
Expand Down Expand Up @@ -1539,6 +1740,9 @@ int OptimizeLayerList(PAGXDocument* doc, std::vector<Layer*>& layers,
if (options.mergeAdjacentShellLayers) {
changed |= MergeAdjacentShellLayers(doc, layers, maskRefs);
}
if (options.collapseSingleChildLayers) {
changed |= CollapseSingleChildLayersInList(layers, maskRefs);
}
if (!changed) {
return iter + 1;
}
Expand Down
7 changes: 7 additions & 0 deletions src/pagx/PAGXOptimizerOptions.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading