diff --git a/resources/cli/import_resolve_skew_siblings.pagx b/resources/cli/import_resolve_skew_siblings.pagx
new file mode 100644
index 0000000000..baf214301b
--- /dev/null
+++ b/resources/cli/import_resolve_skew_siblings.pagx
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/resources/cli/import_resolve_transformed_siblings.pagx b/resources/cli/import_resolve_transformed_siblings.pagx
new file mode 100644
index 0000000000..c135537621
--- /dev/null
+++ b/resources/cli/import_resolve_transformed_siblings.pagx
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/src/cli/CommandResolve.cpp b/src/cli/CommandResolve.cpp
index 604f014f14..1ff13635f3 100644
--- a/src/cli/CommandResolve.cpp
+++ b/src/cli/CommandResolve.cpp
@@ -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 {
@@ -125,6 +124,20 @@ static int ParseResolveOptions(int argc, char* argv[], ResolveOptions* options)
// Resolve logic
//--------------------------------------------------------------------------------------------------
+// A Group's position/scale/rotation fields can represent a matrix whose axes remain orthogonal.
+// Compare the normalized axis dot product so the result does not depend on the matrix scale.
+// Degenerate axes are kept as Layers because the decomposition below cannot recover their rotation.
+static bool CanDowngradeMatrixToGroup(const Matrix& matrix) {
+ float xLength = std::hypot(matrix.a, matrix.b);
+ float yLength = std::hypot(matrix.c, matrix.d);
+ if (pag::FloatNearlyZero(xLength) || pag::FloatNearlyZero(yLength)) {
+ return false;
+ }
+ float normalizedDot =
+ (matrix.a / xLength) * (matrix.c / yLength) + (matrix.b / xLength) * (matrix.d / yLength);
+ return pag::FloatNearlyZero(normalizedDot);
+}
+
static bool ResolveOneLayer(Layer* layer, const std::string& baseDir,
const ImportFormatOptions& formatOptions, PAGXDocument* doc) {
bool hasImportSource = !layer->importDirective.source.empty();
@@ -209,7 +222,8 @@ static bool ResolveOneLayer(Layer* layer, const std::string& baseDir,
} else if (elementLayers.size() > 1) {
canDowngradeAll = true;
for (auto* el : elementLayers) {
- if (!el->children.empty() || HasLayerOnlyFeatures(el)) {
+ if (!el->children.empty() || HasLayerOnlyFeatures(el) ||
+ !CanDowngradeMatrixToGroup(el->matrix)) {
canDowngradeAll = false;
break;
}
@@ -221,52 +235,36 @@ 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. Structurally, this uses the same
+ // all-siblings Group strategy as PAGXOptimizer::DowngradeShellChildren, while also supporting
+ // transformable non-identity matrices here.
+ for (auto* elemLayer : elementLayers) {
+ if (elemLayer->contents.empty() && elemLayer->customData.empty()) {
+ 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->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 m = elemLayer->matrix;
+ auto* group = doc->makeNode();
+ group->elements = std::move(elemLayer->contents);
+ group->customData = std::move(elemLayer->customData);
+ group->sourceLine = elemLayer->sourceLine;
+ 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) {
diff --git a/src/pagx/TextLayout.cpp b/src/pagx/TextLayout.cpp
index 448872ed5c..6e3f110d28 100644
--- a/src/pagx/TextLayout.cpp
+++ b/src/pagx/TextLayout.cpp
@@ -25,6 +25,7 @@
#include "pagx/nodes/Group.h"
#include "pagx/nodes/Text.h"
#include "pagx/nodes/TextBox.h"
+#include "pagx/utils/CSSFontStyle.h"
#include "pagx/utils/TextUtils.h"
#include "renderer/BidiResolver.h"
#include "renderer/LineBreaker.h"
@@ -382,6 +383,18 @@ class TextLayoutContext {
float textScale = 1.0f) {
auto primaryTypeface = findTypeface(glyph.fontFamily, glyph.fontStyle);
+ // Keep the requested weight as a real face label so an installed Bold / SemiBold / Black face
+ // can be selected precisely. If lookup falls back to a lighter face (or finds no primary face),
+ // synthesize the missing weight at layout time. The faux flag then propagates to per-character
+ // fallback fonts in TextShaper as well.
+ bool fauxBold = glyph.fauxBold;
+ if (!fauxBold) {
+ int requestedWeight = ParseFontStyleName(glyph.fontStyle).weight;
+ int resolvedWeight =
+ primaryTypeface == nullptr ? 400 : ParseFontStyleName(primaryTypeface->fontStyle()).weight;
+ fauxBold = resolvedWeight < requestedWeight;
+ }
+
// When the primary typeface is not found, find a fallback typeface for font metrics used by
// special characters (newline, tab) that do not go through per-character glyph fallback.
auto metricsTypeface = primaryTypeface;
@@ -391,10 +404,10 @@ class TextLayoutContext {
float effectiveFontSize = glyph.fontSize * textScale;
tgfx::Font primaryFont(primaryTypeface, effectiveFontSize);
- primaryFont.setFauxBold(glyph.fauxBold);
+ primaryFont.setFauxBold(fauxBold);
primaryFont.setFauxItalic(glyph.fauxItalic);
tgfx::Font metricsFont(metricsTypeface, effectiveFontSize);
- metricsFont.setFauxBold(glyph.fauxBold);
+ metricsFont.setFauxBold(fauxBold);
metricsFont.setFauxItalic(glyph.fauxItalic);
float currentX = 0;
const std::string& content = glyph.text;
diff --git a/src/pagx/html/importer/HTMLBoxAttributes.h b/src/pagx/html/importer/HTMLBoxAttributes.h
index a100c06fc7..e3157a0b4e 100644
--- a/src/pagx/html/importer/HTMLBoxAttributes.h
+++ b/src/pagx/html/importer/HTMLBoxAttributes.h
@@ -40,8 +40,8 @@ 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"
+ * `ResolveFontStyleSynthesis` leaves the style label empty for the plain Regular-weight upright
+ * face (italic is carried by `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";
@@ -74,12 +74,14 @@ struct HTMLInheritedStyle {
std::string fontSize = {};
std::string fontWeight = {};
std::string fontStyle = {};
- std::string fontStyleName = {}; // real-face style label, e.g. "Light" / "Medium" / ""
- // Synthetic weight / slant the renderer must emboss on top of the resolved face. Set by
- // `resolveInheritedStyle` for bold (CSS weight >= 600) and italic/oblique requests whose axis is
- // dropped from `fontStyleName` (see `ResolveFontStyleSynthesis`). Carried through to
- // `Text::fauxBold` / `Text::fauxItalic` so authored weight / slant survives even when the styled
- // web face is not installed on the render host.
+ std::string fontStyleName = {}; // real-face style label, e.g. "Light" / "Bold" / "Black" / ""
+ // Synthetic slant the renderer must emboss on top of the resolved face. Set by
+ // `resolveInheritedStyle` for italic/oblique requests, whose axis is dropped from `fontStyleName`
+ // (see `ResolveFontStyleSynthesis`) and carried through to `Text::fauxItalic` so the authored
+ // slant survives even when the styled italic face is not installed on the render host. The
+ // importer never pre-synthesises the weight axis: it stays in `fontStyleName` as a real-face
+ // keyword, so `fauxBold` is false here. TextLayout may add faux bold later if font lookup resolves
+ // a face that is lighter than requested.
bool fauxBold = false;
bool fauxItalic = false;
std::string letterSpacing = {};
diff --git a/src/pagx/html/importer/HTMLElementEmitter.cpp b/src/pagx/html/importer/HTMLElementEmitter.cpp
index 3d880adf01..8544994988 100644
--- a/src/pagx/html/importer/HTMLElementEmitter.cpp
+++ b/src/pagx/html/importer/HTMLElementEmitter.cpp
@@ -697,8 +697,8 @@ void HTMLParserContext::applyMaskOrClip(Layer* layer, const HTMLBoxAttributes& b
float intrinsicH = svgDoc->height;
// The mask SVG produces one or more content layers (a single drawn shape in the common case).
- // Wrap them under one invisible, layout-excluded mask layer so a multi-shape clip-path also
- // works, then transplant every node from the temporary SVG document into ours.
+ // Wrap them under one layout-excluded mask layer so a multi-shape clip-path also works, then
+ // transplant every node from the temporary SVG document into ours.
Layer* maskLayer = nullptr;
if (svgDoc->layers.size() == 1) {
maskLayer = svgDoc->layers[0];
@@ -708,7 +708,6 @@ void HTMLParserContext::applyMaskOrClip(Layer* layer, const HTMLBoxAttributes& b
maskLayer->children.push_back(l);
}
}
- maskLayer->visible = false;
maskLayer->includeInLayout = false;
// CSS `mask-size` / `mask-position` scale and offset the intrinsic mask box onto the masked
// element; replay that transform onto the mask layer. Contour clip-paths are framed to the box
@@ -730,9 +729,9 @@ void HTMLParserContext::applyMaskOrClip(Layer* layer, const HTMLBoxAttributes& b
layer->mask = maskLayer;
layer->maskType = maskType;
// The mask layer must be reachable in the display list for the renderer's mask lookup, and must
- // share the masked layer's local coordinate origin. Adding it as an invisible, layout-excluded
- // child satisfies both: it is walked by LayerBuilder but neither drawn (maskOwner is set) nor
- // laid out (includeInLayout is false).
+ // share the masked layer's local coordinate origin. Adding it as a layout-excluded child
+ // satisfies both: it is walked by LayerBuilder but neither drawn (maskOwner is set on the tgfx
+ // side) nor laid out (includeInLayout is false).
layer->children.push_back(maskLayer);
}
@@ -767,7 +766,6 @@ void HTMLParserContext::applyRoundedOverflowClip(Layer* layer, const HTMLBoxAttr
// resolved by layout. A Contour mask reads only the shape's coverage, clipping descendants to
// the rounded outline instead of the layer's rectangle.
auto* maskLayer = _document->makeNode();
- maskLayer->visible = false;
maskLayer->includeInLayout = false;
maskLayer->percentWidth = 100.0f;
maskLayer->percentHeight = 100.0f;
@@ -782,8 +780,8 @@ void HTMLParserContext::applyRoundedOverflowClip(Layer* layer, const HTMLBoxAttr
// The rounded mask now performs the clip; drop the rectangular scrollRect so it does not also
// square off the corners the mask just rounded.
layer->clipToBounds = false;
- // Invisible, layout-excluded child so it shares the masked layer's local coordinate origin and
- // stays reachable by the renderer's mask lookup (mirrors `applyMaskOrClip`).
+ // Layout-excluded child so it shares the masked layer's local coordinate origin and stays
+ // reachable by the renderer's mask lookup (mirrors `applyMaskOrClip`).
layer->children.push_back(maskLayer);
}
diff --git a/src/pagx/html/importer/HTMLParserContext.h b/src/pagx/html/importer/HTMLParserContext.h
index ded413402f..9e0a6dd290 100644
--- a/src/pagx/html/importer/HTMLParserContext.h
+++ b/src/pagx/html/importer/HTMLParserContext.h
@@ -124,7 +124,7 @@ class HTMLParserContext {
// 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
+ // as a layout-excluded child of `layer` so it shares the masked layer's local
// coordinate space and is reachable by the renderer's mask lookup. No-op when the box carries
// neither a mask nor a clip-path reference. `box` supplies the masked layer's resolved size used
// to frame a contour clip-path SVG.
diff --git a/src/pagx/html/importer/HTMLStyleCascade.cpp b/src/pagx/html/importer/HTMLStyleCascade.cpp
index a6f1a7cbdb..35027523e7 100644
--- a/src/pagx/html/importer/HTMLStyleCascade.cpp
+++ b/src/pagx/html/importer/HTMLStyleCascade.cpp
@@ -545,11 +545,11 @@ HTMLInheritedStyle HTMLStyleCascade::resolveInheritedStyle(const std::shared_ptr
out.textFillImage = ownBgImage;
}
// Split the CSS font-weight / font-style request into the real-face style label PAGX Text
- // resolves plus the synthetic (faux) axes the renderer embosses on top. Bold (weight >= 600) and
- // italic/oblique are baked as faux flags and dropped from the label so an uninstalled web face
- // (e.g. "Noto Sans SC Black Italic") still renders at the authored weight and slant instead of
- // collapsing to a thin upright fallback. Lighter weights (Light / Medium) cannot be synthesised
- // and stay in the label.
+ // resolves plus the synthetic (faux) italic axis the renderer embosses on top. The weight axis
+ // is always written as a real-face keyword (Bold / SemiBold / Black) so the renderer resolves the
+ // authored heavy face when it is installed or embedded and preserves the SemiBold / Bold / Black
+ // distinction. TextLayout adds faux emboldening only when font lookup resolves a lighter face.
+ // Italic stays a faux flag so an oblique slant survives when the styled italic face is unavailable.
FontStyleSynthesis fontSynthesis = ResolveFontStyleSynthesis(out.fontWeight, out.fontStyle);
out.fontStyleName = fontSynthesis.fontStyleName;
out.fauxBold = fontSynthesis.fauxBold;
diff --git a/src/pagx/html/importer/HTMLTextFragmentBuilder.h b/src/pagx/html/importer/HTMLTextFragmentBuilder.h
index 47ee271639..0b559b21a2 100644
--- a/src/pagx/html/importer/HTMLTextFragmentBuilder.h
+++ b/src/pagx/html/importer/HTMLTextFragmentBuilder.h
@@ -58,10 +58,12 @@ class HTMLTextFragmentBuilder {
struct TextFragment {
std::string text = {};
std::string fontFamily = {};
- std::string fontStyleName = {}; // real-face style label, e.g. "Light" / "Medium" / ""
- // Synthetic weight / slant baked in from the CSS request (see `ResolveFontStyleSynthesis`).
- // Surface as `Text::fauxBold` / `Text::fauxItalic` so authored bold / italic survives a
- // missing styled face on the render host.
+ std::string fontStyleName = {}; // real-face style label, e.g. "Light" / "Bold" / "Black" / ""
+ // Synthetic slant baked in from the CSS request (see `ResolveFontStyleSynthesis`). Surfaces as
+ // `Text::fauxItalic` so an authored oblique slant survives a missing styled italic face on the
+ // render host. The importer does not pre-synthesise the weight axis (it stays in
+ // `fontStyleName`), so `fauxBold` is false here; TextLayout may add it after resolving a lighter
+ // face at runtime.
bool fauxBold = false;
bool fauxItalic = false;
float fontSize = HTML_DEFAULT_FONT_SIZE;
diff --git a/src/pagx/html/importer/HTMLValueParser.cpp b/src/pagx/html/importer/HTMLValueParser.cpp
index bad3bec673..48b65de492 100644
--- a/src/pagx/html/importer/HTMLValueParser.cpp
+++ b/src/pagx/html/importer/HTMLValueParser.cpp
@@ -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 = std::abs(cxPx);
+ float right = std::abs(boxWidth - cxPx);
+ float top = std::abs(cyPx);
+ float bottom = std::abs(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));
+ }
+ 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,
@@ -924,24 +961,11 @@ 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 `%` 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 single explicit length implies a circle. An extent keyword without a shape still uses CSS's
+ // default ellipse, so it must not enter the circle-only pixel-radius path below.
+ bool implicitCircle =
+ !explicitEllipse && sizeTokens.size() == 1 && !IsRadialExtentKeyword(sizeTokens[0]);
+ bool isCircle = explicitCircle || implicitCircle;
// Position: `at `. 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`
@@ -972,15 +996,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 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 `%` 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;
@@ -1083,6 +1150,38 @@ bool HTMLValueParser::finaliseGradientStops(GradientStops& stops) {
float steps = static_cast(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 to avoid tinting the visible,
+ // higher-alpha side of the fade.
+ 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;
}
diff --git a/src/pagx/utils/CSSFontStyle.cpp b/src/pagx/utils/CSSFontStyle.cpp
index 050aa068e8..a732332031 100644
--- a/src/pagx/utils/CSSFontStyle.cpp
+++ b/src/pagx/utils/CSSFontStyle.cpp
@@ -134,20 +134,18 @@ FontStyleSynthesis ResolveFontStyleSynthesis(const std::string& cssFontWeight,
const std::string& cssFontStyle) {
FontStyleSynthesis out;
int numericWeight = CssFontWeightToNumeric(cssFontWeight);
- bool italic = IsItalicCssStyle(cssFontStyle);
- // Threshold mirrors CSS bold synthesis: SemiBold-or-heavier (>= 600) is treated as a faux-bold
- // request because the renderer's faux emboldening is a single fixed step and cannot distinguish
- // SemiBold from Black anyway.
- out.fauxBold = numericWeight >= 600;
- out.fauxItalic = italic;
- // Keep only the real-face axes in the style label: a synthesised axis is dropped so the renderer
- // resolves a base (Regular / lighter) face and faux adds the missing weight / slant on top,
- // instead of resolving the styled face and doubling up.
- const char* weightKeyword =
- out.fauxBold ? nullptr : WeightKeywordForRoundedHundreds(numericWeight);
+ // The weight axis is always carried as a real-face style label (Bold / SemiBold / Black / etc.),
+ // not pre-synthesised: this lets TextLayout resolve the authored heavy face when it is installed
+ // or embedded, then apply faux emboldening only if the resolved face is lighter than requested.
+ // This preserves distinct face selection without losing weight when a face is missing.
+ out.fauxBold = false;
+ const char* weightKeyword = WeightKeywordForRoundedHundreds(numericWeight);
if (weightKeyword) {
out.fontStyleName = weightKeyword;
}
+ // Italic is kept as a faux axis: an oblique slant can be synthesised on top of any upright face,
+ // so it survives even when the styled italic face is unavailable.
+ out.fauxItalic = IsItalicCssStyle(cssFontStyle);
return out;
}
diff --git a/src/pagx/utils/CSSFontStyle.h b/src/pagx/utils/CSSFontStyle.h
index 961bffb647..d89e66338d 100644
--- a/src/pagx/utils/CSSFontStyle.h
+++ b/src/pagx/utils/CSSFontStyle.h
@@ -37,27 +37,27 @@ namespace pagx {
std::string ResolveFontStyleName(const std::string& cssFontWeight, const std::string& cssFontStyle);
// Splits a CSS font-weight / font-style request into the real face style the renderer should
-// resolve plus the synthetic (faux) axes it must emboss on top. This lets an importer bake the
-// authored weight / slant into a `.pagx` without relying on render-time face introspection:
-// uninstalled web faces (the common case for HTML / SVG imports such as "Noto Sans SC Black
-// Italic") still render at the authored weight and slant via faux synthesis.
+// resolve plus the synthetic (faux) italic axis it may emboss on top.
//
-// The synthesised axes are *removed* from `fontStyleName` rather than kept alongside the faux
-// flags. A host that does ship the real heavy / italic face must not be emboldened twice
-// (faux-on-top-of-real, which the renderer layers additively), and keeping the styled name would
-// trigger exactly that whenever the styled face is resolvable. Stripping it makes the rendered
-// result identical whether or not the styled face is installed. Weights below the bold threshold
-// (Thin / ExtraLight / Light / Medium) cannot be synthesised — faux only adds weight, never
-// removes it — so those keywords are preserved in `fontStyleName` and carry no faux flag.
+// The weight axis is always emitted as a real-face style label (Bold / SemiBold / Black / etc. per
+// the numeric weight rounded to the nearest hundred; 400 leaves the weight portion empty), never as
+// a faux flag. This lets the renderer resolve the authored heavy face when it is installed or
+// embedded (preserving the distinction between SemiBold, Bold and Black). During layout, the
+// resolved face's actual weight is compared with this requested label and faux emboldening is added
+// only when the resolved face is lighter, instead of always synthesising a single fixed step on a
+// Regular base.
//
-// Examples (threshold mirrors CSS bold synthesis at weight >= 600):
-// ("900", "italic") -> {fontStyleName: "", fauxBold: true, fauxItalic: true}
-// ("700", "") -> {fontStyleName: "", fauxBold: true, fauxItalic: false}
-// ("600", "italic") -> {fontStyleName: "", fauxBold: true, fauxItalic: true}
-// ("500", "italic") -> {fontStyleName: "Medium", fauxBold: false, fauxItalic: true}
-// ("300", "") -> {fontStyleName: "Light", fauxBold: false, fauxItalic: false}
-// ("400", "italic") -> {fontStyleName: "", fauxBold: false, fauxItalic: true}
-// ("400", "") -> {fontStyleName: "", fauxBold: false, fauxItalic: false}
+// Italic stays a synthetic axis (`fauxItalic`): an oblique slant can be synthesised on top of any
+// upright face, so it survives even when the styled italic face is unavailable.
+//
+// Examples:
+// ("900", "italic") -> {fontStyleName: "Black", fauxBold: false, fauxItalic: true}
+// ("700", "") -> {fontStyleName: "Bold", fauxBold: false, fauxItalic: false}
+// ("600", "italic") -> {fontStyleName: "SemiBold", fauxBold: false, fauxItalic: true}
+// ("500", "italic") -> {fontStyleName: "Medium", fauxBold: false, fauxItalic: true}
+// ("300", "") -> {fontStyleName: "Light", fauxBold: false, fauxItalic: false}
+// ("400", "italic") -> {fontStyleName: "", fauxBold: false, fauxItalic: true}
+// ("400", "") -> {fontStyleName: "", fauxBold: false, fauxItalic: false}
struct FontStyleSynthesis {
std::string fontStyleName = {};
bool fauxBold = false;
diff --git a/test/src/PAGXCSSFontStyleTest.cpp b/test/src/PAGXCSSFontStyleTest.cpp
index de61886567..be452368de 100644
--- a/test/src/PAGXCSSFontStyleTest.cpp
+++ b/test/src/PAGXCSSFontStyleTest.cpp
@@ -82,27 +82,27 @@ CLI_TEST(PAGXCSSFontStyleTest, ResolveName_WhitespaceAndCaseNormalised) {
EXPECT_EQ(pagx::ResolveFontStyleName(" BOLD ", " ITALIC "), "Bold Italic");
}
-// ResolveFontStyleSynthesis: splits the request into a real-face label plus faux axes. The bold
-// synthesis threshold is weight >= 600; weights below stay as keywords with no faux flag.
+// ResolveFontStyleSynthesis: splits the request into a real-face label plus a faux italic axis. The
+// weight axis is always a real-face keyword (never faux); only italic is synthesised.
-CLI_TEST(PAGXCSSFontStyleTest, Synthesis_BlackItalicIsAllFaux) {
+CLI_TEST(PAGXCSSFontStyleTest, Synthesis_BlackItalicKeepsRealWeightFauxItalic) {
auto out = pagx::ResolveFontStyleSynthesis("900", "italic");
- EXPECT_EQ(out.fontStyleName, "");
- EXPECT_TRUE(out.fauxBold);
+ EXPECT_EQ(out.fontStyleName, "Black");
+ EXPECT_FALSE(out.fauxBold);
EXPECT_TRUE(out.fauxItalic);
}
-CLI_TEST(PAGXCSSFontStyleTest, Synthesis_BoldOnly) {
+CLI_TEST(PAGXCSSFontStyleTest, Synthesis_BoldKeepsRealWeight) {
auto out = pagx::ResolveFontStyleSynthesis("700", "");
- EXPECT_EQ(out.fontStyleName, "");
- EXPECT_TRUE(out.fauxBold);
+ EXPECT_EQ(out.fontStyleName, "Bold");
+ EXPECT_FALSE(out.fauxBold);
EXPECT_FALSE(out.fauxItalic);
}
-CLI_TEST(PAGXCSSFontStyleTest, Synthesis_SemiBoldThresholdIsFauxBold) {
+CLI_TEST(PAGXCSSFontStyleTest, Synthesis_SemiBoldKeepsRealWeightFauxItalic) {
auto out = pagx::ResolveFontStyleSynthesis("600", "italic");
- EXPECT_EQ(out.fontStyleName, "");
- EXPECT_TRUE(out.fauxBold);
+ EXPECT_EQ(out.fontStyleName, "SemiBold");
+ EXPECT_FALSE(out.fauxBold);
EXPECT_TRUE(out.fauxItalic);
}
diff --git a/test/src/PAGXCliTest.cpp b/test/src/PAGXCliTest.cpp
index 112380b824..901761914e 100644
--- a/test/src/PAGXCliTest.cpp
+++ b/test/src/PAGXCliTest.cpp
@@ -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"
@@ -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);
@@ -2691,19 +2693,60 @@ 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(element);
+ ASSERT_EQ(group->elements.size(), 2u);
+ 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"));
}
+CLI_TEST(PAGXCliTest, Resolve_SkewSiblingKeepsAllLayers) {
+ // The first sibling has both a tiny scale and a real skew. Skew detection must be
+ // scale-independent, and one non-downgradable matrix must keep every sibling in children so
+ // contents/children paint ordering cannot reverse them.
+ auto pagxPath = CopyToTemp("import_resolve_skew_siblings.pagx", "resolve_skew_siblings.pagx");
+ auto ret = CallRun(pagx::cli::RunResolve, {"resolve", pagxPath});
+ EXPECT_EQ(ret, 0);
+
+ auto doc = pagx::PAGXImporter::FromFile(pagxPath);
+ ASSERT_NE(doc, nullptr);
+ ASSERT_EQ(doc->layers.size(), 1u);
+ auto* hostLayer = doc->layers[0];
+ EXPECT_TRUE(hostLayer->contents.empty());
+ ASSERT_EQ(hostLayer->children.size(), 2u);
+ EXPECT_FALSE(hostLayer->children[0]->matrix.isIdentity());
+ EXPECT_TRUE(hostLayer->children[1]->matrix.isIdentity());
+}
+
+CLI_TEST(PAGXCliTest, Resolve_TransformableSiblingMatricesBecomeGroups) {
+ auto pagxPath =
+ CopyToTemp("import_resolve_transformed_siblings.pagx", "resolve_transformed_siblings.pagx");
+ auto ret = CallRun(pagx::cli::RunResolve, {"resolve", pagxPath});
+ EXPECT_EQ(ret, 0);
+
+ auto doc = pagx::PAGXImporter::FromFile(pagxPath);
+ ASSERT_NE(doc, nullptr);
+ ASSERT_EQ(doc->layers.size(), 1u);
+ auto* hostLayer = doc->layers[0];
+ EXPECT_TRUE(hostLayer->children.empty());
+ ASSERT_EQ(hostLayer->contents.size(), 2u);
+ ASSERT_EQ(hostLayer->contents[0]->nodeType(), pagx::NodeType::Group);
+ auto* transformed = static_cast(hostLayer->contents[0]);
+ EXPECT_FLOAT_EQ(transformed->position.x, 4.0f);
+ EXPECT_FLOAT_EQ(transformed->position.y, 5.0f);
+ EXPECT_FLOAT_EQ(transformed->rotation, 30.0f);
+ EXPECT_FLOAT_EQ(transformed->scale.x, 2.0f);
+ EXPECT_FLOAT_EQ(transformed->scale.y, 3.0f);
+ EXPECT_EQ(hostLayer->contents[1]->nodeType(), pagx::NodeType::Group);
+}
+
CLI_TEST(PAGXCliTest, Resolve_DeduplicatesInlineSvgImageIds) {
// Each inline