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"));