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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ broke. The heading is parsed by `scripts/release-notes.ps1`, so keep its shape;
under it is ordinary Markdown, and the renderer handles paragraphs, lists, links, `code`
and **bold**.

## 1.7.4 - 30 September 2026

### Templates from Microsoft Whiteboard
Templates from boards made with the Windows 10 app of Microsoft Whiteboard now come across
with their title and the notes, lists, text, pictures, shapes, and ink placed on them.
They used to be listed as left out.

## 1.7.3 - 30 September 2026

### Microsoft Whiteboard imports closer to the original
Expand Down
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.7.3</VersionPrefix>
<VersionPrefix>1.7.4</VersionPrefix>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
10 changes: 6 additions & 4 deletions docs/microsoft-whiteboard-import.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Microsoft Whiteboard import

**Implemented in 1.7.0**, with the objects of older versions of the app in 1.7.1,
1.7.2, and 1.7.3. This document records what a Microsoft Whiteboard export contains, how the
1.7.2, 1.7.3, and 1.7.4. This document records what a Microsoft Whiteboard export contains, how the
importer maps it onto a board, and what it cannot carry over. Microsoft retires its
standalone Whiteboard apps on 16 October 2026 (see [retirement](retirement/README.md)),
and the export is the only way to take a board out of it. The format is undocumented.
Expand Down Expand Up @@ -56,6 +56,7 @@ no model of the board beyond what it draws.
| `AzureImage` | A picture on a board made with an older version of the app, written as a `FluidImage` is, with the data URI labelled `image/*`. |
| `AzureGif` | An animated GIF, written as an `AzureImage` is. Not seen in a sample; the web client converts it with the same function as `AzureImage`. |
| `LegacySticker` | A sticker from the Windows 10 app: a picture written as an `AzureImage` is, and a caption in a `div` moved from the picture's corner by its `transform`, with `font-size` 30 and the family and weight in its style. The text box inside has the caption's width, and a smaller `font-size` when the text shrank to fit. Not seen in a sample; read from the web client's `ImageUI` caption aspect. |
| `LegacyTemplate` | A template from the Windows 10 app, drawn as a collection: one `div.topLeft` per child with its own `left`, `top`, and `transform`, holding the content of one object without the anchor around it. The first child is the title, a `textbox.templateTitle` that the stylesheet draws as a white panel 60 pixels tall with a 4-pixel `#0C34FA` bar on top, 6 and 8 pixels of padding, and bold 24-pixel text. The web client moves every child by the same offset, so that none sits left of or above the title. It draws a placeholder instead when its `EnableTemplates` flag is off. Not seen in a sample; read from the web client's template conversion and `CollectionComponent`. |
| `DocumentPage` | A page of an inserted PDF, written as a `FluidImage` is. |
| `ReactionStickers` | A sticker, written as a 64 × 64 `FluidImage` with an SVG data URI. |
| `Shape` | An SVG whose `g` has `fill`, `stroke`, and `stroke-width` (in `pt`), and whose `path` draws the outline around the middle of the box. The shape's text sits in `div.textBoxContainer`. |
Expand Down Expand Up @@ -129,6 +130,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C
| Connector | `ConnectorBoardObject`, straight, with its ends bound to shapes they sit on |
| Ink table | One `ShapeBoardObject` rectangle per cell with the border's color and 4-unit outline, then the cells' ink as strokes |
| Legacy sticker | The picture, and its caption as a `FreeTextBoardObject` centered across the caption's box |
| Legacy template | The title's bar as a filled rectangle and its text as a bold label, then each child read by the reader for what it holds: ink, a note, a note grid, a list, a text box, a picture, or a shape. A picture or a shape moves by half its size, because a child is placed from its corner and those are centered on their anchor at the top level. A child of any other kind is counted as a template item |
| Link card | Markdown `TextBoardObject` titled *Link* |
| List | Markdown `TextBoardObject` titled *List*: the list's title in bold, then a Markdown list of bullets, or one line per item with ☒ for a done task and ☐ for an open one |
| Comment thread | Markdown `TextBoardObject` titled *Comment*, in a column to the right of the board |
Expand Down Expand Up @@ -232,12 +234,12 @@ parses in about 160 ms.
- PowerPoint pages, which the Windows client cannot insert, videos, and Loop components
- SVG pictures, which the Windows client refuses to insert
- Groups, which the Windows client does not offer
- GIFs, lists, ink tables, and legacy stickers, which are read from the web client's code,
not from a sample
- GIFs, lists, ink tables, legacy stickers, and legacy templates, which are read from the
web client's code, not from a sample

The web client's code names every type an export can hold. Its `data-whiteboard-type`
values, as of version 26.10910.101 in September 2026, are the types above and these,
which the importer reports and leaves out: `LegacyTemplate`, `LoopObject`,
which the importer reports and leaves out: `LoopObject`,
`HostedFluidObject`, `GroupFluidObject`, `AppIframeHost`, `WorkItem`, `Frame`, and
`CustomElement`, and an `Unknown` that holds no table. The export replaces a Loop component and an app frame with a
placeholder picture.
Expand Down
1 change: 1 addition & 0 deletions site/export-microsoft-whiteboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ <h2 id="what-comes-across">What comes across</h2>
<li>Shapes with their fill, outline, angle, and text.</li>
<li>Sticky notes and note grids, as shapes in the same colors with their text.</li>
<li>Text boxes, lists, tables drawn with ink, connectors, and links.</li>
<li>Templates from the Windows 10 app, with their title and what is placed on them.</li>
<li>Comments, as text beside the board with the names of their authors.</li>
</ul>
<p>A highlighter stroke has one width, and Rainbow and Galaxy ink become one color. Elbow connectors become straight. The background color and reactions are left out. After the import, a message lists anything the board did not bring across.</p>
Expand Down
181 changes: 181 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ public sealed partial class MicrosoftWhiteboardExport
/// </summary>
private const double StickerCaptionFontSize = 30;

/// <summary>
/// A template's title panel in the web client's light theme: a 4-pixel bar in
/// this blue on top of a white panel, with the text padded 8 and 6 pixels in.
/// </summary>
private const double TemplateTitleBar = 4;
private const uint TemplateTitleArgb = 0xFF0C34FA;
private static readonly PointD TemplateTitlePadding = new(8, 6);

/// <summary>
/// A vertical list's columns in the web client: the icon, the text, the text
/// beside an Assigned to column, that column, and the likes.
Expand Down Expand Up @@ -241,6 +249,9 @@ void Skip(string what, int count = 1)
items.Add(link);
}

break;
case "LegacyTemplate":
items.AddRange(ReadTemplate(anchor, tags, what => Skip(what)));
break;
case "LegacySticker":
items.AddRange(ReadSticker(anchor, tags));
Expand Down Expand Up @@ -940,6 +951,171 @@ private static IEnumerable<MicrosoftWhiteboardItem> ReadSticker(Anchor anchor, L
width);
}

/// <summary>
/// A template from the Windows 10 app: a title and the items placed on it, which the
/// web client draws as a collection. Each child is a <c>div.topLeft</c> with its own
/// <c>left</c>, <c>top</c>, and <c>transform</c>, holding the same content a board
/// object holds without the anchor around it, so a child is recognized by what it
/// holds and read by the reader for that kind, placed from its corner. The title is
/// a white panel with a blue bar on top and bold 24-pixel text; the bar and the text
/// are what show on a white board. A child of any other kind is counted.
/// </summary>
private static IEnumerable<MicrosoftWhiteboardItem> ReadTemplate(Anchor anchor, List<HtmlTag> tags, Action<string> skip)
{
var children = new List<int>();
for (var index = 0; index < tags.Count; index++)
{
var tag = tags[index];
if (tag.Name == "div" && tag.HasClass("topLeft") &&
StyleValue(tag.Attribute("style"), "left") is not null)
{
children.Add(index);
}
}

if (children.Count == 0)
{
skip(anchor.Type);
yield break;
}

for (var child = 0; child < children.Count; child++)
{
var start = children[child];
var end = child + 1 < children.Count ? children[child + 1] : tags.Count;
var content = tags.GetRange(start + 1, end - start - 1);
var place = Anchor.From(tags[start], anchor.Type).Inside(anchor, default);
var items = ReadTemplateChild(place, tags[start], content).ToArray();
if (items.Length == 0 && ReadText(content, 0, content.Count).Length + content.Count(tag => tag.Name == "svg") > 0)
{
skip("Template items");
}

foreach (var item in items)
{
yield return item;
}
}
}

private static IEnumerable<MicrosoftWhiteboardItem> ReadTemplateChild(Anchor place, HtmlTag child, List<HtmlTag> content)
{
bool Has(Func<HtmlTag, bool> test) => content.Any(test);
HtmlTag? First(Func<HtmlTag, bool> test) => content.FirstOrDefault(test);

if (Has(tag => tag.HasClass("templateTitle")))
{
var title = ReadText(content, 0, content.Count);
var width = StyleLength(child.Attribute("style"), "width") ?? 0;
if (width > 0)
{
yield return new MicrosoftWhiteboardShape(
place.ToCanvas(new PointD(width / 2, TemplateTitleBar / 2)),
width * place.Scale,
TemplateTitleBar * place.Scale,
place.AngleDegrees,
ShapeKind.Rectangle,
TemplateTitleArgb,
TemplateTitleArgb,
place.Scale,
string.Empty,
new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20, Black, false, false, false));
}

if (title.Length > 0)
{
yield return new MicrosoftWhiteboardLabel(
place.ToCanvas(default),
place.Scale,
place.AngleDegrees,
new PointD(TemplateTitlePadding.X, TemplateTitleBar + TemplateTitlePadding.Y),
title,
new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 24, Black, true, false, false),
double.MaxValue,
null);
}

yield break;
}

if (Has(tag => tag.Name == "svg" && tag.HasClass("inkGroup")))
{
var from = content.FindIndex(tag => tag.Name == "svg" && tag.HasClass("inkGroup"));
foreach (var item in ReadInkGroup(place, content.GetRange(from, content.Count - from)))
{
yield return item;
}

yield break;
}

if (Has(tag => tag.HasClass("stickyNote")))
{
yield return ReadNote(place, content, 0, content.Count, default);
yield break;
}

if (Has(tag => tag.HasClass("listChildren")))
{
foreach (var item in ReadGrid(place, content))
{
yield return item;
}

yield break;
}

if (Has(tag => tag.HasClass("legacyListContainer")))
{
var kind = Has(tag => tag.HasClass("checkedListItemIcon") || tag.HasClass("uncheckedListItemIcon"))
? "VerticalCheckboxList"
: Has(tag => tag.Attribute("data-icon-name") == "RadioBullet") ? "VerticalBulletList" : "VerticalList";
if (ReadList(place with { Type = kind }, content) is { } list)
{
yield return list;
}

yield break;
}

if (Has(tag => tag.HasClass("plainText")))
{
if (ReadLabel(place, content) is { } label)
{
yield return label;
}

yield break;
}

// A picture and a shape are centered on their anchor at the top level, and
// placed from their corner here, so their anchor moves to their middle.
if (First(tag => tag.Name == "div" && tag.HasClass("imageComponent")) is { } box)
{
var size = new PointD(StyleLength(box.Attribute("style"), "width") ?? 0, StyleLength(box.Attribute("style"), "height") ?? 0);
if (ReadImage(place.Shifted(new PointD(size.X / 2, size.Y / 2)), content) is { } picture)
{
yield return picture;
}

yield break;
}

if (First(tag => tag.Name == "svg" && tag.HasClass("shape")) is { } svg)
{
var size = new PointD(Numbers(svg.Attribute("width")).FirstOrDefault(), Numbers(svg.Attribute("height")).FirstOrDefault());
var shape = Has(tag => tag.Name == "polygon")
? ReadLegacyShape(place, content)
: Has(tag => tag.Name == "ellipse")
? ReadLegacyShape(place.Shifted(new PointD(size.X / 2, size.Y / 2)), content)
: ReadShape(place.Shifted(new PointD(size.X / 2, size.Y / 2)), content);
if (shape is not null)
{
yield return shape;
}
}
}

/// <summary>
/// A connector's SVG draws its route from the anchor, and its head as a small
/// path moved to one end. A route with corners is kept as a straight line.
Expand Down Expand Up @@ -1421,6 +1597,11 @@ public static Anchor From(HtmlTag tag, string type)
/// This anchor placed at a point inside another, so that a point here reaches
/// the canvas through both transforms.
/// </summary>
/// <summary>
/// This anchor moved to a point of its own, keeping its scale and turn.
/// </summary>
public Anchor Shifted(PointD local) => new(Type, ToCanvas(local), A, B, C, D, 0, 0);

public Anchor Inside(Anchor parent, PointD at) => new(
Type,
parent.ToCanvas(at + Origin + new PointD(E, F)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public static void Run()
RunOlderObjects();
RunLists();
RunTables();
RunTemplate();
}

/// <summary>
Expand Down Expand Up @@ -514,6 +515,61 @@ private static void RunTables()
"A sticker's caption is a label centered across its box, at the size it shrank to, moved from the picture's corner.");
}

/// <summary>
/// A template from the Windows 10 app as the web client draws it: a collection of
/// children placed from their corners, each holding the content of one object.
/// </summary>
private static readonly string TemplatePage = $$"""
<html><body><div id="canvasContent" class="contentOrigin">
<div class="anchor align topLeft" data-whiteboard-type="LegacyTemplate" style="left: 1000px; top: 1000px;">
<div class="canvasChild"><div class="collection" role="collection">
<div><div class="topLeft" style="left: 0px; top: 0px; width: 600px; height: 60px; position: absolute;">
<div class="textbox templateTitle"><div class="textBoxCore"><div data-block="true"><span data-text="true">Retrospective</span></div></div></div></div></div>
<div><div class="topLeft" style="left: 0px; top: 100px; position: absolute;">
<div class="textBoxBackground softBlueGradient"><div class="textbox stickyNote" style="width: 304px; height: 265px; font-size: 32px;">
<div class="textBoxCore"><div data-block="true"><span data-text="true">Went well</span></div></div></div></div></div></div>
<div><div class="topLeft" style="left: 400px; top: 100px; position: absolute;">
<div class="content imageComponent" style="height: 50px; width: 100px;"><img src="data:image/*;base64,{{OnePixelPng}}"></div></div></div>
<div><div class="topLeft" style="left: 0px; top: 500px; position: absolute; transform: matrix(2, 0, 0, 2, 0, 0);">
<svg class="inkGroup ink PenStroke" viewBox="0 0 100 10" width="100" height="10"><g class="inkStroke" transform="matrix(0.0078125, 0, 0, 0.0078125, 0, 0)">
<path d="M0,-256L2560,-256A256,256 0 0 0 2560,256L0,256A256,256 0 0 0 0,-256" fill="rgba(0,0,0,1)"></path>
<polyline class="inkHitTestOverlay" points="0,0 2560,0 "></polyline></g></svg></div></div>
<div><div class="topLeft" style="left: 600px; top: 500px; position: absolute;">
<svg class="shape polygon" height="100" width="200"><polygon points="0, 0, 200, 0, 200, 100, 0, 100" stroke="rgba(0,0,0,1)" style="fill: transparent; stroke-width: 4;"></polygon></svg></div></div>
<div><div class="topLeft" style="left: 900px; top: 500px; position: absolute;"><svg class="loopPlaceholder"></svg></div></div>
</div></div>
</div>
<div class="anchor align topLeft" data-whiteboard-type="LegacyTemplate" style="left: 0px; top: 0px;"><div class="canvasChild"></div></div>
</div></body></html>
""";

private static void RunTemplate()
{
var board = MicrosoftWhiteboardExport.Parse(TemplatePage, "Template");
Assert(board.Skipped.Count == 2 && board.Skipped["Template items"] == 1 && board.Skipped["LegacyTemplate"] == 1,
"A template child of an unknown kind is counted, and a template drawn without its children is reported.");

Assert(board.Items[0] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 600, Height: 4, FillArgb: 0xFF0C34FA } bar &&
bar.Center == new PointD(1300, 1002),
"A template's title has the blue bar across the top of its panel.");
Assert(board.Items[1] is MicrosoftWhiteboardLabel { Text: "Retrospective", Font: { Bold: true, Size: 24 } } title &&
title.Origin == new PointD(1000, 1000) && title.Inset == new PointD(8, 10),
"A template's title is bold 24-pixel text inside the panel's padding, under the bar.");
Assert(board.Items[2] is MicrosoftWhiteboardShape { Text: "Went well", FillArgb: 0xFF99C9EF } note &&
note.Center == new PointD(1152, 1252.5),
"A note in a template is placed from its child's corner.");
Assert(board.Items[3] is MicrosoftWhiteboardImage picture && picture.Bounds == new RectD(1400, 1100, 100, 50),
"A picture in a template starts at its child's corner, where at the top level it is centered on its anchor.");
var stroke = (MicrosoftWhiteboardStroke)board.Items[4];
AssertNear(1000, stroke.Points[0].X, "Ink in a template starts at its child's corner.");
AssertNear(1500, stroke.Points[0].Y, "Ink in a template starts at its child's corner.");
AssertNear(1040, stroke.Points[^1].X, "Ink in a template takes its child's scale.");
Assert(board.Items[5] is MicrosoftWhiteboardShape { Kind: ShapeKind.Rectangle, Width: 200, Height: 100 } frame &&
frame.Center == new PointD(1700, 1550),
"An older polygon in a template starts at its child's corner, as it does at the top level.");
Assert(board.Items.Count == 6, "Nothing else is read from the template.");
}

private static void RunArchive()
{
var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));
Expand Down
Loading