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", """ + + + Q&A & R D © 2026 &bogus; + + """); + + 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; &copy")] + [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("]>")] + [InlineData("x && y")] + [InlineData("no ampersand")] + 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("cafĂ© & co"))); + [Fact] public async Task Svgz_round_trip() {