diff --git a/src/Avalonia.Base/Media/Color.cs b/src/Avalonia.Base/Media/Color.cs index eaa886ccbd..cb90404f6d 100644 --- a/src/Avalonia.Base/Media/Color.cs +++ b/src/Avalonia.Base/Media/Color.cs @@ -166,7 +166,10 @@ namespace Avalonia.Media return true; } - if (s.Length > 5 && + // Note: The length checks are also an important optimization. + // The shortest possible CSS format is "rbg(0,0,0)", Length = 10. + + if (s.Length >= 10 && (s[0] == 'r' || s[0] == 'R') && (s[1] == 'g' || s[1] == 'G') && (s[2] == 'b' || s[2] == 'B') && @@ -175,7 +178,7 @@ namespace Avalonia.Media return true; } - if (s.Length > 5 && + if (s.Length >= 10 && (s[0] == 'h' || s[0] == 'H') && (s[1] == 's' || s[1] == 'S') && (s[2] == 'l' || s[2] == 'L') && @@ -185,7 +188,7 @@ namespace Avalonia.Media return true; } - if (s.Length > 5 && + if (s.Length >= 10 && (s[0] == 'h' || s[0] == 'H') && (s[1] == 's' || s[1] == 'S') && (s[2] == 'v' || s[2] == 'V') && @@ -229,7 +232,10 @@ namespace Avalonia.Media // At this point all parsing uses strings var str = s.ToString(); - if (s.Length > 5 && + // Note: The length checks are also an important optimization. + // The shortest possible CSS format is "rbg(0,0,0)", Length = 10. + + if (s.Length >= 10 && (s[0] == 'r' || s[0] == 'R') && (s[1] == 'g' || s[1] == 'G') && (s[2] == 'b' || s[2] == 'B') && @@ -238,7 +244,7 @@ namespace Avalonia.Media return true; } - if (s.Length > 5 && + if (s.Length >= 10 && (s[0] == 'h' || s[0] == 'H') && (s[1] == 's' || s[1] == 'S') && (s[2] == 'l' || s[2] == 'L') && @@ -248,7 +254,7 @@ namespace Avalonia.Media return true; } - if (s.Length > 5 && + if (s.Length >= 10 && (s[0] == 'h' || s[0] == 'H') && (s[1] == 's' || s[1] == 'S') && (s[2] == 'v' || s[2] == 'V') && @@ -271,6 +277,9 @@ namespace Avalonia.Media return false; } + /// + /// Parses the given span of characters representing a hex color value into a new . + /// private static bool TryParseHexFormat(ReadOnlySpan s, out Color color) { static bool TryParseCore(ReadOnlySpan input, ref Color color) @@ -325,8 +334,13 @@ namespace Avalonia.Media return TryParseCore(input, ref color); } + /// + /// Parses the given string representing a CSS color value into a new . + /// private static bool TryParseCssFormat(string s, out Color color) { + bool prefixMatched = false; + color = default; if (s is null) @@ -342,27 +356,35 @@ namespace Avalonia.Media return false; } - if (workingString.Length > 6 && + if (workingString.Length >= 11 && workingString.StartsWith("rgba(", StringComparison.OrdinalIgnoreCase) && workingString.EndsWith(")", StringComparison.Ordinal)) { workingString = workingString.Substring(5, workingString.Length - 6); + prefixMatched = true; } - if (workingString.Length > 5 && + if (prefixMatched == false && + workingString.Length >= 10 && workingString.StartsWith("rgb(", StringComparison.OrdinalIgnoreCase) && workingString.EndsWith(")", StringComparison.Ordinal)) { workingString = workingString.Substring(4, workingString.Length - 5); + prefixMatched = true; + } + + if (prefixMatched == false) + { + return false; } string[] components = workingString.Split(','); if (components.Length == 3) // RGB { - if (byte.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out byte red) && - byte.TryParse(components[1], NumberStyles.Number, CultureInfo.InvariantCulture, out byte green) && - byte.TryParse(components[2], NumberStyles.Number, CultureInfo.InvariantCulture, out byte blue)) + if (InternalTryParseByte(components[0], out byte red) && + InternalTryParseByte(components[1], out byte green) && + InternalTryParseByte(components[2], out byte blue)) { color = new Color(0xFF, red, green, blue); return true; @@ -370,18 +392,45 @@ namespace Avalonia.Media } else if (components.Length == 4) // RGBA { - if (byte.TryParse(components[0], NumberStyles.Number, CultureInfo.InvariantCulture, out byte red) && - byte.TryParse(components[1], NumberStyles.Number, CultureInfo.InvariantCulture, out byte green) && - byte.TryParse(components[2], NumberStyles.Number, CultureInfo.InvariantCulture, out byte blue) && - TryInternalParse(components[3], out double alpha)) + if (InternalTryParseByte(components[0], out byte red) && + InternalTryParseByte(components[1], out byte green) && + InternalTryParseByte(components[2], out byte blue) && + InternalTryParseDouble(components[3], out double alpha)) { - color = new Color((byte)(alpha * 255), red, green, blue); + color = new Color((byte)Math.Round(alpha * 255.0), red, green, blue); return true; } } + // Local function to specially parse a byte value with an optional percentage sign + bool InternalTryParseByte(string inString, out byte outByte) + { + // The percent sign, if it exists, must be at the end of the number + int percentIndex = inString.IndexOf("%", StringComparison.Ordinal); + + if (percentIndex >= 0) + { + var result = double.TryParse( + inString.Substring(0, percentIndex), + NumberStyles.Number, + CultureInfo.InvariantCulture, + out double percentage); + + outByte = (byte)Math.Round((percentage / 100.0) * 255.0); + return result; + } + else + { + return byte.TryParse( + inString, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out outByte); + } + } + // Local function to specially parse a double value with an optional percentage sign - bool TryInternalParse(string inString, out double outDouble) + bool InternalTryParseDouble(string inString, out double outDouble) { // The percent sign, if it exists, must be at the end of the number int percentIndex = inString.IndexOf("%", StringComparison.Ordinal); diff --git a/src/Avalonia.Base/Media/HslColor.cs b/src/Avalonia.Base/Media/HslColor.cs index e27a4f3106..e8a4d6f94f 100644 --- a/src/Avalonia.Base/Media/HslColor.cs +++ b/src/Avalonia.Base/Media/HslColor.cs @@ -12,6 +12,7 @@ namespace Avalonia.Media { /// /// Defines a color using the hue/saturation/lightness (HSL) model. + /// This uses a cylindrical-coordinate representation of a color. /// #if !BUILDTASK public @@ -98,24 +99,53 @@ namespace Avalonia.Media } /// - /// Gets the Alpha (transparency) component in the range from 0..1. + /// Gets the Alpha (transparency) component in the range from 0..1 (percentage). /// + /// + /// + /// 0 is fully transparent. + /// 1 is fully opaque. + /// + /// public double A { get; } /// - /// Gets the Hue component in the range from 0..360. + /// Gets the Hue component in the range from 0..360 (degrees). + /// This is the color's location, in degrees, on a color wheel/circle from 0 to 360. /// Note that 360 is equivalent to 0 and will be adjusted automatically. /// + /// + /// + /// 0/360 degrees is Red. + /// 60 degrees is Yellow. + /// 120 degrees is Green. + /// 180 degrees is Cyan. + /// 240 degrees is Blue. + /// 300 degrees is Magenta. + /// + /// public double H { get; } /// - /// Gets the Saturation component in the range from 0..1. + /// Gets the Saturation component in the range from 0..1 (percentage). /// + /// + /// + /// 0 is a shade of gray (no color). + /// 1 is the full color. + /// + /// public double S { get; } /// - /// Gets the Lightness component in the range from 0..1. + /// Gets the Lightness component in the range from 0..1 (percentage). /// + /// + /// + /// 0 is fully black. + /// 1 is fully white. + /// + /// public double L { get; } /// @@ -226,6 +256,8 @@ namespace Avalonia.Media /// True if parsing was successful; otherwise, false. public static bool TryParse(string s, out HslColor hslColor) { + bool prefixMatched = false; + hslColor = default; if (s is null) @@ -241,18 +273,29 @@ namespace Avalonia.Media return false; } - if (workingString.Length > 6 && + // Note: The length checks are also an important optimization. + // The shortest possible format is "hsl(0,0,0)", Length = 10. + + if (workingString.Length >= 11 && workingString.StartsWith("hsla(", StringComparison.OrdinalIgnoreCase) && workingString.EndsWith(")", StringComparison.Ordinal)) { workingString = workingString.Substring(5, workingString.Length - 6); + prefixMatched = true; } - if (workingString.Length > 5 && + if (prefixMatched == false && + workingString.Length >= 10 && workingString.StartsWith("hsl(", StringComparison.OrdinalIgnoreCase) && workingString.EndsWith(")", StringComparison.Ordinal)) { workingString = workingString.Substring(4, workingString.Length - 5); + prefixMatched = true; + } + + if (prefixMatched == false) + { + return false; } string[] components = workingString.Split(','); diff --git a/src/Avalonia.Base/Media/HsvColor.cs b/src/Avalonia.Base/Media/HsvColor.cs index 164aeb1df1..924ef4778b 100644 --- a/src/Avalonia.Base/Media/HsvColor.cs +++ b/src/Avalonia.Base/Media/HsvColor.cs @@ -12,6 +12,7 @@ namespace Avalonia.Media { /// /// Defines a color using the hue/saturation/value (HSV) model. + /// This uses a cylindrical-coordinate representation of a color. /// #if !BUILDTASK public @@ -98,24 +99,53 @@ namespace Avalonia.Media } /// - /// Gets the Alpha (transparency) component in the range from 0..1. + /// Gets the Alpha (transparency) component in the range from 0..1 (percentage). /// + /// + /// + /// 0 is fully transparent. + /// 1 is fully opaque. + /// + /// public double A { get; } /// - /// Gets the Hue component in the range from 0..360. + /// Gets the Hue component in the range from 0..360 (degrees). + /// This is the color's location, in degrees, on a color wheel/circle from 0 to 360. /// Note that 360 is equivalent to 0 and will be adjusted automatically. /// + /// + /// + /// 0/360 degrees is Red. + /// 60 degrees is Yellow. + /// 120 degrees is Green. + /// 180 degrees is Cyan. + /// 240 degrees is Blue. + /// 300 degrees is Magenta. + /// + /// public double H { get; } /// - /// Gets the Saturation component in the range from 0..1. + /// Gets the Saturation component in the range from 0..1 (percentage). /// + /// + /// + /// 0 is a shade of gray (no color). + /// 1 is the full color. + /// + /// public double S { get; } /// - /// Gets the Value component in the range from 0..1. + /// Gets the Value (or Brightness/Intensity) component in the range from 0..1 (percentage). /// + /// + /// + /// 0 is fully black and shows no color. + /// 1 is the brightest and shows full color. + /// + /// public double V { get; } /// @@ -226,6 +256,8 @@ namespace Avalonia.Media /// True if parsing was successful; otherwise, false. public static bool TryParse(string s, out HsvColor hsvColor) { + bool prefixMatched = false; + hsvColor = default; if (s is null) @@ -241,18 +273,29 @@ namespace Avalonia.Media return false; } - if (workingString.Length > 6 && + // Note: The length checks are also an important optimization. + // The shortest possible format is "hsv(0,0,0)", Length = 10. + + if (workingString.Length >= 11 && workingString.StartsWith("hsva(", StringComparison.OrdinalIgnoreCase) && workingString.EndsWith(")", StringComparison.Ordinal)) { workingString = workingString.Substring(5, workingString.Length - 6); + prefixMatched = true; } - if (workingString.Length > 5 && + if (prefixMatched == false && + workingString.Length >= 10 && workingString.StartsWith("hsv(", StringComparison.OrdinalIgnoreCase) && workingString.EndsWith(")", StringComparison.Ordinal)) { workingString = workingString.Substring(4, workingString.Length - 5); + prefixMatched = true; + } + + if (prefixMatched == false) + { + return false; } string[] components = workingString.Split(','); diff --git a/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs b/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs index 1392635b32..36929d5e95 100644 --- a/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs +++ b/tests/Avalonia.Base.UnitTests/Media/ColorTests.cs @@ -216,8 +216,8 @@ namespace Avalonia.Base.UnitTests.Media Tuple.Create("hsl(-1000, -1000, -1000)", new HslColor(1, 0, 0, 0)), // Clamps to min Tuple.Create("hsl(-1000, -1000%, -1000%)", new HslColor(1, 0, 0, 0)), // Clamps to min - Tuple.Create("hsl(1000, 1000, 1000)", new HslColor(1, 0, 1, 1)), // Clamps to max - Tuple.Create("hsl(1000, 1000%, 1000%)", new HslColor(1, 0, 1, 1)), // Clamps to max + Tuple.Create("hsl(1000, 1000, 1000)", new HslColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) + Tuple.Create("hsl(1000, 1000%, 1000%)", new HslColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) Tuple.Create("hsl(300, 0.8, 0.2)", new HslColor(1.0, 300, 0.8, 0.2)), Tuple.Create("hsl(300, 80%, 20%)", new HslColor(1.0, 300, 0.8, 0.2)), @@ -262,8 +262,8 @@ namespace Avalonia.Base.UnitTests.Media Tuple.Create("hsv(-1000, -1000, -1000)", new HsvColor(1, 0, 0, 0)), // Clamps to min Tuple.Create("hsv(-1000, -1000%, -1000%)", new HsvColor(1, 0, 0, 0)), // Clamps to min - Tuple.Create("hsv(1000, 1000, 1000)", new HsvColor(1, 0, 1, 1)), // Clamps to max - Tuple.Create("hsv(1000, 1000%, 1000%)", new HsvColor(1, 0, 1, 1)), // Clamps to max + Tuple.Create("hsv(1000, 1000, 1000)", new HsvColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) + Tuple.Create("hsv(1000, 1000%, 1000%)", new HsvColor(1, 0, 1, 1)), // Clamps to max (Hue wraps to zero) Tuple.Create("hsv(300, 0.8, 0.2)", new HsvColor(1.0, 300, 0.8, 0.2)), Tuple.Create("hsv(300, 80%, 20%)", new HsvColor(1.0, 300, 0.8, 0.2)), @@ -303,8 +303,20 @@ namespace Avalonia.Base.UnitTests.Media Tuple.Create("#123456", new Color(0xff, 0x12, 0x34, 0x56)), Tuple.Create("rgb(100, 30, 45)", new Color(255, 100, 30, 45)), - Tuple.Create("rgba(100, 30, 45, 0.9)", new Color(229, 100, 30, 45)), - Tuple.Create("rgba(100, 30, 45, 90%)", new Color(229, 100, 30, 45)), + Tuple.Create("rgba(100, 30, 45, 0.9)", new Color(230, 100, 30, 45)), + Tuple.Create("rgba(100, 30, 45, 90%)", new Color(230, 100, 30, 45)), + + Tuple.Create("rgb(255,0,0)", new Color(255, 255, 0, 0)), + Tuple.Create("rgb(0,255,0)", new Color(255, 0, 255, 0)), + Tuple.Create("rgb(0,0,255)", new Color(255, 0, 0, 255)), + + Tuple.Create("rgb(100%, 0, 0)", new Color(255, 255, 0, 0)), + Tuple.Create("rgb(0, 100%, 0)", new Color(255, 0, 255, 0)), + Tuple.Create("rgb(0, 0, 100%)", new Color(255, 0, 0, 255)), + + Tuple.Create("rgba(0, 0, 100%, 50%)", new Color(128, 0, 0, 255)), + Tuple.Create("rgba(50%, 10%, 80%, 50%)", new Color(128, 128, 26, 204)), + Tuple.Create("rgba(50%, 10%, 80%, 0.5)", new Color(128, 128, 26, 204)), // HSL Tuple.Create("hsl(296, 85%, 12%)", new Color(255, 53, 5, 57)),