Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions src/Svg.Animation/Animation/SvgAnimationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
8 changes: 8 additions & 0 deletions src/Svg.Custom/Compatibility/SvgCssCompatibilityProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
58 changes: 58 additions & 0 deletions src/Svg.Custom/Compatibility/SvgCssPaintDeclarationValidator.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions src/Svg.Custom/Compatibility/SvgInlineStyleAttributeParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
117 changes: 115 additions & 2 deletions src/Svg.Custom/Painting/SvgPaintServerFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
return SvgPaintServer.None;
else if (colorValue.Equals("currentColor", StringComparison.OrdinalIgnoreCase))
// Keep the parse-time document for consistency with url(...) paint servers.
return new SvgDeferredPaintServer(document, "currentColor");

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Build (macos-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Build (macos-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Build MAUI (macos-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Build MAUI (macos-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Build (ubuntu-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Pack MAUI (macos-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Build (windows-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Build (windows-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Test (macos-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Test (macos-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Test (windows-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Pack (ubuntu-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'

Check warning on line 49 in src/Svg.Custom/Painting/SvgPaintServerFactory.cs

View workflow job for this annotation

GitHub Actions / Pack (ubuntu-latest)

'SvgDeferredPaintServer.SvgDeferredPaintServer(SvgDocument, string)' is obsolete: 'Will be removed.'
else if (colorValue.Equals("inherit", StringComparison.OrdinalIgnoreCase))
return SvgPaintServer.Inherit;
else if (colorValue.StartsWith("url(", StringComparison.OrdinalIgnoreCase))
Expand All @@ -71,8 +71,18 @@
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve alpha when interpolating parsed hex colors

When a SMIL animation uses #RGBA/#RRGGBBAA for fill or stroke values, this branch now lets the animation code treat those values as interpolatable colors. The interpolation path later serializes with new SvgColourServer(color).ToString() in SvgAnimationController.TryInterpolateColor, but SvgColourServer.ToString() formats ToArgb().Substring(2), dropping alpha; for example, animating #0000 to #0000 writes #000000 and renders opaque black instead of transparent. Please serialize interpolated colors through the paint converter or another alpha-preserving format when A != 255.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in af561f94d39672783c785f1ef195878414e702f5 (Address PR paint parsing feedback).

SvgAnimationController now serializes synthesized animation colors through a new FormatColor helper. Opaque colors keep the existing SvgColourServer.ToString() output, while colors with alpha are emitted as #RRGGBBAA, which the paint converter can parse back without losing alpha. I replaced the interpolation, additive, and accumulation color paths that previously used new SvgColourServer(color).ToString() and therefore dropped alpha.

I added SvgAnimationControllerTests.CreateAnimatedDocument_PreservesAlphaWhenInterpolatingHexAlphaPaint, which animates fill from #00000000 to #00000080 and asserts the midpoint alpha is 64, not opaque black.

}

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)
Expand Down Expand Up @@ -114,6 +124,11 @@
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));
}

Expand All @@ -135,5 +150,103 @@

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);
}
}
}
26 changes: 26 additions & 0 deletions tests/Svg.Skia.UnitTests/SKSvgTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,32 @@ public void Save_InheritedCurrentColor_UsesConsumingElementsColor()
Assert.Equal((byte)255, pixel.A);
}

[Fact]
public void Save_CssHexAlphaFill_RendersAlpha()
{
const string svgMarkup = """
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
<style>#target { fill: #11223380; }</style>
<rect id="target" width="10" height="10" />
</svg>
""";

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<Rgba32>(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()
{
Expand Down
29 changes: 29 additions & 0 deletions tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SvgRectangle>("target");
Assert.NotNull(target);

var fill = Assert.IsType<SvgColourServer>(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()
{
Expand Down Expand Up @@ -1097,6 +1115,17 @@ private static string GetW3CTestSvgPath(string name)
</svg>
""";

private const string HexAlphaPaintAnimationSvg = """
<svg xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 10 10">
<rect id="target" x="0" y="0" width="10" height="10" fill="#00000000">
<animate attributeName="fill" from="#00000000" to="#00000080" dur="2s" fill="freeze" />
</rect>
</svg>
""";

private const string TopLevelLayeredAnimationSvg = """
<svg xmlns="http://www.w3.org/2000/svg"
width="40"
Expand Down
Loading
Loading