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