using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using A = DocumentFormat.OpenXml.Drawing; using P = DocumentFormat.OpenXml.Presentation; namespace AIStudio.Tools.PowerPoint; /// /// Writes a presentation from a template and a checked slide deck. /// /// /// The presentation is the template itself, with its slides replaced: masters, layouts, theme, fonts /// and table styles stay exactly as their author made them. Every new slide is based on a layout of /// the template and fills that layout's placeholders, without any formatting of its own, so the /// corporate design applies to everything written here. Slots without content get no placeholder /// at all, which leaves no prompt text such as "Click to add text" behind. Picture slots are the /// exception when the user asks for it: an empty one keeps its placeholder, which shows the prompt /// text of the layout while editing and nothing in the slide show, so that the user can insert a /// picture of their own. /// /// The template slides the deck refers to stay, with their pictures and notes. They take the color /// variant and the footer of the presentation, so that they look like the slides around them. A /// slide the model edited takes its new content into its slots, and keeps the rest. /// /// The deck has to pass the SlideDeckSpecValidator first; what fits the template is not /// checked again here. /// internal static partial class PresentationWriter { /// /// The smallest slide ID PowerPoint accepts. /// private const uint FIRST_SLIDE_ID = 256; /// /// The picture formats PowerPoint shows everywhere. WebP, for instance, only newer versions do. /// private static readonly HashSet SUPPORTED_PICTURE_TYPES = new(StringComparer.OrdinalIgnoreCase) { "image/png", "image/jpeg", "image/gif", "image/bmp", "image/tiff", }; /// /// Writes the presentation into a copy of the template, which it edits in place. /// /// /// The caller copies the template, e.g., into a temporary file as PresentationFile does, so that the /// template stays untouched and no further copy of the file is held in memory. The Open XML SDK /// still holds the compressed parts of the whole file in memory while it saves: measured with a /// template of 150 MB, editing a copy on disk allocated about 150 MB, while copying the file into /// memory streams, as this writer did before, allocated about 810 MB. /// /// A copy of the template, a .pptx or a .potx, positioned at its start; readable, writable, and seekable. /// The template as read from the same file. /// The slide deck, already checked against the template. /// What the user chose for the presentation as a whole. /// Where the pictures the deck refers to come from. /// The cancellation token. /// What happened beyond writing. public static async Task WriteAsync(Stream presentationFile, PresentationTemplate template, SlideDeckSpec deck, PresentationWriteOptions options, IPictureSource pictures, CancellationToken token) { // // Loading the pictures is the only part which waits. The document is edited in one go // afterwards, since the Open XML SDK works synchronously: // var (loadedPictures, skippedPictures) = await LoadPicturesAsync(deck, pictures, token); using (var document = PresentationDocument.Open(presentationFile, true)) { // A template (.potx) becomes a presentation; everything else about it stays: if (document.DocumentType != PresentationDocumentType.Presentation) document.ChangeDocumentType(PresentationDocumentType.Presentation); var presentationPart = document.PresentationPart ?? throw new InvalidDataException("The file contains no presentation."); var presentation = presentationPart.Presentation ?? throw new InvalidDataException("The file contains no presentation."); var slides = deck.Slides; var keptSlideParts = RemoveSlides(presentationPart, presentation, TemplateSlidePartUrisOf(template, slides)); var layoutParts = presentationPart.SlideMasterParts.SelectMany(master => master.SlideLayoutParts).ToDictionary(part => part.Uri); var tableStyleId = presentationPart.TableStylesPart?.TableStyleList?.Default?.Value; var slideIdList = presentation.SlideIdList ??= new P.SlideIdList(); var variantLabel = VariantLabelOf(template, options); var slideIds = new List(slides.Count); var usedTemplateSlides = new HashSet(); var slideArea = (double)template.SlideWidth * template.SlideHeight; for (var index = 0; index < slides.Count; index++) { var slide = slides[index]; SlidePart slidePart; if (slide.TemplateSlide is { } number) { var templateSlide = template.FindSlide(number) ?? throw new InvalidOperationException($"The slide deck uses template slide {number}, which the template does not have. Check the deck before writing it."); slidePart = TemplateSlidePartOf(keptSlideParts, usedTemplateSlides, templateSlide); var layout = AdaptTemplateSlide(slidePart, templateSlide, template, layoutParts, variantLabel, options.FooterText); if (slide.HasContent) { if (layout is null) throw new InvalidOperationException($"The slide deck writes content into template slide {number}, whose layout has no slots for it. Check the deck before writing it."); EditTemplateSlide(slidePart, templateSlide, new SlideContent(slide, layout, slidePart.SlideLayoutPart?.SlideLayout, loadedPictures, tableStyleId, options.FooterText, slideArea, options.KeepEmptyPicturePlaceholders)); } } else { var family = template.FindFamily(slide.Layout) ?? throw new InvalidOperationException($"The slide deck uses the layout '{slide.Layout}', which the template does not have. Check the deck before writing it."); var layout = family.GetLayout(variantLabel); var layoutPart = LayoutPartOf(layoutParts, layout); slidePart = presentationPart.AddNewPart(); slidePart.AddPart(layoutPart); var content = new SlideContent(slide, layout, layoutPart.SlideLayout, loadedPictures, tableStyleId, options.FooterText, slideArea, options.KeepEmptyPicturePlaceholders); slidePart.Slide = new P.Slide( new P.CommonSlideData(ShapeTreeOf(slidePart, content)), new P.ColorMapOverride(new A.MasterColorMapping())); } var slideId = FIRST_SLIDE_ID + (uint)index; slideIdList.Append(new P.SlideId { Id = slideId, RelationshipId = presentationPart.GetIdOfPart(slidePart) }); slideIds.Add(slideId); } WriteSections(presentation, template, deck.Title, slides, slideIds); } return new PresentationWriteResult(skippedPictures); } private static SlideLayoutPart LayoutPartOf(Dictionary layoutParts, TemplateLayout layout) => layoutParts.TryGetValue(layout.PartUri, out var layoutPart) ? layoutPart : throw new InvalidOperationException($"The template has no layout at '{layout.PartUri}'. The template was read from another file than the one being written."); private static async Task LoadPicturesAsync(SlideDeckSpec deck, IPictureSource pictures, CancellationToken token) { var loaded = new Dictionary(); var skipped = new List(); foreach (var number in deck.Slides.SelectMany(slide => slide.Pictures).Distinct()) { var picture = await pictures.LoadAsync(number, token); if (picture is { Width: > 0, Height: > 0 } && SUPPORTED_PICTURE_TYPES.Contains(picture.ContentType)) loaded[number] = picture; else skipped.Add(number); } return new LoadedPictures(loaded, skipped); } /// /// Removes the slides of the template, which are samples or hints for the user, except those the deck keeps. /// /// /// Whatever lists slides by their IDs has to go with them, or PowerPoint finds references to /// nothing and offers to repair the file: custom shows and sections. Sections are written anew /// for the new slides. The kept slides lose their place in the list as well, and get it back in /// the order of the deck. /// /// The presentation part, which holds the slides. /// The presentation, which lists the slides. /// Where the slides the deck keeps are stored inside the file. /// The slides the deck keeps, by where they are stored. private static Dictionary RemoveSlides(PresentationPart presentationPart, P.Presentation presentation, IReadOnlySet keptSlides) { var keptSlideParts = new Dictionary(); foreach (var slideId in presentation.SlideIdList?.Elements().ToList() ?? []) { if (slideId.RelationshipId?.Value is { } relationshipId && presentationPart.TryGetPartById(relationshipId, out var slidePart)) { if (slidePart is SlidePart keptSlidePart && keptSlides.Contains(keptSlidePart.Uri)) keptSlideParts.TryAdd(keptSlidePart.Uri, keptSlidePart); else presentationPart.DeletePart(slidePart); } slideId.Remove(); } presentation.CustomShowList = null; RemoveSections(presentation); return keptSlideParts; } /// /// The title of a slide of the deck: its own, or that of the template slide it keeps. /// private static string TitleOf(SlideSpec slide, PresentationTemplate template) => slide.Title.Length > 0 ? slide.Title : slide.TemplateSlide is { } number ? template.FindSlide(number)?.Title ?? string.Empty : string.Empty; /// /// Says which variant all slides are written in: the one the user chose, or the first one of /// the template when it has no such variant. /// /// The label of the variant; null when the template carries every layout once. private static string? VariantLabelOf(PresentationTemplate template, PresentationWriteOptions options) => options.VariantLabel is { } label && template.VariantLabels.Contains(label) ? label : template.VariantLabels.FirstOrDefault(); /// /// Everything needed to fill the shape tree of one slide. /// /// What goes on the slide. /// The layout the slide is based on, as read from the template. /// The same layout in the document being written, whose placeholders the slide refers to. /// The loaded pictures, by their numbers. /// The default table style of the template, if it states one. /// The footer text; empty for no footer. /// The area of a slide, in square EMU. /// Whether a new slide keeps the picture placeholders which no picture fills. private sealed record SlideContent(SlideSpec Slide, TemplateLayout Layout, P.SlideLayout? LayoutElement, IReadOnlyDictionary Pictures, string? TableStyleId, string FooterText, double SlideArea, bool KeepEmptyPicturePlaceholders); /// /// The pictures a slide deck refers to, as far as they could be loaded. /// /// The loaded pictures, by their numbers. /// The numbers of the pictures which cannot be read, or which come in a format PowerPoint does not show. private sealed record LoadedPictures(IReadOnlyDictionary Pictures, IReadOnlyList Skipped); }