From 16e92cd6c82092b62004b5c6e593fba2c2427406 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Fri, 24 Jul 2026 15:36:36 -0400 Subject: [PATCH] Reject a single-axis position followed by a length in transform-origin transform-origin accepted `top 2px` (resolved to center/top with 2px as the z-offset) and `2px left` (a length in the horizontal slot paired with a horizontal keyword in the vertical slot), both invalid per CSS Transforms 1 (https://www.w3.org/TR/css-transforms-1/#transform-origin-property). The two-value form is ordered (horizontal then vertical) and only the keyword-only form may be reordered; the trailing z-offset exists only in the two-value production. Restructure the converter around explicit Horizontal/Vertical axis converters and a TwoValuePosition that requires both axes, and gate the z-offset behind it so it can only follow a genuine two-value position. Fixes the two tests that asserted the non-conformant `2px left` forms. --- .../PropertyTests/TransformProperty.cs | 48 ++++++++++-- .../Transform/TransformOriginProperty.cs | 78 +++++++++++++------ 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/ExCSS.Tests/PropertyTests/TransformProperty.cs b/src/ExCSS.Tests/PropertyTests/TransformProperty.cs index 533a0abe..d7cd1f59 100644 --- a/src/ExCSS.Tests/PropertyTests/TransformProperty.cs +++ b/src/ExCSS.Tests/PropertyTests/TransformProperty.cs @@ -214,6 +214,36 @@ public void CssTransformStyleNoneIllegal() Assert.False(concrete.HasValue); } + [Fact] + public void CssTransformOriginVerticalKeywordThenLengthIllegal() + { + // "top 2px" is invalid (CSS Transforms 1): the trailing z-offset only exists in the + // two-value production, but "top" is a vertical-only keyword that cannot fill the horizontal + // position, and there is no second position value - so this is neither a valid single value nor a + // valid two-value pair, and the "2px" cannot be reinterpreted as a z-offset. + var snippet = "transform-origin: top 2px "; + var property = ParseDeclaration(snippet); + Assert.Equal("transform-origin", property.Name); + Assert.False(property.IsImportant); + Assert.IsType(property); + var concrete = (TransformOriginProperty)property; + Assert.False(concrete.HasValue); + } + + [Fact] + public void CssTransformOriginVerticalKeywordThenLengthWithZIllegal() + { + // As above, invalid for the same reason; a trailing z-offset cannot rescue an invalid + // single-keyword-plus-length position. + var snippet = "transform-origin: top 2px 10px "; + var property = ParseDeclaration(snippet); + Assert.Equal("transform-origin", property.Name); + Assert.False(property.IsImportant); + Assert.IsType(property); + var concrete = (TransformOriginProperty)property; + Assert.False(concrete.HasValue); + } + [Fact] public void CssTransformOriginXOffsetLegal() { @@ -257,17 +287,19 @@ public void CssTransformOriginYOffsetLegal() } [Fact] - public void CssTransformOriginYOffsetXKeywordLegal() + public void CssTransformOriginLengthThenWrongAxisKeywordIllegal() { + // "2px left" is invalid (CSS Transforms 1): a length in the first (horizontal) position forces + // the ordered two-value form, whose second value must be a vertical keyword or a + // - but "left" is a horizontal-only keyword, so the pair is invalid. Only the keyword-only form may + // be reordered. var snippet = "transform-origin: 2px left"; var property = ParseDeclaration(snippet); Assert.Equal("transform-origin", property.Name); Assert.False(property.IsImportant); Assert.IsType(property); var concrete = (TransformOriginProperty)property; - Assert.False(concrete.IsInherited); - Assert.True(concrete.HasValue); - Assert.Equal("2px left", concrete.Value); + Assert.False(concrete.HasValue); } [Fact] @@ -327,17 +359,17 @@ public void CssTransformOriginXYZLegal() } [Fact] - public void CssTransformOriginYXKeywordZLegal() + public void CssTransformOriginLengthThenWrongAxisKeywordWithZIllegal() { + // As above, invalid for the same reason; the trailing z-offset cannot rescue an + // invalid horizontal/vertical pair. var snippet = "transform-origin: 2px left 10px "; var property = ParseDeclaration(snippet); Assert.Equal("transform-origin", property.Name); Assert.False(property.IsImportant); Assert.IsType(property); var concrete = (TransformOriginProperty)property; - Assert.False(concrete.IsInherited); - Assert.True(concrete.HasValue); - Assert.Equal("2px left 10px", concrete.Value); + Assert.False(concrete.HasValue); } [Fact] diff --git a/src/ExCSS/StyleProperties/Transform/TransformOriginProperty.cs b/src/ExCSS/StyleProperties/Transform/TransformOriginProperty.cs index dce3b2d7..d8486ab3 100644 --- a/src/ExCSS/StyleProperties/Transform/TransformOriginProperty.cs +++ b/src/ExCSS/StyleProperties/Transform/TransformOriginProperty.cs @@ -1,29 +1,63 @@ -namespace ExCSS +namespace ExCSS { using static Converters; internal sealed class TransformOriginProperty : Property { - private static readonly IValueConverter StyleConverter = WithOrder( - LengthOrPercentConverter.Or(Keywords.Center, Point.Center).Or(WithAny( - LengthOrPercentConverter.Or(Keywords.Left, Length.Zero) - .Or(Keywords.Right, Length.Full) - .Or(Keywords.Center, Length.Half) - .Option(Length.Half), - LengthOrPercentConverter.Or(Keywords.Top, Length.Zero) - .Or(Keywords.Bottom, Length.Full) - .Or(Keywords.Center, Length.Half) - .Option(Length.Half))).Or( - WithAny( - LengthOrPercentConverter.Or(Keywords.Top, Length.Zero) - .Or(Keywords.Bottom, Length.Full) - .Or(Keywords.Center, Length.Half) - .Option(Length.Half), - LengthOrPercentConverter.Or(Keywords.Left, Length.Zero) - .Or(Keywords.Right, Length.Full) - .Or(Keywords.Center, Length.Half) - .Option(Length.Half))).Required(), - LengthConverter.Option(Length.Zero)).OrDefault(Point.Center); + // CSS Transforms 1 (https://www.w3.org/TR/css-transforms-1/#transform-origin-property): + // [ left | center | right | top | bottom | ] + // | [ left | center | right | ] [ top | center | bottom | ] ? + // | [ [ center | left | right ] && [ center | top | bottom ] ] ? + // + // The two-value form is ORDERED (horizontal then vertical). Only the keyword-only + // form may be reordered ("center left" == "left center"). So "2px left" and "top 2px" are invalid: a + // length in one axis forces the ordered form, and the remaining token is a keyword for the wrong axis. + private static readonly IValueConverter Horizontal = + LengthOrPercentConverter + .Or(Keywords.Left, Length.Zero) + .Or(Keywords.Right, Length.Full) + .Or(Keywords.Center, Length.Half); + + private static readonly IValueConverter Vertical = + LengthOrPercentConverter + .Or(Keywords.Top, Length.Zero) + .Or(Keywords.Bottom, Length.Full) + .Or(Keywords.Center, Length.Half); + + private static readonly IValueConverter HorizontalKeyword = + Assign(Keywords.Left, Length.Zero) + .Or(Keywords.Right, Length.Full) + .Or(Keywords.Center, Length.Half); + + private static readonly IValueConverter VerticalKeyword = + Assign(Keywords.Top, Length.Zero) + .Or(Keywords.Bottom, Length.Full) + .Or(Keywords.Center, Length.Half); + + // A one- or two-value with no z-offset (this is exactly the perspective-origin grammar). + private static readonly IValueConverter Position = + LengthOrPercentConverter.Or(Keywords.Center, Point.Center) + // Ordered horizontal-then-vertical (rejects a keyword in the wrong axis position). + .Or(WithOrder(Horizontal.Option(Length.Half), Vertical.Option(Length.Half))) + // Keyword-only form, any order ("center left"): a token accepted by both axes must not be + // claimed positionally, so this needs order-independent matching. + .Or(WithAnyOrderIndependent(HorizontalKeyword.Option(Length.Half), VerticalKeyword.Option(Length.Half))); + + // A genuine two-value position: BOTH a horizontal and a vertical component must be present. The axis + // converters are bare (not .Option), so each must actually consume a token. The z-offset only + // exists in this two-value production, so gating the z-offset behind this (rather than the shared outer + // WithOrder) is what makes a single-value position + length such as "top 2px" invalid. + private static readonly IValueConverter TwoValuePosition = + WithOrder(Horizontal, Vertical) + .Or(WithAnyOrderIndependent(HorizontalKeyword, VerticalKeyword)); + + private static readonly IValueConverter StyleConverter = + // Two-value position followed by a (required) z-offset - this branch only matches when a + // third length token is actually present... + WithOrder(TwoValuePosition, LengthConverter) + // ...otherwise a plain one- or two-value with no z-offset. + .Or(Position) + .OrDefault(Point.Center); internal TransformOriginProperty() : base(PropertyNames.TransformOrigin, PropertyFlags.Animatable) @@ -32,4 +66,4 @@ internal TransformOriginProperty() internal override IValueConverter Converter => StyleConverter; } -} \ No newline at end of file +}