diff --git a/src/Filee.Engines/Vector/SvgRenderer.cs b/src/Filee.Engines/Vector/SvgRenderer.cs
index e43de355..3ee25d8d 100644
--- a/src/Filee.Engines/Vector/SvgRenderer.cs
+++ b/src/Filee.Engines/Vector/SvgRenderer.cs
@@ -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)
{
@@ -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);
+ }
+
/// The file's SVG text as bytes: SVGZ (or a gzipped .svg) is decompressed.
public static byte[] ReadSvgBytes(string path)
{
diff --git a/src/Filee.Engines/Vector/SvgXmlRepair.cs b/src/Filee.Engines/Vector/SvgXmlRepair.cs
new file mode 100644
index 00000000..5abdc7f1
--- /dev/null
+++ b/src/Filee.Engines/Vector/SvgXmlRepair.cs
@@ -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 , a URL query in an href) and HTML entities XML doesn't know ( , ©). 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
+{
+ /// The entities every XML document has; anything else must be declared in the DTD.
+ private static readonly HashSet XmlEntities = ["amp", "lt", "gt", "quot", "apos"];
+
+ ///
+ /// Returns with every "&" 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 &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).
+ ///
+ 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
+ }
+
+ /// Appends to repaired; true if anything changed.
+ private static bool Repair(ReadOnlySpan part, HashSet 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("&").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(@"&(?:(?#[0-9]+;|#[xX][0-9A-Fa-f]+;)|(?[A-Za-z_][A-Za-z0-9._-]*);)?")]
+ private static partial Regex Ampersand();
+
+ [GeneratedRegex(@"||]*(?:\[.*?\])?\s*>", RegexOptions.Singleline)]
+ private static partial Regex Untouched();
+
+ [GeneratedRegex(@" 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 stopped the XML parser ("parsing EntityName").
+ var dir = fx.NewFolder();
+ var svg = Write(dir, "gemini.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("A & B", "A & B")]
+ [InlineData("R D ©", "R D ©")]
+ [InlineData("&bogus; ©", "&bogus; ©")]
+ [InlineData("", "")]
+ 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("A & B < & &")]
+ [InlineData("]>