using AIStudio.Tools.PowerPoint; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Validation; using A = DocumentFormat.OpenXml.Drawing; using P = DocumentFormat.OpenXml.Presentation; using P14 = DocumentFormat.OpenXml.Office2010.PowerPoint; namespace AIStudio.Tests.Tools.PowerPoint; /// /// Checks the presentations written from a template: valid files, filled placeholders, variants, /// pictures, tables, sections, and the template slides a deck keeps or edits. /// /// /// The test template reads as L1 "Title" (A, B), L2 "Content" (A, B), L3 "Picture with text", /// L4 "Backdrop" and L5 "Table". /// [TestFixture] public sealed class PresentationWriterTests { private static readonly SlotBounds SUBTITLE = new(1_524_000, 3_602_038, 9_144_000, 1_655_762); private static readonly SlotBounds HEADING = new(838_200, 1_400_000, 3_000_000, 300_000); private static readonly SlotBounds LEFT_TEXT = new(838_200, 1_825_625, 5_150_000, 4_351_338); private static readonly SlotBounds RIGHT_PICTURE = new(6_200_000, 1_825_625, 5_150_000, 3_500_000); private static readonly SlotBounds WHOLE_SLIDE = new(0, 0, TestTemplate.SLIDE_WIDTH, TestTemplate.SLIDE_HEIGHT); private static readonly SlotBounds FOOTER = new(838_200, 6_356_350, 4_114_800, 365_125); private static readonly SlotBounds SLIDE_NUMBER = new(8_610_600, 6_356_350, 2_743_200, 365_125); /// /// Four times as wide as high, which neither slot of the test template is. /// private static readonly SlidePicture WIDE_PICTURE = new([1, 2, 3], "image/png", 400, 100); private static readonly PresentationWriteOptions OPTIONS = new("A", "Mission team", false); private static readonly SlideDeckSpec DECK = new("Mars", [ new SlideSpec("L1", false, "Mars", "A plan", [], [], [], null), new SlideSpec("L2", true, "Why Mars", string.Empty, [Text(new SlideParagraph("Close to Earth", 0), new SlideParagraph("Two years per trip", 1))], [], [], null), new SlideSpec("L3", false, "The rocket", string.Empty, [Text(new SlideParagraph("Reusable", 0))], [], [1], null), new SlideSpec("L4", true, "Landing", string.Empty, [], [], [2], null), new SlideSpec("L5", false, "Costs", string.Empty, [], [], [], new SlideTableSpec(["Item", "Cost"], [["Fuel", "10"]])), ]); [Test] public async Task TheFileIsValidAndHoldsTheNewSlidesOnly() { var (file, _) = await WriteAsync(DECK, OPTIONS, new TestTemplateOptions(ExampleSlides: 2), new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE)); using var document = PresentationDocument.Open(file, false); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(document.PresentationPart!.SlideParts.Count(), Is.EqualTo(5), "The sample slides of the template are gone, parts included."); Assert.That(SlidesOf(document).Select(TitleOf), Is.EqualTo(new[] { "Mars", "Why Mars", "The rocket", "Landing", "Costs" }), "The presentation holds the slides of the deck, nothing more."); } } [Test] public async Task EveryChapterBecomesASection() { var (file, _) = await WriteAsync(DECK, OPTIONS, new TestTemplateOptions(ExampleSlides: 2), new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE)); using var document = PresentationDocument.Open(file, false); var sections = document.PresentationPart!.Presentation!.Descendants().ToList(); using (Assert.EnterMultipleScope()) { Assert.That(sections.Select(section => section.Name?.Value), Is.EqualTo(new[] { "Mars", "Why Mars", "Landing" }), "The section of the template's samples is gone, and the title slide forms a section named after the presentation."); Assert.That(sections.Select(section => section.Descendants().Count()), Is.EqualTo(new[] { 1, 2, 2 })); } } [Test] public async Task OnlyPlaceholdersWithContentAreWritten() { var (file, _) = await WriteAsync(DECK, OPTIONS); using var document = PresentationDocument.Open(file, false); // The second slide has no heading for its text, so the label placeholder (idx 17) stays away: var slide = SlidesOf(document)[1].Slide!; var placeholders = slide.Descendants().Select(placeholder => (placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value)).ToList(); var textBody = slide.Descendants().Single(shape => shape.Descendants().Single().Index?.Value == 1).TextBody!; using (Assert.EnterMultipleScope()) { Assert.That(placeholders, Is.EqualTo(new (string, uint?)[] { ("title", null), ("obj", 1), ("ftr", 10), ("sldNum", 11) })); Assert.That(textBody.Elements().Select(paragraph => paragraph.ParagraphProperties?.Level?.Value ?? 0), Is.EqualTo(new[] { 0, 1 })); Assert.That(textBody.InnerText, Is.EqualTo("Close to EarthTwo years per trip")); Assert.That(slide.Descendants().Any(shape => shape.InnerText == "Mission team"), Is.True, "The footer carries the text the user chose."); Assert.That(slide.Descendants().Single().Type?.Value, Is.EqualTo("slidenum"), "PowerPoint fills in the number itself."); } } [Test] public async Task AllSlidesUseTheChosenVariant() { var deck = new SlideDeckSpec("Mars", [ new SlideSpec("L1", false, "Mars", string.Empty, [], [], [], null), new SlideSpec("L2", true, "One", string.Empty, [], [], [], null), new SlideSpec("L2", false, "One more", string.Empty, [], [], [], null), new SlideSpec("L2", true, "Two", string.Empty, [], [], [], null), ]); var (chosen, _) = await WriteAsync(deck, OPTIONS with { VariantLabel = "B" }); var (unknown, _) = await WriteAsync(deck, OPTIONS with { VariantLabel = "Z" }); using (Assert.EnterMultipleScope()) { Assert.That(LayoutNamesOf(chosen), Is.EqualTo(new[] { "B Title", "B Content", "B Content", "B Content" }), "Chapters keep the variant."); Assert.That(LayoutNamesOf(unknown), Is.EqualTo(new[] { "A Title", "A Content", "A Content", "A Content" }), "A variant the template lacks falls back to its first one."); } } [Test] public async Task APictureFillsABackdropAndFitsIntoASmallSlot() { var (file, _) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE)); using var document = PresentationDocument.Open(file, false); var slides = SlidesOf(document); // In the small slot, the whole picture is scaled down to the slot's width and centered: var small = slides[2].Slide!.Descendants().Single(); var transform = small.ShapeProperties!.Transform2D!; // The backdrop is filled, so the sides of the wide picture are cut off evenly: var backdrop = slides[3].Slide!.Descendants().Single(); var crop = backdrop.BlipFill!.GetFirstChild()!; using (Assert.EnterMultipleScope()) { Assert.That(transform.Extents!.Cx!.Value, Is.EqualTo(RIGHT_PICTURE.Width)); Assert.That(transform.Extents.Cy!.Value, Is.EqualTo(RIGHT_PICTURE.Width / 4)); Assert.That(transform.Offset!.Y!.Value, Is.EqualTo(RIGHT_PICTURE.Y + (RIGHT_PICTURE.Height - RIGHT_PICTURE.Width / 4) / 2)); Assert.That(crop.Left?.Value, Is.EqualTo(27_778), "16:9 out of 4:1 keeps 44.4 % of the width."); Assert.That(crop.Right?.Value, Is.EqualTo(27_778)); Assert.That(crop.Top, Is.Null); Assert.That(slides[2].ImageParts.Count(), Is.EqualTo(1)); } } [Test] public async Task AnIconIsNeverCropped() { var (file, _) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE with { IsIcon = true })); using var document = PresentationDocument.Open(file, false); // The icon lands in the backdrop, which the validator advises against, and keeps its edges anyway: var icon = SlidesOf(document)[3].Slide!.Descendants().Single(); using (Assert.EnterMultipleScope()) { Assert.That(icon.BlipFill!.GetFirstChild(), Is.Null); Assert.That(icon.ShapeProperties!.Transform2D!.Extents!.Cx!.Value, Is.EqualTo(WHOLE_SLIDE.Width)); Assert.That(icon.ShapeProperties.Transform2D.Extents.Cy!.Value, Is.EqualTo(WHOLE_SLIDE.Width / 4)); Assert.That(ValidationErrorsOf(document), Is.Empty); } } [Test] public async Task APictureInAFormatPowerPointDoesNotShowIsSkipped() { var (file, result) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE with { ContentType = "image/webp" }, WIDE_PICTURE)); using var document = PresentationDocument.Open(file, false); using (Assert.EnterMultipleScope()) { Assert.That(result.SkippedPictures, Is.EqualTo(new[] { 1 })); Assert.That(SlidesOf(document)[2].Slide!.Descendants(), Is.Empty, "The slot stays empty rather than holding a picture nobody can see."); Assert.That(ValidationErrorsOf(document), Is.Empty); } } [Test] public async Task AnEmptyPictureSlotStaysOnlyWhenTheUserAsksForIt() { var deck = new SlideDeckSpec("Mars", [ new SlideSpec("L3", false, "The rocket", string.Empty, [Text(new SlideParagraph("Reusable", 0))], [], [], null), ]); var (left, _) = await WriteAsync(deck, OPTIONS); var (kept, _) = await WriteAsync(deck, OPTIONS with { KeepEmptyPicturePlaceholders = true }); using var leftDocument = PresentationDocument.Open(left, false); using var keptDocument = PresentationDocument.Open(kept, false); var keptSlide = SlidesOf(keptDocument)[0]; using (Assert.EnterMultipleScope()) { Assert.That(PicturePlaceholdersOf(SlidesOf(leftDocument)[0]), Is.Empty, "By default, no prompt text stays behind."); Assert.That(PicturePlaceholdersOf(keptSlide), Has.Count.EqualTo(1)); Assert.That(PlaceholderShapeOf(keptSlide, "pic", 20).TextBody, Is.Null, "Without a text of its own, the placeholder shows the prompt text of the layout."); Assert.That(keptSlide.ImageParts, Is.Empty); Assert.That(ValidationErrorsOf(keptDocument), Is.Empty); } } [Test] public async Task ASkippedPictureLeavesItsPlaceholderWhenTheUserAsksForIt() { var (file, result) = await WriteAsync(DECK, OPTIONS with { KeepEmptyPicturePlaceholders = true }, pictures: new FakePictureSource(WIDE_PICTURE with { ContentType = "image/webp" }, WIDE_PICTURE)); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[2]; using (Assert.EnterMultipleScope()) { Assert.That(result.SkippedPictures, Is.EqualTo(new[] { 1 })); Assert.That(slide.Slide!.Descendants(), Is.Empty); Assert.That(PicturePlaceholdersOf(slide), Has.Count.EqualTo(1), "The user can insert the picture there themselves."); Assert.That(ValidationErrorsOf(document), Is.Empty); } } [Test] public async Task TheTableUsesTheStyleOfTheTemplate() { var (file, _) = await WriteAsync(DECK, OPTIONS); using var document = PresentationDocument.Open(file, false); var table = SlidesOf(document)[4].Slide!.Descendants().Single(); using (Assert.EnterMultipleScope()) { Assert.That(table.TableProperties!.GetFirstChild()?.Text, Is.EqualTo(TestTemplate.DEFAULT_TABLE_STYLE_ID)); Assert.That(table.TableProperties.FirstRow?.Value, Is.True, "The headers are the first row."); Assert.That(table.Elements().Select(row => row.InnerText), Is.EqualTo(new[] { "ItemCost", "Fuel10" })); Assert.That(table.TableGrid!.Elements().Sum(column => column.Width!.Value), Is.EqualTo(TestTemplate.MASTER_BODY_BOUNDS.Width), "The columns span the slot exactly."); } } [Test] public async Task ATemplateFileBecomesAPresentation() { var (file, _) = await WriteAsync(DECK, OPTIONS, new TestTemplateOptions(AsTemplate: true)); using var document = PresentationDocument.Open(file, false); using (Assert.EnterMultipleScope()) { Assert.That(document.DocumentType, Is.EqualTo(PresentationDocumentType.Presentation)); Assert.That(ValidationErrorsOf(document), Is.Empty); } } [Test] public async Task KeptTemplateSlidesStayInTheOrderOfTheDeck() { var deck = new SlideDeckSpec("Weekly", [ Keep(1), new SlideSpec("L2", false, "Agenda", string.Empty, [Text(new SlideParagraph("News", 0))], [], [], null), Keep(2), Keep(3), Keep(4), ]); var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_SLIDES); using var document = PresentationDocument.Open(file, false); var slides = SlidesOf(document); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(slides.Select(TitleOf), Is.EqualTo(new[] { "Consultation hour", "Agenda", "News", "Questions", "Impressum" })); Assert.That(document.PresentationPart!.SlideParts.Count(), Is.EqualTo(5), "The slide the deck leaves out is gone, part included."); Assert.That(BodyParagraphsOf(slides[2]).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "AI Studio:", "EINSTEIN:" })); Assert.That(slides[3].Slide!.Show, Is.Null, "The user chose the slide, so it shows although the template hides it."); Assert.That(document.PresentationPart.Presentation!.SlideIdList!.Elements().Select(slideId => slideId.Id!.Value), Is.Unique); } } [Test] public async Task AKeptSlideStaysWhereTheDeckPutsItWhateverItsTitle() { var deck = new SlideDeckSpec("Weekly", [ Keep(1), Keep(4), new SlideSpec("L2", true, "Agenda", string.Empty, [Text(new SlideParagraph("News", 0))], [], [], null), Keep(2), ]); var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_SLIDES); using var document = PresentationDocument.Open(file, false); var sections = document.PresentationPart!.Presentation!.Descendants().ToList(); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(SlidesOf(document).Select(TitleOf), Is.EqualTo(new[] { "Consultation hour", "Impressum", "Agenda", "News" }), "An imprint is a slide like any other: nothing moves it to the end."); Assert.That(sections.Select(section => section.Name?.Value), Is.EqualTo(new[] { "Weekly", "Agenda" })); Assert.That(sections.Select(section => section.Descendants().Count()), Is.EqualTo(new[] { 2, 2 })); } } [Test] public async Task KeptSlidesTakeTheColorVariantAndTheFooterOfThePresentation() { var deck = new SlideDeckSpec("Weekly", [Keep(1), Keep(2)]); var (withFooter, _) = await WriteAsync(deck, OPTIONS with { VariantLabel = "B" }, TEMPLATE_WITH_SLIDES); var (withoutFooter, _) = await WriteAsync(deck, OPTIONS with { FooterText = string.Empty }, TEMPLATE_WITH_SLIDES); using var document = PresentationDocument.Open(withFooter, false); var slides = SlidesOf(document); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(slides.Select(slide => slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value), Is.EqualTo(new[] { "B Title", "B Content" })); Assert.That(document.PresentationPart!.SlideMasterParts.Single().SlideLayoutParts.Count(), Is.EqualTo(Layouts().Length), "The layout a slide leaves stays in the template."); Assert.That(FootersOf(slides[0]), Is.EqualTo(new[] { "Mission team" })); Assert.That(FootersOf(slides[1]), Is.EqualTo(new[] { "Mission team" })); Assert.That(FootersOfSlide(withoutFooter, 1), Is.EqualTo(new[] { "Old footer" }), "Without a footer of the presentation, a kept slide keeps its own."); } } [Test] public async Task AKeptSlideIsLinkedToTheIndicesOfItsNewVariant() { using var templateFile = TestTemplate.Create( new TestTemplateOptions(Slides: [new TestSlide(0, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(null, 1), "A point"), (new TestPlaceholder(P.PlaceholderValues.Footer, 10), "Old footer"))]), new TestLayout("A Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(10, FOOTER)), new TestLayout("B Content", TestPlaceholder.Title(), TestPlaceholder.Text(2, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(12, FOOTER))); var template = PresentationTemplate.Read(templateFile); templateFile.Position = 0; using var output = new MemoryStream(); await templateFile.CopyToAsync(output); output.Position = 0; await PresentationWriter.WriteAsync(output, template, new SlideDeckSpec("Weekly", [Keep(1)]), OPTIONS with { VariantLabel = "B" }, new FakePictureSource(), CancellationToken.None); output.Position = 0; using var document = PresentationDocument.Open(output, false); var slide = SlidesOf(document)[0]; using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value, Is.EqualTo("B Content")); Assert.That(slide.Slide!.Descendants().Select(placeholder => (placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value)), Is.EqualTo(new (string, uint?)[] { ("title", null), ("obj", 2), ("ftr", 12) })); Assert.That(FootersOf(slide), Is.EqualTo(new[] { "Mission team" })); } } [Test] public async Task AnEditedSlideTakesTheNewTextsInTheFormattingOfTheOldOnes() { using var templateFile = TestTemplate.Create(TEMPLATE_WITH_SLIDES, Layouts()); using (var templateDocument = PresentationDocument.Open(templateFile, true)) { // // The author made the title of the news slide bold by hand, and left a sub-point // waiting for text: without a bullet, and in a size of its own: // var news = SlidesOf(templateDocument)[1]; foreach (var run in PlaceholderShapeOf(news, "title", null).Descendants().ToList()) run.RunProperties = new A.RunProperties { Bold = true }; PlaceholderShapeOf(news, "obj", 1).TextBody!.Append(new A.Paragraph(new A.ParagraphProperties(new A.NoBullet()) { Level = 1 }, new A.EndParagraphRunProperties { FontSize = 1600 })); } var deck = new SlideDeckSpec("Weekly", [Edit(2, "News of the week", texts: [Text(new SlideParagraph("A new release", 0), new SlideParagraph("Faster", 1))], labels: ["This week"])]); var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; var paragraphs = BodyParagraphsOf(slide); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(TitleOf(slide), Is.EqualTo("News of the week")); Assert.That(PlaceholderShapeOf(slide, "title", null).Descendants().Select(run => run.RunProperties?.Bold?.Value), Is.All.True, "The new title keeps the formatting of the old one."); Assert.That(paragraphs.Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "A new release", "Faster" })); Assert.That(paragraphs[1].ParagraphProperties?.Level?.Value, Is.EqualTo(1)); Assert.That(paragraphs[1].ParagraphProperties?.GetFirstChild(), Is.Not.Null, "A sub-point keeps what the author chose for sub-points."); Assert.That(paragraphs[1].Elements().Single().RunProperties?.FontSize?.Value, Is.EqualTo(1600)); Assert.That(paragraphs[0].Elements().Single().RunProperties?.FontSize, Is.Null, "A main point keeps what the author chose for main points."); Assert.That(PlaceholderShapeOf(slide, "body", 17).TextBody!.InnerText, Is.EqualTo("This week"), "A slot the template slide left out gets its placeholder from the layout."); Assert.That(slide.Slide!.Descendants().Select(properties => properties.Id!.Value), Is.Unique); } } [Test] public async Task WhatAnEditedSlideLeavesEmptyKeepsTheContentOfTheTemplate() { var deck = new SlideDeckSpec("Weekly", [Edit(2, labels: ["This week"])]); var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_SLIDES); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; using (Assert.EnterMultipleScope()) { Assert.That(TitleOf(slide), Is.EqualTo("News")); Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "AI Studio:", "EINSTEIN:" })); } } [Test] public async Task APictureOfAnEditedSlideReplacesTheOneOfTheTemplate() { // Last week's presentation is this week's template: var (lastWeek, _) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE)); var tallPicture = new SlidePicture([4, 5, 6], "image/png", 100, 400); var deck = new SlideDeckSpec("Mars", [Edit(3, pictures: [1])]); var (file, _) = await WriteFromAsync(lastWeek, deck, OPTIONS, new FakePictureSource(tallPicture)); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; using var pictureData = new MemoryStream(); using (var stream = slide.ImageParts.Single().GetStream(FileMode.Open, FileAccess.Read)) stream.CopyTo(pictureData); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(TitleOf(slide), Is.EqualTo("The rocket")); Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "Reusable" })); Assert.That(slide.Slide!.Descendants().Count(), Is.EqualTo(1)); Assert.That(pictureData.ToArray(), Is.EqualTo(new byte[] { 4, 5, 6 }), "The old picture leaves the file along with its shape."); Assert.That(slide.Slide!.Descendants().Single().ShapeProperties!.Transform2D!.Extents!.Cy!.Value, Is.EqualTo(RIGHT_PICTURE.Height), "The old picture was fit into its slot, which therefore stays as large as the layout makes it."); } } [Test] public async Task APictureGoesWhereTheSlideMovedItsSlot() { var moved = new SlotBounds(6_200_000, 4_000_000, 2_000_000, 1_500_000); var templateOptions = new TestTemplateOptions(Slides: [new TestSlide(4, (TestPlaceholder.Title(), "The rocket"), (TestPlaceholder.Picture(20, moved), string.Empty))]); var deck = new SlideDeckSpec("Mars", [Edit(1, pictures: [1])]); var (file, _) = await WriteAsync(deck, OPTIONS, templateOptions, new FakePictureSource(WIDE_PICTURE)); using var document = PresentationDocument.Open(file, false); var transform = SlidesOf(document)[0].Slide!.Descendants().Single().ShapeProperties!.Transform2D!; var bounds = new SlotBounds(transform.Offset!.X!.Value, transform.Offset.Y!.Value, transform.Extents!.Cx!.Value, transform.Extents.Cy!.Value); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(moved.Contains(bounds, 0, 0), Is.True, $"The picture fits into the slot where the slide put it, but lies at {bounds}."); Assert.That(bounds.Width, Is.EqualTo(moved.Width), "The wide picture takes the whole width of the moved slot."); } } [Test] public async Task ATableOfAnEditedSlideReplacesTheFreeTableOfTheTemplate() { var deck = new SlideDeckSpec("Mars", [ Edit(1, table: new SlideTableSpec(["Item", "Cost"], [["Fuel", "10"], ["Food", "5"]])), Edit(2, table: new SlideTableSpec(["Step", "Date"], [["Launch", "2030"]])), ]); var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_FREE_TABLES); using var document = PresentationDocument.Open(file, false); var slides = SlidesOf(document); var shapeTree = slides[0].Slide!.CommonSlideData!.ShapeTree!; var table = shapeTree.Elements().Single(); var note = shapeTree.Elements().Single(shape => shape.TextBody?.InnerText == "Source: last year"); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(RowsOf(table), Is.EqualTo(new[] { "Item | Cost", "Fuel | 10", "Food | 5" }), "No row of the old table is left below the shorter new one."); Assert.That(table.Descendants().Select(placeholder => (placeholder.Type?.InnerText, placeholder.Index?.Value)), Is.EqualTo(new (string?, uint?)[] { ("tbl", 11) })); Assert.That(shapeTree.Elements().ToList().IndexOf(table), Is.LessThan(shapeTree.Elements().ToList().IndexOf(note)), "The new table lies where the old one did, behind what lay in front of it."); Assert.That(RowsOf(slides[1].Slide!.Descendants().Single()), Is.EqualTo(new[] { "Step | Date", "Launch | 2030" })); Assert.That(slides[1].Slide!.Descendants().Count(placeholder => placeholder.Type?.InnerText == "tbl"), Is.EqualTo(1), "The empty placeholder the free table stood in for is gone."); } } [Test] public async Task WhatAnEditedSlideLeavesEmptyKeepsTheFreeTable() { var deck = new SlideDeckSpec("Mars", [Edit(1, "New costs")]); var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_FREE_TABLES); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; using (Assert.EnterMultipleScope()) { Assert.That(TitleOf(slide), Is.EqualTo("New costs")); Assert.That(RowsOf(slide.Slide!.Descendants().Single()), Has.Count.EqualTo(6)); } } [Test] public async Task AnEditedSlideLosesWhatTheModelRemoves() { var deck = new SlideDeckSpec("Weekly", [Edit(1, texts: [Text(new SlideParagraph("A new release", 0))]) with { Removals = ["element 1", "element 2", "label 1"] }]); var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_ELEMENTS); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "A new release" })); Assert.That(slide.Slide!.Descendants(), Is.Empty); Assert.That(slide.ImageParts, Is.Empty, "The picture leaves the file along with its shape."); Assert.That(slide.Slide.Descendants().Select(shape => shape.TextBody?.InnerText), Has.None.EqualTo("Bildquelle hier angeben")); Assert.That(slide.Slide.Descendants().Where(placeholder => placeholder.Index?.Value == 17), Is.Empty, "The label of the template is gone, with its sample text."); Assert.That(slide.Slide.Descendants().Count(), Is.EqualTo(1), "What the model does not remove stays."); Assert.That(slide.ChartParts.Count(), Is.EqualTo(1)); } } [Test] public async Task AnEditedSlideFillsTheCopiesOfItsSlotsInTheirFormatting() { using var templateFile = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout()); var deck = new SlideDeckSpec("Missions", [Edit(1, texts: [Text(new SlideParagraph("To Mars", 0)), Text(new SlideParagraph("To the Moon", 0))], labels: ["Mars", "Moon"]) with { Removals = ["label 3", "text 3"] }]); var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; var secondHeading = ShapeWithIdOf(slide, 5); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(PlaceholderShapeOf(slide, "body", 17).TextBody!.InnerText, Is.EqualTo("Mars")); Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "To Mars" })); Assert.That(secondHeading.TextBody!.InnerText, Is.EqualTo("Moon"), "The second label is the first copy of the heading."); Assert.That(secondHeading.ShapeProperties!.GetFirstChild()?.RgbColorModelHex?.Val?.Value, Is.EqualTo(StackedHeadings.HEADING_FILL), "A copy keeps the look of the heading it repeats."); Assert.That(ShapeWithIdOf(slide, 6).TextBody!.InnerText, Is.EqualTo("To the Moon")); Assert.That(slide.Slide!.Descendants().Select(properties => properties.Id!.Value), Has.None.EqualTo(7U).And.None.EqualTo(8U), "The third heading and its text are removed."); Assert.That(slide.Slide.Descendants().Select(shape => shape.TextBody?.InnerText), Has.Some.EqualTo("Bildquelle hier angeben"), "What the model does not remove stays."); Assert.That(slide.Slide.Descendants().Count(), Is.EqualTo(1)); } } [Test] public async Task TheCopiesOfSlotsStayFillableOnAnotherVariant() { using var templateFile = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout("A Content"), StackedHeadings.Layout("B Content")); var deck = new SlideDeckSpec("Missions", [Edit(1, labels: ["Mars", "Moon", "Venus"])]); var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS with { VariantLabel = "B" }); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value, Is.EqualTo("B Content")); Assert.That(PlaceholderShapeOf(slide, "body", 17).TextBody!.InnerText, Is.EqualTo("Mars")); Assert.That(ShapeWithIdOf(slide, 5).TextBody!.InnerText, Is.EqualTo("Moon")); Assert.That(ShapeWithIdOf(slide, 7).TextBody!.InnerText, Is.EqualTo("Venus")); Assert.That(ShapeWithIdOf(slide, 6).TextBody!.InnerText, Is.EqualTo("Duis autem"), "A copy the slide leaves empty keeps the content of the template."); } } [Test] public async Task RemovingAChartRemovesItsPart() { var deck = new SlideDeckSpec("Weekly", [Edit(1) with { Removals = ["element 4"] }]); var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_ELEMENTS); using var document = PresentationDocument.Open(file, false); var slide = SlidesOf(document)[0]; using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(slide.Slide!.Descendants(), Is.Empty); Assert.That(slide.ChartParts, Is.Empty); Assert.That(slide.ImageParts.Count(), Is.EqualTo(1), "The picture nobody removed keeps its part."); } } [Test] public async Task RemovingAnAnimatedShapeDropsTheAnimationsOfItsSlide() { using var templateFile = TestTemplate.Create(TEMPLATE_WITH_ELEMENTS with { Slides = [TEMPLATE_WITH_ELEMENTS.Slides![0], TEMPLATE_WITH_ELEMENTS.Slides[0]] }, Layouts()); using (var templateDocument = PresentationDocument.Open(templateFile, true)) { // The text box naming the source of the picture fades in on both slides: foreach (var templateSlide in SlidesOf(templateDocument)) templateSlide.Slide!.Timing = new P.Timing(new P.BuildList(new P.BuildParagraph { ShapeId = "6", GroupId = 0U })); } var deck = new SlideDeckSpec("Weekly", [Edit(1) with { Removals = ["element 2"] }, Edit(2) with { Removals = ["element 3"] }]); var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS); using var document = PresentationDocument.Open(file, false); var slides = SlidesOf(document); using (Assert.EnterMultipleScope()) { Assert.That(ValidationErrorsOf(document), Is.Empty); Assert.That(slides[0].Slide!.Timing, Is.Null, "An animation of a shape which is gone would break the slide."); Assert.That(slides[1].Slide!.Timing, Is.Not.Null, "The animated shape is still there."); } } /// /// Slides of the Layouts: a title slide, three content slides of which one is hidden /// and one an imprint, and a hint nobody keeps. /// private static readonly TestTemplateOptions TEMPLATE_WITH_SLIDES = new(Slides: [ new TestSlide(0, (TestPlaceholder.Title(), "Consultation hour"), (new TestPlaceholder(P.PlaceholderValues.SubTitle, 1), "Every Monday"), (new TestPlaceholder(P.PlaceholderValues.Footer, 10), "Old footer")), new TestSlide(2, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(null, 1), "AI Studio:\nEINSTEIN:"), (new TestPlaceholder(P.PlaceholderValues.Footer, 10), "Old footer")), new TestSlide(2, (TestPlaceholder.Title(), "Questions")) { IsHidden = true }, new TestSlide(2, (TestPlaceholder.Title(), "Impressum"), (new TestPlaceholder(null, 1), "Thema:\tConsultation hour\nDatum:\t2026-10-12\nAutoren:\tThe team")), new TestSlide(0, (TestPlaceholder.Title(), "A hint to delete")), ]); /// /// Slides of the Layouts on the table layout whose tables are no placeholders: one /// pasted onto the slide, with a note in front of it, and one standing in for an empty placeholder. /// private static readonly TestTemplateOptions TEMPLATE_WITH_FREE_TABLES = new(Slides: [ new TestSlide(6, (TestPlaceholder.Title(), "Costs")) { Elements = [ TestElement.Table(TestTemplate.MASTER_BODY_BOUNDS, ["Item", "Cost"], ["A", "1"], ["B", "2"], ["C", "3"], ["D", "4"], ["E", "5"]), TestElement.TextBox(new SlotBounds(838_200, 6_000_000, 4_000_000, 300_000), "Source: last year"), ], }, new TestSlide(6, (TestPlaceholder.Title(), "Plan"), (new TestPlaceholder(P.PlaceholderValues.Table, 11), string.Empty)) { Elements = [TestElement.Table(TestTemplate.MASTER_BODY_BOUNDS, ["Step", "Date"], ["Test", "2029"])], }, ]); /// /// A slide of the Layouts on the content layout with a sample label, and with /// elements of its own: a picture (element 1), a text box naming its source (element 2), a /// colored rectangle (element 3), and a chart (element 4). /// private static readonly TestTemplateOptions TEMPLATE_WITH_ELEMENTS = new(Slides: [ new TestSlide(2, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(P.PlaceholderValues.Body, 17), "Lorem ipsum"), (new TestPlaceholder(null, 1), "AI Studio:\nEINSTEIN:")) { Elements = [ TestElement.Picture(RIGHT_PICTURE, 7, 8, 9), TestElement.TextBox(new SlotBounds(6_200_000, 5_400_000, 5_150_000, 300_000), "Bildquelle hier angeben"), TestElement.Rectangle(new SlotBounds(0, 6_000_000, TestTemplate.SLIDE_WIDTH, 300_000)), TestElement.Chart(LEFT_TEXT), ], }, ]); private static SlideSpec Keep(int templateSlide, bool startsChapter = false) => new(string.Empty, startsChapter, string.Empty, string.Empty, [], [], [], null, templateSlide); private static SlideSpec Edit(int templateSlide, string title = "", IReadOnlyList? texts = null, IReadOnlyList? labels = null, IReadOnlyList? pictures = null, SlideTableSpec? table = null) => new(string.Empty, false, title, string.Empty, texts ?? [], labels ?? [], pictures ?? [], table, templateSlide); /// /// The rows of a table, with " | " between their cells. /// private static List RowsOf(P.GraphicFrame table) => table.Descendants() .Select(row => string.Join(" | ", row.Elements().Select(cell => cell.InnerText))) .ToList(); /// /// The shape of a slide which fills the placeholder of the given type and index. /// private static P.Shape PlaceholderShapeOf(SlidePart slide, string type, uint? index) => slide.Slide!.Descendants() .Single(shape => shape.Descendants().Any(placeholder => (placeholder.Type?.InnerText ?? "obj") == type && placeholder.Index?.Value == index)); /// /// The empty placeholders of a slide which wait for a picture. /// private static List PicturePlaceholdersOf(SlidePart slide) => slide.Slide!.Descendants() .Where(shape => shape.Descendants().Any(placeholder => placeholder.Type?.InnerText == "pic")) .ToList(); /// /// The shape of a slide with the given ID, e.g., a text box the template slide carries. /// private static P.Shape ShapeWithIdOf(SlidePart slide, uint id) => slide.Slide!.Descendants() .Single(shape => shape.NonVisualShapeProperties?.NonVisualDrawingProperties?.Id?.Value == id); private static TestLayout[] Layouts() => [ new("A Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)), new("B Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)), new("A Content", TestPlaceholder.Title(), TestPlaceholder.Label(17, HEADING), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)), new("B Content", TestPlaceholder.Title(), TestPlaceholder.Label(17, HEADING), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)), new("Picture with text", TestPlaceholder.Title(), TestPlaceholder.Text(1, LEFT_TEXT), TestPlaceholder.Picture(20, RIGHT_PICTURE)), new("Backdrop", TestPlaceholder.Picture(11, WHOLE_SLIDE), TestPlaceholder.Title()), new("Table", TestPlaceholder.Title(), TestPlaceholder.Table(11, TestTemplate.MASTER_BODY_BOUNDS)), ]; private static async Task WriteAsync(SlideDeckSpec deck, PresentationWriteOptions options, TestTemplateOptions? templateOptions = null, IPictureSource? pictures = null) { using var templateFile = TestTemplate.Create(templateOptions ?? new TestTemplateOptions(), Layouts()); return await WriteFromAsync(templateFile, deck, options, pictures); } /// /// Writes a presentation from a template file of the test's own, e.g., from a presentation written before. /// private static async Task WriteFromAsync(Stream templateFile, SlideDeckSpec deck, PresentationWriteOptions options, IPictureSource? pictures = null) { templateFile.Position = 0; var template = PresentationTemplate.Read(templateFile); templateFile.Position = 0; // The writer edits a copy of the template, as the Slide Assistant does with one on disk: var output = new MemoryStream(); await templateFile.CopyToAsync(output); output.Position = 0; var result = await PresentationWriter.WriteAsync(output, template, deck, options, pictures ?? new FakePictureSource(), CancellationToken.None); output.Position = 0; return new WrittenPresentation(output, result); } private static List ValidationErrorsOf(PresentationDocument document) => new OpenXmlValidator(FileFormatVersions.Office2019) .Validate(document) .Select(error => $"{error.Part?.Uri} {error.Path?.XPath}: {error.Description}") .ToList(); /// /// The paragraphs of the text placeholder with index 1. /// private static List BodyParagraphsOf(SlidePart slide) => slide.Slide!.Descendants() .Single(shape => shape.Descendants().Any(placeholder => placeholder.Index?.Value == 1)) .TextBody!.Elements() .ToList(); private static List FootersOfSlide(MemoryStream file, Index slide) { using var document = PresentationDocument.Open(file, false); return FootersOf(SlidesOf(document)[slide]); } private static List FootersOf(SlidePart slide) => slide.Slide!.Descendants() .Where(shape => shape.Descendants().Any(placeholder => placeholder.Type?.InnerText == "ftr")) .Select(shape => shape.TextBody!.InnerText) .ToList(); private static List SlidesOf(PresentationDocument document) { var presentationPart = document.PresentationPart!; return presentationPart.Presentation!.SlideIdList!.Elements() .Select(slideId => (SlidePart)presentationPart.GetPartById(slideId.RelationshipId!.Value!)) .ToList(); } private static string TitleOf(SlidePart slide) => slide.Slide!.Descendants() .Single(shape => shape.Descendants().Any(placeholder => placeholder.Type?.InnerText == "title")) .TextBody!.InnerText; private static List LayoutNamesOf(MemoryStream file) { using var document = PresentationDocument.Open(file, false); return SlidesOf(document).Select(slide => slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value).ToList(); } private static SlideTextBlock Text(params SlideParagraph[] paragraphs) => new(paragraphs); private sealed class FakePictureSource(params SlidePicture?[] pictures) : IPictureSource { public Task LoadAsync(int number, CancellationToken token) => Task.FromResult(number >= 1 && number <= pictures.Length ? pictures[number - 1] : null); } /// /// A presentation a test wrote, and what the writer reported beyond writing it. /// /// The presentation, positioned at its start. /// What happened beyond writing. private sealed record WrittenPresentation(MemoryStream File, PresentationWriteResult Result); }