Browse Source

Add support for parsing BoxShadows with color functions (#20321)

* Add support for parsing BoxShadows with colors expressions with parentheses

* Added BoxShadowsTests

* BoxShadow parsing should also respect parentheses

* Add StringSplitter

* Fix test

* Apply suggestions from code review

Co-authored-by: Copilot <[email protected]>

* StringSplitter should not accept same bracket pairs

* Returns empty array only when input is null

* Add StringSplitterTests

---------

Co-authored-by: Copilot <[email protected]>
Ge 2 weeks ago
parent
commit
62597de97c

+ 6 - 1
src/Avalonia.Base/Media/BoxShadow.cs

@@ -12,6 +12,8 @@ namespace Avalonia.Media
     public struct BoxShadow
     {
         private readonly static char[] s_Separator = new char[] { ' ', '\t' };
+        private const char OpeningParenthesis = '(';
+        private const char ClosingParenthesis = ')';
 
         /// <summary>
         /// Gets or sets the horizontal offset (distance) of the shadow.
@@ -208,7 +210,10 @@ namespace Avalonia.Media
                 throw new FormatException();
             }
 
-            var p = s.Split(s_Separator, StringSplitOptions.RemoveEmptyEntries);
+            var p = StringSplitter.SplitRespectingBrackets(
+                s, s_Separator,
+                OpeningParenthesis, ClosingParenthesis,
+                StringSplitOptions.RemoveEmptyEntries);
             if (p.Length == 1 && p[0] == "none")
             {
                 return default;

+ 7 - 3
src/Avalonia.Base/Media/BoxShadows.cs

@@ -9,7 +9,9 @@ namespace Avalonia.Media
     /// </summary>
     public struct BoxShadows
     {
-        private static readonly char[] s_Separators = new[] { ',' };
+        private const char Separator = ',';
+        private const char OpeningParenthesis = '(';
+        private const char ClosingParenthesis = ')';
 
         private readonly BoxShadow _first;
         private readonly BoxShadow[]? _list;
@@ -120,7 +122,9 @@ namespace Avalonia.Media
         /// <returns>A new <see cref="BoxShadows"/> collection.</returns>
         public static BoxShadows Parse(string s)
         {
-            var sp = s.Split(s_Separators, StringSplitOptions.RemoveEmptyEntries);
+            var sp = StringSplitter.SplitRespectingBrackets(
+                s, Separator, OpeningParenthesis, ClosingParenthesis,
+                StringSplitOptions.RemoveEmptyEntries);
             if (sp.Length == 0
                 || (sp.Length == 1 &&
                     (string.IsNullOrWhiteSpace(sp[0])
@@ -236,7 +240,7 @@ namespace Avalonia.Media
         /// <returns>
         /// <c>true</c> if the two <see cref="BoxShadows"/> collections are equal; otherwise, <c>false</c>.
         /// </returns>
-        public static bool operator ==(BoxShadows left, BoxShadows right) => 
+        public static bool operator ==(BoxShadows left, BoxShadows right) =>
             left.Equals(right);
 
         /// <summary>

+ 114 - 0
src/Avalonia.Base/Utilities/StringSplitter.cs

@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Utilities;
+
+/// <summary>
+/// Helpers for splitting strings.
+/// </summary>
+internal static class StringSplitter
+{
+    private const char DefaultOpeningParenthesis = '(';
+    private const char DefaultClosingParenthesis = ')';
+
+    /// <summary>
+    /// Splits the provided string by the specified separators, but ignores separators that
+    /// appear inside matching bracket pairs (<paramref name="openingBracket"/> / <paramref name="closingBracket"/>).
+    /// </summary>
+    /// <param name="s">The input string to split. If <c>null</c>, an empty array is returned.</param>
+    /// <param name="separator">The separator character to split on.</param>
+    /// <param name="openingBracket">The character that opens a bracketed section. <c>(</c> by default.</param>
+    /// <param name="closingBracket">The character that closes a bracketed section. <c>)</c> by default.</param>
+    /// <param name="options">Options for trimming entries and removing empty entries.</param>
+    /// <returns>An array of split segments. Returns an empty array if the input is null or only whitespace.</returns>
+    public static string[] SplitRespectingBrackets(string s, char separator,
+        char openingBracket = DefaultOpeningParenthesis, char closingBracket = DefaultClosingParenthesis,
+        StringSplitOptions options = StringSplitOptions.None) =>
+        SplitRespectingBrackets(s, [separator], openingBracket, closingBracket, options);
+
+    /// <summary>
+    /// Splits the provided string by the specified separator, but ignores separators that
+    /// appear inside matching bracket pairs (<paramref name="openingBracket"/> / <paramref name="closingBracket"/>).
+    /// </summary>
+    /// <param name="s">The input string to split. If <c>null</c>, an empty array is returned.</param>
+    /// <param name="separators">The separator characters to split on.</param>
+    /// <param name="openingBracket">The character that opens a bracketed section. <c>(</c> by default.</param>
+    /// <param name="closingBracket">The character that closes a bracketed section. <c>)</c> by default.</param>
+    /// <param name="options">Options for trimming entries and removing empty entries.</param>
+    /// <returns>An array of split segments. Returns an empty array if the input is null or only whitespace.</returns>
+    public static string[] SplitRespectingBrackets(string s, ReadOnlySpan<char> separators,
+        char openingBracket = DefaultOpeningParenthesis, char closingBracket = DefaultClosingParenthesis,
+        StringSplitOptions options = StringSplitOptions.None)
+    {
+        if (openingBracket == closingBracket)
+            throw new ArgumentException($"Opening bracket and closing bracket cannot be the same character '{openingBracket}'.", nameof(closingBracket));
+
+        if (s is null)
+            return [];
+
+        var span = s.AsSpan();
+
+        var ranges = new List<(int start, int length)>();
+        int depth = 0;
+        int segStart = 0;
+
+        bool removeEmptyEntries = options.HasFlag(StringSplitOptions.RemoveEmptyEntries);
+        bool trimEntries = options.HasFlag(StringSplitOptions.TrimEntries);
+
+        for (int i = 0; i < span.Length; i++)
+        {
+            char ch = span[i];
+            if (ch == openingBracket)
+                depth++;
+            else if (ch == closingBracket)
+            {
+                if (depth <= 0)
+                    throw new FormatException($"Unmatched closing bracket '{closingBracket}' at position {i}.");
+                depth--;
+            }
+            else if (separators.Contains(ch))
+            {
+                if (depth != 0)
+                    continue;
+                ProcessSegment(segStart, i - 1);
+                segStart = i + 1;
+            }
+        }
+
+        if (depth != 0)
+            throw new FormatException($"Unmatched opening bracket '{openingBracket}' in input string.");
+        // last segment
+        ProcessSegment(segStart, span.Length - 1);
+
+        if (ranges.Count == 0)
+            return [];
+
+        var result = new string[ranges.Count];
+        for (int i = 0; i < ranges.Count; i++)
+        {
+            var r = ranges[i];
+#if NET6_0_OR_GREATER
+            result[i] = new string(span.Slice(r.start, r.length));
+#else
+            result[i] = span.Slice(r.start, r.length).ToString();
+#endif
+        }
+
+        return result;
+
+        void ProcessSegment(int start, int end)
+        {
+            if (trimEntries)
+            {
+                while (start <= end && char.IsWhiteSpace(s[start]))
+                    start++;
+                while (end >= start && char.IsWhiteSpace(s[end]))
+                    end--;
+            }
+
+            int length = end - start + 1;
+            if (length > 0 || !removeEmptyEntries)
+                ranges.Add((start, length));
+        }
+    }
+}

+ 75 - 0
tests/Avalonia.Base.UnitTests/Media/BoxShadowsTests.cs

@@ -0,0 +1,75 @@
+using Avalonia.Media;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Media
+{
+    public class BoxShadowsTests
+    {
+        [Theory]
+        [InlineData("none")]
+        [InlineData(" none ")]
+        public void Parse_None_ReturnsEmpty(string input)
+        {
+            var bs = BoxShadows.Parse(input);
+            Assert.Equal(0, bs.Count);
+            Assert.Equal(default, bs);
+            Assert.Equal("none", bs.ToString());
+        }
+
+        [Theory]
+        [InlineData("0 0 5 0 #FF0000")]
+        [InlineData("10 20 30 5 rgba(0,0,0,0.5)")]
+        [InlineData("10 20 30 5 rgba(0, 0, 0, 0.5)")]
+        [InlineData("  10  20  30  5  rgba(0,  0,  0,  0.5)  ")]
+        public void Parse_SingleShadow_ToString_RoundTrip(string input)
+        {
+            var bs = BoxShadows.Parse(input);
+            Assert.Equal(1, bs.Count);
+            var str = bs.ToString();
+            var reparsed = BoxShadows.Parse(str);
+            Assert.Equal(bs, reparsed);
+        }
+
+        [Theory]
+        [InlineData("0 0 5 0 #FF0000", 10.0)]
+        [InlineData("0 0 10 0 rgba(0,0,0,0.5)", 20.0)]
+        public void TransformBounds_IncludesShadowExpansion(string input, double minExpansion)
+        {
+            var bs = BoxShadows.Parse(input);
+            var rect = new Rect(0, 0, 100, 100);
+            var transformed = bs.TransformBounds(rect);
+            Assert.True(transformed.Width >= rect.Width + minExpansion);
+            Assert.True(transformed.Height >= rect.Height + minExpansion);
+        }
+
+        [Theory]
+        [InlineData("5 5 10 0 rgba(10,20,30,0.4)")]
+        [InlineData("5 5 10 0 hsla(10,20%,30%,0.4)")]
+        [InlineData("5 5 10 0 hsva(10,20%,30%,0.4)")]
+        public void Parse_ColorFunction_IsHandled(string input)
+        {
+            var bs = BoxShadows.Parse(input);
+            Assert.Equal(1, bs.Count);
+            var reparsed = BoxShadows.Parse(bs.ToString());
+            Assert.Equal(bs, reparsed);
+        }
+
+        [Theory]
+        [InlineData("1 2 3 0 #FF0000", 1)]
+        [InlineData("10 20 30 5 rgba(0,0,0,0.5)", 1)]
+        [InlineData("1 2 3 0 #FF0000, 1 2 3 0 #FF0000", 2)]
+        [InlineData("10 20 30 5 rgba(0,0,0,0.5), 1 2 3 0 #FF0000", 2)]
+        [InlineData("10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 rgba(0,0,0,0.5)", 2)]
+        [InlineData("10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 rgba(0,0,0,0.5)", 3)]
+        [InlineData("10 20 30 5 rgba(0,0,0,0.5), 10 20 30 5 #ffffff, 10 20 30 5 Red", 3)]
+        [InlineData("  10 20 30 5 rgba(0, 0, 0, 0.5), 10 20 30 5 rgba(0, 0, 0, 0.5), 10 20 30 5 rgba(0, 0, 0, 0.5)  ", 3)]
+        [InlineData("  10 20 30 5 rgba(0, 0, 0, 0.5), 10 20 30 5 #ffffff, 10 20 30 5 Red  ", 3)]
+        public void Parse_MultipleShadows(string input, int count)
+        {
+            var bs = BoxShadows.Parse(input);
+            Assert.Equal(count, bs.Count);
+            var reparsed = BoxShadows.Parse(bs.ToString());
+            Assert.Equal(bs, reparsed);
+        }
+    }
+}

+ 175 - 0
tests/Avalonia.Base.UnitTests/Utilities/StringSplitterTests.cs

@@ -0,0 +1,175 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Utilities;
+using Xunit;
+
+namespace Avalonia.Base.UnitTests.Utilities;
+
+public class StringSplitterTests
+{
+    #region Tests without brackets - should match string.Split behavior
+
+    [Fact]
+    public void SplitRespectingBrackets_WithoutBrackets_NullReturnsEmptyArray()
+    {
+        var result = StringSplitter.SplitRespectingBrackets(null, ',');
+        Assert.Empty(result);
+    }
+
+    [Theory]
+    [InlineData("")]
+    [InlineData("   ")]
+    [InlineData("\t\n")]
+    [InlineData("abc")]
+    [InlineData("a,b,c")]
+    [InlineData("a,,c")]
+    [InlineData("a,,,b")]
+    [InlineData(",a,b,")]
+    [InlineData(" a , b , c ")]
+    [InlineData(" a ,,,, c ")]
+    [InlineData(" a ,  , c ")]
+    [InlineData(" a, b ,c ")]
+    [InlineData(" , a , b , ")]
+    [InlineData("  a  ,  b  ,  c  ")]
+    [InlineData("First,Second,Third")]
+    [InlineData("Header\nBody\nFooter\n", '\n')]
+    [InlineData("Width;Height;Margin;Padding", ';')]
+    [InlineData("Avalonia.Utilities.StringSplitter", '.')]
+    public void SplitRespectingBrackets_WithoutBrackets_SingleSeparator(string input, char separator = ',')
+    {
+        foreach (var options in EnumerateStringSplitOptionsCombinations())
+        {
+            var result = StringSplitter.SplitRespectingBrackets(input, separator, options: options);
+            var expected = input.Split(separator, options);
+            Assert.Equal(expected, result);
+        }
+    }
+
+    [Theory]
+    [InlineData("a,b;c,d")]
+    [InlineData("a,b;,;c,d")]
+    [InlineData(" a , b ; c , d ")]
+    [InlineData(" a , b ; ; c , d ")]
+    [InlineData(" a , b ;,; c , d ")]
+    [InlineData(" ; a , b ; c , d ; ")]
+    public void SplitRespectingBrackets_WithoutBrackets_MultipleSeparators(string input)
+    {
+        char[] separators = [',', ';'];
+        foreach (var options in EnumerateStringSplitOptionsCombinations())
+        {
+            var result = StringSplitter.SplitRespectingBrackets(input, separators, options: options);
+            var expected = input.Split(separators, options);
+            Assert.Equal(expected, result);
+        }
+    }
+
+    #endregion
+
+    #region Tests with brackets - should respect bracket pairs
+
+    [Theory]
+    [InlineData("(a)(b,c)", new[] { "(a)(b,c)" })]
+    [InlineData("a,(),b", new[] { "a", "()", "b" })]
+    [InlineData("a,(b,c),d", new[] { "a", "(b,c)", "d" })]
+    [InlineData("a,(b,(c,d)),e", new[] { "a", "(b,(c,d))", "e" })]
+    [InlineData(",a,(b,c),d,", new[] { "", "a", "(b,c)", "d", "" })]
+    [InlineData("(a,b),(c,d),(e,f)", new[] { "(a,b)", "(c,d)", "(e,f)" })]
+    [InlineData("a,(b,(c,(d,e))),f", new[] { "a", "(b,(c,(d,e)))", "f" })]
+    [InlineData("Button,TextBox(Width=100,Height=50),Label", new[] { "Button", "TextBox(Width=100,Height=50)", "Label" })]
+    [InlineData("string,List(int),Dictionary(string,object)", new[] { "string", "List(int)", "Dictionary(string,object)" })]
+    [InlineData("FirstItem,Item(param1,param2,param3),x,VeryLongItemName(a,b),Short", new[] { "FirstItem", "Item(param1,param2,param3)", "x", "VeryLongItemName(a,b)", "Short" })]
+    [InlineData("BindingPath,Converter(Type=MyConverter,Parameter=Value123),Mode=TwoWay", new[] { "BindingPath", "Converter(Type=MyConverter,Parameter=Value123)", "Mode=TwoWay" })]
+    [InlineData("Observable(List(Dictionary(string,int))),SimpleType,AnotherObservable(string)", new[] { "Observable(List(Dictionary(string,int)))", "SimpleType", "AnotherObservable(string)" })]
+    [InlineData("OuterType(InnerType(DeepType(VeryDeepValue1,VeryDeepValue2),InnerValue),OuterValue)", new[] { "OuterType(InnerType(DeepType(VeryDeepValue1,VeryDeepValue2),InnerValue),OuterValue)" })]
+    [InlineData("0 4 6 -1 #FF000000,0 2 4 -1 rgba(0,0,0,0.06),inset 0 1 2 0 rgba(255,255,255,0.1)", new[] { "0 4 6 -1 #FF000000", "0 2 4 -1 rgba(0,0,0,0.06)", "inset 0 1 2 0 rgba(255,255,255,0.1)" })]
+    public void SplitRespectingBrackets_WithBrackets_DefaultBrackets(string input, string[] expected)
+    {
+        var result = StringSplitter.SplitRespectingBrackets(input, ',');
+        Assert.Equal(expected, result);
+    }
+
+    [Theory]
+    [InlineData("a,(b,c;d);e", new[] { "a", "(b,c;d)", "e" })]
+    [InlineData("Width=100,Height=200;Margin(10;20;30;40),Padding=5", new[] { "Width=100", "Height=200", "Margin(10;20;30;40)", "Padding=5" })]
+    public void SplitRespectingBrackets_WithBrackets_MultipleSeparators(string input, string[] expected)
+    {
+        var result = StringSplitter.SplitRespectingBrackets(input, [',', ';']);
+        Assert.Equal(expected, result);
+    }
+
+    [Theory]
+    [InlineData("a,(b,c),d", '[', ']', new[] { "a", "(b", "c)", "d" })]
+    [InlineData("a,[b,c],d", '[', ']', new[] { "a", "[b,c]", "d" })]
+    [InlineData("x,<y,z>,w", '<', '>', new[] { "x", "<y,z>", "w" })]
+    [InlineData("Property1,Property2[Index1,Index2],Property3", '[', ']', new[] { "Property1", "Property2[Index1,Index2]", "Property3" })]
+    public void SplitRespectingBrackets_WithBrackets_CustomBrackets(string input, char openingBracket, char closingBracket, string[] expected)
+    {
+        var result = StringSplitter.SplitRespectingBrackets(input, ',', openingBracket, closingBracket);
+        Assert.Equal(expected, result);
+    }
+
+    [Theory]
+    [InlineData("a,,(b,c),,d", StringSplitOptions.None, new[] { "a", "", "(b,c)", "", "d" })]
+    [InlineData("a,,(b,c),,d", StringSplitOptions.RemoveEmptyEntries, new[] { "a", "(b,c)", "d" })]
+    [InlineData(",a,(b,c),d,", StringSplitOptions.None, new[] { "", "a", "(b,c)", "d", "" })]
+    [InlineData(",a,(b,c),d,", StringSplitOptions.RemoveEmptyEntries, new[] { "a", "(b,c)", "d" })]
+    [InlineData(" a , (b, c) , d ", StringSplitOptions.None, new[] { " a ", " (b, c) ", " d " })]
+    [InlineData(" a , (b, c) , d ", StringSplitOptions.TrimEntries, new[] { "a", "(b, c)", "d" })]
+    [InlineData(" a ,  , (b, c) ,  , d ", StringSplitOptions.None, new[] { " a ", "  ", " (b, c) ", "  ", " d " })]
+    [InlineData(" a ,  , (b, c) ,  , d ", StringSplitOptions.TrimEntries, new[] { "a", "", "(b, c)", "", "d" })]
+    [InlineData(" a ,  , (b, c) ,  , d ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries, new[] { "a", "(b, c)", "d" })]
+    [InlineData(" , a , ( b , ( c , d ) ) , , e , ", StringSplitOptions.None, new[] { " ", " a ", " ( b , ( c , d ) ) ", " ", " e ", " " })]
+    [InlineData(" , a , ( b , ( c , d ) ) , , e , ", StringSplitOptions.TrimEntries, new[] { "", "a", "( b , ( c , d ) )", "", "e", "" })]
+    [InlineData(" , a , ( b , ( c , d ) ) , , e , ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries, new[] { "a", "( b , ( c , d ) )", "e" })]
+    public void SplitRespectingBrackets_WithBrackets_WithOptions(string input, StringSplitOptions options, string[] expected)
+    {
+        var result = StringSplitter.SplitRespectingBrackets(input, ',', options: options);
+        Assert.Equal(expected, result);
+    }
+
+    #endregion
+
+    #region Tests for mismatched brackets - should throw exceptions
+
+    [Theory]
+    [InlineData("(")]
+    [InlineData(")")]
+    [InlineData(")a,b(")]
+    [InlineData("a,b),c")]
+    [InlineData("a,(b,c")]
+    [InlineData("a,b))c")]
+    [InlineData("a,((b,c)")]
+    [InlineData("a,(b,(c)),d)")]
+    [InlineData("x,[y,z", '[', ']')]
+    [InlineData("x,y],z", '[', ']')]
+    [InlineData("Type1,Type2(Inner1,Inner2)),Type3")]
+    [InlineData("Property1,Property2(Parameter1,Parameter2,Property3")]
+    [InlineData("OuterType(InnerType(DeepType(Value1,Value2),MiddleType(Value3)")]
+    public void SplitRespectingBrackets_UnmatchedBrackets_ThrowsFormatException(string input, char openingBracket = '(', char closingBracket = ')')
+    {
+        Assert.Throws<FormatException>(() =>
+            StringSplitter.SplitRespectingBrackets(input, ',', openingBracket, closingBracket));
+    }
+
+    [Theory]
+    [InlineData('(', '(')]
+    [InlineData('[', '[')]
+    [InlineData('.', '.')]
+    public void SplitRespectingBrackets_SameOpeningAndClosingBracket_ThrowsArgumentException(char bracket1, char bracket2)
+    {
+        var input = "a,b,c";
+
+        Assert.Throws<ArgumentException>(() =>
+            StringSplitter.SplitRespectingBrackets(input, ',', bracket1, bracket2));
+    }
+
+    #endregion
+
+    private static IEnumerable<StringSplitOptions> EnumerateStringSplitOptionsCombinations()
+    {
+        yield return StringSplitOptions.None;
+        yield return StringSplitOptions.RemoveEmptyEntries;
+        yield return StringSplitOptions.TrimEntries;
+        yield return StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
+    }
+}