using DocumentFormat.OpenXml.Packaging; using A = DocumentFormat.OpenXml.Drawing; using P = DocumentFormat.OpenXml.Presentation; namespace AIStudio.Tools.PowerPoint; internal sealed partial class PresentationTemplate { /// /// The slide size PowerPoint assumes when a file states none: 16:9, 13.333 by 7.5 inches. /// private const long DEFAULT_SLIDE_WIDTH = 12_192_000; private const long DEFAULT_SLIDE_HEIGHT = 6_858_000; /// /// 18 pt, in the hundredths of a point the file uses. PowerPoint's own default. /// private const int DEFAULT_FONT_SIZE = 1800; private const double EMU_PER_POINT = 12_700; /// /// The space a text box keeps free at its left and right edges unless it says otherwise: 0.1 inch. /// private const long DEFAULT_HORIZONTAL_INSET = 91_440; /// /// The space a text box keeps free at its top and bottom edges unless it says otherwise: 0.05 inch. /// private const long DEFAULT_VERTICAL_INSET = 45_720; /// /// A line of text takes about 1.2 times the font size. /// private const double LINE_HEIGHT_FACTOR = 1.2; /// /// An average character takes about half the font size in width. /// private const double CHARACTER_WIDTH_FACTOR = 0.5; /// /// Text placeholders covering less than this share of the slide are labels. /// /// /// A heading above a text area, the source of a picture or a chapter number cover one or two /// percent of a slide; the smallest real text area, a quarter of a slide split into a grid, /// covers more than five. /// private const double LABEL_AREA_SHARE = 0.05; /// /// Slots whose top edges lie within one row of this many rows per slide are read as one row. /// private const int READING_ROWS = 50; /// /// How a template file is opened for reading. /// /// /// Other programs may go on writing or deleting the file meanwhile. PowerPoint keeps a file open /// for writing as long as it shows it, and a template which is still open there has to be /// readable all the same. /// public const FileShare FILE_SHARE = FileShare.ReadWrite | FileShare.Delete; /// /// Reads the template file at the given path, as Read does with a stream. /// /// /// Throws when the file is missing, cannot be opened, or is no presentation at all; the caller /// decides what the user hears about it. /// /// The path of the template file. /// The template. public static PresentationTemplate ReadFile(string path) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FILE_SHARE); return Read(stream); } /// /// Reads the layouts of a template, and the slides it carries. /// /// /// Works for presentations (.pptx) and templates (.potx) alike. The slides the file contains are /// read with what they hold, yet a presentation written later starts without them: in a template /// they are mostly samples or hints for the user, and only those the user chooses are kept. The /// Open XML SDK throws when the file is no presentation at all. /// /// The file, opened for reading. /// The template. public static PresentationTemplate Read(Stream stream) { using var document = PresentationDocument.Open(stream, false); 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 slideWidth = presentation.SlideSize?.Cx?.Value ?? DEFAULT_SLIDE_WIDTH; var slideHeight = presentation.SlideSize?.Cy?.Value ?? DEFAULT_SLIDE_HEIGHT; var defaultFontSize = FontSizeOf(presentation.DefaultTextStyle?.Level1ParagraphProperties) ?? DEFAULT_FONT_SIZE; var layouts = new List(); foreach (var masterId in presentation.SlideMasterIdList?.Elements() ?? []) { if (masterId.RelationshipId?.Value is not { } masterRelationshipId || !presentationPart.TryGetPartById(masterRelationshipId, out var masterCandidate) || masterCandidate is not SlideMasterPart { SlideMaster: { } slideMaster } masterPart) continue; var master = MasterOf(slideMaster, defaultFontSize); foreach (var layoutId in slideMaster.SlideLayoutIdList?.Elements() ?? []) { if (layoutId.RelationshipId?.Value is not { } layoutRelationshipId || !masterPart.TryGetPartById(layoutRelationshipId, out var layoutCandidate) || layoutCandidate is not SlideLayoutPart { SlideLayout: { } slideLayout } layoutPart) continue; layouts.Add(ReadLayout(slideLayout, layoutPart.Uri, master, slideWidth, slideHeight)); } } var (families, variantLabels) = GroupIntoFamilies(layouts, slideWidth, slideHeight); var slides = ReadSlides(presentationPart, presentation, layouts, families, slideWidth, slideHeight); return new PresentationTemplate(slideWidth, slideHeight, families, variantLabels, slides); } private static MasterStyles MasterOf(P.SlideMaster master, int defaultFontSize) { var placeholders = new Dictionary(StringComparer.Ordinal); foreach (var placeholder in PlaceholdersOf(master.CommonSlideData)) placeholders.TryAdd(MasterTypeOf(placeholder.Type), placeholder); var titleFontSize = FontSizeOf(master.TextStyles?.TitleStyle?.Level1ParagraphProperties) ?? defaultFontSize; var bodyFontSize = FontSizeOf(master.TextStyles?.BodyStyle?.Level1ParagraphProperties) ?? defaultFontSize; return new MasterStyles(placeholders, titleFontSize, bodyFontSize, defaultFontSize); } private static TemplateLayout ReadLayout(P.SlideLayout layout, Uri partUri, MasterStyles master, long slideWidth, long slideHeight) { var slideArea = (double)slideWidth * slideHeight; var slots = new List(); foreach (var placeholder in PlaceholdersOf(layout.CommonSlideData)) { // // A layout often leaves out where its title goes, because the slide master already // says so. PowerPoint then takes the place from the master, and so do we: // master.Placeholders.TryGetValue(MasterTypeOf(placeholder.Type), out var inherited); if ((placeholder.Bounds ?? inherited?.Bounds) is not { } bounds) continue; var role = RoleOf(placeholder.Type, bounds, slideArea); if (!role.IsSlot()) continue; // // A table takes its text from the table style, which starts out at the default // size of the presentation rather than at the size of the body text: // var fontSize = role switch { PlaceholderRole.TABLE => master.DefaultFontSize, PlaceholderRole.TITLE => placeholder.FontSize ?? inherited?.FontSize ?? master.TitleFontSize, _ => placeholder.FontSize ?? inherited?.FontSize ?? master.BodyFontSize, }; slots.Add(new TemplateSlot(role, 0, bounds, placeholder.Type, placeholder.Index, CapacityOf(role, bounds, fontSize, placeholder.Insets))); } return new TemplateLayout( layout.CommonSlideData?.Name?.Value ?? string.Empty, partUri, layout.Type?.InnerText ?? string.Empty, InReadingOrder(slots, slideHeight)); } private static IEnumerable PlaceholdersOf(P.CommonSlideData? commonSlideData) { foreach (var element in commonSlideData?.ShapeTree?.Elements() ?? []) { P.PlaceholderShape? placeholder; SlotBounds? bounds; P.TextBody? textBody = null; switch (element) { case P.Shape shape: placeholder = shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape; bounds = BoundsOf(shape.ShapeProperties?.Transform2D?.Offset, shape.ShapeProperties?.Transform2D?.Extents); textBody = shape.TextBody; break; case P.Picture picture: placeholder = picture.NonVisualPictureProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape; bounds = BoundsOf(picture.ShapeProperties?.Transform2D?.Offset, picture.ShapeProperties?.Transform2D?.Extents); break; case P.GraphicFrame frame: placeholder = frame.NonVisualGraphicFrameProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape; bounds = BoundsOf(frame.Transform?.Offset, frame.Transform?.Extents); break; default: continue; } if (placeholder is null) continue; yield return new PlaceholderInfo( // A placeholder without a type is an object placeholder, according to the standard: placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value, bounds, FontSizeOf(textBody?.ListStyle?.Level1ParagraphProperties), InsetsOf(textBody?.BodyProperties)); } } /// /// The type of the master placeholder a layout placeholder of the given type takes its defaults from. /// private static string MasterTypeOf(string placeholderType) => placeholderType switch { "title" or "ctrTitle" => "title", "dt" => "dt", "ftr" => "ftr", "sldNum" => "sldNum", _ => "body", }; private static PlaceholderRole RoleOf(string placeholderType, SlotBounds bounds, double slideArea) => placeholderType switch { "title" or "ctrTitle" => PlaceholderRole.TITLE, "subTitle" => PlaceholderRole.SUBTITLE, "pic" => PlaceholderRole.PICTURE, "tbl" => PlaceholderRole.TABLE, "ftr" => PlaceholderRole.FOOTER, "sldNum" => PlaceholderRole.SLIDE_NUMBER, "dt" => PlaceholderRole.DATE, "body" or "obj" => bounds.Area < slideArea * LABEL_AREA_SHARE ? PlaceholderRole.LABEL : PlaceholderRole.TEXT, _ => PlaceholderRole.UNSUPPORTED, }; private static TextCapacity? CapacityOf(PlaceholderRole role, SlotBounds bounds, int fontSize, TextInsets insets) { if (role is PlaceholderRole.PICTURE) return null; var fontHeight = fontSize / 100.0 * EMU_PER_POINT; var lineHeight = fontHeight * LINE_HEIGHT_FACTOR; var width = (double)bounds.Width; var height = (double)bounds.Height; if (role is PlaceholderRole.TABLE) { // A table row keeps the default cell margins above and below its text: lineHeight += 2 * DEFAULT_VERTICAL_INSET; } else { width -= insets.Left + insets.Right; height -= insets.Top + insets.Bottom; } var lines = (int)(height / lineHeight); var charactersPerLine = (int)(width / (fontHeight * CHARACTER_WIDTH_FACTOR)); return new TextCapacity(Math.Max(1, lines), Math.Max(1, charactersPerLine)); } /// /// Brings the slots into the order a reader takes them in, and numbers the slots of each role. /// private static List InReadingOrder(IEnumerable slots, long slideHeight) { var ordered = slots .OrderBy(slot => ReadingKeyOf(slot.Bounds, slideHeight)) .ToList(); var counters = new Dictionary(); for (var index = 0; index < ordered.Count; index++) { var ordinal = counters.GetValueOrDefault(ordered[index].Role) + 1; counters[ordered[index].Role] = ordinal; ordered[index] = ordered[index] with { Ordinal = ordinal }; } return ordered; } /// /// Where a reader takes something in: by its row first, then from left to right. /// private static ReadingPosition ReadingKeyOf(SlotBounds bounds, long slideHeight) => new((int)Math.Round(bounds.Y * (double)READING_ROWS / slideHeight), bounds.X); private static SlotBounds? BoundsOf(A.Offset? offset, A.Extents? extents) { if (offset?.X?.Value is not { } x || offset.Y?.Value is not { } y || extents?.Cx?.Value is not { } width || extents.Cy?.Value is not { } height) return null; return new SlotBounds(x, y, width, height); } private static int? FontSizeOf(A.Level1ParagraphProperties? properties) => properties?.GetFirstChild()?.FontSize?.Value; private static TextInsets InsetsOf(A.BodyProperties? bodyProperties) => new( bodyProperties?.LeftInset?.Value ?? DEFAULT_HORIZONTAL_INSET, bodyProperties?.TopInset?.Value ?? DEFAULT_VERTICAL_INSET, bodyProperties?.RightInset?.Value ?? DEFAULT_HORIZONTAL_INSET, bodyProperties?.BottomInset?.Value ?? DEFAULT_VERTICAL_INSET); /// /// A placeholder as the slide, the layout or the slide master states it, before anything is inherited. /// private sealed record PlaceholderInfo(string Type, uint? Index, SlotBounds? Bounds, int? FontSize, TextInsets Insets); /// /// What the layouts of one slide master inherit from it: its placeholders, keyed by the type /// layout placeholders look them up with, and its font sizes. /// private sealed record MasterStyles(IReadOnlyDictionary Placeholders, int TitleFontSize, int BodyFontSize, int DefaultFontSize); private readonly record struct TextInsets(long Left, long Top, long Right, long Bottom); /// /// Where a reader comes across something on a slide, which orders the slots. /// /// The row, of the rows a slide is divided into for reading. /// The left edge, in EMU. private readonly record struct ReadingPosition(int Row, long X) : IComparable { public int CompareTo(ReadingPosition other) => this.Row != other.Row ? this.Row.CompareTo(other.Row) : this.X.CompareTo(other.X); } }