using System.Text; using AIStudio.Tools.PowerPoint; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using A = DocumentFormat.OpenXml.Drawing; using C = DocumentFormat.OpenXml.Drawing.Charts; using P = DocumentFormat.OpenXml.Presentation; using P14 = DocumentFormat.OpenXml.Office2010.PowerPoint; namespace AIStudio.Tests.Tools.PowerPoint; /// /// Builds small templates in memory, so that no test depends on the template file of an organization. /// internal static class TestTemplate { public const long SLIDE_WIDTH = 12_192_000; public const long SLIDE_HEIGHT = 6_858_000; public const int MASTER_TITLE_FONT_SIZE = 4400; public const int MASTER_BODY_FONT_SIZE = 2800; public static readonly SlotBounds MASTER_TITLE_BOUNDS = new(838_200, 365_125, 10_515_600, 1_325_563); public static readonly SlotBounds MASTER_BODY_BOUNDS = new(838_200, 1_825_625, 10_515_600, 4_351_338); /// /// PowerPoint's own default table style, which the test template names as its default. /// public const string DEFAULT_TABLE_STYLE_ID = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"; /// /// The smallest theme the schema allows: PowerPoint opens no presentation without one. /// private const string THEME_XML = """ """; private const string SECTION_LIST_URI = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}"; /// /// Writes a presentation with one slide master and the given layouts. /// /// The layouts, in the order the master lists them. /// The file, positioned at its start. public static MemoryStream Create(params TestLayout[] layouts) => Create(new TestTemplateOptions(), layouts); /// /// Writes a presentation with one slide master and the given layouts. /// /// What else the file holds, and whether it is a template (.potx). /// The layouts, in the order the master lists them. /// The file, positioned at its start. public static MemoryStream Create(TestTemplateOptions options, params TestLayout[] layouts) { var stream = new MemoryStream(); using (var document = PresentationDocument.Create(stream, options.AsTemplate ? PresentationDocumentType.Template : PresentationDocumentType.Presentation)) { var presentationPart = document.AddPresentationPart(); var masterPart = presentationPart.AddNewPart("rId1"); var themePart = masterPart.AddNewPart($"rId{layouts.Length + 2}"); using (var theme = new MemoryStream(Encoding.UTF8.GetBytes(THEME_XML))) themePart.FeedData(theme); var tableStylesPart = presentationPart.AddNewPart("rId2"); tableStylesPart.TableStyleList = new A.TableStyleList { Default = DEFAULT_TABLE_STYLE_ID }; var layoutParts = new List(); var layoutIds = new P.SlideLayoutIdList(); for (var index = 0; index < layouts.Length; index++) { var relationshipId = $"rId{index + 2}"; var layoutPart = masterPart.AddNewPart(relationshipId); layoutPart.SlideLayout = new P.SlideLayout( new P.CommonSlideData(ShapeTreeOf(layouts[index].Placeholders)) { Name = layouts[index].Name }, new P.ColorMapOverride(new A.MasterColorMapping())); layoutPart.AddPart(masterPart); layoutParts.Add(layoutPart); layoutIds.Append(new P.SlideLayoutId { Id = 2_147_483_649U + (uint)index, RelationshipId = relationshipId }); } masterPart.SlideMaster = new P.SlideMaster( new P.CommonSlideData(ShapeTreeOf([ new TestPlaceholder(P.PlaceholderValues.Title, null, MASTER_TITLE_BOUNDS), new TestPlaceholder(P.PlaceholderValues.Body, 1, MASTER_BODY_BOUNDS), ])), new P.ColorMap { Background1 = A.ColorSchemeIndexValues.Light1, Text1 = A.ColorSchemeIndexValues.Dark1, Background2 = A.ColorSchemeIndexValues.Light2, Text2 = A.ColorSchemeIndexValues.Dark2, Accent1 = A.ColorSchemeIndexValues.Accent1, Accent2 = A.ColorSchemeIndexValues.Accent2, Accent3 = A.ColorSchemeIndexValues.Accent3, Accent4 = A.ColorSchemeIndexValues.Accent4, Accent5 = A.ColorSchemeIndexValues.Accent5, Accent6 = A.ColorSchemeIndexValues.Accent6, Hyperlink = A.ColorSchemeIndexValues.Hyperlink, FollowedHyperlink = A.ColorSchemeIndexValues.FollowedHyperlink, }, layoutIds, new P.TextStyles( new P.TitleStyle(new A.Level1ParagraphProperties(new A.DefaultRunProperties { FontSize = MASTER_TITLE_FONT_SIZE })), new P.BodyStyle(new A.Level1ParagraphProperties(new A.DefaultRunProperties { FontSize = MASTER_BODY_FONT_SIZE })), new P.OtherStyle())); // // Templates come with sample slides and hints, grouped into sections, which a // presentation written from the template must not keep: // var slideIds = new P.SlideIdList(); var sectionSlideIds = new P14.SectionSlideIdList(); for (var index = 0; index < options.ExampleSlides; index++) { var relationshipId = $"rId{100 + index}"; var slidePart = presentationPart.AddNewPart(relationshipId); slidePart.AddPart(layoutParts[0]); slidePart.Slide = new P.Slide( new P.CommonSlideData(ShapeTreeOf([TestPlaceholder.Title()])), new P.ColorMapOverride(new A.MasterColorMapping())); slideIds.Append(new P.SlideId { Id = 256U + (uint)index, RelationshipId = relationshipId }); sectionSlideIds.Append(new P14.SectionSlideIdListEntry { Id = 256U + (uint)index }); } // A presentation used as a template carries slides worth keeping, after the samples: var slides = options.Slides ?? []; var contentSectionSlideIds = new P14.SectionSlideIdList(); for (var index = 0; index < slides.Count; index++) { var relationshipId = $"rId{200 + index}"; var slidePart = presentationPart.AddNewPart(relationshipId); slidePart.AddPart(layoutParts[slides[index].Layout]); // The elements lie in front of the placeholders, with the IDs after theirs: var shapeTree = ShapeTreeOf(slides[index].Texts.Select(text => text.Placeholder).ToList(), slides[index].Texts.Select(text => text.Text).ToList()); for (var elementIndex = 0; elementIndex < slides[index].Elements.Count; elementIndex++) shapeTree.Append(slides[index].Elements[elementIndex].Create((uint)(slides[index].Texts.Length + 2 + elementIndex), slidePart)); slidePart.Slide = new P.Slide( new P.CommonSlideData(shapeTree), new P.ColorMapOverride(new A.MasterColorMapping())); if (slides[index].IsHidden) slidePart.Slide.Show = false; var slideId = 256U + (uint)(options.ExampleSlides + index); slideIds.Append(new P.SlideId { Id = slideId, RelationshipId = relationshipId }); contentSectionSlideIds.Append(new P14.SectionSlideIdListEntry { Id = slideId }); } presentationPart.Presentation = new P.Presentation( new P.SlideMasterIdList(new P.SlideMasterId { Id = 2_147_483_648U, RelationshipId = "rId1" }), slideIds, new P.SlideSize { Cx = (int)SLIDE_WIDTH, Cy = (int)SLIDE_HEIGHT }, new P.NotesSize { Cx = 6_858_000, Cy = 9_144_000 }); if (options.ExampleSlides > 0) { var sections = new P14.SectionList(new P14.Section(sectionSlideIds) { Name = "Hints", Id = "{8A7F8F2C-6E59-4C61-9D3E-5B4D2D8F0A11}" }); if (slides.Count > 0) sections.Append(new P14.Section(contentSectionSlideIds) { Name = "Slides", Id = "{3C1E6B0D-2F4A-4E8B-9C7D-1A5F6E2B8D40}" }); presentationPart.Presentation.PresentationExtensionList = new P.PresentationExtensionList( new P.PresentationExtension(sections) { Uri = SECTION_LIST_URI }); } } stream.Position = 0; return stream; } /// /// Writes a presentation with the given layouts and reads it as a template. /// public static PresentationTemplate Read(params TestLayout[] layouts) { using var stream = Create(layouts); return PresentationTemplate.Read(stream); } /// /// The shape tree of a layout, a master or a slide. /// /// The placeholders. /// The text of each placeholder, for a slide; a line break starts a new paragraph. private static P.ShapeTree ShapeTreeOf(IReadOnlyList placeholders, IReadOnlyList? texts = null) { var shapeTree = new P.ShapeTree( new P.NonVisualGroupShapeProperties( new P.NonVisualDrawingProperties { Id = 1U, Name = string.Empty }, new P.NonVisualGroupShapeDrawingProperties(), new P.ApplicationNonVisualDrawingProperties()), new P.GroupShapeProperties(new A.TransformGroup())); for (var index = 0; index < placeholders.Count; index++) shapeTree.Append(ShapeOf((uint)index + 2, placeholders[index], texts?[index] ?? string.Empty)); return shapeTree; } private static P.Shape ShapeOf(uint id, TestPlaceholder placeholder, string text) { var placeholderShape = new P.PlaceholderShape(); if (placeholder.Type is { } type) placeholderShape.Type = type; if (placeholder.Index is { } index) placeholderShape.Index = index; var shapeProperties = new P.ShapeProperties(); if (placeholder.Bounds is { } bounds) shapeProperties.Transform2D = new A.Transform2D( new A.Offset { X = bounds.X, Y = bounds.Y }, new A.Extents { Cx = bounds.Width, Cy = bounds.Height }); var listStyle = placeholder.FontSize is { } fontSize ? new A.ListStyle(new A.Level1ParagraphProperties(new A.DefaultRunProperties { FontSize = fontSize })) : new A.ListStyle(); var textBody = new P.TextBody(new A.BodyProperties(), listStyle); if (text.Length == 0) textBody.Append(new A.Paragraph()); else textBody.Append(text.Split('\n').Select(line => new A.Paragraph(new A.Run(new A.Text(line))))); return new P.Shape( new P.NonVisualShapeProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Placeholder {id}" }, new P.NonVisualShapeDrawingProperties(), new P.ApplicationNonVisualDrawingProperties(placeholderShape)), shapeProperties, textBody); } } /// /// What a test template holds beyond its layouts. /// /// How many sample slides the template carries, grouped into one section. /// Whether the file is a template (.potx) rather than a presentation. /// The slides with content the template carries after the sample slides. internal sealed record TestTemplateOptions(int ExampleSlides = 0, bool AsTemplate = false, IReadOnlyList? Slides = null); /// /// A slide of a test template, based on one of its layouts. /// /// The position of the layout in the template, starting at 0. /// The placeholders of the slide with their texts; a line break starts a new paragraph. Placeholders without bounds take the place of the layout's. internal sealed record TestSlide(int Layout, params (TestPlaceholder Placeholder, string Text)[] Texts) { /// /// Whether the slide is hidden in the slide show. /// public bool IsHidden { get; init; } /// /// The shapes of the slide which are no placeholders, in front of the placeholders. /// public IReadOnlyList Elements { get; init; } = []; } /// /// A shape of a test slide which is no placeholder, e.g., a table an author pasted onto the slide. /// /// Writes the shape with the given ID for the given slide. internal sealed record TestElement(Func Create) { private const long ROW_HEIGHT = 370_840; /// /// A table which is no placeholder, in equally wide columns; its first row is the header. /// public static TestElement Table(SlotBounds bounds, params string[][] rows) => new((id, _) => TableOf(id, null, bounds, rows)); /// /// A table which fills the table placeholder of the given index, as PowerPoint writes one. /// public static TestElement TablePlaceholder(uint index, SlotBounds bounds, params string[][] rows) => new((id, _) => TableOf(id, index, bounds, rows)); private static P.GraphicFrame TableOf(uint id, uint? placeholderIndex, SlotBounds bounds, string[][] rows) { var columns = rows[0].Length; var grid = new A.TableGrid(); for (var column = 0; column < columns; column++) grid.Append(new A.GridColumn { Width = bounds.Width / columns }); var table = new A.Table(new A.TableProperties { FirstRow = true, BandRow = true }, grid); foreach (var row in rows) { var tableRow = new A.TableRow { Height = ROW_HEIGHT }; foreach (var cell in row) tableRow.Append(new A.TableCell(new A.TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph(new A.Run(new A.Text(cell)))), new A.TableCellProperties())); table.Append(tableRow); } var applicationProperties = new P.ApplicationNonVisualDrawingProperties(); if (placeholderIndex is { } index) applicationProperties.PlaceholderShape = new P.PlaceholderShape { Type = P.PlaceholderValues.Table, Index = index }; return new P.GraphicFrame( new P.NonVisualGraphicFrameProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Table {id}" }, new P.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoGrouping = true }), applicationProperties), new P.Transform( new A.Offset { X = bounds.X, Y = bounds.Y }, new A.Extents { Cx = bounds.Width, Cy = ROW_HEIGHT * rows.Length }), new A.Graphic(new A.GraphicData(table) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/table" })); } /// /// A text box: a shape with text which is no placeholder. Without text, it shows nothing. /// /// Where the text box sits. /// Its text. /// The color it is filled with, e.g., "4472C4" for a heading copied from a colored placeholder; null for none. public static TestElement TextBox(SlotBounds bounds, string text, string? fill = null) => new((id, _) => { var properties = new P.ShapeProperties(TransformOf(bounds), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle }); if (fill is not null) properties.Append(new A.SolidFill(new A.RgbColorModelHex { Val = fill })); return new P.Shape( new P.NonVisualShapeProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Text box {id}" }, new P.NonVisualShapeDrawingProperties { TextBox = true }, new P.ApplicationNonVisualDrawingProperties()), properties, new P.TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph(new A.Run(new A.Text(text))))); }); /// /// A filled rectangle without text, e.g., a colored area behind a chart. /// public static TestElement Rectangle(SlotBounds bounds) => new((id, _) => new P.Shape( new P.NonVisualShapeProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Rectangle {id}" }, new P.NonVisualShapeDrawingProperties(), new P.ApplicationNonVisualDrawingProperties()), new P.ShapeProperties( TransformOf(bounds), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle }, new A.SolidFill(new A.RgbColorModelHex { Val = "4472C4" })), new P.TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph()))); /// /// A straight line. /// public static TestElement Line(SlotBounds bounds) => new((id, _) => new P.ConnectionShape( new P.NonVisualConnectionShapeProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Line {id}" }, new P.NonVisualConnectorShapeDrawingProperties(), new P.ApplicationNonVisualDrawingProperties()), new P.ShapeProperties( TransformOf(bounds), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Line }, new A.Outline(new A.SolidFill(new A.RgbColorModelHex { Val = "000000" }))))); /// /// A picture which is no placeholder, with an image part of its own. /// public static TestElement Picture(SlotBounds bounds, params byte[] data) => new((id, slidePart) => { var imagePart = slidePart.AddImagePart("image/png"); using (var stream = new MemoryStream(data)) imagePart.FeedData(stream); return new P.Picture( new P.NonVisualPictureProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Picture {id}" }, new P.NonVisualPictureDrawingProperties(new A.PictureLocks { NoChangeAspect = true }), new P.ApplicationNonVisualDrawingProperties()), new P.BlipFill(new A.Blip { Embed = slidePart.GetIdOfPart(imagePart) }, new A.Stretch(new A.FillRectangle())), new P.ShapeProperties(TransformOf(bounds), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })); }); /// /// A bar chart without data, with a chart part of its own. /// public static TestElement Chart(SlotBounds bounds) => new((id, slidePart) => { var chartPart = slidePart.AddNewPart(); using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(CHART_XML))) chartPart.FeedData(stream); return new P.GraphicFrame( new P.NonVisualGraphicFrameProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Chart {id}" }, new P.NonVisualGraphicFrameDrawingProperties(), new P.ApplicationNonVisualDrawingProperties()), new P.Transform( new A.Offset { X = bounds.X, Y = bounds.Y }, new A.Extents { Cx = bounds.Width, Cy = bounds.Height }), new A.Graphic(new A.GraphicData(new C.ChartReference { Id = slidePart.GetIdOfPart(chartPart) }) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/chart" })); }); /// /// Elements grouped into one, which keep their own places within the group. /// public static TestElement Group(SlotBounds bounds, params TestElement[] children) => new((id, slidePart) => { var group = new P.GroupShape( new P.NonVisualGroupShapeProperties( new P.NonVisualDrawingProperties { Id = id, Name = $"Group {id}" }, new P.NonVisualGroupShapeDrawingProperties(), new P.ApplicationNonVisualDrawingProperties()), new P.GroupShapeProperties(new A.TransformGroup( new A.Offset { X = bounds.X, Y = bounds.Y }, new A.Extents { Cx = bounds.Width, Cy = bounds.Height }, new A.ChildOffset { X = bounds.X, Y = bounds.Y }, new A.ChildExtents { Cx = bounds.Width, Cy = bounds.Height }))); // The children take IDs of their own, apart from those of the slide's top-level shapes: for (var index = 0; index < children.Length; index++) group.Append(children[index].Create(id * 100 + (uint)index, slidePart)); return group; }); private const string CHART_XML = """ """; private static A.Transform2D TransformOf(SlotBounds bounds) => new( new A.Offset { X = bounds.X, Y = bounds.Y }, new A.Extents { Cx = bounds.Width, Cy = bounds.Height }); } /// /// A slide layout of a test template. /// internal sealed record TestLayout(string Name, params TestPlaceholder[] Placeholders); /// /// A placeholder of a test layout; without bounds, it takes the place of the master placeholder. /// internal sealed record TestPlaceholder(P.PlaceholderValues? Type, uint? Index = null, SlotBounds? Bounds = null, int? FontSize = null) { public static TestPlaceholder Title() => new(P.PlaceholderValues.Title); public static TestPlaceholder Subtitle(SlotBounds bounds) => new(P.PlaceholderValues.SubTitle, 1, bounds); /// /// A content placeholder, which the file writes without a type. /// public static TestPlaceholder Text(uint index, SlotBounds bounds, int? fontSize = null) => new(null, index, bounds, fontSize); public static TestPlaceholder Label(uint index, SlotBounds bounds, int? fontSize = null) => new(P.PlaceholderValues.Body, index, bounds, fontSize); public static TestPlaceholder Picture(uint index, SlotBounds bounds) => new(P.PlaceholderValues.Picture, index, bounds); public static TestPlaceholder Table(uint index, SlotBounds bounds) => new(P.PlaceholderValues.Table, index, bounds); public static TestPlaceholder Footer(uint index, SlotBounds bounds) => new(P.PlaceholderValues.Footer, index, bounds); public static TestPlaceholder SlideNumber(uint index, SlotBounds bounds) => new(P.PlaceholderValues.SlideNumber, index, bounds); }