Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
70 changes: 27 additions & 43 deletions src/cli/CommandResolve.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
#include "pagx/PAGXOptimizer.h"
#include "pagx/nodes/Composition.h"
#include "pagx/nodes/Group.h"
#include "pagx/nodes/LayoutNode.h"

namespace pagx::cli {

Expand Down Expand Up @@ -221,52 +220,37 @@ static bool ResolveOneLayer(Layer* layer, const std::string& baseDir,
layer->contents.push_back(element);
}
} else if (canDowngradeAll) {
for (size_t i = 0; i < elementLayers.size(); i++) {
auto* elemLayer = elementLayers[i];
bool unpackFirst = false;
if (i == 0 && elemLayer->matrix.isIdentity()) {
unpackFirst = true;
for (auto* child : elemLayer->contents) {
auto* layoutNode = LayoutNode::AsLayoutNode(child);
if (layoutNode != nullptr &&
(!std::isnan(layoutNode->right) || !std::isnan(layoutNode->bottom) ||
!std::isnan(layoutNode->centerX) || !std::isnan(layoutNode->centerY))) {
unpackFirst = false;
break;
}
}
// Wrap every element layer uniformly in its own Group so sibling shapes stay at the same
// depth (peer Groups), preserving the source hierarchy. Flattening only the first layer's
// contents while wrapping the rest would break that symmetry and is unnecessary — a trailing
// painter is isolated by its enclosing Group either way. This mirrors the uniform handling in
Comment thread
OnionsYu marked this conversation as resolved.
Outdated
// PAGXOptimizer::DowngradeShellChildren.
for (auto* elemLayer : elementLayers) {
auto m = elemLayer->matrix;
bool hasSkew = !pag::FloatNearlyEqual(m.a * m.c + m.b * m.d, 0.0f);
Comment thread
OnionsYu marked this conversation as resolved.
Outdated
if (hasSkew) {
Comment thread
OnionsYu marked this conversation as resolved.
Outdated
// Matrix contains skew which cannot be represented by Group's
// position/scale/rotation. Keep it as a Layer instead of downgrading.
layer->children.push_back(elemLayer);
continue;
}
if (unpackFirst) {
for (auto* element : elemLayer->contents) {
layer->contents.push_back(element);
}
} else {
auto m = elemLayer->matrix;
bool hasSkew = !pag::FloatNearlyEqual(m.a * m.c + m.b * m.d, 0.0f);
if (hasSkew) {
// Matrix contains skew which cannot be represented by Group's
// position/scale/rotation. Keep it as a Layer instead of downgrading.
layer->children.push_back(elemLayer);
continue;
}
auto* group = doc->makeNode<Group>();
group->elements = std::move(elemLayer->contents);
if (!elemLayer->matrix.isIdentity()) {
group->position = {m.tx, m.ty};
if (m.a != 1 || m.b != 0 || m.c != 0 || m.d != 1) {
float sx = std::sqrt(m.a * m.a + m.b * m.b);
float sy = std::sqrt(m.c * m.c + m.d * m.d);
float det = m.a * m.d - m.b * m.c;
if (det < 0) {
sy = -sy;
}
float rot = pag::RadiansToDegrees(std::atan2(m.b, m.a));
group->scale = {sx, sy};
group->rotation = rot;
auto* group = doc->makeNode<Group>();
Comment thread
OnionsYu marked this conversation as resolved.
group->elements = std::move(elemLayer->contents);
if (!elemLayer->matrix.isIdentity()) {
group->position = {m.tx, m.ty};
if (m.a != 1 || m.b != 0 || m.c != 0 || m.d != 1) {
float sx = std::sqrt(m.a * m.a + m.b * m.b);
float sy = std::sqrt(m.c * m.c + m.d * m.d);
float det = m.a * m.d - m.b * m.c;
if (det < 0) {
sy = -sy;
}
float rot = pag::RadiansToDegrees(std::atan2(m.b, m.a));
group->scale = {sx, sy};
group->rotation = rot;
}
layer->contents.push_back(group);
}
layer->contents.push_back(group);
}
} else {
for (auto* elemLayer : elementLayers) {
Expand Down
151 changes: 124 additions & 27 deletions src/pagx/html/importer/HTMLValueParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,43 @@ Color SampleRepeatingPeriod(const HTMLValueParser::GradientStops& stops, float f
return stops.back().second;
}

// CSS radial extent keywords control how far the ending shape reaches; they carry no scalar radius
// in the token itself (the radius is derived from the center and box). Returns true for any of the
// four keywords so the caller can compute the corresponding px radius from the center position.
bool IsRadialExtentKeyword(const std::string& token) {
return token == "closest-side" || token == "closest-corner" || token == "farthest-side" ||
token == "farthest-corner";
}

// Computes the px radius of a CSS `circle` ending shape for the given extent keyword, measured from
// a center at (cxPx, cyPx) within a (0,0)-(boxWidth,boxHeight) box. An empty/unknown keyword
// defaults to `farthest-corner`, matching CSS when the size is omitted. `closest-corner` /
// `farthest-corner` are the Euclidean distances to the nearest / farthest box corner;
// `closest-side` / `farthest-side` are the min / max of the perpendicular distances to the four
// edges.
float CircleExtentRadiusPx(const std::string& keyword, float cxPx, float cyPx, float boxWidth,
float boxHeight) {
float left = cxPx;
float right = boxWidth - cxPx;
float top = cyPx;
float bottom = boxHeight - cyPx;
float dx = std::max(left, right);
float dy = std::max(top, bottom);
if (keyword == "closest-side") {
return std::min(std::min(left, right), std::min(top, bottom));
Comment thread
OnionsYu marked this conversation as resolved.
}
if (keyword == "farthest-side") {
return std::max(dx, dy);
}
if (keyword == "closest-corner") {
float nx = std::min(left, right);
float ny = std::min(top, bottom);
return std::sqrt(nx * nx + ny * ny);
}
// farthest-corner (also the default when the size is omitted).
return std::sqrt(dx * dx + dy * dy);
}

} // namespace

ColorSource* HTMLValueParser::parseRepeatingLinearGradientPattern(const std::string& value,
Expand Down Expand Up @@ -924,24 +961,10 @@ void HTMLValueParser::parseRadialDescriptor(const std::string& descriptor, float
}
}

// Radius: the exporter writes `rx = radius * boxWidth` (and an ellipse's `ry` is implied by the
// box height under PAGX's single-radius + fitsToGeometry model), so a length token divided by
// boxWidth recovers the normalised radius. A bare `<pct>%` is already box-relative. Track whether
// the radius came from an explicit px length so a circle on a non-square box can later switch to
// the fitsToGeometry=false pixel model (see below).
bool radiusFromPxLength = false;
if (!sizeTokens.empty() && boxWidth > 0) {
float radius = resolveRadialLength(sizeTokens[0], boxWidth);
if (!std::isnan(radius)) {
grad->radius = radius;
radiusFromPxLength = !sizeTokens[0].empty() && sizeTokens[0].back() != '%';
} else {
// Extent keywords (closest-side / farthest-corner / ...) have no scalar PAGX radius; keep
// the box-filling default and surface a diagnostic instead of silently mis-sizing.
_diagnostics.warn("html: radial-gradient size '" + sizeTokens[0] +
"' not supported; using box-filling radius");
}
}
// A circle keeps one uniform radius; an ellipse (explicit, or the shapeless default) stretches
// per axis. Detected up front because extent-keyword and omitted sizes resolve the radius from
// the center, which the position pass below establishes first.
bool isCircle = explicitCircle || (!explicitEllipse && sizeTokens.size() == 1);
Comment thread
OnionsYu marked this conversation as resolved.
Outdated

// Position: `at <x> <y>`. Axis-locked keywords (left/right -> x, top/bottom -> y) are assigned
// first so author order is irrelevant (`at top left` == `at left top`); the remaining `center`
Expand Down Expand Up @@ -972,15 +995,58 @@ void HTMLValueParser::parseRadialDescriptor(const std::string& descriptor, float
if (!std::isnan(cx)) grad->center.x = cx;
if (!std::isnan(cy)) grad->center.y = cy;

// A CSS `circle <r>px` keeps a single uniform radius regardless of box aspect ratio. PAGX's
// default fitsToGeometry=true model stretches the normalised radius by box width and height
// independently, so on a non-square box it would render the circle as an ellipse. Switch such a
// circle to the fitsToGeometry=false pixel model (center/radius in the geometry's local px
// space, where the box spans (0,0)-(boxWidth,boxHeight)) so the radius stays isotropic. Square
// boxes, ellipses, and percentage/extent sizes keep the compact normalised representation.
bool isCircle = explicitCircle || (!explicitEllipse && sizeTokens.size() == 1);
if (isCircle && radiusFromPxLength && boxWidth > 0 && boxHeight > 0 &&
std::abs(boxWidth - boxHeight) > 0.01f) {
// Radius: a length token divided by boxWidth recovers the normalised radius (a bare `<pct>%` is
// already box-relative); track whether it came from an explicit px length so a circle on a
// non-square box can later switch to the fitsToGeometry=false pixel model. An extent keyword
// (or, for a circle, an omitted size — CSS defaults it to farthest-corner) has no scalar radius
// in the token, so a circle derives the px radius from its center and the box; `circleExtentPx`
// then routes it through the pixel model below since the value is already in px.
bool radiusFromPxLength = false;
bool circleExtentPx = false;
if (!sizeTokens.empty() && boxWidth > 0) {
float radius = resolveRadialLength(sizeTokens[0], boxWidth);
if (!std::isnan(radius)) {
grad->radius = radius;
radiusFromPxLength = !sizeTokens[0].empty() && sizeTokens[0].back() != '%';
} else if (IsRadialExtentKeyword(sizeTokens[0])) {
// Only an explicit `circle` maps cleanly to PAGX's single radius. An implicit shape with an
// extent keyword (or an explicit ellipse) is an ellipse in CSS and needs per-axis radii the
// model can't represent, so keep the box-filling default and surface a diagnostic.
if (explicitCircle && boxHeight > 0) {
grad->radius = CircleExtentRadiusPx(sizeTokens[0], grad->center.x * boxWidth,
grad->center.y * boxHeight, boxWidth, boxHeight);
circleExtentPx = true;
} else {
_diagnostics.warn("html: radial-gradient size '" + sizeTokens[0] +
"' not supported; using box-filling radius");
}
} else {
_diagnostics.warn("html: radial-gradient size '" + sizeTokens[0] +
"' not supported; using box-filling radius");
}
} else if (sizeTokens.empty() && explicitCircle && boxWidth > 0 && boxHeight > 0) {
// A `circle` with no size defaults to farthest-corner in CSS.
grad->radius = CircleExtentRadiusPx("", grad->center.x * boxWidth, grad->center.y * boxHeight,
boxWidth, boxHeight);
circleExtentPx = true;
}

// Keep a circle's single radius isotropic. The default fitsToGeometry=true model scales the
// normalised radius by box width and height independently, so on a non-square box it would render
// a circle as an ellipse; such circles switch to the fitsToGeometry=false pixel model (center /
// radius in the geometry's local px space, where the box spans (0,0)-(boxWidth,boxHeight)). On a
// square box the normalised model is already isotropic, so keep the compact representation:
// extent/omitted sizes carry a px radius that is normalised back by boxWidth, while an explicit
// px length was already normalised above. Ellipses and percentage sizes stay normalised too.
bool nonSquare = boxWidth > 0 && boxHeight > 0 && std::abs(boxWidth - boxHeight) > 0.01f;
if (circleExtentPx) {
if (nonSquare) {
grad->center = {grad->center.x * boxWidth, grad->center.y * boxHeight};
grad->fitsToGeometry = false;
} else {
grad->radius = grad->radius / boxWidth;
}
} else if (isCircle && radiusFromPxLength && nonSquare) {
grad->center = {grad->center.x * boxWidth, grad->center.y * boxHeight};
grad->radius = grad->radius * boxWidth;
grad->fitsToGeometry = false;
Expand Down Expand Up @@ -1083,6 +1149,37 @@ bool HTMLValueParser::finaliseGradientStops(GradientStops& stops) {
float steps = static_cast<float>(next - (i - 1));
stops[i].first = prevOffset + (nextOffset - prevOffset) / steps;
}

// CSS interpolates gradient stops in premultiplied-alpha space, so a `transparent` (or any
// alpha=0) stop contributes only its neighbour's colour as the alpha fades — e.g. a
// `rgba(220,210,255,0.4) -> transparent` ramp stays purple while vanishing. The renderer
// interpolates unpremultiplied, where a keyword `transparent` carries black RGB and would drag
// the ramp toward grey/black. Rewrite each fully transparent stop's RGB to that of its nearest
// opaque neighbour (alpha kept at 0) so the unpremultiplied interpolation matches CSS. A stop
// between two opaque colours prefers the earlier neighbour, matching the premultiplied midpoint.
Comment thread
OnionsYu marked this conversation as resolved.
Outdated
for (size_t i = 0; i < stops.size(); ++i) {
if (stops[i].second.alpha > 0.0f) continue;
size_t donor = stops.size();
for (size_t back = i; back-- > 0;) {
if (stops[back].second.alpha > 0.0f) {
donor = back;
break;
}
}
if (donor == stops.size()) {
for (size_t fwd = i + 1; fwd < stops.size(); ++fwd) {
if (stops[fwd].second.alpha > 0.0f) {
donor = fwd;
break;
}
}
}
if (donor != stops.size()) {
stops[i].second.red = stops[donor].second.red;
stops[i].second.green = stops[donor].second.green;
stops[i].second.blue = stops[donor].second.blue;
}
}
return true;
}

Expand Down
19 changes: 11 additions & 8 deletions test/src/PAGXCliTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
#include "pagx/PAGXExporter.h"
#include "pagx/PAGXImporter.h"
#include "pagx/nodes/Font.h"
#include "pagx/nodes/Group.h"
#include "pagx/nodes/Image.h"
#include "pagx/nodes/ImagePattern.h"
#include "tgfx/core/Bitmap.h"
Expand Down Expand Up @@ -2679,8 +2680,9 @@ CLI_TEST(PAGXCliTest, Resolve_MissingFile) {
}

CLI_TEST(PAGXCliTest, Resolve_MultiLayerPreservesIsolation) {
// Verifies that resolving an inline SVG with multiple elements preserves each SVG element
// in a separate painter scope, preventing painter accumulation bugs.
// Resolving an inline SVG with two sibling paths must preserve their common source depth:
// each path and its painter live in a separate peer Group. Flattening only the first path while
// grouping the second would make the output asymmetric and can change painter accumulation.
auto pagxPath = CopyToTemp("import_resolve_multi_layer.pagx", "resolve_multi_layer.pagx");
auto ret = CallRun(pagx::cli::RunResolve, {"resolve", pagxPath});
EXPECT_EQ(ret, 0);
Expand All @@ -2691,14 +2693,15 @@ CLI_TEST(PAGXCliTest, Resolve_MultiLayerPreservesIsolation) {

ASSERT_EQ(doc->layers.size(), 1u);
auto* hostLayer = doc->layers[0];
EXPECT_FALSE(hostLayer->contents.empty());
size_t groupCount = 0;
EXPECT_TRUE(hostLayer->children.empty());
ASSERT_EQ(hostLayer->contents.size(), 2u);
for (auto* element : hostLayer->contents) {
if (element->nodeType() == pagx::NodeType::Group) {
groupCount++;
}
ASSERT_EQ(element->nodeType(), pagx::NodeType::Group);
auto* group = static_cast<pagx::Group*>(element);
ASSERT_EQ(group->elements.size(), 2u);
Comment thread
OnionsYu marked this conversation as resolved.
EXPECT_EQ(group->elements[0]->nodeType(), pagx::NodeType::Path);
EXPECT_EQ(group->elements[1]->nodeType(), pagx::NodeType::Stroke);
}
EXPECT_GE(groupCount, 1u);

// Screenshot test: render the resolved file and compare against baseline.
EXPECT_TRUE(RenderAndCompare({"render", pagxPath}, "PAGXCliTest/ImportResolve_MultiLayer"));
Expand Down
Loading
Loading