From bbb0c742ac162de5b7c49f85ebc16cfa7d7d9725 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Tue, 29 Sep 2026 22:24:13 +0200 Subject: [PATCH] Import objects from older Microsoft Whiteboard boards (1.7.1) Boards made with older versions of Microsoft Whiteboard hold AzureImage, LegacyEllipse, and LegacyPolygon objects, which the import listed as left out. Pictures are read as FluidImage is, ellipses take the radii the page draws, and polygons take the kind their corners have. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ Directory.Build.props | 2 +- docs/microsoft-whiteboard-import.md | 33 ++++--- .../Import/MicrosoftWhiteboardExport.cs | 85 +++++++++++++++++++ .../MicrosoftWhiteboardSmokeTests.cs | 48 +++++++++++ 5 files changed, 161 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39673f8..3631dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ 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.1 - 29 September 2026 + +### Import boards made with older versions of Microsoft Whiteboard +Ovals, rectangles, and pictures from boards made with older versions of Microsoft +Whiteboard now come across. The import used to list them as left out. + ## 1.7.0 - 27 September 2026 ### Bring a Microsoft Whiteboard board across diff --git a/Directory.Build.props b/Directory.Build.props index 0c5ea2f..47c1830 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.0 + 1.7.1 latest enable enable diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index 0b4068e..375d381 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -1,12 +1,13 @@ # Microsoft Whiteboard import -**Implemented in 1.7.0.** 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. Everything below was established from eight exports of +**Implemented in 1.7.0**, with the objects of older versions of the app in 1.7.1. 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. Everything below was established from eight exports of real teaching boards and one test board holding every kind of object the Windows client -offers, made between 20 and 27 September 2026. +offers, made between 20 and 27 September 2026. Six more teaching boards, exported on 29 +September, added the objects that older versions of the app wrote, which 1.7.1 reads. ## Where it starts @@ -35,8 +36,9 @@ no model of the board beyond what it draws. - An anchor is a box of no size at `left` and `top`, in canvas pixels. Its inline `transform: matrix(a, b, c, d, e, f)` scales, turns, and moves the object around that point, because the transform origin of a zero-size box is its position. Ink, text, - notes, grids, and connectors start at the point; shapes and pictures are centered on - it. The inline transform overrides the `translate(-50%, -50%)` of the `center` class. + notes, grids, connectors, and older polygons start at the point; shapes, older + ellipses, and pictures are centered on it. The inline transform overrides the + `translate(-50%, -50%)` of the `center` class. - Text is a Draft.js editor: one `div[data-block]` per line, with the characters in `span[data-text]`. The editor's `textBoxCore` style carries color, family, weight, italic, and underline for the whole object; no board in the samples styles a run. @@ -50,9 +52,12 @@ no model of the board beyond what it draws. | --- | --- | | `InkGroup` | An SVG with a view box whose origin sits at the anchor, class `PenStroke`, `Highlighter`, or `Mixed`. One `g.inkStroke` per stroke with `transform="matrix(0.0078125, …)"`: 1/128 of a pixel. Inside it, a filled `path` outline and a `polyline.inkHitTestOverlay` centerline. | | `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/*`. | | `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`. | +| `LegacyEllipse` | A shape from an older version of the app: an SVG `ellipse` with the outline in its `stroke` attribute and the width and fill in its style. The radii can differ from the SVG's box, in proportion as well as in size, and the page draws the radii. | +| `LegacyPolygon` | A shape from an older version of the app: an SVG `polygon` whose points start at the anchor, styled as a `LegacyEllipse` is. Every sample is a rectangle. | | `Note` | A sticky note: `div.textBoxBackground` with a color class such as `softBlueGradient`, a 40-pixel author bar, and `div.stickyNote` with the text area's size and font size. | | `GridList` | A note grid: a title editor, then `div.listChild` notes in a CSS grid of `repeat(n, auto)` columns. | | `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. | @@ -104,6 +109,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | Picture, document page, sticker | `BoardAsset` with the sniffed type, and an `ImageBoardObject` | | Turned picture | An SVG asset that draws the picture turned, in the box it covers | | Shape | `ShapeBoardObject` of the kind its outline has, with fill, outline, angle, and text | +| Older ellipse or polygon | `ShapeBoardObject`, an oval with the ellipse's radii or the kind the polygon's corners have, without text | | Sticky note | `ShapeBoardObject`, rounded rectangle in the note's color, with the note's text | | Note grid | A white rounded rectangle for the panel, a label for the title, and one note per cell | | Text box | `FreeTextBoardObject`, wrapped into the lines the box showed | @@ -201,11 +207,14 @@ rotated in the Windows client, with the mouse, the pen, or touch. Each sample was opened in the application and compared with the same page rendered by Edge, fitted to the same content. Positions, picture sizes, scaled ink groups, and colors -matched on all eight teaching boards. Regions with pressure-drawn handwriting and with -highlighters were compared at 150% against a WPF rendering that uses the application's own -drawing attributes. The test board was compared region by region in the application: +matched on all eight teaching boards. The six later boards were opened in the +application, where the older ellipses, rectangles, and pictures sit in place against the +ink drawn around them. Regions with pressure-drawn handwriting and with highlighters were +compared at 150% against a WPF rendering that uses the application's own drawing +attributes. The test board was compared region by region in the application: ink, text boxes, notes, the note grid, shapes, connectors, pictures, the template, and the comments. The Core smoke tests cover the transforms, width recovery, highlighter classification, shape kinds and quarter turns, notes, grids, wrapping and centering, connector binding, links, comments, turned pictures, Galaxy ink, placement, container -binding, and recognizing the ZIP, on two synthetic pages built like an export. +binding, the objects of older versions, and recognizing the ZIP, on three synthetic pages +built like an export. diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index 7b35797..8b5b4df 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -160,6 +160,7 @@ void Skip(string what, int count = 1) items.AddRange(ReadInkGroup(anchor, tags)); break; case "FluidImage": + case "AzureImage": case "DocumentPage": case "ReactionStickers": if (ReadImage(anchor, tags) is { } image) @@ -174,6 +175,14 @@ void Skip(string what, int count = 1) items.Add(shape); } + break; + case "LegacyEllipse": + case "LegacyPolygon": + if (ReadLegacyShape(anchor, tags) is { } legacy) + { + items.Add(legacy); + } + break; case "Note": items.Add(ReadNote(anchor, tags, 0, tags.Count, default)); @@ -387,6 +396,82 @@ internal static byte[] TurnedPicture( ReadFont(core?.Attribute("style"), fontSize * anchor.Scale, defaultBold: true)); } + /// + /// A shape from an older version of the app, drawn by a plain SVG ellipse or polygon + /// with its outline in the element's style. An ellipse is centered on its anchor and + /// its radii can disagree with the SVG's box, so the radii are what the page draws. + /// A polygon's corners start at its anchor. + /// + private static MicrosoftWhiteboardShape? ReadLegacyShape(Anchor anchor, List tags) + { + var svg = tags.FirstOrDefault(tag => tag.Name == "svg" && tag.HasClass("shape")); + var element = tags.FirstOrDefault(tag => tag.Name is "ellipse" or "polygon"); + if (svg is null || element is null) + { + return null; + } + + PointD center; + double width; + double height; + ShapeKind kind; + if (element.Name == "ellipse") + { + var boxWidth = Numbers(svg.Attribute("width")).FirstOrDefault(); + var boxHeight = Numbers(svg.Attribute("height")).FirstOrDefault(); + var cx = Numbers(element.Attribute("cx")).FirstOrDefault(); + var cy = Numbers(element.Attribute("cy")).FirstOrDefault(); + center = new PointD(cx - (boxWidth / 2), cy - (boxHeight / 2)); + width = 2 * Numbers(element.Attribute("rx")).FirstOrDefault(); + height = 2 * Numbers(element.Attribute("ry")).FirstOrDefault(); + kind = ShapeKind.Ellipse; + } + else + { + var numbers = Numbers(element.Attribute("points")); + var corners = new List(); + for (var index = 0; index + 1 < numbers.Count; index += 2) + { + corners.Add(new PointD(numbers[index], numbers[index + 1])); + } + + if (corners.Count < 3) + { + return null; + } + + var left = corners.Min(point => point.X); + var top = corners.Min(point => point.Y); + width = corners.Max(point => point.X) - left; + height = corners.Max(point => point.Y) - top; + center = new PointD(left + (width / 2), top + (height / 2)); + var outline = "M" + string.Join( + " L", + corners.Select(point => string.Create( + CultureInfo.InvariantCulture, + $"{point.X - center.X},{point.Y - center.Y}"))); + kind = ClassifyShape(outline, width, height); + } + + if (width <= 0 || height <= 0) + { + return null; + } + + var style = element.Attribute("style"); + return new MicrosoftWhiteboardShape( + anchor.ToCanvas(center), + width * anchor.Scale, + height * anchor.Scale, + anchor.AngleDegrees, + kind, + PaintOrNull(StyleValue(style, "stroke") ?? element.Attribute("stroke")) ?? Transparent, + PaintOrNull(StyleValue(style, "fill") ?? element.Attribute("fill")), + CssLength(StyleValue(style, "stroke-width") ?? element.Attribute("stroke-width"), 2) * anchor.Scale, + string.Empty, + new MicrosoftWhiteboardFont(LabelStyles.DefaultFontFamily, 20, Black, false, false, false)); + } + /// /// The kind of a shape from the outline it draws, because the name on it is /// written in the language of whoever made the board. diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index b9cd1bf..392d462 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -131,6 +131,7 @@ public static void Run() RunArchive(); RunObjects(); + RunOlderObjects(); } /// @@ -289,6 +290,53 @@ private static void RunObjects() "A comment goes in a column to the right of everything else."); } + /// + /// Objects written by older versions of the app, as found on boards made years ago. + /// + private static readonly string OlderObjectsPage = $$""" +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """; + + private static void RunOlderObjects() + { + var board = MicrosoftWhiteboardExport.Parse(OlderObjectsPage, "Older"); + Assert(board.Skipped.Count == 0 && board.Items.Count == 4, + "Ellipses, polygons, and pictures from older versions of the app are all read."); + + var ellipse = (MicrosoftWhiteboardShape)board.Items[0]; + Assert(ellipse is { Kind: ShapeKind.Ellipse, Width: 120, Height: 140, OutlineArgb: 0xFFE71224, FillArgb: null, Thickness: 4 } && + ellipse.Center == new PointD(400, 300), + "An ellipse is centered on its anchor and has the radii the page draws, not the size of its box."); + + var rectangle = (MicrosoftWhiteboardShape)board.Items[1]; + Assert(rectangle is { Kind: ShapeKind.RoundedRectangle, Width: 400, Height: 200, Thickness: 8 } && + rectangle.Center == new PointD(300, 150), + "A polygon's corners start at its anchor, and the anchor's scale applies to them."); + + Assert(board.Items[2] is MicrosoftWhiteboardShape { Kind: ShapeKind.Triangle, FillArgb: 0xFFFF0000 }, + "A polygon's kind comes from its corners, and its fill from its style."); + + Assert(board.Items[3] is MicrosoftWhiteboardImage { ContentType: "image/png" } picture && + picture.Bounds == new RectD(300, 400, 400, 200), + "A picture from the older image service is read as any other picture."); + } + private static void RunArchive() { var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));