Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -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.
-->
<VersionPrefix>1.0.3</VersionPrefix>
<VersionPrefix>1.1.0</VersionPrefix>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<text>` 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.
Expand Down
3 changes: 3 additions & 0 deletions docs/samples/contoso-workshop.wimport
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions docs/wimport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<image href>` 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)`
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion installer/msix/STORE-LISTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion installer/winget/SQLBI.Whiteboard.locale.en-US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion site/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -686,11 +686,12 @@ <h3>Navigation</h3>
<p>The window can sit in a narrow strip beside another app. <kbd>F11</kbd> fills the monitor and hides the title and tabs. <kbd>Ctrl</kbd><span class="plus">+</span><kbd>F11</kbd> hides the same chrome and keeps the window where it is. <kbd>Escape</kbd> leaves either unless a text container is being edited.</p>

<h3>Containers</h3>
<p>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.</p>
<p>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.</p>
<p><span class="ui">View → Bring to front</span> and <span class="ui">View → Send to back</span> reorder the selected container and its linked strokes. Strokes cannot be reordered on their own.</p>

<h3>Text, SQL, and DAX</h3>
<p>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.</p>
<p>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.</p>
<p><span class="ui">Help → Preferences</span> 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.</p>
<p><kbd>F6</kbd> formats SQL or DAX on the selected container, without entering edit mode. <kbd>F2</kbd> edits the container, and double-click still centers it and fits it to the canvas; inside an edit <kbd>F6</kbd> formats in place and <kbd>Ctrl</kbd><span class="plus">+</span><kbd>Enter</kbd> commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps <code>GO</code> batch separators, and leaves an invalid script unchanged. <kbd>Escape</kbd> restores the previous text, language, and size.</p>

Expand Down
4 changes: 2 additions & 2 deletions site/wimport.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ <h2>Heading grammar</h2>
<h2>How the body becomes a container</h2>
<p>Recognizers run in this order. The first match wins. Extra material after the match is ignored.</p>
<ol>
<li><strong>Markdown image</strong>: <code>![optional alt](relative/path.png)</code>. Image container. Title is the <code>##</code> heading, then the alt text, then the file name. Allowed extensions: <code>.png</code>, <code>.jpg</code>, <code>.jpeg</code>, <code>.bmp</code>, <code>.gif</code>.</li>
<li><strong>Markdown image</strong>: <code>![optional alt](relative/path.png)</code>. Image container. Title is the <code>##</code> heading, then the alt text, then the file name. Allowed extensions: <code>.png</code>, <code>.jpg</code>, <code>.jpeg</code>, <code>.bmp</code>, <code>.gif</code>, <code>.svg</code>. An <code>.svg</code> stays vector on the board, so give it a <code>width</code>/<code>height</code> or a <code>viewBox</code> to be sized from; anything it fetches from elsewhere is ignored.</li>
<li><strong>Fenced code</strong> whose info-string is a registered language. Text container. Contents of the fence. Language from the tag.</li>
<li><strong>Markdown link</strong> to an image extension: same as an image.</li>
<li><strong>Markdown link</strong> to a registered language extension: <code>[label](relative/path.dax)</code>. Text container. File contents. Language from the extension.</li>
Expand Down Expand Up @@ -134,7 +134,7 @@ <h2>Checklist</h2>
<li>File name ends in <code>.wimport</code>.</li>
<li>Every container is a <code>##</code> heading with exactly one payload.</li>
<li>Images and linked code exist next to the file, using relative paths.</li>
<li>Image files are png/jpeg/bmp/gif. Linked code files are <code>.dax</code> or <code>.sql</code>, or the code is embedded in a <code>dax</code> / <code>sql</code> / <code>tsql</code> fence.</li>
<li>Image files are png/jpeg/bmp/gif/svg. Linked code files are <code>.dax</code> or <code>.sql</code>, or the code is embedded in a <code>dax</code> / <code>sql</code> / <code>tsql</code> fence.</li>
<li>Row breaks are a thematic break on its own line, not a fake heading.</li>
<li>You did not use <code>http://</code>, YAML, or explicit positions.</li>
<li>Opening the file in a Markdown preview still reads as a normal document.</li>
Expand Down
114 changes: 114 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/DroppedFileImport.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text;

namespace SQLBI.Whiteboard.Core.Import;

public enum DroppedFileKind
Expand All @@ -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<string> PlainTextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
Expand Down Expand Up @@ -101,4 +108,111 @@ public static bool LooksLikeText(byte[] bytes)

return true;
}

/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// Measures an XML declaration, comment, or doctype sitting ahead of the root element.
/// </summary>
private static bool TryMeasurePrologue(string text, out int length)
{
length = 0;
var (opening, closing) = text switch
{
_ when text.StartsWith("<?", StringComparison.Ordinal) => ("<?", "?>"),
_ when text.StartsWith("<!--", StringComparison.Ordinal) => ("<!--", "-->"),
_ when text.StartsWith("<!", StringComparison.Ordinal) => ("<!", ">"),
_ => (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;
}
}
2 changes: 1 addition & 1 deletion src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public sealed class ImportCatalog
Extensions = [".sql"],
},
],
[".png", ".jpg", ".jpeg", ".bmp", ".gif"]);
[".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg"]);

public ImportCatalog(
IReadOnlyList<ImportLanguage> languages,
Expand Down
14 changes: 14 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/ImportLayout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RectD> Place(
IReadOnlyList<(double Width, double Height, bool StartNewRow)> items,
Expand Down Expand Up @@ -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);
}

/// <summary>
/// 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.
/// </summary>
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);
}
}
Loading