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
93 changes: 93 additions & 0 deletions src/Svg.Custom/Compatibility/SvgElementFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,87 @@ private static bool IsStyleAttribute(string name)

return false;
}

private static bool IsOpacityAttribute(string name)
{
switch (name)
{
case "fill-opacity":
case "flood-opacity":
case "opacity":
case "stop-opacity":
case "stroke-opacity":
return true;
}

return false;
}

private static bool IsNonInheritedOpacityAttribute(string name)
{
return name == "opacity";
}

private static ReadOnlySpan<char> TrimWhitespace(ReadOnlySpan<char> value)
{
#if NETSTANDARD20
var start = 0;
while (start < value.Length && char.IsWhiteSpace(value[start]))
{
start++;
}

var end = value.Length;
while (end > start && char.IsWhiteSpace(value[end - 1]))
{
end--;
}

return value.Slice(start, end - start);
#else
return value.Trim();
#endif
}

private static bool TryParseInvariantFloat(ReadOnlySpan<char> value, out float parsed)
{
#if NETSTANDARD20
return float.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out parsed);
#else
return float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out parsed);
#endif
}

private static bool TryHandlePercentageOpacityAttribute(string attributeName, string attributeValue, out string normalizedValue)
{
normalizedValue = attributeValue;

if (!IsOpacityAttribute(attributeName) || string.IsNullOrWhiteSpace(attributeValue))
{
return false;
}

var trimmedValue = TrimWhitespace(attributeValue.AsSpan());
if (trimmedValue.Length == 0 || trimmedValue[trimmedValue.Length - 1] != '%')
{
return false;
}

var percentageValue = TrimWhitespace(trimmedValue.Slice(0, trimmedValue.Length - 1));
if (percentageValue.Length == 0 || !TryParseInvariantFloat(percentageValue, out var parsedPercentage))
{
return true;
}

if (parsedPercentage == 100f && IsNonInheritedOpacityAttribute(attributeName))
{
normalizedValue = "1";
return false;
}

return true;
}

internal static bool SetPropertyValue(
SvgElement element,
string ns,
Expand Down Expand Up @@ -314,6 +395,18 @@ internal static bool SetPropertyValue(
{
attributeValue = "1";
}

if (TryHandlePercentageOpacityAttribute(attributeName, attributeValue, out var normalizedOpacityValue))
{
// SVG 1.1 opacity properties are numeric, not percentages. Treat percentage tokens
// as invalid declarations, except for the non-inherited "opacity" property where
// the browser-authored "100%" case can be normalized to the default value without
// overriding inherited paint-opacity state.
return true;
}

attributeValue = normalizedOpacityValue;

var setValueResult = element.SetValue(attributeName, document, CultureInfo.InvariantCulture, attributeValue);
if (setValueResult)
{
Expand Down
14 changes: 14 additions & 0 deletions tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgSourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ namespace Avalonia.Svg.Skia.UnitTests;
public class SvgSourceTests
{
private const string SampleSvg = "<svg width=\"10\" height=\"10\"><rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" fill=\"red\" /></svg>";
private const string PercentageOpacitySvg = """
<svg xmlns="http://www.w3.org/2000/svg" height="40" width="40" viewBox="0 0 24 24" fill="#fff" x="0" y="0" opacity="100%">
<path d="m19 1-5 5v11l5-4.5V1zM1 6v14.65c0 .25.25.5.5.5.1 0 .15-.05.25-.05C3.1 20.45 5.05 20 6.5 20c1.95 0 4.05.4 5.5 1.5V6c-1.45-1.1-3.55-1.5-5.5-1.5S2.45 4.9 1 6zm22 13.5V6c-.6-.45-1.25-.75-2-1v13.5c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5v2c1.35-.85 3.8-1.5 5.5-1.5 1.65 0 3.35.3 4.75 1.05.1.05.15.05.25.05.25 0 .5-.25.5-.5v-1.1z"/>
</svg>
""";
private const string JavaScriptMutationSvg = """
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
<rect id="target" width="10" height="10" fill="red" />
Expand Down Expand Up @@ -51,6 +56,15 @@ public void LoadFromSvg_SetsSvg()
Assert.NotNull(source.Picture);
}

[AvaloniaFact]
public void LoadFromSvg_WithPercentageOpacity_DoesNotThrow()
{
using var source = SvgSource.LoadFromSvg(PercentageOpacitySvg);

Assert.NotNull(source.Svg);
Assert.NotNull(source.Picture);
}

[AvaloniaFact]
public void LoadFromSvg_UsesSvgFontsByDefault()
{
Expand Down
58 changes: 58 additions & 0 deletions tests/Svg.Skia.UnitTests/SvgDocumentCompatibilityLoaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,64 @@ public void FromSvg_ParsesInlineStyleAttributesWithEmptyDeclarationsAndWhitespac
Assert.Equal(Color.Blue.ToArgb(), stroke.Colour.ToArgb());
}

[Fact]
public void FromSvg_TreatsOpacity100PercentPresentationAttributeAsDefaultOpacity()
{
const string svg = """
<svg xmlns="http://www.w3.org/2000/svg" opacity="100%">
<rect width="10" height="10" fill="green" />
</svg>
""";

var document = SvgDocumentCompatibilityLoader.FromSvg<SvgDocument>(svg);

Assert.Equal(1f, document.Opacity, 3);
}

[Fact]
public void FromSvg_IgnoresInvalidPercentageOpacityInlineStyles()
{
const string svg = """
<svg xmlns="http://www.w3.org/2000/svg" style="opacity: 0.1%">
<rect id="target"
width="10"
height="10"
stroke="#000000"
style="fill-opacity: 50%; stroke-opacity: 25%" />
</svg>
""";

var document = SvgDocumentCompatibilityLoader.FromSvg<SvgDocument>(svg);
var rect = document.Descendants().OfType<SvgRectangle>().Single(static element => element.ID == "target");

Assert.Equal(1f, document.Opacity, 3);
Assert.Equal(1f, rect.FillOpacity, 3);
Assert.Equal(1f, rect.StrokeOpacity, 3);
}

[Fact]
public void FromSvg_IgnoresPercentagePaintOpacityAttributesAndPreservesInheritance()
{
const string svg = """
<svg xmlns="http://www.w3.org/2000/svg">
<g fill-opacity="0.3" stroke-opacity="0.4">
<rect id="target"
width="10"
height="10"
stroke="#000000"
fill-opacity="100%"
stroke-opacity="100%" />
</g>
</svg>
""";

var document = SvgDocumentCompatibilityLoader.FromSvg<SvgDocument>(svg);
var rect = document.Descendants().OfType<SvgRectangle>().Single(static element => element.ID == "target");

Assert.Equal(0.3f, rect.FillOpacity, 3);
Assert.Equal(0.4f, rect.StrokeOpacity, 3);
}

private static LoadResult CaptureLoad(Func<SvgDocument> load)
{
try
Expand Down
Loading