diff --git a/src/Avalonia.Base/Media/BoxShadow.cs b/src/Avalonia.Base/Media/BoxShadow.cs
index 9ea2a992d7..7da504c14c 100644
--- a/src/Avalonia.Base/Media/BoxShadow.cs
+++ b/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 = ')';
///
/// 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;
diff --git a/src/Avalonia.Base/Media/BoxShadows.cs b/src/Avalonia.Base/Media/BoxShadows.cs
index 02e9d4279b..7a926b1191 100644
--- a/src/Avalonia.Base/Media/BoxShadows.cs
+++ b/src/Avalonia.Base/Media/BoxShadows.cs
@@ -9,7 +9,9 @@ namespace Avalonia.Media
///
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
/// A new collection.
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
///
/// true if the two collections are equal; otherwise, false.
///
- public static bool operator ==(BoxShadows left, BoxShadows right) =>
+ public static bool operator ==(BoxShadows left, BoxShadows right) =>
left.Equals(right);
///
diff --git a/src/Avalonia.Base/Utilities/StringSplitter.cs b/src/Avalonia.Base/Utilities/StringSplitter.cs
new file mode 100644
index 0000000000..95e1049f8f
--- /dev/null
+++ b/src/Avalonia.Base/Utilities/StringSplitter.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Utilities;
+
+///
+/// Helpers for splitting strings.
+///
+internal static class StringSplitter
+{
+ private const char DefaultOpeningParenthesis = '(';
+ private const char DefaultClosingParenthesis = ')';
+
+ ///
+ /// Splits the provided string by the specified separators, but ignores separators that
+ /// appear inside matching bracket pairs ( / ).
+ ///
+ /// The input string to split. If null, an empty array is returned.
+ /// The separator character to split on.
+ /// The character that opens a bracketed section. ( by default.
+ /// The character that closes a bracketed section. ) by default.
+ /// Options for trimming entries and removing empty entries.
+ /// An array of split segments. Returns an empty array if the input is null or only whitespace.
+ public static string[] SplitRespectingBrackets(string s, char separator,
+ char openingBracket = DefaultOpeningParenthesis, char closingBracket = DefaultClosingParenthesis,
+ StringSplitOptions options = StringSplitOptions.None) =>
+ SplitRespectingBrackets(s, [separator], openingBracket, closingBracket, options);
+
+ ///
+ /// Splits the provided string by the specified separator, but ignores separators that
+ /// appear inside matching bracket pairs ( / ).
+ ///
+ /// The input string to split. If null, an empty array is returned.
+ /// The separator characters to split on.
+ /// The character that opens a bracketed section. ( by default.
+ /// The character that closes a bracketed section. ) by default.
+ /// Options for trimming entries and removing empty entries.
+ /// An array of split segments. Returns an empty array if the input is null or only whitespace.
+ public static string[] SplitRespectingBrackets(string s, ReadOnlySpan 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));
+ }
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Media/BoxShadowsTests.cs b/tests/Avalonia.Base.UnitTests/Media/BoxShadowsTests.cs
new file mode 100644
index 0000000000..8090ab7d50
--- /dev/null
+++ b/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);
+ }
+ }
+}
diff --git a/tests/Avalonia.Base.UnitTests/Utilities/StringSplitterTests.cs b/tests/Avalonia.Base.UnitTests/Utilities/StringSplitterTests.cs
new file mode 100644
index 0000000000..52bbdbff56
--- /dev/null
+++ b/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,,w", '<', '>', new[] { "x", "", "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(() =>
+ 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(() =>
+ StringSplitter.SplitRespectingBrackets(input, ',', bracket1, bracket2));
+ }
+
+ #endregion
+
+ private static IEnumerable EnumerateStringSplitOptionsCombinations()
+ {
+ yield return StringSplitOptions.None;
+ yield return StringSplitOptions.RemoveEmptyEntries;
+ yield return StringSplitOptions.TrimEntries;
+ yield return StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
+ }
+}