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() {