From 8a77443cc5830709c2431c2e025c4b7bd5a8352f Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Wed, 30 Sep 2026 20:52:33 +0200 Subject: [PATCH] Import groups from Microsoft Whiteboard, flattened GroupFluidObject is a group of objects, not Fluid content as #177 read it, so a group and everything in it was reported as left out. A group is drawn as a collection, as a template is. Templates and groups now share one reader that takes only the collection's own children, keeps each child's origin, reads a nested collection the same way, and reads every kind of child as its top-level counterpart is read. The tokenizer yields the end of each div so that depth can be counted. The board has no groups, so a group becomes its members. The import doc says what to change once grouping exists, and the design doc points there. Read from the web client's group conversion and CollectionComponent, because no sample holds a group. It joins the 1.7.4 notes, which is not released yet. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- docs/design-objects.md | 4 +- docs/microsoft-whiteboard-import.md | 30 ++- site/export-microsoft-whiteboard.html | 2 +- src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs | 13 +- .../Import/MicrosoftWhiteboardExport.cs | 179 +++++++++++++----- .../MicrosoftWhiteboardSmokeTests.cs | 50 +++++ 7 files changed, 228 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82a9ba7..20a3e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,11 @@ and **bold**. ### 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. +with their title and what is placed on them. They used to be listed as left out. + +### Groups from Microsoft Whiteboard +A group of objects now comes across as the objects in it, each where it was. It used to +be listed as left out, with everything in it. ### Loop components, apps, and other objects from Microsoft Whiteboard A Loop component now comes across as a link to it, an app made by Copilot as an empty diff --git a/docs/design-objects.md b/docs/design-objects.md index 6d5a00d..4e7f5a7 100644 --- a/docs/design-objects.md +++ b/docs/design-objects.md @@ -38,7 +38,9 @@ What a person gets by upgrading: Out of scope for 1.6.0, unless a decision below says otherwise: text inside a shape, rotating a shape, elbow connectors, connectors with an arrowhead at both ends, per-board -grid settings, and grouping as a saved object. +grid settings, and grouping as a saved object. The Microsoft Whiteboard import flattens +groups today, and [its document](microsoft-whiteboard-import.md#when-the-board-has-groups) says what it +should do once grouping exists. ## Decisions taken diff --git a/docs/microsoft-whiteboard-import.md b/docs/microsoft-whiteboard-import.md index 17bc7ef..3b30953 100644 --- a/docs/microsoft-whiteboard-import.md +++ b/docs/microsoft-whiteboard-import.md @@ -56,7 +56,8 @@ 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`. | +| `GroupFluidObject` | A group of objects, despite its name. The web client draws it as a collection, as it draws a template, without a title: one child per member, named after the member's own origin, `topLeft` or `center`, holding the member's content. A member can be a group. Until 1.7.4 the importer took the name for live Fluid content, and a group and everything in it was reported as left out. Not seen in a sample; read from the web client's group conversion and `CollectionComponent`. | +| `LegacyTemplate` | A template from the Windows 10 app, drawn as a collection: a `div` with the role `collection`, and for each child a wrapper `div` holding a `div.topLeft` 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`. | @@ -69,7 +70,7 @@ no model of the board beyond what it draws. | `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(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. | -| `LoopObject`, `HostedFluidObject`, `GroupFluidObject` | Live content that the export does not keep. A Loop component is a `div.loopParentDiv` with the component's address in `data-loop-url`; the export replaces its content with an `a` to that address around the picture `LoopExportThumbnail.svg`, which it does not embed. Not seen in a sample; read from the web client's `LoopComponentConnected` and export code. | +| `LoopObject`, `HostedFluidObject` | Live content that the export does not keep. A Loop component is a `div.loopParentDiv` with the component's address in `data-loop-url`; the export replaces its content with an `a` to that address around the picture `LoopExportThumbnail.svg`, which it does not embed. Not seen in a sample; read from the web client's `LoopComponentConnected` and export code. | | `AppIframeHost` | An app that Copilot made, running in a sandboxed `iframe`. The export replaces the frame with a `#faf9f8` panel and keeps the `div.appFrameContainer` with its `width` and `height` and its header, whose `span.appFrameHeaderTitle` says *App* in the board's language. The app's name, code, and state are not in the export. Not seen in a sample; read from the web client's app frame component and export code. | | `WorkItem` | An Azure DevOps work item: a `div.WorkItem` card, 220 × 145 pixels in the stylesheet, white with a 4-pixel gold border on its left. A `span.WorkItemTextField` holds a `strong` with the type's `img.WorkItemIcon` (its `alt` names the type) and the ID, followed by the title; then come who it is assigned to and, in `span.WorkItemText`, the word *State* in the board's language and the state. The web client draws a placeholder instead when its `EnableWorkItems` flag is off. Not seen in a sample; read from the web client's `WorkItemConnected`. | | `Frame` | A frame Copilot drew around objects it grouped: a `section.whiteboardFrame` with its `width` and `height` and a theme class, `whiteboardFrame--blue`, `--green`, `--purple`, `--orange`, or neutral without one. The stylesheet gives it a 3-pixel border, a translucent fill, 14-pixel corners, and a 44-pixel title bar with bold 15-pixel text in `span.whiteboardFrame__titleText`, padded 14 pixels in. A pill names the first source and counts the rest; the full list is in a popover that is drawn only when open. The web client draws nothing when its `EnableFrames` flag is off. Not seen in a sample; read from the web client's frame component. | @@ -136,7 +137,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 | +| Legacy template or group | For a template, the title's bar as a filled rectangle and its text as a bold label. Then each child is read by the reader for what it holds, as the same kind is read at the top level: ink, a note, a note grid, a list, a text box, a table, a picture or a sticker, a shape, a connector, a link card, Loop content, an app, a work item, a custom object, a placeholder, or a collection of its own. Only the collection's own children are read, so the tokenizer yields the end of each `div` as a tag named `/div` and the depth is counted. A child that is centered on its place is read as centered; one placed from its corner moves a picture, a shape, or a sticker by half its size, because those are centered at the top level. A child of any other kind is counted as a template item or a group item. A group itself becomes nothing: its members stand on the board as separate objects | | Loop component | Markdown `TextBoardObject` titled *Loop*, linking to the component, as wide as it was | | App made by Copilot | `ShapeBoardObject` rectangle of the frame's size in the panel's color, with the header's title as its text | | Work item | Markdown `TextBoardObject` titled *Work item*: the type and ID in bold before the title, then who it is assigned to and its state, as wide as the card | @@ -209,6 +210,10 @@ parses in about 160 ms. ## What is lost +- **Groups are flattened.** The board has no groups, so a group's members come across as + separate objects, each where it was and in its drawing order. What changes when the + board has groups is under *When the board has groups*. + - **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 @@ -248,7 +253,7 @@ parses in about 160 ms. - The Professional font, and text boxes with more than one style in them - 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 +- Groups, which the Windows client does not offer and are read from the web client's code - GIFs, lists, ink tables, legacy stickers, legacy templates, Loop components, apps, work items, Copilot frames, and custom objects, which are read from the web client's code, not from a sample @@ -265,6 +270,23 @@ item's title comes after the bold ID. Each is reported by type after the import. Sticky notes cannot be rotated in the Windows client, with the mouse, the pen, or touch. +## When the board has groups + +Grouping as a saved object is out of scope for the board (see +[design-objects](design-objects.md)). When it arrives, the import should change here: + +- `ReadCollection` reads a group's members already; a group would then become a group + object holding the objects it returned, rather than those objects on their own. +- A group inside a group should stay nested if the board's groups can nest, and be + flattened into its parent otherwise. +- A template is also a collection. Whether a template becomes a group, with its title + as a member, is a choice to make then; a template is not a group in Microsoft + Whiteboard. +- A group's own transform is composed into each member's place today. With groups, it + might belong to the group, so turning or scaling the group turns or scales its members. +- The smoke test `RunGroup` checks positions only; it would check the group and its + members then. + ## Verification Each sample was opened in the application and compared with the same page rendered by diff --git a/site/export-microsoft-whiteboard.html b/site/export-microsoft-whiteboard.html index a779958..b5f96ec 100644 --- a/site/export-microsoft-whiteboard.html +++ b/site/export-microsoft-whiteboard.html @@ -224,7 +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.
  • +
  • Templates from the Windows 10 app, with their title and what is placed on them, and groups, as the objects in them.
  • Loop components as links to them, and Azure DevOps work items with their title, owner, and state.
  • Comments, as text beside the board with the names of their authors.
  • diff --git a/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs b/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs index 0cc1b15..cae0ae1 100644 --- a/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs +++ b/src/SQLBI.Whiteboard.Core/Import/HtmlTags.cs @@ -5,7 +5,9 @@ namespace SQLBI.Whiteboard.Core.Import; /// /// The start tags of an HTML page, in document order, with their attributes and the /// text that follows each one up to the next tag. Text that follows an end tag, such as -/// a title after a bold ID, comes as a tag named #text. The Microsoft Whiteboard +/// a title after a bold ID, comes as a tag named #text, and the end of each +/// div as a tag named /div, so that a reader can tell which divs are +/// inside which. The Microsoft Whiteboard /// export is generated markup, so quoting is regular, but it is HTML rather than XML: /// some elements are never closed. /// @@ -19,6 +21,10 @@ internal static class HtmlTags public const string TextName = "#text"; + public const string EndDivName = "/div"; + + private static readonly HtmlTag EndDiv = new(EndDivName, new Dictionary()); + private static readonly Dictionary NoAttributes = new(StringComparer.OrdinalIgnoreCase); public static IEnumerable StartTags(string html, int start) @@ -41,6 +47,11 @@ public static IEnumerable StartTags(string html, int start) yield break; } + if (close - open == 5 && string.CompareOrdinal(html, open + 2, "div", 0, 3) == 0) + { + yield return EndDiv; + } + var after = html.IndexOf('<', close); var length = (after < 0 ? html.Length : after) - close - 1; if (length is > 0 and < MaximumText && !html.AsSpan(close + 1, length).IsWhiteSpace()) diff --git a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs index cdbab80..1223195 100644 --- a/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs +++ b/src/SQLBI.Whiteboard.Core/Import/MicrosoftWhiteboardExport.cs @@ -359,7 +359,6 @@ void Skip(string what, int count = 1) break; case "LoopObject": case "HostedFluidObject": - case "GroupFluidObject": if (ReadLoop(anchor, tags) is { } loop) { items.Add(loop); @@ -382,7 +381,10 @@ void Skip(string what, int count = 1) break; case "LegacyTemplate": - items.AddRange(ReadTemplate(anchor, tags, what => Skip(what))); + items.AddRange(ReadCollection(anchor, tags, what => Skip(what), "Template items")); + break; + case "GroupFluidObject": + items.AddRange(ReadCollection(anchor, tags, what => Skip(what), "Group items")); break; case "LegacySticker": items.AddRange(ReadSticker(anchor, tags)); @@ -411,9 +413,9 @@ void Skip(string what, int count = 1) break; } - // A note keeps its reactions in its text, and so does a note in a grid or a - // template. Reactions on anything else are counted. - if (anchor.Type is not ("Note" or "GridList" or "LegacyTemplate")) + // A note keeps its reactions in its text, and so does a note in a grid, a + // template, or a group. Reactions on anything else are counted. + if (anchor.Type is not ("Note" or "GridList" or "LegacyTemplate" or "GroupFluidObject")) { Skip("Reactions", tags.Count(tag => tag.Name == "div" && tag.HasClass("ReactionPillContainer"))); } @@ -1114,57 +1116,112 @@ private static IEnumerable ReadSticker(Anchor anchor, L } /// - /// 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. + /// A template from the Windows 10 app and a group of objects are both drawn as a + /// collection: a div with the role collection, holding for each child + /// a div named after the child's origin, topLeft or center, with + /// its own left, top, and transform. The child holds the same + /// content a board object holds, without the anchor around it, so it is recognized + /// by what it holds and read by the reader for that kind. Only the collection's own + /// children are read here, and a child that is itself a collection is read the same + /// way, which is why the depth of each div is counted. A template's first + /// child is its title. A group becomes its members, because the board has no groups: + /// see docs/microsoft-whiteboard-import.md for what changes when it has. A child of + /// any other kind is counted. /// - private static IEnumerable ReadTemplate(Anchor anchor, List tags, Action skip) + private static IEnumerable ReadCollection(Anchor anchor, List tags, Action skip, string items) { - 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) + var root = tags.FindIndex(tag => tag.Name == "div" && tag.Attribute("role") == "collection"); + if (root < 0) { skip(anchor.Type); yield break; } - for (var child = 0; child < children.Count; child++) + var rootEnd = EndOfDiv(tags, root); + var depth = 0; + for (var index = root; index < rootEnd; index++) { - 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) + var tag = tags[index]; + if (tag.Name == HtmlTags.EndDivName) + { + depth--; + continue; + } + + if (tag.Name != "div") { - skip("Template items"); + continue; + } + + depth++; + + // The root is at depth 1, a wrapper for each child at 2, and the child at 3. + if (depth != 3 || StyleValue(tag.Attribute("style"), "left") is null) + { + continue; } - foreach (var item in items) + var end = EndOfDiv(tags, index); + var content = tags.GetRange(index + 1, Math.Max(0, end - index - 1)); + var place = Anchor.From(tag, anchor.Type).Inside(anchor, default); + var read = ReadCollectionChild(place, tag, content, skip, items).ToArray(); + if (read.Length == 0 && ReadText(content, 0, content.Count).Length + content.Count(item => item.Name == "svg") > 0) + { + skip(items); + } + + foreach (var item in read) { yield return item; } + + // The child's own divs are read with it. + index = end; + depth--; } } - private static IEnumerable ReadTemplateChild(Anchor place, HtmlTag child, List content) + /// + /// The index of the end tag that closes the div at . + /// + private static int EndOfDiv(List tags, int start) + { + var depth = 0; + for (var index = start; index < tags.Count; index++) + { + if (tags[index].Name == "div") + { + depth++; + } + else if (tags[index].Name == HtmlTags.EndDivName && --depth == 0) + { + return index; + } + } + + return tags.Count; + } + + private static IEnumerable ReadCollectionChild(Anchor place, HtmlTag child, List content, Action skip, string items) { bool Has(Func test) => content.Any(test); HtmlTag? First(Func test) => content.FirstOrDefault(test); + // A picture, a shape, and a sticker are centered on their anchor at the top + // level. A child placed from its corner moves its anchor to the middle first. + Anchor Centered(double width, double height) => + place.Centered ? place : place.Shifted(new PointD(width / 2, height / 2)); + + if (Has(tag => tag.Name == "div" && tag.Attribute("role") == "collection")) + { + foreach (var item in ReadCollection(place, content, skip, items)) + { + yield return item; + } + + yield break; + } + if (ReadPlaceholder(place, content) is { } placeholder) { yield return placeholder; @@ -1206,6 +1263,16 @@ private static IEnumerable ReadTemplateChild(Anchor pla yield break; } + if (Has(tag => tag.Name == "div" && tag.HasClass("inkTableContainer"))) + { + foreach (var item in ReadTable(place, content)) + { + yield return item; + } + + yield break; + } + if (Has(tag => tag.Name == "svg" && tag.HasClass("inkGroup"))) { var from = content.FindIndex(tag => tag.Name == "svg" && tag.HasClass("inkGroup")); @@ -1256,14 +1323,28 @@ private static IEnumerable ReadTemplateChild(Anchor pla 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. + MicrosoftWhiteboardItem? single = + Has(tag => tag.HasClass("loopParentDiv")) ? ReadLoop(place, content) + : Has(tag => tag.HasClass("appFrameContainer")) ? ReadAppFrame(place, content) + : Has(tag => tag.HasClass("WorkItem")) ? ReadWorkItem(place, content) + : Has(tag => tag.Attribute("data-custom-widget-kind") is not null) ? ReadCustomElement(place, content) + : Has(tag => tag.HasClass("previewCardTitleContainer")) ? ReadLink(place, content) + : Has(tag => tag.Name == "svg" && tag.HasClass("connector")) ? ReadConnector(place, content) + : null; + if (single is not null) + { + yield return single; + yield break; + } + 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) + var anchor = Centered(StyleLength(box.Attribute("style"), "width") ?? 0, StyleLength(box.Attribute("style"), "height") ?? 0); + + // A picture with a caption is a sticker from the Windows 10 app. + foreach (var item in ReadSticker(anchor, content)) { - yield return picture; + yield return item; } yield break; @@ -1271,12 +1352,13 @@ private static IEnumerable ReadTemplateChild(Anchor pla 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 width = Numbers(svg.Attribute("width")).FirstOrDefault(); + var height = 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); + ? ReadLegacyShape(Centered(width, height), content) + : ReadShape(Centered(width, height), content); if (shape is not null) { yield return shape; @@ -1496,14 +1578,14 @@ private static IEnumerable ReadFrame(Anchor anchor, Lis /// holds a title and a description in the board's language and nothing of the /// content, and the stylesheet makes it at least 448 by 170 pixels, white, with a /// shadow. It becomes a white rectangle of that size with a light outline, because - /// a shadow does not carry over, and the two lines as its text. A template whose - /// children were drawn is read child by child, and a child can be a placeholder. + /// a shadow does not carry over, and the two lines as its text. A template or a + /// group whose children were drawn is read child by child, and a child can be a + /// placeholder. /// private static MicrosoftWhiteboardShape? ReadPlaceholder(Anchor anchor, List tags) { var index = tags.FindIndex(tag => tag.Name == "div" && tag.HasClass("unknownObject")); - if (index < 0 || tags.Any(tag => tag.Name == "div" && tag.HasClass("topLeft") && - StyleValue(tag.Attribute("style"), "left") is not null)) + if (index < 0 || tags.Any(tag => tag.Name == "div" && tag.Attribute("role") == "collection")) { return null; } @@ -2025,7 +2107,8 @@ public static Anchor From(HtmlTag tag, string type) (parent.A * C) + (parent.C * D), (parent.B * C) + (parent.D * D), 0, - 0); + 0, + Centered); private static double[] StyleMatrix(string? style) { diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs index df5a32f..2d81e34 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/MicrosoftWhiteboardSmokeTests.cs @@ -145,6 +145,7 @@ public static void Run() RunCards(); RunCustom(); RunPlaceholders(); + RunGroup(); } /// @@ -733,6 +734,55 @@ private static void RunPlaceholders() "In a template whose children were drawn, a child drawn as a placeholder is the box, placed from its corner."); } + /// + /// A group as the web client draws it: a collection whose children keep their own + /// origin, one of them a group of its own. + /// + private static readonly string GroupPage = $$""" +
    +
    +
    +
    +
    +
    Grouped
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + """; + + private static void RunGroup() + { + var board = MicrosoftWhiteboardExport.Parse(GroupPage, "Group"); + Assert(board.Skipped.Count == 1 && board.Skipped["Group items"] == 1, + "A group's members are read, and a member of an unknown kind is counted."); + + Assert(board.Items[0] is MicrosoftWhiteboardShape { Text: "Grouped", Width: 608, Height: 610 } note && + note.Center == new PointD(1304, 1305), + "A member placed from its corner starts at its place in the group, scaled with the group."); + Assert(board.Items[1] is MicrosoftWhiteboardImage picture && picture.Bounds == new RectD(1900, 1150, 200, 100), + "A member centered on its place stays centered there."); + Assert(board.Items[2] is MicrosoftWhiteboardShape { Kind: ShapeKind.Ellipse, Width: 120, Height: 80 } ellipse && + ellipse.Center == new PointD(1200, 1900), + "A group inside a group places its members through both."); + var stroke = (MicrosoftWhiteboardStroke)board.Items[3]; + AssertNear(1000, stroke.Points[0].X, "Ink in an inner group starts at its place."); + AssertNear(1800, stroke.Points[0].Y, "Ink in an inner group starts at its place."); + AssertNear(1040, stroke.Points[^1].X, "Ink in an inner group takes both groups' scale."); + Assert(board.Items.Count == 4, "A group is its members; nothing stands for the group itself."); + } + private static void RunArchive() { var folder = Path.Combine(Path.GetTempPath(), "wb-mswb-" + Guid.NewGuid().ToString("N"));