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
23 changes: 21 additions & 2 deletions src/Filee.Engines/Vector/SvgRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,23 @@ public static Drawing Load(string path)
svg.Settings.EnableJavaScript = false;
svg.Settings.EnableExternalJavaScript = false;
SKPicture? picture;
var uri = new Uri(Path.GetFullPath(path));
try
{
using var stream = new MemoryStream(bytes, writable: false);
picture = svg.Load(stream, null, new Uri(Path.GetFullPath(path)));
picture = Parse(svg, bytes, uri);
}
catch (Exception ex) when (ex is not OutOfMemoryException && SvgXmlRepair.FixAmpersands(bytes) is { } repaired)
{
// "A & B" or   in the text (common in generated SVGs): read it again with those escaped.
try
{
picture = Parse(svg, repaired, uri);
}
catch (Exception retry) when (retry is not OutOfMemoryException)
{
svg.Dispose();
throw new InvalidDataException("The SVG file could not be read: " + ex.Message, ex);
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
Expand All @@ -75,6 +88,12 @@ public static Drawing Load(string path)
return new Drawing(svg, picture, bounds);
}

private static SKPicture? Parse(SKSvg svg, byte[] bytes, Uri baseUri)
{
using var stream = new MemoryStream(bytes, writable: false);
return svg.Load(stream, null, baseUri);
}

/// <summary>The file's SVG text as bytes: SVGZ (or a gzipped .svg) is decompressed.</summary>
public static byte[] ReadSvgBytes(string path)
{
Expand Down
94 changes: 94 additions & 0 deletions src/Filee.Engines/Vector/SvgXmlRepair.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Repairs the one mistake that makes many hand-written and AI-generated SVGs unreadable: an "&" that isn't an entity
// ("A & B" in a <text>, a URL query in an href) and HTML entities XML doesn't know (&nbsp;, &copy;). Browsers forgive
// some of it; the XML parser Svg.Skia uses stops at the first one (issue #120).

using System.Net;
using System.Text;
using System.Text.RegularExpressions;

namespace Filee.Engines.Vector;

internal static partial class SvgXmlRepair
{
/// <summary>The entities every XML document has; anything else must be declared in the DTD.</summary>
private static readonly HashSet<string> XmlEntities = ["amp", "lt", "gt", "quot", "apos"];

/// <summary>
/// Returns <paramref name="svg"/> with every "&amp;" that doesn't start a character reference or a known entity
/// escaped, and HTML named entities turned into character references. Entities declared in the document's DTD
/// (Illustrator's &amp;ns_svg;), the DOCTYPE, comments and CDATA sections are left alone. Null when nothing needed repair, or
/// when the bytes aren't UTF-8 (another declared encoding is read as it is).
/// </summary>
public static byte[]? FixAmpersands(byte[] svg)
{
if (svg.AsSpan().IndexOf((byte)'&') < 0)
return null;
string text;
try
{
text = new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(svg);
}
catch (DecoderFallbackException)
{
return null;
}

var declared = DeclaredEntity().Matches(text).Select(m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal);
var result = new StringBuilder(text.Length + 64);
var changed = false;
var position = 0;
foreach (Match skip in Untouched().Matches(text))
{
changed |= Repair(text.AsSpan(position, skip.Index - position), declared, result);
result.Append(skip.Value);
position = skip.Index + skip.Length;
}
changed |= Repair(text.AsSpan(position), declared, result);
if (!changed)
return null;
return Encoding.UTF8.GetBytes(result.ToString()); // a byte order mark was decoded as U+FEFF and stays
}

/// <summary>Appends <paramref name="part"/> to <paramref name="result"/> repaired; true if anything changed.</summary>
private static bool Repair(ReadOnlySpan<char> part, HashSet<string> declared, StringBuilder result)
{
var changed = false;
var text = part.ToString();
var last = 0;
foreach (Match amp in Ampersand().Matches(text))
{
result.Append(text, last, amp.Index - last);
last = amp.Index + amp.Length;
var name = amp.Groups["name"];
if (amp.Groups["number"].Success || name.Success && (XmlEntities.Contains(name.Value) || declared.Contains(name.Value)))
{
result.Append(amp.Value);
continue;
}
changed = true;
if (name.Success && WebUtility.HtmlDecode(amp.Value) is { } decoded && decoded != amp.Value)
{
// An HTML entity: the same character as a reference XML reads.
foreach (var rune in decoded.EnumerateRunes())
result.Append("&#").Append(rune.Value).Append(';');
}
else
{
// A lone "&", or an entity nobody defines: the "&" itself is text.
result.Append("&amp;").Append(amp.Value, 1, amp.Value.Length - 1);
}
}
result.Append(text, last, text.Length - last);
return changed;
}

// "&", then a character reference, a name with its ";", or neither (a lone "&").
[GeneratedRegex(@"&(?:(?<number>#[0-9]+;|#[xX][0-9A-Fa-f]+;)|(?<name>[A-Za-z_][A-Za-z0-9._-]*);)?")]
private static partial Regex Ampersand();

[GeneratedRegex(@"<!--.*?-->|<!\[CDATA\[.*?\]\]>|<!DOCTYPE[^\[>]*(?:\[.*?\])?\s*>", RegexOptions.Singleline)]
private static partial Regex Untouched();

[GeneratedRegex(@"<!ENTITY\s+([A-Za-z_][A-Za-z0-9._-]*)")]
private static partial Regex DeclaredEntity();
}
39 changes: 39 additions & 0 deletions tests/Filee.Engines.Tests/GraphicsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,45 @@ public async Task Svg_to_png_is_rendered_large_enough_and_keeps_transparency()
Assert.True(Pixel(flat, 3, 3).R > 245, "JPG gets the preset background");
}

[Fact]
public async Task Svg_with_a_loose_ampersand_and_html_entities_still_converts()
{
// Issue #120: generated SVGs with "A & B" in a <text> stopped the XML parser ("parsing EntityName").
var dir = fx.NewFolder();
var svg = Write(dir, "gemini.svg", """
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100">
<rect width="200" height="100" fill="#ff0000"/>
<text x="10" y="50" font-size="20">Q&A &amp; R&nbsp;D &copy; 2026 &bogus;</text>
</svg>
""");

var job = await fx.ConvertAsync([svg], new Preset { TargetFormat = "png" });

AssertDone(job);
using var png = new MagickImage(job.Outputs.Single());
Assert.True(Pixel(png, 3, 3).R > 240, "the drawing was rendered");
}

[Theory]
[InlineData("<t>A & B</t>", "<t>A &amp; B</t>")]
[InlineData("<t>R&nbsp;D &copy;</t>", "<t>R&#160;D &#169;</t>")]
[InlineData("<t>&bogus; &copy</t>", "<t>&amp;bogus; &amp;copy</t>")]
[InlineData("<a href=\"x?a=1&b=2\"/>", "<a href=\"x?a=1&amp;b=2\"/>")]
public void Loose_ampersands_are_escaped_and_html_entities_become_references(string svg, string repaired) =>
Assert.Equal(repaired, Encoding.UTF8.GetString(SvgXmlRepair.FixAmpersands(Encoding.UTF8.GetBytes(svg))!));

[Theory]
[InlineData("<t>A &amp; B &lt; &#38; &#x26;</t>")]
[InlineData("<!DOCTYPE svg [<!ENTITY ns_svg \"http://www.w3.org/2000/svg\">]><svg xmlns=\"&ns_svg;\"/>")]
[InlineData("<t><!-- a & b --><![CDATA[x && y]]></t>")]
[InlineData("<t>no ampersand</t>")]
public void Valid_xml_is_left_as_it_is(string svg) =>
Assert.Null(SvgXmlRepair.FixAmpersands(Encoding.UTF8.GetBytes(svg)));

[Fact]
public void Text_in_another_encoding_is_not_touched() =>
Assert.Null(SvgXmlRepair.FixAmpersands(Encoding.Latin1.GetBytes("<t encoding=\"latin1\">café & co</t>")));

[Fact]
public async Task Svgz_round_trip()
{
Expand Down
Loading