From 82eb6773c4b1758a22600517a894e608a6f7b0a3 Mon Sep 17 00:00:00 2001 From: 1644ye <202804169+1644ye@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:57:18 +0800 Subject: [PATCH 1/2] feat: svg selection --- src/Filee.App/Assets/i18n/ar.json | 3 + src/Filee.App/Assets/i18n/de.json | 3 + src/Filee.App/Assets/i18n/en.json | 3 + src/Filee.App/Assets/i18n/es.json | 3 + src/Filee.App/Assets/i18n/fr.json | 3 + src/Filee.App/Assets/i18n/hi.json | 3 + src/Filee.App/Assets/i18n/id.json | 3 + src/Filee.App/Assets/i18n/it.json | 3 + src/Filee.App/Assets/i18n/ja.json | 3 + src/Filee.App/Assets/i18n/ko.json | 3 + src/Filee.App/Assets/i18n/ne.json | 3 + src/Filee.App/Assets/i18n/pl.json | 3 + src/Filee.App/Assets/i18n/pt-BR.json | 3 + src/Filee.App/Assets/i18n/pt-PT.json | 3 + src/Filee.App/Assets/i18n/ru.json | 3 + src/Filee.App/Assets/i18n/th.json | 3 + src/Filee.App/Assets/i18n/tr.json | 3 + src/Filee.App/Assets/i18n/zh-CN.json | 3 + src/Filee.App/Assets/i18n/zh-TW.json | 3 + src/Filee.App/Services/AppHost.cs | 2 + src/Filee.App/Services/BrowserDrags.cs | 45 +++++- .../ViewModels/Pages/TriggersPageViewModel.cs | 12 ++ src/Filee.App/Views/Pages/TriggersPage.axaml | 7 + src/Filee.Core/Settings/AppSettings.cs | 1 + src/Filee.Core/Web/BrowserDrag.cs | 7 +- src/Filee.Core/Web/BrowserDragFormats.cs | 3 + src/Filee.Core/Web/InlineSvg.cs | 151 ++++++++++++++++++ src/Filee.Core/Web/WebDrop.cs | 8 + src/Filee.Engines/Vector/SvgRenderer.cs | 10 ++ tests/Filee.App.Tests/BrowserDropTests.cs | 132 +++++++++++++++ tests/Filee.Core.Tests/InlineSvgTests.cs | 141 ++++++++++++++++ tests/Filee.Engines.Tests/GraphicsTests.cs | 14 ++ 32 files changed, 582 insertions(+), 8 deletions(-) create mode 100644 src/Filee.Core/Web/InlineSvg.cs create mode 100644 tests/Filee.Core.Tests/InlineSvgTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index c3191ee9f..c248a80f0 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "اسحب، أفلت، وحوّل.", "nav.home": "الرئيسية", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index ac26311f5..4ffed0cea 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Ziehen, ablegen, fertig.", "nav.home": "Start", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index f562d0957..8aa5b10af 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Drag, drop, converted.", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index d56c8b153..902424d92 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Arrastra, suelta y convierte.", "nav.home": "Inicio", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index f01e05d9f..3e0002d7f 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Glissez, déposez, c'est converti.", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 52931e1eb..6fd7479ce 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "खींचें, छोड़ें, कन्वर्ट हो गया।", "nav.home": "होम", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index e7c892a01..1825353c2 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Seret, lepas, terkonversi.", "nav.home": "Beranda", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index c7f6ba981..7933cdeb1 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Trascina, rilascia, converti.", "nav.home": "Home", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 507537f83..3659a813c 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "ドラッグ。ドロップ。\n変換完了。", "nav.home": "ホーム", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index c37fdbeda..3409d9102 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "끌어서 놓으면, 변환 끝.", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index e7ece85b0..03a2341bb 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "तान्नुहोस्, छाड्नुहोस्, रूपान्तरण भयो।", "nav.home": "गृहपृष्ठ", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 3cde8246d..af37bd0a0 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Przeciągnij, upuść, gotowe.", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index c04db90d3..313df385c 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Arraste, solte, convertido.", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index d6cd14982..026dfef63 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Arrasta, larga, convertido.", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 7c8dccf03..66860c81c 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Перетащил — и готово.", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 7e7b19eb0..20d653ca1 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "ลาก วาง แปลงไฟล์ทันที", "nav.home": "หน้าแรก", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 8d74f8d44..e7dfe16bc 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "Wake Filee by dragging SVG code", + "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", + "app.name": "Filee", "app.tagline": "Sürükle, bırak, dönüştür.", "nav.home": "Ana sayfa", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 87f876055..8512bea22 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "SVG 代码拖拽唤醒", + "triggers.svg_code_drag_hint": "选中完整 SVG 代码,按住 Ctrl 拖拽即可唤醒圆盘。需启用 Ctrl 拖拽手势;不支持文本拖拽的网页可由 Chrome 配套扩展辅助。关闭此项不影响图片和文件拖拽。", + "app.name": "Filee", "app.tagline": "拖一拖,放一放,转换完成。", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index f6308192c..faf025817 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -1,4 +1,7 @@ { + "triggers.svg_code_drag": "SVG 程式碼拖曳喚醒", + "triggers.svg_code_drag_hint": "選取完整 SVG 程式碼,按住 Ctrl 拖曳即可喚醒圓盤。需啟用 Ctrl 拖曳手勢;Chrome 配套擴充功能可協助不支援文字拖曳的網頁。關閉不影響圖片和檔案拖曳。", + "app.name": "Filee", "app.tagline": "拖一拖,放一放,轉換完成。", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 2eb90c92a..268513e72 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -25,6 +25,8 @@ public static class AppHost public static T Get() where T : notnull => Services.GetRequiredService(); + internal static T? TryGet() where T : class => _services?.GetService(); + public static IServiceProvider Build(string? dataDirectory = null, UserDataStore? preloadedStore = null) { var dataDir = dataDirectory ?? UserDataStore.DefaultDirectory; diff --git a/src/Filee.App/Services/BrowserDrags.cs b/src/Filee.App/Services/BrowserDrags.cs index e663d53ec..332acc94d 100644 --- a/src/Filee.App/Services/BrowserDrags.cs +++ b/src/Filee.App/Services/BrowserDrags.cs @@ -11,6 +11,7 @@ using Avalonia.Platform.Storage; using Filee.Core.Platform; using Filee.Core.Web; +using Filee.Core.Settings; namespace Filee.App.Services; @@ -23,6 +24,7 @@ internal static class BrowserDrags BrowserDragFormats.NetscapeUrl, BrowserDragFormats.UriList, BrowserDragFormats.DownloadUrlFormat, BrowserDragFormats.MozFilePromiseUrl, BrowserDragFormats.MozFilePromiseName, BrowserDragFormats.MacUrl, BrowserDragFormats.MacUrlName, BrowserDragFormats.Html, BrowserDragFormats.WindowsHtml, BrowserDragFormats.MacHtml, + BrowserDragFormats.PlainText, BrowserDragFormats.Svg, BrowserDragFormats.MacSvg, ]; /// Picture data some browsers put on a drag (macOS, X11), with the extension it is saved with. @@ -36,10 +38,25 @@ private static readonly (string Format, string Extension)[] PictureFormats = /// Whether the drag may hold a file from a browser (an address or a virtual file): cheap, for the drop effect while /// dragging over the drop zone. /// - 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))); + private static bool SvgCodeEnabled => AppHost.TryGet()?.Settings.BrowserDrops.EnableSvgCodeDrag ?? true; + + internal static bool IsSvgCodeSelection(IDataTransfer data) + { + // Real files and an explicitly serialized SVG picture are independent of the code-selection toggle. + if (data.Contains(DataFormat.File)) return false; + var texts = Texts(data); + if (texts.ContainsKey(BrowserDragFormats.Svg) || texts.ContainsKey(BrowserDragFormats.MacSvg)) return false; + return InlineSvg.ReadMany(new BrowserDragContent { Texts = texts }).Count > 0; + } + + public static bool MayOffer(IDataTransfer data) + { + if (!SvgCodeEnabled && IsSvgCodeSelection(data)) return false; + return data.Formats.Any(f => f.Kind == DataFormatKind.Platform + && (BrowserDragFormats.FileGroupDescriptorW.Equals(f.Identifier, StringComparison.OrdinalIgnoreCase) + || BrowserDragContent.AddressFormatNames.Contains(f.Identifier, StringComparer.OrdinalIgnoreCase))) + || InlineSvg.ReadMany(new BrowserDragContent { Texts = Texts(data) }).Count > 0; + } /// /// The files of a drag from a browser (or another app that holds them only in memory), standing as placeholders; @@ -54,6 +71,7 @@ public static bool MayOffer(IDataTransfer data) => internal static WebDrop? Read(IDataTransfer data, bool forDrop, string workRoot, Func isTemporary) { + if (!SvgCodeEnabled && IsSvgCodeSelection(data)) return null; var content = Content(data, forDrop, isTemporary); if (content is null) return null; @@ -74,12 +92,17 @@ public static bool MayOffer(IDataTransfer data) => { // A file on disk is read as it is, unless it is a browser's temporary copy (Firefox writes the dragged // picture into the temp folder): that one is saved like any file from the browser. - if (!hasAddress || !isTemporary(path) || !File.Exists(path)) + if ((!hasAddress && !IsChromiumDragFile(path)) || !isTemporary(path) || !File.Exists(path)) { DisposeAll(files); return null; } - files.Add(WebDropItem.FromTemporaryFile(path)); + try { files.Add(WebDropItem.FromTemporaryFile(path, keepOpen: forDrop)); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The browser may have removed the temporary file before Drop was delivered. + // Leave it out instead of throwing on the UI thread; other drag formats can still supply it. + } } else if (storageItem is IStorageFile file) { @@ -96,11 +119,17 @@ 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.Count == 0) return null; return new BrowserDragContent { Files = files, Texts = texts, Picture = Picture(data, texts) }; } + // Chromium can drop a real temporary file without any URL or HTML (notably data-URL SVGs). + // Recognize only its dedicated drag directory, never arbitrary files in the user's temp folder. + internal static bool IsChromiumDragFile(string path) => System.Text.RegularExpressions.Regex.IsMatch( + Path.GetFileName(Path.GetDirectoryName(path)) ?? "", @"^chrome_drag[0-9]+_[0-9]+$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + /// True for a path in the system's temporary folder (but not in Filee's own work folders). internal static bool IsTemporary(string path) { @@ -120,6 +149,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.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs index 1b1fca15b..207d3bc00 100644 --- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs @@ -264,6 +264,18 @@ public TriggersPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServi Flip = new FlipOptionsViewModel(store, loc, Save); } + public bool EnableSvgCodeDrag + { + get => _store.Settings.BrowserDrops.EnableSvgCodeDrag; + set + { + if (value == EnableSvgCodeDrag) return; + _store.Settings.BrowserDrops.EnableSvgCodeDrag = value; + OnPropertyChanged(); + Save(); + } + } + /// The key and the wheel that flip the open donut. public FlipOptionsViewModel Flip { get; } diff --git a/src/Filee.App/Views/Pages/TriggersPage.axaml b/src/Filee.App/Views/Pages/TriggersPage.axaml index 73940d7d3..2062ff752 100644 --- a/src/Filee.App/Views/Pages/TriggersPage.axaml +++ b/src/Filee.App/Views/Pages/TriggersPage.axaml @@ -31,6 +31,13 @@ + + + + + + + diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index ace3a3aa5..ced1e387b 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -339,6 +339,7 @@ public sealed class DefaultOutputSettings /// Files dragged out of a web browser (Settings → General). public sealed class BrowserDropSettings { + public bool EnableSvgCodeDrag { get; set; } = true; /// Where they are saved; empty means the user's Downloads folder (looked up each time, as it can move). public string Folder { get; set; } = ""; diff --git a/src/Filee.Core/Web/BrowserDrag.cs b/src/Filee.Core/Web/BrowserDrag.cs index 256c77952..8c4c22b92 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. +// Selected SVG markup and inline SVG fragments are pictures too. Other plain text is not a file. using Filee.Core.Formats; @@ -61,6 +61,11 @@ public static IReadOnlyList Files(BrowserDragContent content) if (handed.Count > 0) return handed; + var svgs = InlineSvg.ReadMany(content); + if (svgs.Count > 0) + return svgs.Select((svg, index) => WebDropItem.FromBytes( + svgs.Count == 1 ? "image.svg" : $"image-{index + 1}.svg", svg, BrowserDragFormats.Svg)).ToList(); + 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 c8a8a34ce..b68476aaf 100644 --- a/src/Filee.Core/Web/BrowserDragFormats.cs +++ b/src/Filee.Core/Web/BrowserDragFormats.cs @@ -44,6 +44,9 @@ 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"; + public const string Svg = "image/svg+xml"; + public const string MacSvg = "public.svg-image"; // 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 000000000..62fe2a316 --- /dev/null +++ b/src/Filee.Core/Web/InlineSvg.cs @@ -0,0 +1,151 @@ +// SVG code selections and inline SVG pictures carried by browser drags. Parse only complete SVG documents; +// ordinary text remains text. No page access, script execution or external XML resolution is involved. + +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +namespace Filee.Core.Web; + +public static partial class InlineSvg +{ + public const int MaxCharacters = 4 * 1024 * 1024; + private const string SvgNamespace = "http://www.w3.org/2000/svg"; + + /// The single SVG of a selection, or null when it contains none or several. + public static byte[]? Read(BrowserDragContent content) + { + var pictures = ReadMany(content); + return pictures.Count == 1 ? pictures[0] : null; + } + + /// Complete outer SVG elements, in selection order, without surrounding page text or images. + public static IReadOnlyList ReadMany(BrowserDragContent content) + { + foreach (var format in new[] { BrowserDragFormats.Svg, BrowserDragFormats.MacSvg, BrowserDragFormats.PlainText, + BrowserDragFormats.Html, BrowserDragFormats.WindowsHtml, BrowserDragFormats.MacHtml }) + { + var pictures = FromSelection(content.Text(format)); + if (pictures.Count > 0) + return pictures; + } + return []; + } + + private static IReadOnlyList FromSelection(string? html) + { + if (string.IsNullOrWhiteSpace(html) || html.Length > MaxCharacters) + return []; + var start = html.IndexOf("", StringComparison.OrdinalIgnoreCase); + var end = html.IndexOf("", StringComparison.OrdinalIgnoreCase); + if (start >= 0 && end > start) + html = html[(start + "".Length)..end]; + try + { + var pictures = Extract(html); + if (pictures.Count > 0) + return pictures; + // Code selections may arrive only as escaped HTML, with syntax-highlighting spans between tokens. + // Decode once, after removing the presentation markup, so XML entities inside SVG stay XML entities. + var code = WebUtility.HtmlDecode(Markup().Replace(html, "")); + return code == html ? [] : Extract(code); + } + catch (RegexMatchTimeoutException) + { + return []; + } + } + + private static IReadOnlyList Extract(string source) + { + if (source.Contains("(); + var depth = 0; + var start = 0; + foreach (Match token in Markup().Matches(source)) + { + if (token.Groups["name"].Value != "svg") + continue; + var closing = token.Groups["close"].Success; + var empty = token.Value.EndsWith("/>", StringComparison.Ordinal); + if (!closing) + { + if (depth == 0) + start = token.Index; + depth++; + } + else if (depth == 0) + return []; + if (closing || empty) + depth--; + if (depth != 0) + continue; + // Nested SVG remains part of its parent; siblings become separate files with distinct names. + if (Parse(source[start..(token.Index + token.Length)]) is not { } svg || pictures.Count >= 128) + return []; + pictures.Add(svg); + } + return depth == 0 ? pictures : []; + } + + private static byte[]? Parse(string? source) + { + if (string.IsNullOrWhiteSpace(source) || source.Length > MaxCharacters) + return null; + source = source.Trim().TrimStart('\uFEFF'); + if (!source.StartsWith('<')) + return null; + try + { + using var input = new StringReader(source); + using var reader = XmlReader.Create(input, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = MaxCharacters, + }); + var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace); + if (document.Root is not { } root || root.Name.LocalName != "svg" + || (root.Name.NamespaceName.Length != 0 && root.Name.NamespaceName != SvgNamespace)) + return null; + // Browser selections can contain only an empty SVG shell (for example a collapsed code node). + // Do not let that shell win over another drag format containing the actual drawing. + if (!root.Descendants().Any(e => IsDrawingElement(e) + && !e.Ancestors().TakeWhile(a => a != root).Any(a => + a.Name.LocalName is "defs" or "symbol" or "clipPath" or "mask" or "pattern"))) + return null; + // HTML inline SVG often omits 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; + } + } + + private static bool IsDrawingElement(XElement element) => element.Name.LocalName switch + { + "path" => !string.IsNullOrWhiteSpace((string?)element.Attribute("d")), + "polyline" or "polygon" => !string.IsNullOrWhiteSpace((string?)element.Attribute("points")), + "text" => !string.IsNullOrWhiteSpace(element.Value), + "use" => element.Attributes().Any(a => a.Name.LocalName == "href" && !string.IsNullOrWhiteSpace(a.Value)), + "image" or "rect" or "circle" or "ellipse" or "line" or "foreignObject" => true, + _ => false, + }; + + // Consume comments, CDATA and script/style text as whole tokens. Quoted '>' characters are not tag boundaries. + [GeneratedRegex("""||]*>.*?|]*>.*?|<(?/)?(?[A-Za-z_][\w:.-]*)(?=\s|/?>)(?:"[^"]*"|'[^']*'|[^'">])*>""", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant, 100)] + private static partial Regex Markup(); +} diff --git a/src/Filee.Core/Web/WebDrop.cs b/src/Filee.Core/Web/WebDrop.cs index 247333e04..0c23b6cd0 100644 --- a/src/Filee.Core/Web/WebDrop.cs +++ b/src/Filee.Core/Web/WebDrop.cs @@ -42,6 +42,14 @@ public static WebDropItem FromTemporaryFile(string path) => _ => Task.FromResult(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16, useAsync: true)), SizeOf(path), null, null); + /// Pin a dropped temporary file until the queue copies it, even if the browser deletes its pathname. + public static WebDropItem FromTemporaryFile(string path, bool keepOpen) + { + if (!keepOpen) return FromTemporaryFile(path); + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16, useAsync: true); + return FromStream(Path.GetFileName(path), _ => Task.FromResult(stream), stream.Length, stream); + } + private static long? SizeOf(string path) { try diff --git a/src/Filee.Engines/Vector/SvgRenderer.cs b/src/Filee.Engines/Vector/SvgRenderer.cs index 13d8145a2..3fbfcbc61 100644 --- a/src/Filee.Engines/Vector/SvgRenderer.cs +++ b/src/Filee.Engines/Vector/SvgRenderer.cs @@ -217,6 +217,16 @@ public static MagickImage Rasterize(Drawing drawing, float scale) throw new InvalidOperationException("Could not read the rendered SVG."); } var pixels = straight.GetPixelSpan().ToArray(); + var visible = false; + for (var offset = 3; offset < pixels.Length; offset += 4) + { + if (pixels[offset] == 0) + continue; + visible = true; + break; + } + if (!visible) + throw new InvalidDataException("The SVG rendered no visible content. Include the complete drawing, its referenced definitions and styles."); var image = new MagickImage(); image.ReadPixels(pixels, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, PixelMapping.RGBA)); diff --git a/tests/Filee.App.Tests/BrowserDropTests.cs b/tests/Filee.App.Tests/BrowserDropTests.cs index c7c44b519..e2b7860d4 100644 --- a/tests/Filee.App.Tests/BrowserDropTests.cs +++ b/tests/Filee.App.Tests/BrowserDropTests.cs @@ -25,6 +25,59 @@ namespace Filee.App.Tests; public class BrowserDropTests { + [AvaloniaFact] + public void Selected_svg_code_and_inline_svg_are_offered_without_a_web_address() + { + TestServices.EnsureInitialized("en"); + const string svg = ""; + foreach (var item in new[] + { + DataTransferItem.CreateText(svg), + Platform(BrowserDragFormats.WindowsHtml, "
" + svg + "
", utf16: false), + Platform(BrowserDragFormats.Svg, svg, utf16: false), + }) + { + using var data = new DataTransfer(); + data.Add(item); + Assert.True(DraggedFiles.Offered(data)); + foreach (var forDrop in new[] { false, true }) + { + var dragged = DraggedFiles.Read(data, forDrop); + using var web = dragged.Web; + Assert.NotNull(web); + Assert.Equal("image.svg", Path.GetFileName(Assert.Single(dragged.Files))); + Assert.False(File.Exists(dragged.Files[0])); + } + } + } + + [AvaloniaFact] + public void Svg_code_toggle_blocks_code_but_keeps_explicit_svg_pictures() + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var previous = store.Settings.BrowserDrops.EnableSvgCodeDrag; + const string svg = ""; + try + { + using var code = new DataTransfer(); code.Add(DataTransferItem.CreateText(svg)); + store.Settings.BrowserDrops.EnableSvgCodeDrag = false; + Assert.False(DraggedFiles.Offered(code)); + Assert.Empty(DraggedFiles.Read(code, forDrop: false).Files); + Assert.Empty(DraggedFiles.Read(code, forDrop: true).Files); + using var rendered = new DataTransfer(); rendered.Add(Platform(BrowserDragFormats.Svg, svg, utf16: false)); + var picture = DraggedFiles.Read(rendered, forDrop: true); + using var pictureDrop = picture.Web; + Assert.Single(picture.Files); + store.Settings.BrowserDrops.EnableSvgCodeDrag = true; + Assert.True(DraggedFiles.Offered(code)); + var selected = DraggedFiles.Read(code, forDrop: true); + using var selectionDrop = selected.Web; + Assert.Single(selected.Files); + } + finally { store.Settings.BrowserDrops.EnableSvgCodeDrag = previous; } + } + /// A 1×1 PNG. private static readonly byte[] Png = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); @@ -192,6 +245,33 @@ public void Firefox_temporary_copies_count_as_browser_files_but_files_on_disk_st } } + [AvaloniaFact] + public async Task Chrome_temporary_file_without_url_is_pinned_until_saved() + { + TestServices.EnsureInitialized("en"); + var folder = Path.Combine(Path.GetTempPath(), "chrome_drag" + Environment.ProcessId + "_" + DateTime.UtcNow.Ticks); + Directory.CreateDirectory(folder); + var path = Path.Combine(folder, "image.png"); + var destination = Path.Combine(folder, "saved"); + File.WriteAllBytes(path, Png); + try + { + using var data = new DataTransfer(); + data.Add(DataTransferItem.CreateFile(VirtualFile.Create("image.png", Png, path).Item)); + var preview = DraggedFiles.Read(data, forDrop: false); + Assert.NotNull(preview.Web); preview.Web.Dispose(); + var dropped = DraggedFiles.Read(data, forDrop: true); + using var web = dropped.Web; + Assert.NotNull(web); + File.Delete(path); // Chromium cleans up its temporary source after the OS drop. + var fetcher = web.CreateFetcher(new WebDropOptions(destination, false), AppHost.Get()); + var output = await fetcher.FetchAsync(Assert.Single(dropped.Files), new Progress(), CancellationToken.None); + Assert.Equal(Png, File.ReadAllBytes(output)); + Assert.Equal(destination, Path.GetDirectoryName(output)); + } + finally { Directory.Delete(folder, recursive: true); } + } + private static IStorageItem StorageItem(string path) => VirtualFile.Create(Path.GetFileName(path), Png, path).Item; [AvaloniaFact] @@ -241,6 +321,58 @@ public void A_picture_dropped_on_a_donut_slice_is_saved_converted_and_only_the_c } } + [AvaloniaTheory] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 3)] + [InlineData(true, 3)] + public void Svg_selection_uses_the_existing_ctrl_drag_donut_and_conversion(bool rendered, int count) + { + TestServices.EnsureInitialized("en"); + var store = AppHost.Get(); + var folder = UseFolder(store, saveOnlyConverted: true); + var radial = AppHost.Get(); + var conversions = AppHost.Get(); + try + { + radial.ShowForDrag(500, 500); + var window = radial.Window!; + var root = window.FindControl("Root")!; + var donut = window.DonutControl; + const string svg = ""; + var selection = count == 1 ? svg : "

Features

    " + + string.Concat(Enumerable.Repeat("
  • Caption" + svg + "
  • ", count)) + + "
"; + using var data = new DataTransfer(); + data.Add(rendered + ? Platform(BrowserDragFormats.WindowsHtml, "" + selection + "", utf16: false) + : DataTransferItem.CreateText(selection)); + root.RaiseEvent(new DragEventArgs(DragDrop.DragEnterEvent, data, root, new Point(5, 5), KeyModifiers.Control)); + Pump(); + var model = AppHost.Get(); + Assert.Equal(count, model.Files.Count); + Assert.All(model.Files, file => Assert.Equal(".svg", Path.GetExtension(file))); + var (at, preset) = FirstSlice(donut, model); + var before = conversions.Jobs.Count; + root.RaiseEvent(new DragEventArgs(DragDrop.DropEvent, data, root, donut.TranslatePoint(at, root)!.Value, KeyModifiers.Control)); + Assert.True(conversions.Jobs.Count > before, $"no conversion started for {preset}"); + var job = WaitFor(conversions.Jobs[0]); + Assert.Equal(JobState.Completed, job.State); + Assert.Equal(count, job.Outputs.Count()); + foreach (var output in job.Outputs) + { + Assert.Equal(folder, Path.GetDirectoryName(output)); + Assert.True(new FileInfo(output).Length > 0); + } + Assert.Equal(job.Outputs.Order(), Directory.GetFiles(folder).Order()); + } + finally + { + radial.Close(animate: false); + Reset(store, folder); + } + } + /// A point on a slice that converts these files, and its preset. private static (Point At, string Preset) FirstSlice(DonutMenu donut, RadialViewModel model) { diff --git a/tests/Filee.Core.Tests/InlineSvgTests.cs b/tests/Filee.Core.Tests/InlineSvgTests.cs new file mode 100644 index 000000000..607d99364 --- /dev/null +++ b/tests/Filee.Core.Tests/InlineSvgTests.cs @@ -0,0 +1,141 @@ +// Browser SVG selections become standalone vector files; unrelated or incomplete selections remain ignored. + +using System.Text; +using System.Xml.Linq; +using Filee.Core.Web; + +namespace Filee.Core.Tests; + +public class InlineSvgTests +{ + private static BrowserDragContent Drag(string text, string format = BrowserDragFormats.PlainText) => + new() { Texts = new Dictionary { [format] = text } }; + + [Theory] + [InlineData("")] + [InlineData("")] + [InlineData("")] + public void Empty_svg_shells_do_not_become_blank_successful_conversions(string text) => + Assert.Empty(InlineSvg.ReadMany(Drag(text))); + + [Fact] + public void Empty_plain_text_does_not_hide_complete_html_drawing() + { + var content = new BrowserDragContent + { + Texts = new Dictionary + { + [BrowserDragFormats.PlainText] = "", + [BrowserDragFormats.Html] = "", + } + }; + Assert.Single(InlineSvg.ReadMany(content)); + } + + [Theory] + [InlineData(BrowserDragFormats.PlainText)] + [InlineData(BrowserDragFormats.Svg)] + [InlineData(BrowserDragFormats.MacSvg)] + [InlineData(BrowserDragFormats.Html)] + public void Complete_svg_keeps_its_vector_content_and_gets_a_namespace(string format) + { + var bytes = InlineSvg.Read(Drag("中文 & SVG", format)); + Assert.NotNull(bytes); + var root = XElement.Parse(Encoding.UTF8.GetString(bytes)); + XNamespace ns = "http://www.w3.org/2000/svg"; + Assert.Equal(ns + "svg", root.Name); + Assert.Equal("0 0 20 20", (string?)root.Attribute("viewBox")); + Assert.Equal("M0 0L20 20", (string?)root.Element(ns + "path")?.Attribute("d")); + Assert.Equal("中文 & SVG", root.Element(ns + "text")?.Value); + } + + [Fact] + public void A_windows_html_fragment_ignores_the_page_context_and_keeps_nested_svg() + { + var html = "Version:0.9\r\nSourceURL:https://example.com/page\r\nOutside" + + "" + + "DiagramLabelOutside"; + var result = InlineSvg.Read(Drag(html, BrowserDragFormats.WindowsHtml)); + Assert.NotNull(result); + Assert.Contains("Label", Encoding.UTF8.GetString(result)); + Assert.DoesNotContain("Outside", Encoding.UTF8.GetString(result)); + } + + [Fact] + public void Xml_declarations_namespaces_and_local_references_survive() + { + const string source = "" + + ""; + var bytes = InlineSvg.Read(Drag(source)); + Assert.NotNull(bytes); + var root = XElement.Parse(Encoding.UTF8.GetString(bytes)); + Assert.Equal("#shape", root.Descendants().Single(e => e.Name.LocalName == "use") + .Attribute(XName.Get("href", "http://www.w3.org/1999/xlink"))?.Value); + Assert.DoesNotContain("utf-16", Encoding.UTF8.GetString(bytes)); + } + + [Fact] + public void Real_files_keep_priority_over_a_code_selection() + { + using var file = WebDropItem.FromBytes("existing.png", [1, 2, 3]); + var content = new BrowserDragContent + { + Files = [file], + Texts = new Dictionary { [BrowserDragFormats.PlainText] = "" }, + }; + Assert.Same(file, Assert.Single(BrowserDrag.Files(content))); + } + + [Theory] + [InlineData("ordinary text")] + [InlineData("https://example.com/image.svg")] + [InlineData("")] + [InlineData("

text

")] + [InlineData("")] + [InlineData("]>&x;")] + [InlineData("text")] + public void Other_text_invalid_xml_and_external_entities_are_not_files(string text) + { + Assert.Null(InlineSvg.Read(Drag(text))); + Assert.Empty(BrowserDrag.Files(Drag(text))); + } + + [Theory] + [InlineData("

Caption

", 1)] + [InlineData("", 2)] + [InlineData("", 1)] + [InlineData("
Caption
", 3)] + [InlineData("
<svg><text>A &amp; B</text></svg>
", 1)] + [InlineData("", 1)] + public void Complete_svgs_are_extracted_from_mixed_and_escaped_selections(string html, int count) + { + Assert.Equal(count, InlineSvg.ReadMany(Drag(html, BrowserDragFormats.Html)).Count); + var files = BrowserDrag.Files(Drag(html)); + Assert.Equal(count, files.Count); + Assert.Equal(count, files.Select(f => f.Name).Distinct().Count()); + foreach (var file in files) file.Dispose(); + } + + [Fact] + public void Incomplete_and_excessive_batches_are_ignored() + { + Assert.Empty(InlineSvg.ReadMany(Drag(""))); + Assert.Empty(InlineSvg.ReadMany(Drag(string.Concat(Enumerable.Repeat("", 129))))); + } + + [Fact] + public void Oversized_selection_is_ignored() => + Assert.Null(InlineSvg.Read(Drag("" + new string(' ', InlineSvg.MaxCharacters) + ""))); + + [Fact] + public async Task Svg_is_saved_through_the_existing_browser_file_pipeline() + { + using var dir = new TempDir(); + using var downloader = new WebDownloader(); + using var item = Assert.Single(BrowserDrag.Files(Drag(""))); + Assert.Equal("image.svg", item.Name); + var path = await item.SaveAsync(dir.Path, downloader, null, CancellationToken.None); + Assert.Equal("svg", XElement.Load(path).Name.LocalName); + } +} diff --git a/tests/Filee.Engines.Tests/GraphicsTests.cs b/tests/Filee.Engines.Tests/GraphicsTests.cs index 32bc17471..7c61780af 100644 --- a/tests/Filee.Engines.Tests/GraphicsTests.cs +++ b/tests/Filee.Engines.Tests/GraphicsTests.cs @@ -17,6 +17,20 @@ namespace Filee.Engines.Tests; public class GraphicsTests(EngineFixture fx) : IClassFixture { + [Theory] + [InlineData("")] + [InlineData("")] + public void Empty_svg_renders_fail_instead_of_writing_blank_images(string contents) + { + var path = Write(fx.NewFolder(), "empty.svg", + "" + contents + ""); + Assert.Throws(() => + { + using var drawing = SvgRenderer.Load(path); + using var image = SvgRenderer.Rasterize(drawing, 1); + }); + } + private static bool GhostscriptInstalled => GhostscriptConverter.Locate() is not null; private static void AssertDone(ConversionJob job) From a63aa1012e57040c5368f7b7dafc70e1e523414a Mon Sep 17 00:00:00 2001 From: 1644ye <202804169+1644ye@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:44:18 +0800 Subject: [PATCH 2/2] Scope SVG selections to standalone XML and test PNG/PDF drops --- src/Filee.App/Assets/i18n/ar.json | 3 - src/Filee.App/Assets/i18n/de.json | 3 - src/Filee.App/Assets/i18n/en.json | 3 - src/Filee.App/Assets/i18n/es.json | 3 - src/Filee.App/Assets/i18n/fr.json | 3 - src/Filee.App/Assets/i18n/hi.json | 3 - src/Filee.App/Assets/i18n/id.json | 3 - src/Filee.App/Assets/i18n/it.json | 3 - src/Filee.App/Assets/i18n/ja.json | 3 - src/Filee.App/Assets/i18n/ko.json | 3 - src/Filee.App/Assets/i18n/ne.json | 3 - src/Filee.App/Assets/i18n/pl.json | 3 - src/Filee.App/Assets/i18n/pt-BR.json | 3 - src/Filee.App/Assets/i18n/pt-PT.json | 3 - src/Filee.App/Assets/i18n/ru.json | 3 - src/Filee.App/Assets/i18n/th.json | 3 - src/Filee.App/Assets/i18n/tr.json | 3 - src/Filee.App/Assets/i18n/zh-CN.json | 3 - src/Filee.App/Assets/i18n/zh-TW.json | 3 - src/Filee.App/Services/AppHost.cs | 2 - src/Filee.App/Services/BrowserDrags.cs | 46 +---- .../ViewModels/Pages/TriggersPageViewModel.cs | 12 -- src/Filee.App/Views/Pages/TriggersPage.axaml | 7 - src/Filee.Core/Settings/AppSettings.cs | 1 - src/Filee.Core/Web/BrowserDrag.cs | 8 +- src/Filee.Core/Web/BrowserDragFormats.cs | 2 - src/Filee.Core/Web/InlineSvg.cs | 164 +++++++----------- src/Filee.Core/Web/WebDrop.cs | 16 +- src/Filee.Engines/Vector/SvgRenderer.cs | 10 -- tests/Filee.App.Tests/BrowserDropTests.cs | 159 ++++++----------- tests/Filee.Core.Tests/InlineSvgTests.cs | 160 +++++------------ tests/Filee.Engines.Tests/GraphicsTests.cs | 14 -- 32 files changed, 177 insertions(+), 481 deletions(-) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 834d9bc98..0d868347b 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "اسحب، أفلت، وحوّل.", "nav.home": "الرئيسية", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 2ded3d2a9..00c7a0d40 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Ziehen, ablegen, fertig.", "nav.home": "Start", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index dfee0ed73..beb48df7d 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Drag, drop, converted.", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index d77fa2186..43b6267c5 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Arrastra, suelta y convierte.", "nav.home": "Inicio", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 05fa5866a..0bdcd9150 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Glissez, déposez, c'est converti.", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index b73beb127..c38285175 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "खींचें, छोड़ें, कन्वर्ट हो गया।", "nav.home": "होम", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 6db81a354..8d602a6cb 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Seret, lepas, terkonversi.", "nav.home": "Beranda", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index d8b14f37c..ed2bf8708 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Trascina, rilascia, converti.", "nav.home": "Home", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 61650316b..ed9b76640 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "ドラッグ。ドロップ。\n変換完了。", "nav.home": "ホーム", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index dc2bd5283..c91993f90 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "끌어서 놓으면, 변환 끝.", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 2f79b61e5..d421617d4 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "तान्नुहोस्, छाड्नुहोस्, रूपान्तरण भयो।", "nav.home": "गृहपृष्ठ", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 1143d5593..6eff6f74f 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Przeciągnij, upuść, gotowe.", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index f95fea0e7..a03526165 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Arraste, solte, convertido.", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index c798524f9..e74df70e7 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Arrasta, larga, convertido.", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 579df04b6..ba58c738d 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Перетащил — и готово.", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 6ac1ed91f..9fca2fceb 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "ลาก วาง แปลงไฟล์ทันที", "nav.home": "หน้าแรก", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 776c2ecda..c5642c4d1 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "Wake Filee by dragging SVG code", - "triggers.svg_code_drag_hint": "Select complete SVG code, hold Ctrl and drag to open the ring. Requires an enabled Ctrl-drag gesture; the Chrome companion helps webpages that do not support dragging text. Turning this off does not affect picture or file drags.", - "app.name": "Filee", "app.tagline": "Sürükle, bırak, dönüştür.", "nav.home": "Ana sayfa", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 98994eb10..252de6327 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "SVG 代码拖拽唤醒", - "triggers.svg_code_drag_hint": "选中完整 SVG 代码,按住 Ctrl 拖拽即可唤醒圆盘。需启用 Ctrl 拖拽手势;不支持文本拖拽的网页可由 Chrome 配套扩展辅助。关闭此项不影响图片和文件拖拽。", - "app.name": "Filee", "app.tagline": "拖一拖,放一放,转换完成。", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 08c090aef..a2ff2b779 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -1,7 +1,4 @@ { - "triggers.svg_code_drag": "SVG 程式碼拖曳喚醒", - "triggers.svg_code_drag_hint": "選取完整 SVG 程式碼,按住 Ctrl 拖曳即可喚醒圓盤。需啟用 Ctrl 拖曳手勢;Chrome 配套擴充功能可協助不支援文字拖曳的網頁。關閉不影響圖片和檔案拖曳。", - "app.name": "Filee", "app.tagline": "拖一拖,放一放,轉換完成。", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 268513e72..2eb90c92a 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -25,8 +25,6 @@ public static class AppHost public static T Get() where T : notnull => Services.GetRequiredService(); - internal static T? TryGet() where T : class => _services?.GetService(); - public static IServiceProvider Build(string? dataDirectory = null, UserDataStore? preloadedStore = null) { var dataDir = dataDirectory ?? UserDataStore.DefaultDirectory; diff --git a/src/Filee.App/Services/BrowserDrags.cs b/src/Filee.App/Services/BrowserDrags.cs index 332acc94d..7a29a0f5d 100644 --- a/src/Filee.App/Services/BrowserDrags.cs +++ b/src/Filee.App/Services/BrowserDrags.cs @@ -11,7 +11,6 @@ using Avalonia.Platform.Storage; using Filee.Core.Platform; using Filee.Core.Web; -using Filee.Core.Settings; namespace Filee.App.Services; @@ -23,8 +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.PlainText, BrowserDragFormats.Svg, BrowserDragFormats.MacSvg, + 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. @@ -38,25 +36,11 @@ private static readonly (string Format, string Extension)[] PictureFormats = /// Whether the drag may hold a file from a browser (an address or a virtual file): cheap, for the drop effect while /// dragging over the drop zone. ///
- private static bool SvgCodeEnabled => AppHost.TryGet()?.Settings.BrowserDrops.EnableSvgCodeDrag ?? true; - - internal static bool IsSvgCodeSelection(IDataTransfer data) - { - // Real files and an explicitly serialized SVG picture are independent of the code-selection toggle. - if (data.Contains(DataFormat.File)) return false; - var texts = Texts(data); - if (texts.ContainsKey(BrowserDragFormats.Svg) || texts.ContainsKey(BrowserDragFormats.MacSvg)) return false; - return InlineSvg.ReadMany(new BrowserDragContent { Texts = texts }).Count > 0; - } - - public static bool MayOffer(IDataTransfer data) - { - if (!SvgCodeEnabled && IsSvgCodeSelection(data)) return false; - return data.Formats.Any(f => f.Kind == DataFormatKind.Platform - && (BrowserDragFormats.FileGroupDescriptorW.Equals(f.Identifier, StringComparison.OrdinalIgnoreCase) - || BrowserDragContent.AddressFormatNames.Contains(f.Identifier, StringComparer.OrdinalIgnoreCase))) - || InlineSvg.ReadMany(new BrowserDragContent { Texts = Texts(data) }).Count > 0; - } + 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))) + || 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; @@ -71,7 +55,6 @@ public static bool MayOffer(IDataTransfer data) internal static WebDrop? Read(IDataTransfer data, bool forDrop, string workRoot, Func isTemporary) { - if (!SvgCodeEnabled && IsSvgCodeSelection(data)) return null; var content = Content(data, forDrop, isTemporary); if (content is null) return null; @@ -92,17 +75,12 @@ public static bool MayOffer(IDataTransfer data) { // A file on disk is read as it is, unless it is a browser's temporary copy (Firefox writes the dragged // picture into the temp folder): that one is saved like any file from the browser. - if ((!hasAddress && !IsChromiumDragFile(path)) || !isTemporary(path) || !File.Exists(path)) + if (!hasAddress || !isTemporary(path) || !File.Exists(path)) { DisposeAll(files); return null; } - try { files.Add(WebDropItem.FromTemporaryFile(path, keepOpen: forDrop)); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // The browser may have removed the temporary file before Drop was delivered. - // Leave it out instead of throwing on the UI thread; other drag formats can still supply it. - } + files.Add(WebDropItem.FromTemporaryFile(path)); } else if (storageItem is IStorageFile file) { @@ -119,17 +97,11 @@ 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 && texts.Count == 0) + if (files.Count == 0 && !hasAddress && !texts.ContainsKey(BrowserDragFormats.PlainText)) return null; return new BrowserDragContent { Files = files, Texts = texts, Picture = Picture(data, texts) }; } - // Chromium can drop a real temporary file without any URL or HTML (notably data-URL SVGs). - // Recognize only its dedicated drag directory, never arbitrary files in the user's temp folder. - internal static bool IsChromiumDragFile(string path) => System.Text.RegularExpressions.Regex.IsMatch( - Path.GetFileName(Path.GetDirectoryName(path)) ?? "", @"^chrome_drag[0-9]+_[0-9]+$", - System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); - /// True for a path in the system's temporary folder (but not in Filee's own work folders). internal static bool IsTemporary(string path) { diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs index 207d3bc00..1b1fca15b 100644 --- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs @@ -264,18 +264,6 @@ public TriggersPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServi Flip = new FlipOptionsViewModel(store, loc, Save); } - public bool EnableSvgCodeDrag - { - get => _store.Settings.BrowserDrops.EnableSvgCodeDrag; - set - { - if (value == EnableSvgCodeDrag) return; - _store.Settings.BrowserDrops.EnableSvgCodeDrag = value; - OnPropertyChanged(); - Save(); - } - } - /// The key and the wheel that flip the open donut. public FlipOptionsViewModel Flip { get; } diff --git a/src/Filee.App/Views/Pages/TriggersPage.axaml b/src/Filee.App/Views/Pages/TriggersPage.axaml index 2062ff752..73940d7d3 100644 --- a/src/Filee.App/Views/Pages/TriggersPage.axaml +++ b/src/Filee.App/Views/Pages/TriggersPage.axaml @@ -31,13 +31,6 @@ - - - - - - - diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index e626a3e40..45db4a43f 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -345,7 +345,6 @@ public sealed class DefaultOutputSettings /// Files dragged out of a web browser (Settings → General). public sealed class BrowserDropSettings { - public bool EnableSvgCodeDrag { get; set; } = true; /// Where they are saved; empty means the user's Downloads folder (looked up each time, as it can move). public string Folder { get; set; } = ""; diff --git a/src/Filee.Core/Web/BrowserDrag.cs b/src/Filee.Core/Web/BrowserDrag.cs index 8c4c22b92..cbac9c73f 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. // -// Selected SVG markup and inline SVG fragments are pictures too. Other plain text is 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,10 +61,8 @@ public static IReadOnlyList Files(BrowserDragContent content) if (handed.Count > 0) return handed; - var svgs = InlineSvg.ReadMany(content); - if (svgs.Count > 0) - return svgs.Select((svg, index) => WebDropItem.FromBytes( - svgs.Count == 1 ? "image.svg" : $"image-{index + 1}.svg", svg, BrowserDragFormats.Svg)).ToList(); + 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); diff --git a/src/Filee.Core/Web/BrowserDragFormats.cs b/src/Filee.Core/Web/BrowserDragFormats.cs index b68476aaf..8bf912a8b 100644 --- a/src/Filee.Core/Web/BrowserDragFormats.cs +++ b/src/Filee.Core/Web/BrowserDragFormats.cs @@ -45,8 +45,6 @@ public static partial class BrowserDragFormats public const string WindowsHtml = "HTML Format"; public const string MacHtml = "public.html"; public const string PlainText = "text/plain"; - public const string Svg = "image/svg+xml"; - public const string MacSvg = "public.svg-image"; // 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 index 62fe2a316..9b01523ee 100644 --- a/src/Filee.Core/Web/InlineSvg.cs +++ b/src/Filee.Core/Web/InlineSvg.cs @@ -1,7 +1,3 @@ -// SVG code selections and inline SVG pictures carried by browser drags. Parse only complete SVG documents; -// ordinary text remains text. No page access, script execution or external XML resolution is involved. - -using System.Net; using System.Text; using System.Text.RegularExpressions; using System.Xml; @@ -9,96 +5,18 @@ 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"; - /// The single SVG of a selection, or null when it contains none or several. - public static byte[]? Read(BrowserDragContent content) - { - var pictures = ReadMany(content); - return pictures.Count == 1 ? pictures[0] : null; - } - - /// Complete outer SVG elements, in selection order, without surrounding page text or images. - public static IReadOnlyList ReadMany(BrowserDragContent content) - { - foreach (var format in new[] { BrowserDragFormats.Svg, BrowserDragFormats.MacSvg, BrowserDragFormats.PlainText, - BrowserDragFormats.Html, BrowserDragFormats.WindowsHtml, BrowserDragFormats.MacHtml }) - { - var pictures = FromSelection(content.Text(format)); - if (pictures.Count > 0) - return pictures; - } - return []; - } - - private static IReadOnlyList FromSelection(string? html) - { - if (string.IsNullOrWhiteSpace(html) || html.Length > MaxCharacters) - return []; - var start = html.IndexOf("", StringComparison.OrdinalIgnoreCase); - var end = html.IndexOf("", StringComparison.OrdinalIgnoreCase); - if (start >= 0 && end > start) - html = html[(start + "".Length)..end]; - try - { - var pictures = Extract(html); - if (pictures.Count > 0) - return pictures; - // Code selections may arrive only as escaped HTML, with syntax-highlighting spans between tokens. - // Decode once, after removing the presentation markup, so XML entities inside SVG stay XML entities. - var code = WebUtility.HtmlDecode(Markup().Replace(html, "")); - return code == html ? [] : Extract(code); - } - catch (RegexMatchTimeoutException) - { - return []; - } - } - - private static IReadOnlyList Extract(string source) - { - if (source.Contains("(); - var depth = 0; - var start = 0; - foreach (Match token in Markup().Matches(source)) - { - if (token.Groups["name"].Value != "svg") - continue; - var closing = token.Groups["close"].Success; - var empty = token.Value.EndsWith("/>", StringComparison.Ordinal); - if (!closing) - { - if (depth == 0) - start = token.Index; - depth++; - } - else if (depth == 0) - return []; - if (closing || empty) - depth--; - if (depth != 0) - continue; - // Nested SVG remains part of its parent; siblings become separate files with distinct names. - if (Parse(source[start..(token.Index + token.Length)]) is not { } svg || pictures.Count >= 128) - return []; - pictures.Add(svg); - } - return depth == 0 ? pictures : []; - } - - private static byte[]? Parse(string? source) + public static byte[]? Read(string? source) { if (string.IsNullOrWhiteSpace(source) || source.Length > MaxCharacters) return null; - source = source.Trim().TrimStart('\uFEFF'); - if (!source.StartsWith('<')) + source = source.Trim(); + if (!source.StartsWith(" Extract(string source) MaxCharactersInDocument = MaxCharacters, }); var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace); - if (document.Root is not { } root || root.Name.LocalName != "svg" + if (document.Root is not { } root || root.IsEmpty || root.Name.LocalName != "svg" + || document.Nodes().Any(node => node != root && (node is not XText text || !string.IsNullOrWhiteSpace(text.Value))) || (root.Name.NamespaceName.Length != 0 && root.Name.NamespaceName != SvgNamespace)) return null; - // Browser selections can contain only an empty SVG shell (for example a collapsed code node). - // Do not let that shell win over another drag format containing the actual drawing. - if (!root.Descendants().Any(e => IsDrawingElement(e) - && !e.Ancestors().TakeWhile(a => a != root).Any(a => - a.Name.LocalName is "defs" or "symbol" or "clipPath" or "mask" or "pattern"))) - return null; - // HTML inline SVG often omits xmlns. Standalone converters need the SVG namespace. + // 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)) @@ -134,18 +47,57 @@ private static IReadOnlyList Extract(string source) } } - private static bool IsDrawingElement(XElement element) => element.Name.LocalName switch + /// 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) { - "path" => !string.IsNullOrWhiteSpace((string?)element.Attribute("d")), - "polyline" or "polygon" => !string.IsNullOrWhiteSpace((string?)element.Attribute("points")), - "text" => !string.IsNullOrWhiteSpace(element.Value), - "use" => element.Attributes().Any(a => a.Name.LocalName == "href" && !string.IsNullOrWhiteSpace(a.Value)), - "image" or "rect" or "circle" or "ellipse" or "line" or "foreignObject" => true, - _ => false, - }; + 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); + } + } - // Consume comments, CDATA and script/style text as whole tokens. Quoted '>' characters are not tag boundaries. - [GeneratedRegex("""||]*>.*?|]*>.*?|<(?/)?(?[A-Za-z_][\w:.-]*)(?=\s|/?>)(?:"[^"]*"|'[^']*'|[^'">])*>""", - RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant, 100)] - private static partial Regex Markup(); + [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 0c23b6cd0..fb0fab63b 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. @@ -42,14 +50,6 @@ public static WebDropItem FromTemporaryFile(string path) => _ => Task.FromResult(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16, useAsync: true)), SizeOf(path), null, null); - /// Pin a dropped temporary file until the queue copies it, even if the browser deletes its pathname. - public static WebDropItem FromTemporaryFile(string path, bool keepOpen) - { - if (!keepOpen) return FromTemporaryFile(path); - var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16, useAsync: true); - return FromStream(Path.GetFileName(path), _ => Task.FromResult(stream), stream.Length, stream); - } - private static long? SizeOf(string path) { try diff --git a/src/Filee.Engines/Vector/SvgRenderer.cs b/src/Filee.Engines/Vector/SvgRenderer.cs index 3fbfcbc61..13d8145a2 100644 --- a/src/Filee.Engines/Vector/SvgRenderer.cs +++ b/src/Filee.Engines/Vector/SvgRenderer.cs @@ -217,16 +217,6 @@ public static MagickImage Rasterize(Drawing drawing, float scale) throw new InvalidOperationException("Could not read the rendered SVG."); } var pixels = straight.GetPixelSpan().ToArray(); - var visible = false; - for (var offset = 3; offset < pixels.Length; offset += 4) - { - if (pixels[offset] == 0) - continue; - visible = true; - break; - } - if (!visible) - throw new InvalidDataException("The SVG rendered no visible content. Include the complete drawing, its referenced definitions and styles."); var image = new MagickImage(); image.ReadPixels(pixels, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, PixelMapping.RGBA)); diff --git a/tests/Filee.App.Tests/BrowserDropTests.cs b/tests/Filee.App.Tests/BrowserDropTests.cs index 2d4bfd950..71431dc9f 100644 --- a/tests/Filee.App.Tests/BrowserDropTests.cs +++ b/tests/Filee.App.Tests/BrowserDropTests.cs @@ -25,59 +25,6 @@ namespace Filee.App.Tests; public class BrowserDropTests { - [AvaloniaFact] - public void Selected_svg_code_and_inline_svg_are_offered_without_a_web_address() - { - TestServices.EnsureInitialized("en"); - const string svg = ""; - foreach (var item in new[] - { - DataTransferItem.CreateText(svg), - Platform(BrowserDragFormats.WindowsHtml, "
" + svg + "
", utf16: false), - Platform(BrowserDragFormats.Svg, svg, utf16: false), - }) - { - using var data = new DataTransfer(); - data.Add(item); - Assert.True(DraggedFiles.Offered(data)); - foreach (var forDrop in new[] { false, true }) - { - var dragged = DraggedFiles.Read(data, forDrop); - using var web = dragged.Web; - Assert.NotNull(web); - Assert.Equal("image.svg", Path.GetFileName(Assert.Single(dragged.Files))); - Assert.False(File.Exists(dragged.Files[0])); - } - } - } - - [AvaloniaFact] - public void Svg_code_toggle_blocks_code_but_keeps_explicit_svg_pictures() - { - TestServices.EnsureInitialized("en"); - var store = AppHost.Get(); - var previous = store.Settings.BrowserDrops.EnableSvgCodeDrag; - const string svg = ""; - try - { - using var code = new DataTransfer(); code.Add(DataTransferItem.CreateText(svg)); - store.Settings.BrowserDrops.EnableSvgCodeDrag = false; - Assert.False(DraggedFiles.Offered(code)); - Assert.Empty(DraggedFiles.Read(code, forDrop: false).Files); - Assert.Empty(DraggedFiles.Read(code, forDrop: true).Files); - using var rendered = new DataTransfer(); rendered.Add(Platform(BrowserDragFormats.Svg, svg, utf16: false)); - var picture = DraggedFiles.Read(rendered, forDrop: true); - using var pictureDrop = picture.Web; - Assert.Single(picture.Files); - store.Settings.BrowserDrops.EnableSvgCodeDrag = true; - Assert.True(DraggedFiles.Offered(code)); - var selected = DraggedFiles.Read(code, forDrop: true); - using var selectionDrop = selected.Web; - Assert.Single(selected.Files); - } - finally { store.Settings.BrowserDrops.EnableSvgCodeDrag = previous; } - } - /// A 1×1 PNG. private static readonly byte[] Png = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); @@ -245,33 +192,6 @@ public void Firefox_temporary_copies_count_as_browser_files_but_files_on_disk_st } } - [AvaloniaFact] - public async Task Chrome_temporary_file_without_url_is_pinned_until_saved() - { - TestServices.EnsureInitialized("en"); - var folder = Path.Combine(Path.GetTempPath(), "chrome_drag" + Environment.ProcessId + "_" + DateTime.UtcNow.Ticks); - Directory.CreateDirectory(folder); - var path = Path.Combine(folder, "image.png"); - var destination = Path.Combine(folder, "saved"); - File.WriteAllBytes(path, Png); - try - { - using var data = new DataTransfer(); - data.Add(DataTransferItem.CreateFile(VirtualFile.Create("image.png", Png, path).Item)); - var preview = DraggedFiles.Read(data, forDrop: false); - Assert.NotNull(preview.Web); preview.Web.Dispose(); - var dropped = DraggedFiles.Read(data, forDrop: true); - using var web = dropped.Web; - Assert.NotNull(web); - File.Delete(path); // Chromium cleans up its temporary source after the OS drop. - var fetcher = web.CreateFetcher(new WebDropOptions(destination, false), AppHost.Get()); - var output = await fetcher.FetchAsync(Assert.Single(dropped.Files), new Progress(), CancellationToken.None); - Assert.Equal(Png, File.ReadAllBytes(output)); - Assert.Equal(destination, Path.GetDirectoryName(output)); - } - finally { Directory.Delete(folder, recursive: true); } - } - private static IStorageItem StorageItem(string path) => VirtualFile.Create(Path.GetFileName(path), Png, path).Item; [AvaloniaFact] @@ -322,11 +242,11 @@ public void A_picture_dropped_on_a_donut_slice_is_saved_converted_and_only_the_c } [AvaloniaTheory] - [InlineData(false, 1)] - [InlineData(true, 1)] - [InlineData(false, 3)] - [InlineData(true, 3)] - public void Svg_selection_uses_the_existing_ctrl_drag_donut_and_conversion(bool rendered, int count) + [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(); @@ -335,36 +255,49 @@ public void Svg_selection_uses_the_existing_ctrl_drag_donut_and_conversion(bool 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; - const string svg = ""; - var selection = count == 1 ? svg : "

Features

    " + - string.Concat(Enumerable.Repeat("
  • Caption" + svg + "
  • ", count)) + - "
"; - using var data = new DataTransfer(); - data.Add(rendered - ? Platform(BrowserDragFormats.WindowsHtml, "" + selection + "", utf16: false) - : DataTransferItem.CreateText(selection)); root.RaiseEvent(new DragEventArgs(DragDrop.DragEnterEvent, data, root, new Point(5, 5), KeyModifiers.Control)); Pump(); var model = AppHost.Get(); - Assert.Equal(count, model.Files.Count); - Assert.All(model.Files, file => Assert.Equal(".svg", Path.GetExtension(file))); - var (at, preset) = FirstSlice(donut, model); + 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.True(conversions.Jobs.Count > before, $"no conversion started for {preset}"); + Assert.Equal(before + 1, conversions.Jobs.Count); var job = WaitFor(conversions.Jobs[0]); - Assert.Equal(JobState.Completed, job.State); - Assert.Equal(count, job.Outputs.Count()); - foreach (var output in job.Outputs) + 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(folder, Path.GetDirectoryName(output)); - Assert.True(new FileInfo(output).Length > 0); + 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); + } } - Assert.Equal(job.Outputs.Order(), Directory.GetFiles(folder).Order()); } finally { @@ -373,8 +306,23 @@ public void Svg_selection_uses_the_existing_ctrl_drag_donut_and_conversion(bool } } + [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; @@ -383,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 index 607d99364..18b3b78bd 100644 --- a/tests/Filee.Core.Tests/InlineSvgTests.cs +++ b/tests/Filee.Core.Tests/InlineSvgTests.cs @@ -1,5 +1,3 @@ -// Browser SVG selections become standalone vector files; unrelated or incomplete selections remain ignored. - using System.Text; using System.Xml.Linq; using Filee.Core.Web; @@ -8,134 +6,66 @@ namespace Filee.Core.Tests; public class InlineSvgTests { - private static BrowserDragContent Drag(string text, string format = BrowserDragFormats.PlainText) => - new() { Texts = new Dictionary { [format] = text } }; - - [Theory] - [InlineData("")] - [InlineData("")] - [InlineData("")] - public void Empty_svg_shells_do_not_become_blank_successful_conversions(string text) => - Assert.Empty(InlineSvg.ReadMany(Drag(text))); - - [Fact] - public void Empty_plain_text_does_not_hide_complete_html_drawing() - { - var content = new BrowserDragContent - { - Texts = new Dictionary - { - [BrowserDragFormats.PlainText] = "", - [BrowserDragFormats.Html] = "", - } - }; - Assert.Single(InlineSvg.ReadMany(content)); - } - [Theory] - [InlineData(BrowserDragFormats.PlainText)] - [InlineData(BrowserDragFormats.Svg)] - [InlineData(BrowserDragFormats.MacSvg)] - [InlineData(BrowserDragFormats.Html)] - public void Complete_svg_keeps_its_vector_content_and_gets_a_namespace(string format) - { - var bytes = InlineSvg.Read(Drag("中文 & SVG", format)); - Assert.NotNull(bytes); - var root = XElement.Parse(Encoding.UTF8.GetString(bytes)); - XNamespace ns = "http://www.w3.org/2000/svg"; - Assert.Equal(ns + "svg", root.Name); - Assert.Equal("0 0 20 20", (string?)root.Attribute("viewBox")); - Assert.Equal("M0 0L20 20", (string?)root.Element(ns + "path")?.Attribute("d")); - Assert.Equal("中文 & SVG", root.Element(ns + "text")?.Value); - } - - [Fact] - public void A_windows_html_fragment_ignores_the_page_context_and_keeps_nested_svg() - { - var html = "Version:0.9\r\nSourceURL:https://example.com/page\r\nOutside" + - "" + - "DiagramLabelOutside"; - var result = InlineSvg.Read(Drag(html, BrowserDragFormats.WindowsHtml)); - Assert.NotNull(result); - Assert.Contains("Label", Encoding.UTF8.GetString(result)); - Assert.DoesNotContain("Outside", Encoding.UTF8.GetString(result)); - } - - [Fact] - public void Xml_declarations_namespaces_and_local_references_survive() + [InlineData("")] + [InlineData("")] + [InlineData(" \r\nA & B\t ")] + [InlineData("")] + public void A_single_well_formed_svg_is_accepted(string source) { - const string source = "" + - ""; - var bytes = InlineSvg.Read(Drag(source)); + var bytes = InlineSvg.Read(source); Assert.NotNull(bytes); - var root = XElement.Parse(Encoding.UTF8.GetString(bytes)); - Assert.Equal("#shape", root.Descendants().Single(e => e.Name.LocalName == "use") - .Attribute(XName.Get("href", "http://www.w3.org/1999/xlink"))?.Value); - Assert.DoesNotContain("utf-16", Encoding.UTF8.GetString(bytes)); - } - - [Fact] - public void Real_files_keep_priority_over_a_code_selection() - { - using var file = WebDropItem.FromBytes("existing.png", [1, 2, 3]); - var content = new BrowserDragContent - { - Files = [file], - Texts = new Dictionary { [BrowserDragFormats.PlainText] = "" }, - }; - Assert.Same(file, Assert.Single(BrowserDrag.Files(content))); + 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("

text

")] - [InlineData("")] - [InlineData("]>&x;")] - [InlineData("text")] - public void Other_text_invalid_xml_and_external_entities_are_not_files(string text) - { - Assert.Null(InlineSvg.Read(Drag(text))); - Assert.Empty(BrowserDrag.Files(Drag(text))); - } - - [Theory] - [InlineData("

Caption

", 1)] - [InlineData("", 2)] - [InlineData("", 1)] - [InlineData("
Caption
", 3)] - [InlineData("
<svg><text>A &amp; B</text></svg>
", 1)] - [InlineData("", 1)] - public void Complete_svgs_are_extracted_from_mixed_and_escaped_selections(string html, int count) - { - Assert.Equal(count, InlineSvg.ReadMany(Drag(html, BrowserDragFormats.Html)).Count); - var files = BrowserDrag.Files(Drag(html)); - Assert.Equal(count, files.Count); - Assert.Equal(count, files.Select(f => f.Name).Distinct().Count()); - foreach (var file in files) file.Dispose(); - } + [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 Incomplete_and_excessive_batches_are_ignored() + 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) { - Assert.Empty(InlineSvg.ReadMany(Drag(""))); - Assert.Empty(InlineSvg.ReadMany(Drag(string.Concat(Enumerable.Repeat("", 129))))); + 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 Oversized_selection_is_ignored() => - Assert.Null(InlineSvg.Read(Drag("" + new string(' ', InlineSvg.MaxCharacters) + ""))); - - [Fact] - public async Task Svg_is_saved_through_the_existing_browser_file_pipeline() + public void Html_is_not_used_as_a_fallback_for_selected_svg() { - using var dir = new TempDir(); - using var downloader = new WebDownloader(); - using var item = Assert.Single(BrowserDrag.Files(Drag(""))); - Assert.Equal("image.svg", item.Name); - var path = await item.SaveAsync(dir.Path, downloader, null, CancellationToken.None); - Assert.Equal("svg", XElement.Load(path).Name.LocalName); + var drag = new BrowserDragContent + { + Texts = new Dictionary + { + [BrowserDragFormats.PlainText] = "not SVG", + [BrowserDragFormats.Html] = "" + } + }; + Assert.Empty(BrowserDrag.Files(drag)); } } diff --git a/tests/Filee.Engines.Tests/GraphicsTests.cs b/tests/Filee.Engines.Tests/GraphicsTests.cs index 7c61780af..32bc17471 100644 --- a/tests/Filee.Engines.Tests/GraphicsTests.cs +++ b/tests/Filee.Engines.Tests/GraphicsTests.cs @@ -17,20 +17,6 @@ namespace Filee.Engines.Tests; public class GraphicsTests(EngineFixture fx) : IClassFixture { - [Theory] - [InlineData("")] - [InlineData("")] - public void Empty_svg_renders_fail_instead_of_writing_blank_images(string contents) - { - var path = Write(fx.NewFolder(), "empty.svg", - "" + contents + ""); - Assert.Throws(() => - { - using var drawing = SvgRenderer.Load(path); - using var image = SvgRenderer.Rasterize(drawing, 1); - }); - } - private static bool GhostscriptInstalled => GhostscriptConverter.Locate() is not null; private static void AssertDone(ConversionJob job)