From 536a634adeadc40c3c1eddafc886e92b6d1312e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 00:30:26 +0200 Subject: [PATCH 1/5] Fix CSS alpha hex paint parsing --- .../Painting/SvgPaintServerFactory.cs | 115 +++++++++++++++++- 1 file changed, 113 insertions(+), 2 deletions(-) diff --git a/src/Svg.Custom/Painting/SvgPaintServerFactory.cs b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs index b1f6751f01..ef10a59656 100644 --- a/src/Svg.Custom/Painting/SvgPaintServerFactory.cs +++ b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs @@ -66,8 +66,16 @@ public static SvgPaintServer Create(string value, SvgDocument document) return new SvgDeferredPaintServer(document, id, fallbackServer); } - // Otherwise try and parse as colour - return new SvgColourServer((Color)_colourConverter.ConvertFrom(colorValue)); + // Otherwise try and parse as colour. SvgColourConverter only accepts SVG 1.1 + // hex colours (#RGB/#RRGGBB); support CSS Color 4 alpha forms here because + // CSS style declarations can legally feed #RGBA/#RRGGBBAA into this factory. + if (TryParseHexColorWithAlpha(colorValue, out var hexColor)) + { + return new SvgColourServer(hexColor); + } + + var converted = _colourConverter.ConvertFrom(colorValue); + return converted is Color color ? new SvgColourServer(color) : SvgPaintServer.NotSet; } public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) @@ -109,6 +117,11 @@ public override object ConvertTo(ITypeDescriptorContext context, System.Globaliz var colourServer = value as SvgColourServer; if (colourServer != null) { + if (colourServer.Colour.A != 255) + { + return FormatHexColor(colourServer.Colour); + } + return new SvgColourConverter().ConvertTo(colourServer.Colour, typeof(string)); } @@ -130,5 +143,103 @@ public override object ConvertTo(ITypeDescriptorContext context, System.Globaliz return base.ConvertTo(context, culture, value, destinationType); } + + private static bool TryParseHexColorWithAlpha(string value, out Color color) + { + color = Color.Empty; + + if (value.Length != 5 && value.Length != 9) + { + return false; + } + + if (value[0] != '#') + { + return false; + } + + if (value.Length == 5) + { + var red = FromHex(value[1]); + var green = FromHex(value[2]); + var blue = FromHex(value[3]); + var alpha = FromHex(value[4]); + + if (red < 0 || green < 0 || blue < 0 || alpha < 0) + { + return false; + } + + color = Color.FromArgb( + ExpandHex(alpha), + ExpandHex(red), + ExpandHex(green), + ExpandHex(blue)); + return true; + } + + if (!TryParseHexByte(value, 1, out var r) || + !TryParseHexByte(value, 3, out var g) || + !TryParseHexByte(value, 5, out var b) || + !TryParseHexByte(value, 7, out var a)) + { + return false; + } + + color = Color.FromArgb(a, r, g, b); + return true; + } + + private static bool TryParseHexByte(string value, int index, out byte component) + { + component = 0; + + var high = FromHex(value[index]); + var low = FromHex(value[index + 1]); + + if (high < 0 || low < 0) + { + return false; + } + + component = (byte)((high << 4) | low); + return true; + } + + private static byte ExpandHex(int value) + { + return (byte)((value << 4) | value); + } + + private static int FromHex(char value) + { + if (value >= '0' && value <= '9') + { + return value - '0'; + } + + if (value >= 'A' && value <= 'F') + { + return value - 'A' + 10; + } + + if (value >= 'a' && value <= 'f') + { + return value - 'a' + 10; + } + + return -1; + } + + private static string FormatHexColor(Color color) + { + return string.Format( + CultureInfo.InvariantCulture, + "#{0:X2}{1:X2}{2:X2}{3:X2}", + color.R, + color.G, + color.B, + color.A); + } } } From 88e26020f861f465b32b0806899c85f22351359e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 00:30:30 +0200 Subject: [PATCH 2/5] Add alpha hex color regression tests --- tests/Svg.Skia.UnitTests/SKSvgTests.cs | 26 +++++++++++++ .../SvgDocumentCompatibilityLoaderTests.cs | 37 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/tests/Svg.Skia.UnitTests/SKSvgTests.cs b/tests/Svg.Skia.UnitTests/SKSvgTests.cs index 5ef8be8b52..cabf0cf727 100644 --- a/tests/Svg.Skia.UnitTests/SKSvgTests.cs +++ b/tests/Svg.Skia.UnitTests/SKSvgTests.cs @@ -153,6 +153,32 @@ public void Save_InheritedCurrentColor_UsesConsumingElementsColor() Assert.Equal((byte)255, pixel.A); } + [Fact] + public void Save_CssHexAlphaFill_RendersAlpha() + { + const string svgMarkup = """ + + + + + """; + + var svg = new SKSvg(); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(svgMarkup)); + using var _ = svg.Load(input); + using var output = new MemoryStream(); + + Assert.True(svg.Save(output, SkiaSharp.SKColors.Transparent)); + + output.Position = 0; + using var image = Image.Load(output); + var pixel = image[5, 5]; + Assert.InRange(pixel.R, (byte)0x10, (byte)0x12); + Assert.InRange(pixel.G, (byte)0x21, (byte)0x23); + Assert.InRange(pixel.B, (byte)0x32, (byte)0x34); + Assert.Equal((byte)0x80, pixel.A); + } + [Fact] public void Load_CssFontFaceWithExternalFontUrl_DoesNotCrash() { diff --git a/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs b/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs index ba780cdf04..f56d56f569 100644 --- a/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs @@ -155,6 +155,43 @@ public void FromSvg_DetachesSyntheticCssQueryRootAfterApplyingStyles() Assert.Equal(Color.Green.ToArgb(), fill.Colour.ToArgb()); } + [Theory] + [InlineData("#AABBCC80", 0xAA, 0xBB, 0xCC, 0x80)] + [InlineData("#abc8", 0xAA, 0xBB, 0xCC, 0x88)] + public void FromSvg_ParsesCssHexAlphaFillColors(string fillValue, int red, int green, int blue, int alpha) + { + var svg = $$""" + + + + + """; + + var document = SvgDocumentCompatibilityLoader.FromSvg(svg); + var rect = document.Descendants().OfType().Single(static element => element.ID == "target"); + var fill = Assert.IsType(rect.Fill); + + Assert.Equal(Color.FromArgb(alpha, red, green, blue).ToArgb(), fill.Colour.ToArgb()); + } + + [Theory] + [InlineData("#AABBCC80", 0xAA, 0xBB, 0xCC, 0x80)] + [InlineData("#abc8", 0xAA, 0xBB, 0xCC, 0x88)] + public void FromSvg_ParsesAttributeHexAlphaFillColors(string fillValue, int red, int green, int blue, int alpha) + { + var svg = $$""" + + + + """; + + var document = SvgDocumentCompatibilityLoader.FromSvg(svg); + var rect = document.Descendants().OfType().Single(static element => element.ID == "target"); + var fill = Assert.IsType(rect.Fill); + + Assert.Equal(Color.FromArgb(alpha, red, green, blue).ToArgb(), fill.Colour.ToArgb()); + } + [Fact] public void FromSvg_AggregatesMixedTextAndChildContentInDocumentOrder() { From 0a01d5e0e2902ce17366805fe790dfce89406b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 07:13:50 +0200 Subject: [PATCH 3/5] Fix animation color sentinel handling --- src/Svg.Animation/Animation/SvgAnimationController.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Svg.Animation/Animation/SvgAnimationController.cs b/src/Svg.Animation/Animation/SvgAnimationController.cs index 6994ca62df..229b3ba630 100644 --- a/src/Svg.Animation/Animation/SvgAnimationController.cs +++ b/src/Svg.Animation/Animation/SvgAnimationController.cs @@ -2646,6 +2646,14 @@ private static bool TryGetColor(string value, out Color color) private static bool TryGetColor(SvgPaintServer? paintServer, out Color color) { + if (paintServer == SvgPaintServer.None || + paintServer == SvgPaintServer.Inherit || + paintServer == SvgPaintServer.NotSet) + { + color = default; + return false; + } + if (paintServer is SvgColourServer colourServer) { color = colourServer.Colour; From af561f94d39672783c785f1ef195878414e702f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 9 May 2026 18:35:33 +0200 Subject: [PATCH 4/5] Address PR paint parsing feedback --- .../Animation/SvgAnimationController.cs | 30 ++++++++++++++----- .../Painting/SvgPaintServerFactory.cs | 4 ++- .../SvgAnimationControllerTests.cs | 29 ++++++++++++++++++ .../SvgDocumentCompatibilityLoaderTests.cs | 20 +++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/Svg.Animation/Animation/SvgAnimationController.cs b/src/Svg.Animation/Animation/SvgAnimationController.cs index 5a5e994d38..2166aeaa66 100644 --- a/src/Svg.Animation/Animation/SvgAnimationController.cs +++ b/src/Svg.Animation/Animation/SvgAnimationController.cs @@ -2504,11 +2504,11 @@ private static bool TryApplyAccumulation(AnimationBinding binding, SvgAnimationV TryGetColor(endValue, out var endColor) && TryGetColor(value, out var currentColor)) { - value = new SvgColourServer(Color.FromArgb( + value = FormatColor(Color.FromArgb( ClampToByte(currentColor.A + ((endColor.A - startColor.A) * sample.IterationIndex)), ClampToByte(currentColor.R + ((endColor.R - startColor.R) * sample.IterationIndex)), ClampToByte(currentColor.G + ((endColor.G - startColor.G) * sample.IterationIndex)), - ClampToByte(currentColor.B + ((endColor.B - startColor.B) * sample.IterationIndex)))).ToString(); + ClampToByte(currentColor.B + ((endColor.B - startColor.B) * sample.IterationIndex)))); return true; } @@ -3148,10 +3148,26 @@ private static bool TryInterpolateColor(Color fromColor, Color toColor, float pr ClampToByte(Lerp(fromColor.G, toColor.G, progress)), ClampToByte(Lerp(fromColor.B, toColor.B, progress))); - result = new SvgColourServer(color).ToString(); + result = FormatColor(color); return true; } + private static string FormatColor(Color color) + { + if (color.A == byte.MaxValue) + { + return new SvgColourServer(color).ToString(); + } + + return string.Format( + CultureInfo.InvariantCulture, + "#{0:x2}{1:x2}{2:x2}{3:x2}", + color.R, + color.G, + color.B, + color.A); + } + private static byte ClampToByte(float value) { if (value <= byte.MinValue) @@ -3240,11 +3256,11 @@ byUnitObject is SvgUnit byUnit && if (TryGetColor(baseValue, out var baseColor) && TryGetColor(byValue, out var byColor)) { - result = new SvgColourServer(Color.FromArgb( + result = FormatColor(Color.FromArgb( ClampToByte(baseColor.A + byColor.A), ClampToByte(baseColor.R + byColor.R), ClampToByte(baseColor.G + byColor.G), - ClampToByte(baseColor.B + byColor.B))).ToString(); + ClampToByte(baseColor.B + byColor.B))); return true; } @@ -3339,11 +3355,11 @@ private static bool TryAddTypedValue(object? baseObject, object? byObject, out s case SvgPaintServer basePaint when byObject is SvgPaintServer byPaint: if (TryGetColor(basePaint, out var baseColor) && TryGetColor(byPaint, out var byColor)) { - result = new SvgColourServer(Color.FromArgb( + result = FormatColor(Color.FromArgb( ClampToByte(baseColor.A + byColor.A), ClampToByte(baseColor.R + byColor.R), ClampToByte(baseColor.G + byColor.G), - ClampToByte(baseColor.B + byColor.B))).ToString(); + ClampToByte(baseColor.B + byColor.B))); return true; } diff --git a/src/Svg.Custom/Painting/SvgPaintServerFactory.cs b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs index 7af9372dff..7ae2b63bdc 100644 --- a/src/Svg.Custom/Painting/SvgPaintServerFactory.cs +++ b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs @@ -80,7 +80,9 @@ public static SvgPaintServer Create(string value, SvgDocument document) } var converted = _colourConverter.ConvertFrom(colorValue); - return converted is Color color ? new SvgColourServer(color) : SvgPaintServer.NotSet; + return converted is Color color + ? new SvgColourServer(color) + : throw new InvalidCastException(); } public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) diff --git a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs index 391b075c04..e5fe63cb49 100644 --- a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs @@ -294,6 +294,24 @@ public void CreateAnimatedDocument_AppliesInheritedCssAnimationsWhenWhitespaceCs Assert.InRange(fill.Colour.B, (byte)127, (byte)128); } + [Fact] + public void CreateAnimatedDocument_PreservesAlphaWhenInterpolatingHexAlphaPaint() + { + var document = SvgService.FromSvg(HexAlphaPaintAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var target = animated.GetElementById("target"); + Assert.NotNull(target); + + var fill = Assert.IsType(target!.Fill); + Assert.Equal((byte)0, fill.Colour.R); + Assert.Equal((byte)0, fill.Colour.G); + Assert.Equal((byte)0, fill.Colour.B); + Assert.Equal((byte)64, fill.Colour.A); + } + [Fact] public void SetAnimationTime_RebuildsRootViewBoxAnimations() { @@ -1097,6 +1115,17 @@ private static string GetW3CTestSvgPath(string name) """; + private const string HexAlphaPaintAnimationSvg = """ + + + + + + """; + private const string TopLevelLayeredAnimationSvg = """ + + + + + """; + + var document = SvgDocumentCompatibilityLoader.FromSvg(svg); + var rect = document.Descendants().OfType().Single(static element => element.ID == "target"); + var fill = Assert.IsType(rect.Fill); + + Assert.Equal(Color.Lime.ToArgb(), fill.Colour.ToArgb()); + } + [Fact] public void FromSvg_AggregatesMixedTextAndChildContentInDocumentOrder() { From 7370923de79c906f8ffcf38236c6cc38c4f83b33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 9 May 2026 18:48:59 +0200 Subject: [PATCH 5/5] Ignore invalid CSS paint declarations --- .../SvgCssCompatibilityProcessor.cs | 8 +++ .../SvgCssPaintDeclarationValidator.cs | 58 +++++++++++++++++++ .../SvgInlineStyleAttributeParser.cs | 5 ++ .../SvgDocumentCompatibilityLoaderTests.cs | 39 +++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 src/Svg.Custom/Compatibility/SvgCssPaintDeclarationValidator.cs diff --git a/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs b/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs index 686a109bfb..fab39621b5 100644 --- a/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs +++ b/src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs @@ -544,6 +544,14 @@ private static void ApplyDeclaration(SvgElement element, AppliedDeclaration decl return; } + if (SvgCssPaintDeclarationValidator.ShouldIgnoreInvalidPaintDeclaration( + element, + declaration.Name, + declaration.Value)) + { + return; + } + element.AddStyle(declaration.Name, declaration.Value, specificity); } diff --git a/src/Svg.Custom/Compatibility/SvgCssPaintDeclarationValidator.cs b/src/Svg.Custom/Compatibility/SvgCssPaintDeclarationValidator.cs new file mode 100644 index 0000000000..2a2d0def28 --- /dev/null +++ b/src/Svg.Custom/Compatibility/SvgCssPaintDeclarationValidator.cs @@ -0,0 +1,58 @@ +#nullable enable + +using System; + +namespace Svg; + +internal static class SvgCssPaintDeclarationValidator +{ + public static bool ShouldIgnoreInvalidPaintDeclaration(SvgElement element, string name, string value) + { + if (!IsPaintProperty(name) || + ContainsVarFunction(value) || + IsCssWideKeyword(value)) + { + return false; + } + + try + { + _ = SvgPaintServerFactory.Create(value, element.OwnerDocument); + return false; + } + catch (Exception ex) when (IsPaintConversionFailure(ex)) + { + return true; + } + } + + private static bool IsPaintProperty(string name) + { + return name.Equals("color", StringComparison.OrdinalIgnoreCase) || + name.Equals("fill", StringComparison.OrdinalIgnoreCase) || + name.Equals("flood-color", StringComparison.OrdinalIgnoreCase) || + name.Equals("lighting-color", StringComparison.OrdinalIgnoreCase) || + name.Equals("stop-color", StringComparison.OrdinalIgnoreCase) || + name.Equals("stroke", StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsVarFunction(string value) + { + return value.IndexOf("var(", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool IsCssWideKeyword(string value) + { + var trimmed = value.Trim(); + return trimmed.Equals("initial", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("inherit", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("unset", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("revert", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("revert-layer", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPaintConversionFailure(Exception exception) + { + return exception is ArgumentException or FormatException or InvalidCastException or NotSupportedException; + } +} diff --git a/src/Svg.Custom/Compatibility/SvgInlineStyleAttributeParser.cs b/src/Svg.Custom/Compatibility/SvgInlineStyleAttributeParser.cs index c6892dfd35..d37ec3e4a6 100644 --- a/src/Svg.Custom/Compatibility/SvgInlineStyleAttributeParser.cs +++ b/src/Svg.Custom/Compatibility/SvgInlineStyleAttributeParser.cs @@ -67,6 +67,11 @@ private static void ApplyDeclaration(SvgElement element, string name, string val return; } + if (SvgCssPaintDeclarationValidator.ShouldIgnoreInvalidPaintDeclaration(element, name, value)) + { + return; + } + element.AddStyle(name, value, specificity); } diff --git a/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs b/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs index 86a803527b..cddd2a0029 100644 --- a/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs @@ -212,6 +212,45 @@ public void FromSvg_InvalidPaintValuesDoNotOverrideInheritedFill(string fillValu Assert.Equal(Color.Lime.ToArgb(), fill.Colour.ToArgb()); } + [Theory] + [InlineData("style=\"fill: lime; fill: 123\"")] + [InlineData("style=\"fill: lime; fill: #12345\"")] + public void FromSvg_InvalidInlinePaintDeclarationsDoNotOverrideEarlierFill(string styleAttribute) + { + var svg = $$""" + + + + """; + + var document = SvgDocumentCompatibilityLoader.FromSvg(svg); + var rect = document.Descendants().OfType().Single(static element => element.ID == "target"); + var fill = Assert.IsType(rect.Fill); + + Assert.Equal(Color.Lime.ToArgb(), fill.Colour.ToArgb()); + } + + [Theory] + [InlineData("#target { fill: lime; } #target { fill: 123; }")] + [InlineData("#target { fill: lime; } #target { fill: #12345; }")] + [InlineData("rect { fill: lime; } #target { fill: 123; }")] + [InlineData("rect { fill: lime; } #target { fill: #12345; }")] + public void FromSvg_InvalidStylesheetPaintDeclarationsDoNotOverrideEarlierFill(string css) + { + var svg = $$""" + + + + + """; + + var document = SvgDocumentCompatibilityLoader.FromSvg(svg); + var rect = document.Descendants().OfType().Single(static element => element.ID == "target"); + var fill = Assert.IsType(rect.Fill); + + Assert.Equal(Color.Lime.ToArgb(), fill.Colour.ToArgb()); + } + [Fact] public void FromSvg_AggregatesMixedTextAndChildContentInDocumentOrder() {