diff --git a/src/Filee.App/Services/BrowserDrags.cs b/src/Filee.App/Services/BrowserDrags.cs index e663d53e..7a29a0f5 100644 --- a/src/Filee.App/Services/BrowserDrags.cs +++ b/src/Filee.App/Services/BrowserDrags.cs @@ -22,7 +22,7 @@ internal static class BrowserDrags BrowserDragFormats.UniformResourceLocatorW, BrowserDragFormats.UniformResourceLocator, BrowserDragFormats.MozUrl, BrowserDragFormats.NetscapeUrl, BrowserDragFormats.UriList, BrowserDragFormats.DownloadUrlFormat, BrowserDragFormats.MozFilePromiseUrl, BrowserDragFormats.MozFilePromiseName, BrowserDragFormats.MacUrl, - BrowserDragFormats.MacUrlName, BrowserDragFormats.Html, BrowserDragFormats.WindowsHtml, BrowserDragFormats.MacHtml, + BrowserDragFormats.MacUrlName, BrowserDragFormats.Html, BrowserDragFormats.WindowsHtml, BrowserDragFormats.MacHtml, BrowserDragFormats.PlainText, ]; /// Picture data some browsers put on a drag (macOS, X11), with the extension it is saved with. @@ -39,7 +39,8 @@ private static readonly (string Format, string Extension)[] PictureFormats = public static bool MayOffer(IDataTransfer data) => data.Formats.Any(f => f.Kind == DataFormatKind.Platform && (BrowserDragFormats.FileGroupDescriptorW.Equals(f.Identifier, StringComparison.OrdinalIgnoreCase) - || BrowserDragContent.AddressFormatNames.Contains(f.Identifier, StringComparer.OrdinalIgnoreCase))); + || BrowserDragContent.AddressFormatNames.Contains(f.Identifier, StringComparer.OrdinalIgnoreCase))) + || InlineSvg.Read(data.TryGetText()) is not null; /// /// The files of a drag from a browser (or another app that holds them only in memory), standing as placeholders; @@ -96,7 +97,7 @@ public static bool MayOffer(IDataTransfer data) => files.Add(WebDropItem.FromStream(entry.Name, _ => throw new IOException("The file's contents weren't handed over."))); } - if (files.Count == 0 && !hasAddress) + if (files.Count == 0 && !hasAddress && !texts.ContainsKey(BrowserDragFormats.PlainText)) return null; return new BrowserDragContent { Files = files, Texts = texts, Picture = Picture(data, texts) }; } @@ -120,6 +121,8 @@ internal static bool IsTemporary(string path) private static Dictionary Texts(IDataTransfer data) { var texts = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (data.TryGetText() is { } plain) + texts[BrowserDragFormats.PlainText] = plain; foreach (var item in data.Items) { foreach (var format in item.Formats) diff --git a/src/Filee.Core/Web/BrowserDrag.cs b/src/Filee.Core/Web/BrowserDrag.cs index 256c7795..cbac9c73 100644 --- a/src/Filee.Core/Web/BrowserDrag.cs +++ b/src/Filee.Core/Web/BrowserDrag.cs @@ -9,7 +9,7 @@ // (".html", ".php", no extension). The server's answer is checked again when downloading. // 5. Nothing else, but the drag carries the picture's pixels (a blob: address Filee can't fetch): those, as PNG. // -// Plain text is never read: a selected address is text, not a file. +// A complete standalone SVG text selection is a picture too. Other plain text is not a file. using Filee.Core.Formats; @@ -61,6 +61,9 @@ public static IReadOnlyList Files(BrowserDragContent content) if (handed.Count > 0) return handed; + if (InlineSvg.Read(content.Text(BrowserDragFormats.PlainText)) is { } svg) + return [WebDropItem.FromInlineSvg(svg)]; + var picture = content.Picture is not null; var html = content.Text(BrowserDragFormats.Html) ?? content.Text(BrowserDragFormats.WindowsHtml) ?? content.Text(BrowserDragFormats.MacHtml); if (BrowserDragFormats.ImageOnly(html) is { } src && Address(src, image: true, Base(content)) is { } image) diff --git a/src/Filee.Core/Web/BrowserDragFormats.cs b/src/Filee.Core/Web/BrowserDragFormats.cs index c8a8a34c..8bf912a8 100644 --- a/src/Filee.Core/Web/BrowserDragFormats.cs +++ b/src/Filee.Core/Web/BrowserDragFormats.cs @@ -44,6 +44,7 @@ public static partial class BrowserDragFormats public const string Html = "text/html"; public const string WindowsHtml = "HTML Format"; public const string MacHtml = "public.html"; + public const string PlainText = "text/plain"; // FILEDESCRIPTORW: dwFlags, clsid, sizel, pointl, dwFileAttributes, three FILETIMEs, nFileSizeHigh/Low, // cFileName[MAX_PATH] (UTF-16) = 592 bytes, after a 4-byte count. diff --git a/src/Filee.Core/Web/InlineSvg.cs b/src/Filee.Core/Web/InlineSvg.cs new file mode 100644 index 00000000..9b01523e --- /dev/null +++ b/src/Filee.Core/Web/InlineSvg.cs @@ -0,0 +1,103 @@ +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace Filee.Core.Web; + +/// A complete standalone SVG selection, never HTML extraction or entity decoding. +public static partial class InlineSvg +{ + public const int MaxCharacters = 4 * 1024 * 1024; + private const string SvgNamespace = "http://www.w3.org/2000/svg"; + + public static byte[]? Read(string? source) + { + if (string.IsNullOrWhiteSpace(source) || source.Length > MaxCharacters) + return null; + source = source.Trim(); + if (!source.StartsWith(" node != root && (node is not XText text || !string.IsNullOrWhiteSpace(text.Value))) + || (root.Name.NamespaceName.Length != 0 && root.Name.NamespaceName != SvgNamespace)) + return null; + // HTML selections may omit xmlns; standalone converters need the SVG namespace. + if (root.Name.NamespaceName.Length == 0) + { + foreach (var element in root.DescendantsAndSelf().Where(e => e.Name.NamespaceName.Length == 0)) + element.Name = XName.Get(element.Name.LocalName, SvgNamespace); + root.SetAttributeValue("xmlns", SvgNamespace); + } + return Encoding.UTF8.GetBytes(root.ToString(SaveOptions.DisableFormatting)); + } + catch (XmlException) + { + return null; + } + } + + /// Selections have no base folder or page stylesheet; missing resources become job errors. + public static void EnsureSelfContained(byte[] svg) + { + var root = XElement.Parse(Encoding.UTF8.GetString(svg)); + foreach (var element in root.DescendantsAndSelf()) + { + foreach (var attribute in element.Attributes()) + { + if (attribute.Name.LocalName == "href" && element.Name.LocalName != "a" + || attribute.Name == XNamespace.Xml + "base") + CheckReference(attribute.Value); + CheckCss(attribute.Value); + } + if (element.Name.LocalName == "style") + CheckCss(element.Value); + } + if (root.DescendantNodes().OfType().Any(p => p.Target == "xml-stylesheet")) + throw new InvalidDataException("The selected SVG requires an external stylesheet. Include its styles in the SVG."); + } + + private static void CheckReference(string reference) + { + reference = reference.Trim(); + if (reference.Length == 0 || reference.StartsWith('#') || reference.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + return; + throw new InvalidDataException("The selected SVG contains an unresolved external image, font or stylesheet. Embed the referenced resource in the SVG."); + } + + private static void CheckCss(string css) + { + try + { + css = CssEscapes().Replace(css, match => match.Groups[1].Success + ? new Rune(Convert.ToInt32(match.Groups[1].Value, 16)).ToString() + : match.Groups[2].Value); + css = CssComments().Replace(css, ""); + if (css.Contains("@import", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The selected SVG requires an external stylesheet. Include its styles in the SVG."); + foreach (Match match in CssUrls().Matches(css)) + CheckReference(match.Groups[1].Value.Trim().Trim('\'', '"')); + } + catch (Exception ex) when (ex is RegexMatchTimeoutException or ArgumentOutOfRangeException) + { + throw new InvalidDataException("The selected SVG's resource references could not be checked.", ex); + } + } + + [GeneratedRegex(@"\\([0-9a-fA-F]{1,6})\s?|\\([^\r\n])", RegexOptions.CultureInvariant, 100)] + private static partial Regex CssEscapes(); + [GeneratedRegex(@"/\*.*?\*/", RegexOptions.Singleline | RegexOptions.CultureInvariant, 100)] + private static partial Regex CssComments(); + [GeneratedRegex(@"url\s*\(([^)]*)\)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, 100)] + private static partial Regex CssUrls(); +} diff --git a/src/Filee.Core/Web/WebDrop.cs b/src/Filee.Core/Web/WebDrop.cs index 247333e0..fb0fab63 100644 --- a/src/Filee.Core/Web/WebDrop.cs +++ b/src/Filee.Core/Web/WebDrop.cs @@ -28,6 +28,14 @@ public abstract Task SaveAsync(string folder, WebDownloader downloader, public static WebDropItem FromBytes(string name, byte[] bytes, string? mime = null) => new StreamItem(name, _ => Task.FromResult(new MemoryStream(bytes, writable: false)), bytes.Length, mime, null); + /// A standalone SVG selection, checked for unresolved resources when its job runs. + public static WebDropItem FromInlineSvg(byte[] svg) => + new StreamItem("image.svg", _ => + { + InlineSvg.EnsureSelfContained(svg); + return Task.FromResult(new MemoryStream(svg, writable: false)); + }, svg.Length, "image/svg+xml", null); + /// /// A file whose bytes the source app streams on request (a Windows virtual file). is /// disposed with the item, telling the source the transfer is over. diff --git a/tests/Filee.App.Tests/BrowserDropTests.cs b/tests/Filee.App.Tests/BrowserDropTests.cs index 429b2cba..71431dc9 100644 --- a/tests/Filee.App.Tests/BrowserDropTests.cs +++ b/tests/Filee.App.Tests/BrowserDropTests.cs @@ -241,8 +241,88 @@ public void A_picture_dropped_on_a_donut_slice_is_saved_converted_and_only_the_c } } + [AvaloniaTheory] + [InlineData("png", false)] + [InlineData("pdf", false)] + [InlineData("png", true)] + [InlineData("pdf", true)] + public void Standalone_svg_drag_reaches_png_and_pdf_or_reports_missing_resources(string target, bool external) + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var folder = UseFolder(store, saveOnlyConverted: true); + var radial = AppHost.Get(); + var conversions = AppHost.Get(); + try + { + using var data = new DataTransfer(); + var drawing = external ? "" + : ""; + data.Add(DataTransferItem.CreateText("" + drawing + "")); + Assert.True(DraggedFiles.Offered(data)); + radial.ShowForDrag(500, 500); + var window = radial.Window!; + var root = window.FindControl("Root")!; + var donut = window.DonutControl; + root.RaiseEvent(new DragEventArgs(DragDrop.DragEnterEvent, data, root, new Point(5, 5), KeyModifiers.Control)); + Pump(); + var model = AppHost.Get(); + Assert.EndsWith(".svg", Assert.Single(model.Files)); + var (at, _) = FirstSlice(donut, model, target); + var before = conversions.Jobs.Count; + root.RaiseEvent(new DragEventArgs(DragDrop.DropEvent, data, root, donut.TranslatePoint(at, root)!.Value, KeyModifiers.Control)); + Assert.Equal(before + 1, conversions.Jobs.Count); + var job = WaitFor(conversions.Jobs[0]); + if (external) + { + Assert.Equal(FileState.Failed, Assert.Single(job.Files).State); + Assert.Contains("external", job.Files[0].ErrorDetail); + Assert.Empty(job.Outputs); + Assert.Empty(Directory.GetFiles(folder)); + } + else + { + Assert.Equal(JobState.Completed, job.State); + var output = Assert.Single(job.Outputs); + Assert.Equal("." + target, Path.GetExtension(output)); + if (target == "png") + { + using var bitmap = SkiaSharp.SKBitmap.Decode(output); + Assert.NotNull(bitmap); + Assert.Equal(SkiaSharp.SKColors.Red, bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2)); + } + else + { + using var pdf = UglyToad.PdfPig.PdfDocument.Open(output); + Assert.Equal(1, pdf.NumberOfPages); + Assert.NotEmpty(pdf.GetPage(1).Paths); + } + } + } + finally + { + radial.Close(animate: false); + Reset(store, folder); + } + } + + [AvaloniaTheory] + [InlineData("ordinary text")] + [InlineData("https://example.com/image.svg")] + [InlineData("")] + [InlineData("
")] + [InlineData("")] + [InlineData("<svg></svg>")] + public void Other_text_does_not_offer_files_to_the_donut(string text) + { + using var data = new DataTransfer(); + data.Add(DataTransferItem.CreateText(text)); + Assert.False(DraggedFiles.Offered(data)); + Assert.Empty(DraggedFiles.Read(data, forDrop: true).Files); + } + /// A point on a slice that converts these files, and its preset. - private static (Point At, string Preset) FirstSlice(DonutMenu donut, RadialViewModel model) + private static (Point At, string Preset) FirstSlice(DonutMenu donut, RadialViewModel model, string? target = null) { var center = new Point(donut.Bounds.Width / 2, donut.Bounds.Height / 2); var radius = model.OuterRadius * 0.75; @@ -251,7 +331,8 @@ private static (Point At, string Preset) FirstSlice(DonutMenu donut, RadialViewM var angle = degrees * Math.PI / 180; var p = new Point(center.X + radius * Math.Cos(angle), center.Y + radius * Math.Sin(angle)); var hit = donut.HitTest(p); - if (hit.Kind == DonutHitKind.Segment && model.PresetAt(hit.Index, -1) is { } preset && donut.Converts(hit)) + if (hit.Kind == DonutHitKind.Segment && model.PresetAt(hit.Index, -1) is { } preset && donut.Converts(hit) + && (target is null || preset.TargetFormat == target)) return (p, preset.Id); } throw new InvalidOperationException("No slice converts a PNG."); diff --git a/tests/Filee.Core.Tests/InlineSvgTests.cs b/tests/Filee.Core.Tests/InlineSvgTests.cs new file mode 100644 index 00000000..18b3b78b --- /dev/null +++ b/tests/Filee.Core.Tests/InlineSvgTests.cs @@ -0,0 +1,71 @@ +using System.Text; +using System.Xml.Linq; +using Filee.Core.Web; + +namespace Filee.Core.Tests; + +public class InlineSvgTests +{ + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData(" \r\nA & B\t ")] + [InlineData("")] + public void A_single_well_formed_svg_is_accepted(string source) + { + var bytes = InlineSvg.Read(source); + Assert.NotNull(bytes); + Assert.Equal("http://www.w3.org/2000/svg", XElement.Parse(Encoding.UTF8.GetString(bytes)).Name.NamespaceName); + InlineSvg.EnsureSelfContained(bytes); + } + + [Theory] + [InlineData("ordinary text")] + [InlineData("https://example.com/image.svg")] + [InlineData("
")] + [InlineData("")] + [InlineData("<svg></svg>")] + [InlineData("")] + [InlineData("A & B")] + [InlineData("")] + [InlineData("")] + [InlineData("]>&x;")] + [InlineData("trailing text")] + [InlineData("")] + public void Unrelated_mixed_escaped_and_almost_valid_xml_are_rejected(string source) => + Assert.Null(InlineSvg.Read(source)); + + [Fact] + public void Oversized_selection_is_not_parsed() => + Assert.Null(InlineSvg.Read("" + new string(' ', InlineSvg.MaxCharacters) + "")); + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void External_dependencies_are_reported_when_the_job_fetches_the_selection(string body) + { + var svg = InlineSvg.Read("" + body + ""); + Assert.NotNull(svg); // Valid SVG still reaches the result, with a useful failure message. + var error = Assert.Throws(() => InlineSvg.EnsureSelfContained(svg)); + Assert.Contains("external", error.Message); + } + + [Fact] + public void Html_is_not_used_as_a_fallback_for_selected_svg() + { + var drag = new BrowserDragContent + { + Texts = new Dictionary + { + [BrowserDragFormats.PlainText] = "not SVG", + [BrowserDragFormats.Html] = "" + } + }; + Assert.Empty(BrowserDrag.Files(drag)); + } +}