From 1c5fb7615d912de0d9cc159a5adce5ad4fe0307b Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 30 Sep 2026 17:04:56 +0200 Subject: [PATCH] Import legacy templates from Microsoft Whiteboard (1.7.4) A template from the Windows 10 app is a collection: a title and the items placed on it, each a child placed from its corner holding the content of one object. The title becomes its blue bar and a bold label, and each child is read by the reader for what it holds, through an anchor composed from the template and the child. The markup comes from the web client's template conversion and CollectionComponent, because no sample holds a template. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + Directory.Build.props | 2 +- docs/microsoft-whiteboard-import.md | 10 +- site/export-microsoft-whiteboard.html | 1 + .../Import/MicrosoftWhiteboardExport.cs | 181 ++++++++++++++++++ .../MicrosoftWhiteboardSmokeTests.cs | 56 ++++++ 6 files changed, 252 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28343ee..957db9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props index 222a0d5..438fb68 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.7.3 + 1.7.4 latest enable enable diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index d366a10..5a72d56 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -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. @@ -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`. | @@ -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 | @@ -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. diff --git a/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index 700cbe5..c78ec34 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -224,6 +224,7 @@

What comes across

  • Shapes with their fill, outline, angle, and text.
  • Sticky notes and note grids, as shapes in the same colors with their text.
  • Text boxes, lists, tables drawn with ink, connectors, and links.
  • +
  • Templates from the Windows 10 app, with their title and what is placed on them.
  • Comments, as text beside the board with the names of their authors.
  • 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.

    diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index 0b99af9..c65efd8 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -39,6 +39,14 @@ public sealed partial class MicrosoftWhiteboardExport /// private const double StickerCaptionFontSize = 30; + /// + /// 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. + /// + private const double TemplateTitleBar = 4; + private const uint TemplateTitleArgb = 0xFF0C34FA; + private static readonly PointD TemplateTitlePadding = new(8, 6); + /// /// 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. @@ -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)); @@ -940,6 +951,171 @@ private static IEnumerable ReadSticker(Anchor anchor, L width); } + /// + /// 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 div.topLeft with its own + /// left, top, and transform, 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. + /// + private static IEnumerable ReadTemplate(Anchor anchor, List tags, Action skip) + { + var children = new List(); + 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 ReadTemplateChild(Anchor place, HtmlTag child, List content) + { + bool Has(Func test) => content.Any(test); + HtmlTag? First(Func 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; + } + } + } + /// /// 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. @@ -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. /// + /// + /// This anchor moved to a point of its own, keeping its scale and turn. + /// + 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)), diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index 095a7c3..65a2407 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -140,6 +140,7 @@ public static void Run() RunOlderObjects(); RunLists(); RunTables(); + RunTemplate(); } /// @@ -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."); } + /// + /// 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. + /// + private static readonly string TemplatePage = $$""" +
    +
    +
    +
    +
    Retrospective
    +
    +
    +
    Went well
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    + """; + + 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"));