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: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ and **bold**.
Vertical and diagonal highlighter strokes now come out about as wide as they were,
instead of up to four times wider. Sticky notes and note grids have square corners.

### Lists and GIFs from Microsoft Whiteboard
### Lists, tables, stickers, and GIFs from Microsoft Whiteboard
Text, bulleted, and task lists now come across as text containers, with a box for each
task that shows whether it is done, and GIFs come across as pictures. They used to be
listed as left out.
task that shows whether it is done. Tables drawn with ink come across as rectangles with
their ink, stickers from the Windows 10 app as pictures with their captions, and GIFs as
pictures. They used to be listed as left out.

## 1.7.2 - 30 September 2026

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ How the project is developed and shipped is documented separately:
- Whole-stroke erasing
- PNG, JPEG, BMP, GIF, and SVG import, clipboard bitmap paste, and Explorer drag-and-drop of images, text files, `.wimport` recipes, PowerPoint decks, and Microsoft Whiteboard exports
- PowerPoint decks imported as one picture per slide through PowerPoint from Microsoft 365: SVG, or PNG for a slide whose fonts are not installed, placed in one row per section, with an optional frame around each slide. **File → Open** creates a new board from the deck; **File → Import** and drag-and-drop add it below the board's content
- Boards exported from Microsoft Whiteboard as a `.zip` imported with their ink, pictures, shapes, sticky notes, text, lists, connectors, links, and comments, and the pen's varying width kept. **File → Open** creates a new board from the export; **File → Import** and drag-and-drop add it below the board's content. [docs/microsoft-whiteboard-import.md](docs/microsoft-whiteboard-import.md) lists what is not carried over
- Boards exported from Microsoft Whiteboard as a `.zip` imported with their ink, pictures, shapes, sticky notes, text, lists, tables, connectors, links, and comments, and the pen's varying width kept. **File → Open** creates a new board from the export; **File → Import** and drag-and-drop add it below the board's content. [docs/microsoft-whiteboard-import.md](docs/microsoft-whiteboard-import.md) lists what is not carried over
- SVG is stored as its markup and redrawn as vectors at every zoom and resize. 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. Text set in a Microsoft 365 font such as Aptos is drawn in that font when Office has it on the machine
- Image selection, movement, resizing, and deletion
- Text containers created by pasting plain text, with display and in-place edit modes
Expand Down
12 changes: 8 additions & 4 deletions docs/microsoft-whiteboard-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ no model of the board beyond what it draws.
| `FluidImage` | `div.imageComponent` with the size, and an `img` with a base64 data URI labelled `text/plain` whatever the picture is. |
| `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. |
| `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 All @@ -65,6 +66,7 @@ no model of the board beyond what it draws.
| `VerticalList`, `VerticalBulletList`, `VerticalCheckboxList`, `UnknownList` | A list of text, bullets, or tasks, in `div.legacyListContainer`: a title editor in `div.verticalListTitle`, a `div.listColumnHeading` with the column names, then one `div.listChild` per item with its icon, its text editor, who a task is assigned to (`div.assignedUserDisplayName`), and its likes. A done task's icon is `CheckmarkCircle` with the class `checkedListItemIcon`, an open one `StatusCircle` with `uncheckedListItemIcon`. The list is 44 + 284 + 76 pixels wide, or 44 + 364 + 76 when tasks have an Assigned to column. An `UnknownList` draws no items. Not seen in a sample; read from the web client's `ListContainerComponent`. |
| `PlainText` | A text box: `div.textbox.plainText` with `max-width` and `font-size`, inside a wrapper whose `justify-content` centers the text in a wider box when the text is centered. |
| `Connector` | An SVG with the route as a path from the anchor, and the head as a small path moved to one end with `translate`. |
| `Unknown` holding `div.inkTableContainer` | A table drawn with ink in the Windows 10 app, which the web client registers under no type of its own. The container is a CSS grid whose `grid-template-columns` and `grid-template-rows` give each column and row as `minmax(<size>px, auto)`. One `div.inkCellContainer` per cell, row by row, has a `4px solid` border in the table's color and a `-2px` margin, so the border sits over the grid line. Inside it, a `div` shifts the cell's ink by `left` and `top`, and each ink group sits in a `div` with its own `left`, `top`, and `transform`, holding the same SVG an `InkGroup` has. The web client draws a placeholder instead when its `EnableTables` flag is off. Not seen in a sample; read from the web client's `TableComponent`. |
| `Hyperlink` | A preview card with the page's picture, an `a` with the link and its title, and the description. |
| `CommentThread` | A pin, described above. |

Expand Down Expand Up @@ -125,6 +127,8 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C
| Note grid | A white rectangle for the panel, a label for the title, and one note per cell |
| Text box | `FreeTextBoardObject`, wrapped into the lines the box showed |
| 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 |
| 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 @@ -228,14 +232,14 @@ 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 and lists, which are read from the web client's code, not from a sample
- GIFs, lists, ink tables, and legacy stickers, 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: `Table` (an ink-to-table grid from the
Windows 10 app), `LegacyTemplate`, `LegacySticker`, `LoopObject`,
which the importer reports and leaves out: `LegacyTemplate`, `LoopObject`,
`HostedFluidObject`, `GroupFluidObject`, `AppIframeHost`, `WorkItem`, `Frame`, and
`CustomElement`. The export replaces a Loop component and an app frame with a
`CustomElement`, and an `Unknown` that holds no table. The export replaces a Loop component and an app frame with a
placeholder picture.

Each is reported by type after the import. Sticky notes cannot be
Expand Down
2 changes: 1 addition & 1 deletion site/export-microsoft-whiteboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ <h2 id="what-comes-across">What comes across</h2>
<li>Pictures, GIFs, stickers, and the pages of inserted PDFs.</li>
<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, connectors, and links.</li>
<li>Text boxes, lists, tables drawn with ink, connectors, and links.</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
2 changes: 1 addition & 1 deletion site/guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ <h3>Bring a PowerPoint deck in</h3>

<section class="story" id="microsoft-whiteboard">
<h3>Bring a Microsoft Whiteboard board in</h3>
<p>A board exported from Microsoft Whiteboard as a ZIP comes across with its ink at the same pen widths, pictures, shapes, sticky notes, text, lists, connectors, links, and comments. It can start a new board or join an existing one, below its content, and anything left out is listed after the import. <a href="export-microsoft-whiteboard.html">Export from Microsoft Whiteboard</a> shows how to make the ZIP.</p>
<p>A board exported from Microsoft Whiteboard as a ZIP comes across with its ink at the same pen widths, pictures, shapes, sticky notes, text, lists, tables, connectors, links, and comments. It can start a new board or join an existing one, below its content, and anything left out is listed after the import. <a href="export-microsoft-whiteboard.html">Export from Microsoft Whiteboard</a> shows how to make the ZIP.</p>

<figure class="fig">
<div class="figbox">
Expand Down
188 changes: 188 additions & 0 deletions src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ public sealed partial class MicrosoftWhiteboardExport
private const string CommentTitle = "Comment";
private const string ListTitle = "List";

/// <summary>
/// A table cell's border, which the web client draws over the grid line.
/// </summary>
private const double TableBorder = 4;

/// <summary>
/// The largest size a sticker's caption is drawn at before it shrinks to fit.
/// </summary>
private const double StickerCaptionFontSize = 30;

/// <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 @@ -231,6 +241,21 @@ void Skip(string what, int count = 1)
items.Add(link);
}

break;
case "LegacySticker":
items.AddRange(ReadSticker(anchor, tags));
break;
case "Unknown":
case "Table":
if (tags.Any(tag => tag.Name == "div" && tag.HasClass("inkTableContainer")))
{
items.AddRange(ReadTable(anchor, tags));
}
else
{
Skip(anchor.Type);
}

break;
case "CommentThread":
if (ReadComment(anchor, tags, threads) is { } comment)
Expand Down Expand Up @@ -766,6 +791,155 @@ private static IEnumerable<MicrosoftWhiteboardItem> ReadGrid(Anchor anchor, List
string.Join("\n\n", lines.Where(line => line.Length > 0)));
}

/// <summary>
/// A table drawn with ink in the Windows 10 app. The web client names its type
/// Unknown and draws it as a CSS grid: each column and row is as long as its
/// <c>minmax()</c> says, each cell has a 4-pixel border in the table's color that
/// sits over the grid line, and each cell's ink is a collection of ink groups
/// placed from the cell's corner, moved by the offset that keeps ink reaching left
/// of or above the cell inside it. The cells become rectangles, then the ink comes
/// on top of them.
/// </summary>
private static IEnumerable<MicrosoftWhiteboardItem> ReadTable(Anchor anchor, List<HtmlTag> tags)
{
var gridIndex = tags.FindIndex(tag => tag.Name == "div" && tag.HasClass("inkTableContainer"));
var gridStyle = tags[gridIndex].Attribute("style");
var columns = Numbers(StyleValue(gridStyle, "grid-template-columns"));
var rows = Numbers(StyleValue(gridStyle, "grid-template-rows"));
var cells = new List<int>();
for (var index = gridIndex + 1; index < tags.Count; index++)
{
if (tags[index].Name == "div" && tags[index].HasClass("inkCellContainer"))
{
cells.Add(index);
}
}

if (columns.Count == 0 || rows.Count == 0)
{
yield break;
}

var border = cells.Count > 0 ? StyleValue(tags[cells[0]].Attribute("style"), "border") : null;
var solid = border?.IndexOf("solid", StringComparison.OrdinalIgnoreCase) ?? -1;
var color = (solid >= 0 ? PaintOrNull(border![(solid + 5)..]) : null) ?? Black;
var lefts = Starts(columns);
var tops = Starts(rows);
for (var row = 0; row < rows.Count; row++)
{
for (var column = 0; column < columns.Count; column++)
{
yield return new MicrosoftWhiteboardShape(
anchor.ToCanvas(new PointD(lefts[column] + (columns[column] / 2), tops[row] + (rows[row] / 2))),
columns[column] * anchor.Scale,
rows[row] * anchor.Scale,
anchor.AngleDegrees,
ShapeKind.Rectangle,
color,
null,
TableBorder * anchor.Scale,
string.Empty,
new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20, Black, false, false, false));
}
}

for (var cell = 0; cell < cells.Count && cell / columns.Count < rows.Count; cell++)
{
var start = cells[cell];
var end = cell + 1 < cells.Count ? cells[cell + 1] : tags.Count;
var shiftStyle = start + 1 < end ? tags[start + 1].Attribute("style") : null;
var corner = new PointD(
lefts[cell % columns.Count] + (TableBorder / 2) + (StyleLength(shiftStyle, "left") ?? 0),
tops[cell / columns.Count] + (TableBorder / 2) + (StyleLength(shiftStyle, "top") ?? 0));
var groups = Enumerable.Range(start, end - start)
.Where(index => tags[index].Name == "svg" && tags[index].HasClass("inkGroup"))
.ToArray();
for (var group = 0; group < groups.Length; group++)
{
// The collection child that places the group is the nearest div before
// it with a position of its own, after the div that shifts the cell.
var place = Enumerable.Range(start + 2, Math.Max(0, groups[group] - start - 2))
.Reverse()
.Select(index => tags[index])
.FirstOrDefault(tag => tag.Name == "div" && StyleValue(tag.Attribute("style"), "left") is not null);
var child = place is null
? new Anchor("InkGroup", default, 1, 0, 0, 1, 0, 0)
: Anchor.From(place, "InkGroup");
var groupEnd = group + 1 < groups.Length ? groups[group + 1] : end;
foreach (var item in ReadInkGroup(child.Inside(anchor, corner), tags.GetRange(groups[group], groupEnd - groups[group])))
{
yield return item;
}
}
}
}

private static double[] Starts(List<double> lengths)
{
var starts = new double[lengths.Count];
for (var index = 1; index < lengths.Count; index++)
{
starts[index] = starts[index - 1] + lengths[index - 1];
}

return starts;
}

/// <summary>
/// A sticker from the Windows 10 app is a picture with a caption over it. The web
/// client draws the picture as any other and the caption as a text editor in a box
/// moved from the picture's corner, at 30 pixels or smaller when it shrinks the text
/// to fit. The caption becomes a label centered across that box.
/// </summary>
private static IEnumerable<MicrosoftWhiteboardItem> ReadSticker(Anchor anchor, List<HtmlTag> tags)
{
if (ReadImage(anchor, tags) is { } picture)
{
yield return picture;
}

var captionIndex = tags.FindIndex(tag => tag.Name == "div" &&
StyleValue(tag.Attribute("style"), "font-size") is not null &&
string.Equals(StyleValue(tag.Attribute("style"), "position"), "absolute", StringComparison.OrdinalIgnoreCase));
var box = tags.FirstOrDefault(tag => tag.Name == "div" && tag.HasClass("imageComponent"));
if (captionIndex < 0 || box is null)
{
yield break;
}

var text = ReadText(tags, captionIndex, tags.Count);
if (text.Length == 0)
{
yield break;
}

var boxWidth = StyleLength(box.Attribute("style"), "width") ?? 0;
var boxHeight = StyleLength(box.Attribute("style"), "height") ?? 0;
var caption = Anchor.From(tags[captionIndex], "Caption");
var captionStyle = tags[captionIndex].Attribute("style");
var width = tags.Skip(captionIndex + 1)
.Select(tag => StyleLength(tag.Attribute("style"), "width"))
.FirstOrDefault(value => value > 0) ?? boxWidth;
var fontSize = tags.Skip(captionIndex)
.Select(tag => StyleLength(tag.Attribute("style"), "font-size"))
.OfType<double>()
.Where(value => value > 0)
.DefaultIfEmpty(StickerCaptionFontSize)
.Min();
var core = tags.Skip(captionIndex).FirstOrDefault(tag => tag.HasClass("textBoxCore"))?.Attribute("style");
yield return new MicrosoftWhiteboardLabel(
anchor.ToCanvas(new PointD(
(-boxWidth / 2) + caption.Origin.X + caption.E,
(-boxHeight / 2) + caption.Origin.Y + caption.F)),
anchor.Scale,
anchor.AngleDegrees,
default,
text,
ReadFont($"{core};{captionStyle}", fontSize, defaultBold: false),
Math.Max(1, width),
width);
}

/// <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 @@ -1243,6 +1417,20 @@ public static Anchor From(HtmlTag tag, string type)
Origin.X + (A * local.X) + (C * local.Y) + E,
Origin.Y + (B * local.X) + (D * local.Y) + F);

/// <summary>
/// This anchor placed at a point inside another, so that a point here reaches
/// the canvas through both transforms.
/// </summary>
public Anchor Inside(Anchor parent, PointD at) => new(
Type,
parent.ToCanvas(at + Origin + new PointD(E, F)),
(parent.A * A) + (parent.C * B),
(parent.B * A) + (parent.D * B),
(parent.A * C) + (parent.C * D),
(parent.B * C) + (parent.D * D),
0,
0);

private static double[] StyleMatrix(string? style)
{
if (StyleValue(style, "transform") is { } value && value.Contains("matrix(", StringComparison.Ordinal))
Expand Down
Loading
Loading