diff --git a/src/Svg.Animation/Animation/SvgAnimationController.cs b/src/Svg.Animation/Animation/SvgAnimationController.cs
index 5d8e90f88e..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)
@@ -3189,6 +3205,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;
@@ -3232,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;
}
@@ -3331,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/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/src/Svg.Custom/Painting/SvgPaintServerFactory.cs b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs
index b310dcc766..7ae2b63bdc 100644
--- a/src/Svg.Custom/Painting/SvgPaintServerFactory.cs
+++ b/src/Svg.Custom/Painting/SvgPaintServerFactory.cs
@@ -71,8 +71,18 @@ 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)
+ : throw new InvalidCastException();
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
@@ -114,6 +124,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));
}
@@ -135,5 +150,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);
+ }
}
}
diff --git a/tests/Svg.Skia.UnitTests/SKSvgTests.cs b/tests/Svg.Skia.UnitTests/SKSvgTests.cs
index 00eac983be..828b8715b8 100644
--- a/tests/Svg.Skia.UnitTests/SKSvgTests.cs
+++ b/tests/Svg.Skia.UnitTests/SKSvgTests.cs
@@ -269,6 +269,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 Save_DownscaledMorphologyFilter_RetainsSubpixelInsideStroke()
{
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.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());
+ }
+
+ [Theory]
+ [InlineData("123")]
+ [InlineData("#12345")]
+ public void FromSvg_InvalidPaintValuesDoNotOverrideInheritedFill(string fillValue)
+ {
+ 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("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()
{