diff --git a/Directory.Build.props b/Directory.Build.props index e075473..d309ca7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ scripts/build-installer.ps1 both read it from here, so releasing is a reviewed change to this line rather than an edit in a pipeline variable group. --> - 1.0.3 + 1.1.0 latest enable enable diff --git a/README.md b/README.md index 80f3b1d..9c973e7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ How the project is developed and shipped is documented separately: - Basic palm rejection: touch navigation is suspended when the pen makes contact - Mouse-wheel zoom and middle-button or temporary Space-key panning - Whole-stroke erasing -- PNG, JPEG, BMP, and GIF import, clipboard bitmap paste, and Explorer drag-and-drop of images, text files, and `.wimport` recipes +- PNG, JPEG, BMP, GIF, and SVG import, clipboard bitmap paste, and Explorer drag-and-drop of images, text files, and `.wimport` recipes +- SVG stays vector: it is stored as its markup and redrawn at every zoom and resize rather than rasterized on arrival. Pasting SVG markup that was copied as text — the output of a DAX SVG measure, for instance — creates a picture, and copying an SVG container puts both the markup and a bitmap on the clipboard - Image selection, movement, resizing, and deletion - Text containers created by pasting plain text, with display and in-place edit modes - Plain-text, DAX, and SQL Server language modes, with live syntax highlighting and local F6 formatting diff --git a/docs/decisions.md b/docs/decisions.md index 498ea48..6dea65c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -358,6 +358,32 @@ version its first submission settles on. --- +## 21. SVG is kept as markup and drawn by SharpVectors + +**Implemented.** + +WPF has no SVG decoder, so supporting SVG at all meant taking a rendering library — +including the option of rasterizing on arrival, which needs one just the same. That made +the choice about which library, not whether to have one. + +`SharpVectors.Wpf` produces a `DrawingGroup`, so an SVG container is redrawn at whatever +size it is displayed at rather than stretched from pixels. That is the point of accepting +SVG on a canvas whose zoom is unbounded. It is BSD-3, managed-only, and ships no native +binary, which keeps it out of the signing step and out of the per-architecture question +the installer would otherwise have to answer. Direct2D was the tempting alternative, since +`Vortice` is already referenced and would have cost nothing: it implements a restricted +SVG subset with no `` element, which is most of what a DAX SVG measure emits. + +Assets are stored as the bytes that arrived, and `BoardArchive` has always treated them as +opaque, so the format version did not move. A board holding an SVG opened in 1.0.3 draws +the missing-image placeholder for it rather than failing to open. + +The renderer is given `ExternalResourcesAccessModes.Ignore`. Its default is to fetch what +the markup names, which would let a pasted or dropped file turn opening a board into an +outbound request. + +--- + ## Open questions - arm64 is not built; add it if Surface devices matter for a pen application. diff --git a/docs/samples/contoso-workshop.wimport b/docs/samples/contoso-workshop.wimport index 72f9c7c..4de846b 100644 --- a/docs/samples/contoso-workshop.wimport +++ b/docs/samples/contoso-workshop.wimport @@ -17,6 +17,9 @@ Total Sales := SUM(Sales[Amount]) ## Test picture ![demo](../../src/SQLBI.Whiteboard/Assets/SQLBI.Whiteboard.png) +## Test vector +![the same mark, as SVG](../../src/SQLBI.Whiteboard/Assets/SQLBI.Whiteboard.svg) + ## Warehouse query ```sql SELECT TOP (10) diff --git a/docs/wimport.md b/docs/wimport.md index 7bd7b49..c53fe35 100644 --- a/docs/wimport.md +++ b/docs/wimport.md @@ -46,7 +46,10 @@ ignored. - Image container. - Title is the `##` heading. If the heading is empty, the alt text is used, then the file name. - - Allowed extensions: `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`. + - Allowed extensions: `.png`, `.jpg`, `.jpeg`, `.bmp`, `.gif`, `.svg`. + - An `.svg` stays vector on the board. Whiteboard sizes it from the `width`/`height` or + the `viewBox`, so give the file one; and anything the markup fetches from elsewhere — + an external stylesheet, an `` pointing outside the file — is ignored. 2. **Fenced code** whose info-string is a registered language - Text container. Contents of the fence. Language from the tag. 3. **Markdown link** to an image extension — `[label](relative/path.png)` @@ -131,7 +134,7 @@ A complete sample is `docs/samples/contoso-workshop.wimport`. - File name ends in `.wimport`. - Every container is a `##` heading with exactly one payload. - Images and linked code exist next to the file, using relative paths. -- Image files are png/jpeg/bmp/gif. Code files you link are `.dax` or `.sql`, or the code is +- Image files are png/jpeg/bmp/gif/svg. Code files you link are `.dax` or `.sql`, or the code is embedded in a `dax` / `sql` / `tsql` fence. - Row breaks are a thematic break on its own line, not a fake heading. - You did not use `http://`, YAML, or explicit positions. @@ -141,7 +144,8 @@ A complete sample is `docs/samples/contoso-workshop.wimport`. - Write a `.md` and expect Whiteboard to import it. - Ask Whiteboard to save or export `.wimport`. -- Embed bitmap bytes. Images are always a link to a file. +- Embed bitmap bytes, or paste SVG markup into the document. Images are always a link to a + file, whatever their format. - Mix two payloads in one `##` section. - Use HTML, YAML front matter, or custom XML. - Reference network URLs. diff --git a/installer/msix/STORE-LISTING.md b/installer/msix/STORE-LISTING.md index 63260d9..5c9e462 100644 --- a/installer/msix/STORE-LISTING.md +++ b/installer/msix/STORE-LISTING.md @@ -178,7 +178,7 @@ Live application capture — put any window or display on the board with LiveVie Code containers — paste DAX or T-SQL and get syntax highlighting, a language-aware title, and F6 to format the code in place. Plain text works the same way. -Images and text — import PNG, JPEG, BMP, and GIF, paste from the clipboard, or drag files in from File Explorer. Ink that touches a container travels with it when you move or resize it. +Images and text — import PNG, JPEG, BMP, GIF, and SVG, paste from the clipboard, or drag files in from File Explorer. SVG stays vector, so it is still sharp when you enlarge it. Ink that touches a container travels with it when you move or resize it. Portable board files — boards save as .wboard files with an embedded preview, so File Explorer and Visual Studio Code both show what is inside. diff --git a/installer/winget/SQLBI.Whiteboard.locale.en-US.yaml b/installer/winget/SQLBI.Whiteboard.locale.en-US.yaml index 4916310..1f11525 100644 --- a/installer/winget/SQLBI.Whiteboard.locale.en-US.yaml +++ b/installer/winget/SQLBI.Whiteboard.locale.en-US.yaml @@ -29,7 +29,7 @@ Description: |- Code containers: paste SQL or DAX and get syntax highlighting, a language-aware title, and F6 to format the code in place. Plain text works the same way. - Images and text: import PNG, JPEG, BMP, and GIF, paste from the clipboard, or drag files in from File Explorer. Ink that touches a container travels with it when you move or resize it. + Images and text: import PNG, JPEG, BMP, GIF, and SVG, paste from the clipboard, or drag files in from File Explorer. Ink that touches a container travels with it when you move or resize it. Portable board files: boards save as .wboard files with an embedded preview, so File Explorer and Visual Studio Code both show what is inside. diff --git a/site/guide.html b/site/guide.html index edd7762..fc13ec7 100644 --- a/site/guide.html +++ b/site/guide.html @@ -686,11 +686,12 @@

Navigation

The window can sit in a narrow strip beside another app. F11 fills the monitor and hides the title and tabs. Ctrl+F11 hides the same chrome and keeps the window where it is. Escape leaves either unless a text container is being edited.

Containers

-

Images, LiveViews, and text blocks are containers. With a mouse, click to move, drag the circular handle to resize while keeping aspect ratio. Releasing the mouse returns to the drawing tool you had. Imported images accept PNG, JPEG, BMP, and GIF, including clipboard paste and Explorer drag-and-drop.

+

Images, LiveViews, and text blocks are containers. With a mouse, click to move, drag the circular handle to resize while keeping aspect ratio. Releasing the mouse returns to the drawing tool you had. Imported images accept PNG, JPEG, BMP, GIF, and SVG, including clipboard paste and Explorer drag-and-drop. An SVG is kept as its markup and redrawn at every size, so enlarging one costs it nothing.

View → Bring to front and View → Send to back reorder the selected container and its linked strokes. Strokes cannot be reordered on their own.

Text, SQL, and DAX

Paste prefers an image when the clipboard has one, including a file SnagIt or another capture tool left on the clipboard. Paste plain text to create a selected text container.

+

SVG counts as an image, whether the source application published it as SVG or the markup was simply copied as text — the output of a DAX SVG measure pastes as a picture, not as a snippet. Copying an SVG container puts the markup and a bitmap on the clipboard together, so an editor receives the source and a slide receives the picture.

Help → Preferences sets Snippet format order: paste tries those languages from top to bottom. Plain text always matches, so putting it first keeps paste as plain text. Use the title-bar chip to choose Plain text, SQL Server, or DAX afterward.

F6 formats SQL or DAX on the selected container, without entering edit mode. F2 edits the container, and double-click still centers it and fits it to the canvas; inside an edit F6 formats in place and Ctrl+Enter commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps GO batch separators, and leaves an invalid script unchanged. Escape restores the previous text, language, and size.

diff --git a/site/wimport.html b/site/wimport.html index 211f420..e11b99f 100644 --- a/site/wimport.html +++ b/site/wimport.html @@ -72,7 +72,7 @@

Heading grammar

How the body becomes a container

Recognizers run in this order. The first match wins. Extra material after the match is ignored.

    -
  1. Markdown image: ![optional alt](relative/path.png). Image container. Title is the ## heading, then the alt text, then the file name. Allowed extensions: .png, .jpg, .jpeg, .bmp, .gif.
  2. +
  3. Markdown image: ![optional alt](relative/path.png). Image container. Title is the ## heading, then the alt text, then the file name. Allowed extensions: .png, .jpg, .jpeg, .bmp, .gif, .svg. An .svg stays vector on the board, so give it a width/height or a viewBox to be sized from; anything it fetches from elsewhere is ignored.
  4. Fenced code whose info-string is a registered language. Text container. Contents of the fence. Language from the tag.
  5. Markdown link to an image extension: same as an image.
  6. Markdown link to a registered language extension: [label](relative/path.dax). Text container. File contents. Language from the extension.
  7. @@ -134,7 +134,7 @@

    Checklist

  8. File name ends in .wimport.
  9. Every container is a ## heading with exactly one payload.
  10. Images and linked code exist next to the file, using relative paths.
  11. -
  12. Image files are png/jpeg/bmp/gif. Linked code files are .dax or .sql, or the code is embedded in a dax / sql / tsql fence.
  13. +
  14. Image files are png/jpeg/bmp/gif/svg. Linked code files are .dax or .sql, or the code is embedded in a dax / sql / tsql fence.
  15. Row breaks are a thematic break on its own line, not a fake heading.
  16. You did not use http://, YAML, or explicit positions.
  17. Opening the file in a Markdown preview still reads as a normal document.
  18. diff --git a/src/SQLBI.Whiteboard.Core/Import/DroppedFileImport.cs b/src/SQLBI.Whiteboard.Core/Import/DroppedFileImport.cs index ce96b9b..60e621e 100644 --- a/src/SQLBI.Whiteboard.Core/Import/DroppedFileImport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/DroppedFileImport.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace SQLBI.Whiteboard.Core.Import; public enum DroppedFileKind @@ -11,7 +13,12 @@ public enum DroppedFileKind public static class DroppedFileImport { public const int MaximumTextBytes = 1_000_000; + public const int MaximumSvgBytes = 16_000_000; public const string ImportExtension = ".wimport"; + public const string SvgExtension = ".svg"; + public const string SvgContentType = "image/svg+xml"; + + private const char ByteOrderMark = '\uFEFF'; private static readonly HashSet PlainTextExtensions = new(StringComparer.OrdinalIgnoreCase) { @@ -101,4 +108,111 @@ public static bool LooksLikeText(byte[] bytes) return true; } + + /// + /// Recognizes an SVG document from its markup. Clipboard content arrives without a + /// file name, so the shape of the text is the only signal there is. The root element + /// has to be the SVG one — a page of HTML with a picture inside it carries the same + /// tags and is not an image. + /// + public static bool LooksLikeSvg(string? markup) + { + if (string.IsNullOrWhiteSpace(markup)) + { + return false; + } + + var text = markup.TrimStart(ByteOrderMark).TrimStart(); + while (TryMeasurePrologue(text, out var prologue)) + { + text = text[prologue..].TrimStart(); + } + + if (text.Length < 2 || text[0] != '<') + { + return false; + } + + var end = 1; + while (end < text.Length && + !char.IsWhiteSpace(text[end]) && + text[end] is not ('>' or '/')) + { + end++; + } + + if (end == text.Length) + { + return false; + } + + // Any prefix may be bound to the SVG namespace, so compare the local name. + var name = text[1..end]; + var colon = name.LastIndexOf(':'); + return string.Equals( + colon >= 0 ? name[(colon + 1)..] : name, + "svg", + StringComparison.OrdinalIgnoreCase); + } + + public static bool LooksLikeSvg(byte[]? bytes) + { + if (bytes is null || bytes.Length is 0 or > MaximumSvgBytes) + { + return false; + } + + // Bitmaps fail on their first byte, so the decode below only runs for markup. + var start = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF + ? 3 + : 0; + while (start < bytes.Length && bytes[start] is 0x20 or 0x09 or 0x0D or 0x0A) + { + start++; + } + + if (start >= bytes.Length || bytes[start] != (byte)'<') + { + return false; + } + + try + { + return LooksLikeSvg(new UTF8Encoding(false, throwOnInvalidBytes: true) + .GetString(bytes, start, bytes.Length - start)); + } + catch (ArgumentException) + { + return false; + } + } + + /// + /// Measures an XML declaration, comment, or doctype sitting ahead of the root element. + /// + private static bool TryMeasurePrologue(string text, out int length) + { + length = 0; + var (opening, closing) = text switch + { + _ when text.StartsWith(" (""), + _ when text.StartsWith(""), + _ when text.StartsWith(" (""), + _ => (string.Empty, string.Empty), + }; + + if (opening.Length == 0) + { + return false; + } + + var end = text.IndexOf(closing, opening.Length, StringComparison.Ordinal); + if (end < 0) + { + return false; + } + + length = end + closing.Length; + return true; + } } diff --git a/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs b/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs index 43601ba..d282421 100644 --- a/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs +++ b/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs @@ -28,7 +28,7 @@ public sealed class ImportCatalog Extensions = [".sql"], }, ], - [".png", ".jpg", ".jpeg", ".bmp", ".gif"]); + [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg"]); public ImportCatalog( IReadOnlyList languages, diff --git a/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs b/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs index 830a449..e049b9b 100644 --- a/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs +++ b/src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs @@ -8,6 +8,7 @@ public static class ImportLayout public const double Gap = 32; public const double MaxImageWidth = 900; public const double MaxImageHeight = 700; + public const double MinVectorImageEdge = 240; public static IReadOnlyList Place( IReadOnlyList<(double Width, double Height, bool StartNewRow)> items, @@ -50,4 +51,17 @@ public static (double Width, double Height) ImageSize(double naturalWidth, doubl var scale = Math.Min(1, Math.Min(MaxImageWidth / width, MaxImageHeight / height)); return (width * scale, height * scale); } + + /// + /// Sizes a vector image. Icons are authored at 16 or 24 units and would arrive as a + /// speck on a board whose rows are 2400 wide, so a vector is grown to a legible edge + /// first. Enlarging costs a bitmap its sharpness and a vector nothing. + /// + public static (double Width, double Height) VectorImageSize(double naturalWidth, double naturalHeight) + { + var width = Math.Max(1, naturalWidth); + var height = Math.Max(1, naturalHeight); + var grow = Math.Max(1, MinVectorImageEdge / Math.Max(width, height)); + return ImageSize(width * grow, height * grow); + } } diff --git a/src/SQLBI.Whiteboard/BoardImageCodec.cs b/src/SQLBI.Whiteboard/BoardImageCodec.cs new file mode 100644 index 0000000..f3d10eb --- /dev/null +++ b/src/SQLBI.Whiteboard/BoardImageCodec.cs @@ -0,0 +1,84 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using SQLBI.Whiteboard.Core.Geometry; +using SQLBI.Whiteboard.Core.Import; + +namespace SQLBI.Whiteboard; + +/// +/// A decoded image asset. decides how it is sized on arrival and +/// whether the clipboard can be handed the markup as well as a picture. +/// +internal readonly record struct BoardImage( + ImageSource Source, + double NaturalWidth, + double NaturalHeight, + bool IsVector); + +/// +/// The one place an asset's bytes become something drawable. Assets are stored as they +/// arrived and carry a content type, but boards written before SVG support have none, so +/// the bytes themselves decide which decoder runs. +/// +internal static class BoardImageCodec +{ + public static BoardImage Decode(byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + + if (DroppedFileImport.LooksLikeSvg(bytes)) + { + var drawing = SvgImageCodec.Decode(bytes); + var bounds = drawing.Drawing?.Bounds ?? Rect.Empty; + return new BoardImage( + drawing, + Math.Max(1, bounds.Width), + Math.Max(1, bounds.Height), + IsVector: true); + } + + var bitmap = WpfImageCodec.Decode(bytes); + return new BoardImage( + bitmap, + Math.Max(1, bitmap.PixelWidth), + Math.Max(1, bitmap.PixelHeight), + IsVector: false); + } + + /// + /// The size a newly arrived image takes on the board. + /// + public static (double Width, double Height) ArrivalSize(BoardImage image) => image.IsVector + ? ImportLayout.VectorImageSize(image.NaturalWidth, image.NaturalHeight) + : ImportLayout.ImageSize(image.NaturalWidth, image.NaturalHeight); + + /// + /// Flattens an image to pixels for the clipboard, which cannot carry a WPF drawing. + /// A vector is rendered at whichever is larger of the size it is shown at and its + /// natural size, so the copy is never coarser than either. + /// + public static BitmapSource Rasterize(BoardImage image, RectD? shownAs = null) + { + if (image.Source is BitmapSource bitmap) + { + return bitmap; + } + + var scale = shownAs is { } bounds + ? Math.Max(1, bounds.Width / image.NaturalWidth) + : 1; + var width = (int)Math.Clamp(Math.Round(image.NaturalWidth * scale), 1, 8192); + var height = (int)Math.Clamp(Math.Round(image.NaturalHeight * scale), 1, 8192); + var visual = new DrawingVisual(); + using (var context = visual.RenderOpen()) + { + context.DrawImage(image.Source, new Rect(0, 0, width, height)); + } + + var target = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); + target.Render(visual); + target.Freeze(); + return target; + } +} diff --git a/src/SQLBI.Whiteboard/BoardSurface.cs b/src/SQLBI.Whiteboard/BoardSurface.cs index da8f529..f3c30a2 100644 --- a/src/SQLBI.Whiteboard/BoardSurface.cs +++ b/src/SQLBI.Whiteboard/BoardSurface.cs @@ -2,7 +2,6 @@ using System.Windows.Ink; using System.Windows.Input; using System.Windows.Media; -using System.Windows.Media.Imaging; using SQLBI.Whiteboard.Core.Geometry; using SQLBI.Whiteboard.Core.Model; using SQLBI.Whiteboard.Core.Viewport; @@ -17,7 +16,7 @@ internal sealed class BoardSurface : FrameworkElement private static readonly Brush MissingImageBrush = CreateFrozenBrush(0xFFE5E7EB); private static readonly Pen MissingImagePen = CreateFrozenPen(0xFF9CA3AF, 1); - private readonly Dictionary _bitmapCache = new(StringComparer.Ordinal); + private readonly Dictionary _imageCache = new(StringComparer.Ordinal); private BoardDocument? _document; private Camera2D? _camera; @@ -33,13 +32,13 @@ public void Configure(BoardDocument document, Camera2D camera) { _document = document; _camera = camera; - _bitmapCache.Clear(); + _imageCache.Clear(); InvalidateVisual(); } public void InvalidateAssets() { - _bitmapCache.Clear(); + _imageCache.Clear(); InvalidateVisual(); } @@ -129,9 +128,9 @@ private void DrawImage( Math.Max(1, bottomRight.X - topLeft.X), Math.Max(1, bottomRight.Y - topLeft.Y)); - if (TryGetBitmap(image.AssetId, document, out var bitmap)) + if (TryGetImage(image.AssetId, document, out var source)) { - drawingContext.DrawImage(bitmap, destination); + drawingContext.DrawImage(source, destination); } else { @@ -149,7 +148,7 @@ private void DrawLiveView( ImageSource? source = LiveViewImageSourceProvider?.Invoke(liveView.Id); if (source is null && liveView.SnapshotAssetId is { } assetId && - TryGetBitmap(assetId, document, out BitmapSource? snapshot)) + TryGetImage(assetId, document, out ImageSource? snapshot)) { source = snapshot; } @@ -190,31 +189,31 @@ private double LanguageChipTitleReserve(TextBoardObject text) : 0; } - private bool TryGetBitmap( + private bool TryGetImage( string assetId, BoardDocument document, - out BitmapSource? bitmap) + out ImageSource? image) { - if (_bitmapCache.TryGetValue(assetId, out bitmap)) + if (_imageCache.TryGetValue(assetId, out image)) { return true; } if (!document.Assets.TryGetValue(assetId, out var asset)) { - bitmap = null; + image = null; return false; } try { - bitmap = WpfImageCodec.Decode(asset.Data); - _bitmapCache[assetId] = bitmap; + image = BoardImageCodec.Decode(asset.Data).Source; + _imageCache[assetId] = image; return true; } catch { - bitmap = null; + image = null; return false; } } diff --git a/src/SQLBI.Whiteboard/ClipboardImage.cs b/src/SQLBI.Whiteboard/ClipboardImage.cs index 08aefef..71150f8 100644 --- a/src/SQLBI.Whiteboard/ClipboardImage.cs +++ b/src/SQLBI.Whiteboard/ClipboardImage.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.IO; +using System.Text; using System.Windows; using System.Windows.Media.Imaging; using SQLBI.Whiteboard.Core.Import; @@ -22,6 +23,55 @@ public static string[] GetImportableFiles() return DroppedFileImport.CanImportAny(paths) ? paths : []; } + /// + /// The clipboard format names that carry SVG markup. Illustrator, Inkscape, Office, + /// Figma, and the browsers each publish their own; the last two are what a plain + /// "copy as SVG" command tends to use. + /// + public static readonly string[] SvgFormats = + [ + "image/svg+xml", + "image/svg-xml", + "image/x-inkscape-svg", + "Scalable Vector Graphics", + "SVG", + ]; + + /// + /// Reads SVG off the clipboard, either as one of its own formats or as markup that + /// was copied as text — which is how an SVG built by a DAX measure arrives. + /// + public static byte[]? TryGetSvgBytes() + { + var data = Clipboard.GetDataObject(); + if (data is not null) + { + foreach (var format in SvgFormats) + { + if (!data.GetDataPresent(format, autoConvert: false)) + { + continue; + } + + var bytes = ReadBytes(data.GetData(format)) ?? ReadText(data.GetData(format)); + if (DroppedFileImport.LooksLikeSvg(bytes)) + { + return bytes; + } + } + } + + if (!Clipboard.ContainsText(TextDataFormat.UnicodeText)) + { + return null; + } + + var text = Clipboard.GetText(TextDataFormat.UnicodeText); + return DroppedFileImport.LooksLikeSvg(text) + ? new UTF8Encoding(false).GetBytes(text) + : null; + } + public static byte[]? TryGetEncodedPng() => TryGetPngBytes() ?? TryGetDibPng() ?? TryEncodeWpfImage(); @@ -166,6 +216,9 @@ private static bool IsPng(byte[]? bytes) => bytes[2] == (byte)'N' && bytes[3] == (byte)'G'; + private static byte[]? ReadText(object? data) => + data is string text ? new UTF8Encoding(false).GetBytes(text) : null; + private static byte[]? ReadBytes(object? data) => data switch { MemoryStream stream => CopyStream(stream), diff --git a/src/SQLBI.Whiteboard/MainWindow.xaml.cs b/src/SQLBI.Whiteboard/MainWindow.xaml.cs index bbc0454..145d700 100644 --- a/src/SQLBI.Whiteboard/MainWindow.xaml.cs +++ b/src/SQLBI.Whiteboard/MainWindow.xaml.cs @@ -3307,7 +3307,7 @@ private async Task ImportImageAsync() { Title = "Import", Filter = - "Importable files|*.wimport;*.png;*.jpg;*.jpeg;*.bmp;*.gif|Whiteboard import|*.wimport|Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif", + "Importable files|*.wimport;*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.svg|Whiteboard import|*.wimport|Images|*.png;*.jpg;*.jpeg;*.bmp;*.gif;*.svg", Multiselect = false, }; if (dialog.ShowDialog(this) != true) @@ -3343,6 +3343,18 @@ private async Task PasteFromClipboardAsync() return; } + // Ahead of the bitmap: an application that offers both is offering the same + // picture twice, and only one of the two survives being enlarged. + byte[]? svg = ClipboardImage.TryGetSvgBytes(); + if (svg is not null) + { + AddImage( + svg, + "clipboard-image" + DroppedFileImport.SvgExtension, + DroppedFileImport.SvgContentType); + return; + } + byte[]? png = ClipboardImage.TryGetEncodedPng(); if (png is not null) { @@ -3368,16 +3380,12 @@ private void AddImage( string contentType, PointD? worldCenter = null) { - var bitmap = WpfImageCodec.Decode(bytes); + var decoded = BoardImageCodec.Decode(bytes); var assetId = Guid.NewGuid().ToString("N"); _document.AddAsset(new BoardAsset(assetId, fileName, contentType, bytes)); SceneSurface.InvalidateAssets(); - var naturalWidth = Math.Max(1, bitmap.PixelWidth); - var naturalHeight = Math.Max(1, bitmap.PixelHeight); - var scale = Math.Min(1, Math.Min(900d / naturalWidth, 700d / naturalHeight)); - var width = naturalWidth * scale; - var height = naturalHeight * scale; + var (width, height) = BoardImageCodec.ArrivalSize(decoded); var center = worldCenter ?? _camera.Center; var image = new ImageBoardObject( Guid.NewGuid(), @@ -4091,7 +4099,26 @@ private void CopySelectionToClipboard() return; } - Clipboard.SetImage(WpfImageCodec.Decode(asset.Data)); + BoardImage copied = BoardImageCodec.Decode(asset.Data); + if (!copied.IsVector) + { + Clipboard.SetImage(BoardImageCodec.Rasterize(copied)); + return; + } + + var flattened = BoardImageCodec.Rasterize(copied, selected.Bounds); + + // A vector goes out as both, so an editor receives the markup and a slide + // receives a picture, and pasting it back into a board keeps it a vector. + var vector = new DataObject(); + vector.SetData( + DroppedFileImport.SvgContentType, + new MemoryStream(asset.Data, writable: false)); + vector.SetText( + System.Text.Encoding.UTF8.GetString(asset.Data), + TextDataFormat.UnicodeText); + vector.SetImage(flattened); + Clipboard.SetDataObject(vector, copy: true); } catch (Exception exception) { @@ -4249,8 +4276,8 @@ private void ApplyImport(ImportDocument imported, PointD originTopLeft, bool rec try { - var bitmap = WpfImageCodec.Decode(item.ImageBytes); - var (width, height) = ImportLayout.ImageSize(bitmap.PixelWidth, bitmap.PixelHeight); + var (width, height) = BoardImageCodec.ArrivalSize( + BoardImageCodec.Decode(item.ImageBytes)); sizes.Add((width, height, item.StartNewRow)); decoded.Add((item, item.ImageBytes, width, height)); } @@ -4848,6 +4875,7 @@ private static string ContentTypeFor(string path) => ".jpg" or ".jpeg" => "image/jpeg", ".bmp" => "image/bmp", ".gif" => "image/gif", + DroppedFileImport.SvgExtension => DroppedFileImport.SvgContentType, _ => "application/octet-stream", }; diff --git a/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj b/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj index f87658a..b49a62b 100644 --- a/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj +++ b/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj @@ -32,6 +32,9 @@ + + diff --git a/src/SQLBI.Whiteboard/SvgImageCodec.cs b/src/SQLBI.Whiteboard/SvgImageCodec.cs new file mode 100644 index 0000000..e862c6a --- /dev/null +++ b/src/SQLBI.Whiteboard/SvgImageCodec.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Windows.Media; +using SharpVectors.Converters; +using SharpVectors.Dom; +using SharpVectors.Renderers.Wpf; + +namespace SQLBI.Whiteboard; + +internal static class SvgImageCodec +{ + public static DrawingImage Decode(byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + + var settings = new WpfDrawingSettings + { + // Nothing here needs the SharpVectors runtime types; a plain drawing draws faster. + IncludeRuntime = false, + TextAsGeometry = false, + + // Without these the drawing is only as large as the marks in it, so an SVG + // that centres a label in a 300x60 canvas would arrive cropped to the glyphs + // and stretched. Together they keep the author's viewBox as the container. + EnsureViewboxSize = true, + EnsureViewboxPosition = true, + + // Dropped and pasted SVG is untrusted markup. Left at its default this would + // fetch whatever an or an external stylesheet names, which turns + // opening a board into an outbound request. + ExternalResourcesAccessMode = ExternalResourcesAccessModes.Ignore, + }; + + using var reader = new FileSvgReader(settings); + using var stream = new MemoryStream(bytes, writable: false); + var drawing = reader.Read(stream) + ?? throw new InvalidDataException("The SVG has nothing to draw."); + + var image = new DrawingImage(drawing); + if (image.CanFreeze) + { + image.Freeze(); + } + + return image; + } +} diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs index c699210..1dbf1be 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs @@ -82,6 +82,51 @@ DroppedFileImport.LooksLikeText("DEFINE MEASURE Sales[X] = 1"u8.ToArray()) && !DroppedFileImport.LooksLikeText([0x4D, 0x5A, 0x00, 0x00]), "Text drops should reject files with a NUL in the header."); +Assert( + DroppedFileImport.Classify("logo.svg") == DroppedFileKind.Image && + DroppedFileImport.Classify(@"C:\art\LOGO.SVG") == DroppedFileKind.Image && + ImportCatalog.Default.IsImageExtension(".svg"), + "An SVG is an image everywhere an image is accepted, not a text snippet."); +Assert( + DroppedFileImport.LooksLikeSvg("") && + DroppedFileImport.LooksLikeSvg("\uFEFF \n") && + DroppedFileImport.LooksLikeSvg("") && + DroppedFileImport.LooksLikeSvg("") && + DroppedFileImport.LooksLikeSvg(""), + "Pasted SVG markup should survive a BOM, a prologue, and any namespace prefix."); +Assert( + !DroppedFileImport.LooksLikeSvg("SELECT * FROM -- ") && + !DroppedFileImport.LooksLikeSvg("") && + !DroppedFileImport.LooksLikeSvg("") && + !DroppedFileImport.LooksLikeSvg("") && + !DroppedFileImport.LooksLikeSvg((string?)null), + "Only a document whose root element is svg is a picture; one that contains svg is not."); +Assert( + DroppedFileImport.LooksLikeSvg(""u8.ToArray()) && + !DroppedFileImport.LooksLikeSvg([0x89, 0x50, 0x4E, 0x47]) && + !DroppedFileImport.LooksLikeSvg([0xFF, 0xD8, 0xFF, 0xE0]) && + !DroppedFileImport.LooksLikeSvg((byte[]?)null) && + !DroppedFileImport.LooksLikeSvg([]), + "Asset bytes should choose the SVG decoder without relying on a stored content type."); +Assert( + !DroppedFileImport.LooksLikeSvg([0x3C, 0xFF, 0xFE, 0x3C]), + "Bytes that open with '<' but are not UTF-8 text are not SVG."); + +var svgImport = ImportDocument.Parse( + """ + ## Diagram + ![star](./art/star.svg) + + ## Linked + [logo](./art/logo.svg) + """); +Assert( + svgImport.Items is + [ + { Kind: ImportItemKind.Image, SourcePath: "./art/star.svg" }, + { Kind: ImportItemKind.Image, SourcePath: "./art/logo.svg" }, + ], + "A .wimport should build image containers from SVG, both embedded and linked."); var parsedImport = ImportDocument.Parse( """ @@ -211,6 +256,12 @@ parsedImport.Items[3] is Assert( ImportLayout.ImageSize(1800, 1400) is { Width: 900, Height: 700 }, "Imported images should use the same 900 by 700 cap as a dropped image."); +Assert( + ImportLayout.VectorImageSize(24, 24) is { Width: 240, Height: 240 } && + ImportLayout.VectorImageSize(1800, 1400) is { Width: 900, Height: 700 } && + ImportLayout.VectorImageSize(100, 20) is { Width: 240, Height: 48 } && + ImportLayout.VectorImageSize(400, 100) is { Width: 400, Height: 100 }, + "A vector should be grown to a legible edge, keep its aspect, and keep the 900 by 700 cap."); var document = new BoardDocument(); Assert(document.ContentBounds is null, "An empty document should not have content bounds.");