From 94ec6a00943d87cce34d3dd8e850ae3548dde10a Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 30 Sep 2026 14:07:15 +0200 Subject: [PATCH 1/2] Match Microsoft Whiteboard highlighter widths and note corners (1.7.3) Compared tile by tile with Edge, vertical and diagonal highlighter strokes came out up to four times too wide, and notes had much rounder corners than Microsoft draws. A highlighter's thickness is now the least-squares match between the two tips over the stroke's directions, with Microsoft's tip measured per stroke. Notes and note grid panels use the square-cornered rectangle. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ Directory.Build.props | 2 +- docs/microsoft-whiteboard-import.md | 45 ++++++++++----- site/export-microsoft-whiteboard.html | 2 +- site/guide.html | 8 +-- .../Import/MicrosoftWhiteboardExport.Ink.cs | 56 ++++++++++++++----- .../Import/MicrosoftWhiteboardExport.Place.cs | 50 +++++++++++++++-- .../Import/MicrosoftWhiteboardExport.cs | 4 +- .../Import/MicrosoftWhiteboardItems.cs | 5 +- .../MicrosoftWhiteboardSmokeTests.cs | 8 ++- 10 files changed, 144 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6def4..a4b62ab 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.3 - 30 September 2026 + +### Microsoft Whiteboard imports closer to the original +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. + ## 1.7.2 - 30 September 2026 ### Draw a rectangle with square corners diff --git a/Directory.Build.props b/Directory.Build.props index 090c45e..222a0d5 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.2 + 1.7.3 latest enable enable diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index 4a81eac..d8c35df 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -73,9 +73,9 @@ no model of the board beyond what it draws. centerline point with half the ink's width there as its radius. About 60% of the strokes in the samples have one width; the rest vary by up to 3.3 times, because the pen reported pressure. -- **Highlighter outline.** The tip is an axis-aligned rectangle twice as tall as it is - wide, drawn once at the start and swept along the centerline. Its height follows - pressure. The fill is the color at 0.4 alpha. +- **Highlighter outline.** The tip is an axis-aligned rectangle, twice as tall as it is + wide on most boards and nearly square on some, drawn once at the start and swept + along the centerline. Its height follows pressure. The fill is the color at 0.4 alpha. - **Highlighter without a centerline.** Older versions of the app wrote some highlighter strokes with an empty `polyline` and a scale other than 1/128. The outline is a square tip followed by the sweep, as overlapping pieces, and the path cannot be recovered from @@ -119,8 +119,8 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | 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 | | Rectangle, in either form | `ShapeBoardObject`, `ShapeKind.Rectangle`, with square corners | -| 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 | +| Sticky note | `ShapeBoardObject`, rectangle in the note's color, with the note's text | +| 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 | | Link card | Markdown `TextBoardObject` titled *Link* | @@ -163,10 +163,19 @@ An earlier version measured the distance from each point to the nearest edge of outline. That made small handwriting too thin, because the outline of a small loop overlaps itself. -**Highlighter width.** A highlighter here has one width per stroke, a tip twice as wide -as tall, and a fixed opacity. Its thickness is half the upper quartile of the vertical -edges in the outline, so the height of a horizontal highlight matches the body of the -original stroke. +**Highlighter width.** A highlighter here has one width per stroke, a tip 4t wide and 2t +tall for a thickness t, and a fixed opacity. Microsoft's tip is measured from the +outline. Its width is what the tip adds to the centerline's width. Its height is the +mean of two measures that err in opposite directions: the upper quartile of the vertical +edges, which come mostly from the lightly pressed ends, and what the tip adds to the +centerline's height, which comes from the one point that reaches furthest. A band +across a run at any angle is as wide as the tip reaches across it, so t is the value +that makes the two bands agree best over the stroke's length, by least squares: half +the height for a horizontal stroke, a quarter of the width for a vertical one, and a +compromise for a scribble. Directions are taken over runs at least one tip long, +because a hand-drawn line is made of tiny steps and some of them are steep. Until 1.7.3 +the thickness was half the quartile alone, which drew vertical highlights four times +too wide. **Connectors** are bound at an end that lies within a few pixels of the edge of an upright shape, as a fraction of its box. A turned shape is not bound, because its @@ -182,11 +191,13 @@ parses in about 160 ms. - **Pressure is approximated.** The export keeps the outline, not the pressure. The recovered widths match the original closely at 150% zoom, but not exactly. - **Highlighter shape.** The tip here is wider than tall, while Microsoft's is taller than - wide. A horizontal highlight has the same height and starts and ends a few pixels - further out. A vertical highlight comes out four times as wide as the original. A - highlight has one width throughout. + wide. A horizontal or a vertical highlight comes out about as wide as the original, + but a scribble that runs both ways gets one thickness between the two, and some of + its runs are wider or narrower than they were. A highlight has one width throughout, + and overlapping parts of one stroke do not darken as they do on the page. - **Rainbow and Galaxy** are one color each. -- **Notes and note grids have rounded corners**, because they are rounded rectangles here. +- **Notes and note grids have square corners**, where Microsoft rounds them slightly. + Until 1.7.3 they were rounded rectangles, whose corners are much rounder. - **An old highlighter scribble is a picture**, not ink, because its path is not in the export. It moves and deletes as a picture, and it keeps the few small gaps the page shows where the outline's pieces overlap. @@ -234,3 +245,11 @@ classification, shape kinds and quarter turns, notes, grids, wrapping and center connector binding, links, comments, turned pictures, Galaxy ink, placement, container binding, the objects of older versions, and recognizing the ZIP, on three synthetic pages built like an export. + +For 1.7.3 every sample was compared tile by tile. A scratch tool imported each board +and drew it through `BoardRasterizer`, in tiles of 1600 × 1000 canvas pixels, and +headless Edge drew the same rectangles of the page by setting the canvas transform. A +blurred color difference flagged the tiles that disagree. Across the 18 teaching boards, +the pixels that differ fell from 1.33% of the ink to 0.41%, and the tiles scoring above +3% from ten to three, all highlighter scribbles. The test board differs by its +background and by the notes' corners and text, listed above. diff --git a/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index c26f0ea..d3da8aa 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -226,7 +226,7 @@

What comes across

  • Text boxes, connectors, and links.
  • 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. Notes have rounded corners, and 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.

    +

    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.

    SQLBI Whiteboard keeps each board in a file on your PC and has no real-time collaboration. Compare lists the differences from Microsoft Whiteboard.

    diff --git a/site/guide.html b/site/guide.html index fac712a..25bc5ad 100644 --- a/site/guide.html +++ b/site/guide.html @@ -695,19 +695,19 @@

    Bring a Microsoft Whiteboard board in

    - + - + - + Sales @@ -726,7 +726,7 @@

    Bring a Microsoft Whiteboard board in

    The board keeps its layout, and the connector stays bound to its shape.

    -

    Sticky notes become rounded shapes in their colors, and comments move to a column on the right.

    +

    Sticky notes become shapes in their colors, and comments move to a column on the right.

    diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Ink.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Ink.cs index 0aa5c4b..4bc23c3 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Ink.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Ink.cs @@ -117,16 +117,25 @@ private static IEnumerable ReadStroke( }; // Widths are measured in stroke units, then scaled like the points. - IReadOnlyList widths = kind == PenKind.Highlighter - ? HighlighterWidths(outline, centerline) - : PenWidths(outline, centerline); + IReadOnlyList widths; + double tipWidth = 0; + if (kind == PenKind.Highlighter) + { + (var height, tipWidth) = HighlighterTip(outline, centerline); + widths = Enumerable.Repeat(height, centerline.Count).ToArray(); + } + else + { + widths = PenWidths(outline, centerline); + } var scale = transform.Scale * anchor.Scale * group.Scale; yield return new MicrosoftWhiteboardStroke( centerline.Select(ToCanvas).ToArray(), widths.Select(width => width * scale).ToArray(), argb, - kind); + kind, + tipWidth * scale); } /// @@ -191,23 +200,42 @@ private static IEnumerable ReadStroke( } /// - /// A highlighter's tip is a rectangle twice as tall as it is wide, and its outline - /// shows the tip's height as a vertical edge wherever the tip stops or turns. The - /// height follows pen pressure, so it is shorter where the stroke starts and ends; - /// the upper quartile is the height along the body of the stroke. It is the width - /// of a horizontal highlight, and a highlighter here has one width per stroke. + /// A highlighter's tip is an upright rectangle, twice as tall as it is wide on + /// most boards and nearly square on some. Its height shows twice in the outline: + /// as the vertical edges wherever the tip stops or turns, and as what the tip adds + /// to the centerline's height. The edges come mostly from the ends, where pressure + /// is light, so their upper quartile can be short; the extent comes from the one + /// point that reaches furthest, so it can be long. The height is the mean of the + /// two, and the width is what the tip adds to the centerline's width. /// - private static double[] HighlighterWidths(PathOutline outline, IReadOnlyList centerline) + private static (double Height, double Width) HighlighterTip(PathOutline outline, IReadOnlyList centerline) { var heights = outline.Segments .Where(segment => segment.Start.X == segment.End.X && segment.Start.Y != segment.End.Y) .Select(segment => Math.Abs(segment.End.Y - segment.Start.Y)) .Order() .ToArray(); - var width = heights.Length > 0 - ? heights[heights.Length * 3 / 4] - : NearestEdgeWidth(outline, centerline); - return Enumerable.Repeat(width, centerline.Count).ToArray(); + double? edges = heights.Length > 0 ? heights[heights.Length * 3 / 4] : null; + + var points = outline.Segments.SelectMany(segment => new[] { segment.Start, segment.End }).ToArray(); + double across = 0; + double down = 0; + if (points.Length > 0) + { + across = points.Max(point => point.X) - points.Min(point => point.X) - + (centerline.Max(point => point.X) - centerline.Min(point => point.X)); + down = points.Max(point => point.Y) - points.Min(point => point.Y) - + (centerline.Max(point => point.Y) - centerline.Min(point => point.Y)); + } + + var height = (edges, down > 0) switch + { + ({ } edge, true) => (edge + down) / 2, + ({ } edge, false) => edge, + (null, true) => down, + _ => NearestEdgeWidth(outline, centerline), + }; + return (height, Math.Max(0, across)); } /// diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Place.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Place.cs index 5cdec45..992272d 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Place.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.Place.cs @@ -141,6 +141,49 @@ private void PlaceComments(BoardObject?[] built) } } + /// + /// Microsoft's highlighter tip is w wide and h tall, and the tip here is 4t wide + /// and 2t tall, so a horizontal run matches at t = h / 2 and a vertical one at + /// t = w / 4. A band across a run at any angle is as wide as the tip reaches + /// across it, and t is the value that makes the two bands agree best over the + /// stroke's length, by least squares. Directions are taken over runs at least one + /// tip wide, because a hand-drawn horizontal line is made of tiny steps and some + /// of them are steep. A tip whose width is not known is taken as half its height. + /// + private static double HighlighterThickness(MicrosoftWhiteboardStroke stroke) + { + var h = Median(stroke.Widths); + var w = stroke.TipWidth > 0 ? stroke.TipWidth : h / 2; + double product = 0; + double square = 0; + var start = stroke.Points.Count > 0 ? stroke.Points[0] : default; + for (var index = 1; index < stroke.Points.Count; index++) + { + var dx = stroke.Points[index].X - start.X; + var dy = stroke.Points[index].Y - start.Y; + var length = Math.Sqrt((dx * dx) + (dy * dy)); + if (length < Math.Min(w, h) && index < stroke.Points.Count - 1) + { + continue; + } + + start = stroke.Points[index]; + if (length <= 0) + { + continue; + } + + var across = Math.Abs(dy) / length; + var along = Math.Abs(dx) / length; + var here = (4 * across) + (2 * along); + var there = (w * across) + (h * along); + product += length * here * there; + square += length * here * here; + } + + return square > 0 ? product / square : h / 2; + } + /// /// The width of the ink as a pen style and a pressure per point. WPF draws a point /// at the style's thickness times 0.25 + 1.5 × pressure, so pressure 0.5 is the @@ -152,11 +195,8 @@ public static InkStrokeObject ToInk(MicrosoftWhiteboardStroke stroke, PointD off ArgumentNullException.ThrowIfNull(stroke); if (stroke.Kind == PenKind.Highlighter) { - // A highlighter is drawn with a tip twice as wide as it is tall and at a - // fixed opacity, so the tip's height carries the width and the color is - // made opaque. - var width = Median(stroke.Widths); - var style = new PenStyle(stroke.Argb | 0xFF000000, Math.Max(0.5, width / 2), PenKind.Highlighter); + // A highlighter is drawn at a fixed opacity, so the color is made opaque. + var style = new PenStyle(stroke.Argb | 0xFF000000, Math.Max(0.5, HighlighterThickness(stroke)), PenKind.Highlighter); return InkStrokeObject.Create( stroke.Points.Select((point, index) => new InkPoint(point + offset, 0.5f, index)), style, diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index b04486e..265aa55 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -555,7 +555,7 @@ private static MicrosoftWhiteboardShape ReadNote(Anchor anchor, List ta width * anchor.Scale, fullHeight * anchor.Scale, anchor.AngleDegrees, - ShapeKind.RoundedRectangle, + ShapeKind.Rectangle, color, color, 1, @@ -590,7 +590,7 @@ private static IEnumerable ReadGrid(Anchor anchor, List width * anchor.Scale, height * anchor.Scale, anchor.AngleDegrees, - ShapeKind.RoundedRectangle, + ShapeKind.Rectangle, 0xFFD1D1D1, 0xFFFFFFFF, anchor.Scale, diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardItems.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardItems.cs index 8a3ab64..f17a364 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardItems.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardItems.cs @@ -10,12 +10,15 @@ public abstract record MicrosoftWhiteboardItem; /// /// A stroke. Widths has one entry per point: the full width of the ink at that point. +/// For a highlighter that is the height of its tip, and TipWidth is how wide the tip +/// is, or 0 when only the height is known. /// public sealed record MicrosoftWhiteboardStroke( IReadOnlyList Points, IReadOnlyList Widths, uint Argb, - PenKind Kind) : MicrosoftWhiteboardItem; + PenKind Kind, + double TipWidth = 0) : MicrosoftWhiteboardItem; /// /// A picture: an image, a page of an inserted document, or a sticker. diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index 1c610f4..355e9ca 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -99,7 +99,13 @@ public static void Run() "A highlighter's width is the upper quartile of its outline's vertical edges."); var highlight = MicrosoftWhiteboardExport.ToInk(strokes[3], new PointD(0, 0), 0); Assert(highlight.Style.Argb == 0xFF28F62D && Math.Abs(highlight.Style.Thickness - 16) < 0.000001, - "A highlighter is drawn opaque at half the height, because its tip is twice as wide as tall."); + "A horizontal highlighter is drawn opaque at half the height of Microsoft's tip."); + var upright = MicrosoftWhiteboardExport.ToInk( + new MicrosoftWhiteboardStroke([new PointD(0, 0), new PointD(0, 100)], [32, 32], 0x6628F62D, PenKind.Highlighter), + new PointD(0, 0), + 0); + AssertNear(4, upright.Style.Thickness, + "A vertical highlighter is as wide as Microsoft's tip, which is a quarter of the tip here."); Assert(image.ContentType == "image/png" && image.Extension == ".png", "Pictures are labelled text/plain, so their bytes decide the type."); From f4316eddc95341d21ea66a763b907547a268be89 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 30 Sep 2026 15:38:21 +0200 Subject: [PATCH 2/2] Import lists and GIFs from Microsoft Whiteboard Text, bulleted, and task lists become Markdown text containers titled List, with the list's title in bold and a crossed or empty box for each task. GIFs are read as pictures, as older pictures are. Both were reported as left out. The markup comes from the web client's ListContainerComponent and its list constants, because no sample holds a list or a GIF. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + README.md | 2 +- docs/microsoft-whiteboard-import.md | 18 ++- site/export-microsoft-whiteboard.html | 4 +- site/guide.html | 2 +- .../Import/MicrosoftWhiteboardExport.cs | 105 ++++++++++++++++++ .../MicrosoftWhiteboardSmokeTests.cs | 63 +++++++++++ 7 files changed, 192 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b62ab..57f88bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +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 +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. + ## 1.7.2 - 30 September 2026 ### Draw a rectangle with square corners diff --git a/README.md b/README.md index 5fef552..fa91791 100644 --- a/README.md +++ b/README.md @@ -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, 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, 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 diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index d8c35df..4ccea17 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 and -1.7.2. This document records what a Microsoft Whiteboard export contains, how the +**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 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. @@ -54,6 +54,7 @@ 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/*`. | +| `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`. | | `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`. | @@ -61,6 +62,7 @@ no model of the board beyond what it draws. | `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. | +| `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`. | | `Hyperlink` | A preview card with the page's picture, an `a` with the link and its title, and the description. | @@ -124,6 +126,7 @@ Windows client offers, from `paleYellowGradient` (#FEE15A) to `grayGradient` (#C | Text box | `FreeTextBoardObject`, wrapped into the lines the box showed | | Connector | `ConnectorBoardObject`, straight, with its ends bound to shapes they sit on | | 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 | | Anchor order | Z-order | | Anything else | Counted and reported, not imported | @@ -225,8 +228,17 @@ 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 -Each would be reported by type if it arrives as an unknown object. Sticky notes cannot be +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`, +`HostedFluidObject`, `GroupFluidObject`, `AppIframeHost`, `WorkItem`, `Frame`, and +`CustomElement`. 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 rotated in the Windows client, with the mouse, the pen, or touch. ## Verification diff --git a/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index d3da8aa..c883fef 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -220,10 +220,10 @@

    Open it in SQLBI Whiteboard

    What comes across

    • Ink at the same pen widths, and highlighter strokes.
    • -
    • Pictures, stickers, and the pages of inserted PDFs.
    • +
    • Pictures, GIFs, stickers, and the pages of inserted PDFs.
    • Shapes with their fill, outline, angle, and text.
    • Sticky notes and note grids, as shapes in the same colors with their text.
    • -
    • Text boxes, connectors, and links.
    • +
    • Text boxes, lists, connectors, and links.
    • 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/site/guide.html b/site/guide.html index 25bc5ad..1a92385 100644 --- a/site/guide.html +++ b/site/guide.html @@ -646,7 +646,7 @@

    Bring a PowerPoint deck in

    Bring a Microsoft Whiteboard board in

    -

    A board exported from Microsoft Whiteboard as a ZIP comes across with its ink at the same pen widths, pictures, shapes, sticky notes, text, 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. Export from Microsoft Whiteboard shows how to make the ZIP.

    +

    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. Export from Microsoft Whiteboard shows how to make the ZIP.

    diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index 265aa55..239223c 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -27,6 +27,16 @@ public sealed partial class MicrosoftWhiteboardExport private const string CommentsSuffix = "-comments.json"; private const string CommentTitle = "Comment"; + private const string ListTitle = "List"; + + /// + /// 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. + /// + private const double ListIconWidth = 44; + private const double ListTextWidth = 284; + private const double ListAssignedTextWidth = 170 + 194; + private const double ListLikesWidth = 76; private const string CanvasMarker = "id=\"canvasContent\""; /// @@ -161,6 +171,7 @@ void Skip(string what, int count = 1) break; case "FluidImage": case "AzureImage": + case "AzureGif": case "DocumentPage": case "ReactionStickers": if (ReadImage(anchor, tags) is { } image) @@ -203,6 +214,16 @@ void Skip(string what, int count = 1) items.Add(connector); } + break; + case "VerticalList": + case "VerticalBulletList": + case "VerticalCheckboxList": + case "UnknownList": + if (ReadList(anchor, tags) is { } list) + { + items.Add(list); + } + break; case "Hyperlink": if (ReadLink(anchor, tags) is { } link) @@ -661,6 +682,90 @@ private static IEnumerable ReadGrid(Anchor anchor, List centered); } + /// + /// A list of text, bullets, or tasks becomes Markdown titled List, with the + /// list's own title in bold above one line per item. Tasks show a box that is + /// crossed or empty, because Markdown here has no task lists and the ticked box + /// is drawn as an emoji beside a plain empty one. Who a task is + /// assigned to and its likes are left out. A list is as wide as the web client + /// lays it out: an icon column, the text, and the likes. + /// + private static MicrosoftWhiteboardTextContainer? ReadList(Anchor anchor, List tags) + { + var children = new List(); + var titleIndex = -1; + for (var index = 0; index < tags.Count; index++) + { + if (tags[index].Name != "div") + { + continue; + } + + if (tags[index].HasClass("listChild")) + { + children.Add(index); + } + else if (tags[index].HasClass("verticalListTitle") && titleIndex < 0) + { + titleIndex = index; + } + } + + var lines = new List(); + var title = titleIndex >= 0 + ? ReadText(tags, titleIndex, children.Count > 0 ? children[0] : tags.Count) + : string.Empty; + if (title.Length > 0) + { + lines.Add($"**{EscapeMarkdown(title).Replace("\n", "** \n**", StringComparison.Ordinal)}**"); + } + + var tasks = anchor.Type == "VerticalCheckboxList"; + var bullets = anchor.Type == "VerticalBulletList"; + var items = new List(); + for (var child = 0; child < children.Count; child++) + { + var end = child + 1 < children.Count ? children[child + 1] : tags.Count; + var text = ReadText(tags, children[child], end); + if (text.Length == 0) + { + continue; + } + + var body = EscapeMarkdown(text).Replace("\n", " \n", StringComparison.Ordinal); + if (bullets) + { + items.Add($"- {body}"); + } + else if (tasks) + { + var ticked = tags.Skip(children[child]).Take(end - children[child]).Any(tag => + tag.HasClass("checkedListItemIcon") || + (tag.Attribute("data-icon-name")?.StartsWith("CheckmarkCircle", StringComparison.Ordinal) ?? false)); + items.Add($"{(ticked ? "☒" : "☐")} {body}"); + } + else + { + items.Add(body); + } + } + + if (lines.Count == 0 && items.Count == 0) + { + return null; + } + + // Bullets make a Markdown list; boxes and plain lines break line by line. + lines.Add(string.Join(bullets ? "\n" : " \n", items)); + var assigned = tags.Any(tag => tag.HasClass("assignedUserDisplayName")); + var width = tasks && assigned ? ListIconWidth + ListAssignedTextWidth + ListLikesWidth : ListIconWidth + ListTextWidth + ListLikesWidth; + return new MicrosoftWhiteboardTextContainer( + anchor.ToCanvas(default), + width * anchor.Scale, + ListTitle, + string.Join("\n\n", lines.Where(line => line.Length > 0))); + } + /// /// 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. diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index 355e9ca..5bf6c8f 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -138,6 +138,7 @@ public static void Run() RunArchive(); RunObjects(); RunOlderObjects(); + RunLists(); } /// @@ -391,6 +392,68 @@ private static void RunOlderObjects() "The text keeps the one line it had in Aptos, although Segoe UI measures wider here."); } + private const string OneByOneGif = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + + /// + /// Lists and a GIF as the web client draws them: a title, a column heading, and + /// one list child per item with its icon and its text. + /// + private static readonly string ListsPage = $$""" +
    +
    +
    +
    Groceries
    +
    TasksLikes
    +
    +
    Milk *fresh*
    +
    +
    Bread
    +
    +
    +
    +
    +
    Release
    +
    TasksAssigned toLikes
    +
    +
    Ship it
    +
    Ana
    +
    +
    Test it
    +
    +
    +
    +
    +

    +
    one
    +
    two
    +
    +
    +
    +
    +
    +
    + """; + + private static void RunLists() + { + var board = MicrosoftWhiteboardExport.Parse(ListsPage, "Lists"); + Assert(board.Skipped.Count == 0 && board.Items.Count == 4, + "Bulleted, task, and plain lists, and a GIF, are all read."); + + Assert(board.Items[0] is MicrosoftWhiteboardTextContainer { Title: "List", Width: 808 } groceries && + groceries.TopLeft == new PointD(0, 1000) && + groceries.Markdown == "**Groceries**\n\n- Milk \\*fresh\\*\n- Bread", + "A bulleted list is a Markdown list under its title in bold, as wide as the web client lays it out."); + Assert(board.Items[1] is MicrosoftWhiteboardTextContainer { Width: 484 } release && + release.Markdown == "**Release**\n\n☒ Ship it \n☐ Test it", + "A task shows a crossed or empty box, the column heading and who it is assigned to are left out, and the Assigned to column widens the list."); + Assert(board.Items[2] is MicrosoftWhiteboardTextContainer { Width: 404, Markdown: "one \ntwo" }, + "A plain list without a title is its lines."); + Assert(board.Items[3] is MicrosoftWhiteboardImage { ContentType: "image/gif" } gif && + gif.Bounds == new RectD(2970, 980, 60, 40), + "A GIF is a picture like any other."); + } + private static void RunArchive() { var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));