using System.Text; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using A = DocumentFormat.OpenXml.Drawing; using P = DocumentFormat.OpenXml.Presentation; namespace AIStudio.Tools.PowerPoint; internal sealed partial class PresentationTemplate { /// /// Reads the slides of a template with what their slots hold. /// /// The presentation part, which holds the slides. /// The presentation, which lists the slides in their order. /// Every layout of the template, including those without room for content. /// The layout families the model plans with. /// The width of a slide, in EMU. /// The height of a slide, in EMU. /// The slides, numbered as PowerPoint counts them. private static List ReadSlides(PresentationPart presentationPart, P.Presentation presentation, IReadOnlyList layouts, IReadOnlyList families, long slideWidth, long slideHeight) { var layoutsByUri = new Dictionary(); foreach (var layout in layouts) layoutsByUri.TryAdd(layout.PartUri, layout); var familyIdsByLayoutUri = new Dictionary(); foreach (var family in families) foreach (var variant in family.Variants) familyIdsByLayoutUri.TryAdd(variant.Layout.PartUri, family.Id); var slides = new List(); foreach (var slideId in presentation.SlideIdList?.Elements() ?? []) { if (slideId.RelationshipId?.Value is not { } relationshipId || !presentationPart.TryGetPartById(relationshipId, out var slideCandidate) || slideCandidate is not SlidePart { Slide: { } slide } slidePart) continue; var layoutUri = slidePart.SlideLayoutPart?.Uri; var topLevel = slide.CommonSlideData?.ShapeTree?.Elements().ToList() ?? []; var idCounts = topLevel.Select(TemplateSlideShapes.ShapeIdOf).OfType().CountBy(id => id).ToDictionary(); var slotShapes = new HashSet(); List contents = []; List movedSlots = []; List slots = []; List slotCopies = []; if (layoutUri is not null && layoutsByUri.TryGetValue(layoutUri, out var slideLayout)) { var shapesBySlot = TemplateSlideShapes.SlotShapesOf(slide.CommonSlideData?.ShapeTree, slideLayout); movedSlots = MovedSlotsOf(shapesBySlot, slideLayout, slideWidth, slideHeight); var slotsOnSlide = slideLayout.Slots.Select(slot => movedSlots.FirstOrDefault(moved => moved.Role == slot.Role && moved.Ordinal == slot.Ordinal) ?? slot).ToList(); var copies = SlotCopiesOf(topLevel, idCounts, shapesBySlot, slideLayout, slotsOnSlide, slideWidth, slideHeight); slotCopies = copies.Select(found => found.Copy).ToList(); slots = InReadingOrderWith(slotsOnSlide, slotCopies.Select(copy => copy.Slot), slideHeight); // What fills a slot, by the name the model knows it by: var filledSlots = shapesBySlot.ToDictionary(pair => (pair.Key.Role, pair.Key.Ordinal), pair => (Slot: pair.Key, Shape: pair.Value)); foreach (var (copy, shape) in copies) filledSlots[(copy.Slot.Role, copy.Slot.Ordinal)] = (copy.Slot, shape); contents = ContentsOf(slots, filledSlots); slotShapes = filledSlots.Values.Select(filled => filled.Shape).ToHashSet(); } var title = contents.FirstOrDefault(content => content.Slot.Role is PlaceholderRole.TITLE)?.Text ?? string.Empty; slides.Add(new TemplateSlide( slides.Count + 1, slidePart.Uri, layoutUri, layoutUri is null ? null : familyIdsByLayoutUri.GetValueOrDefault(layoutUri), title.ReplaceLineEndings(" "), contents, movedSlots, slots, slotCopies, ElementsOf(topLevel, idCounts, slotShapes), slide.Show?.Value == false)); } return slides; } /// /// What the slots of a slide hold, in reading order. /// /// /// Placeholders which fill no slot, such as the footer, and slots left empty are left out. A /// picture counts as content even though it has no text, and a table which is no placeholder /// as the content of the table slot, see TemplateSlideShapes.SlotShapesOf. /// /// The slots the slide offers, in reading order. /// The shapes filling the slots, by role and number: the slot of the layout, or the copy, and its shape. private static List ContentsOf(IReadOnlyList slots, IReadOnlyDictionary<(PlaceholderRole Role, int Ordinal), (TemplateSlot Slot, OpenXmlElement Shape)> filledSlots) { var contents = new List(); foreach (var slot in slots) { if (!filledSlots.TryGetValue((slot.Role, slot.Ordinal), out var filled)) continue; var text = TextOf(filled.Shape); if (text.Length > 0 || filled.Shape is P.Picture) contents.Add(new TemplateSlideContent(filled.Slot, text)); } return contents; } /// /// Finds the shapes of a slide which repeat a text or a label slot of its layout, e.g., a second /// heading with its text, which the author copied from the placeholders of the slide. /// /// /// /// PowerPoint turns a copied placeholder into a text box which keeps the look of the placeholder, /// so what tells a copy is its geometry: it has the height of the slot it repeats, a text also /// its width, and it lines up with the slot or with another copy of it, below it in a column or /// beside it in a row. A label may be narrower or wider, since authors fit a heading to its text. /// The name of the shape would tell as well, but it depends on the language of PowerPoint, and /// other programs do not keep it. /// /// /// A copy overlaps no slot and no other copy: a text box lying on a slot is in the way of it, so it /// stays an element, which the model may remove. A second placeholder of a slot whose first one is /// taken may be a copy as well. Size is compared with the slot itself, so that a long row of /// copies does not drift by a tolerance per step. /// /// /// The top-level elements of the slide. /// How often each shape ID occurs among them; a copy is found again by its ID, which therefore has to be unique. /// The shapes of the slide which fill the slots of its layout. /// The layout of the slide. /// The slots of the layout, where the slide puts them; a slot the slide leaves out at the place of the layout. /// The width of a slide, in EMU. /// The height of a slide, in EMU. /// The copies with their shapes, in reading order, numbered after the slots of the layout. private static List SlotCopiesOf(IReadOnlyList topLevel, IReadOnlyDictionary idCounts, IReadOnlyDictionary shapesBySlot, TemplateLayout layout, IReadOnlyList slotsOnSlide, long slideWidth, long slideHeight) { var horizontalTolerance = (long)(slideWidth * SLOT_TOLERANCE); var verticalTolerance = (long)(slideHeight * SLOT_TOLERANCE); // What a copy may repeat: a text or a label the slide carries, and every copy found so far: var references = slotsOnSlide .Where(slot => slot.Role is (PlaceholderRole.TEXT or PlaceholderRole.LABEL) && shapesBySlot.Keys.Any(filled => filled.Role == slot.Role && filled.Ordinal == slot.Ordinal)) .Select(slot => new CopyReference(slot.Bounds, slot)) .ToList(); if (references.Count == 0) return []; var slotShapes = shapesBySlot.Values.ToHashSet(); var candidates = new List<(P.Shape Shape, uint Id, SlotBounds Bounds)>(); foreach (var element in topLevel) { if (element is not P.Shape shape || slotShapes.Contains(shape) || TemplateSlideShapes.ShapeIdOf(shape) is not { } id || idCounts[id] > 1 || TextOf(shape).Length == 0) continue; if (TemplateSlideShapes.PlaceholderOf(shape) is { } placeholder && layout.SlotOf(placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value)?.Role is not (PlaceholderRole.TEXT or PlaceholderRole.LABEL)) continue; if (BoundsOf(shape.ShapeProperties?.Transform2D?.Offset, shape.ShapeProperties?.Transform2D?.Extents) is { } bounds) candidates.Add((shape, id, bounds)); } var occupied = slotsOnSlide.Select(slot => slot.Bounds).ToList(); var remaining = candidates.OrderBy(candidate => ReadingKeyOf(candidate.Bounds, slideHeight)).ToList(); var found = new List<(P.Shape Shape, uint Id, SlotBounds Bounds, TemplateSlot Original)>(); bool foundMore; do { foundMore = false; foreach (var candidate in remaining.ToList()) { if (occupied.Any(bounds => Overlaps(candidate.Bounds, bounds))) { remaining.Remove(candidate); continue; } var reference = references .Where(other => Repeats(candidate.Bounds, other, horizontalTolerance, verticalTolerance)) .MinBy(other => Math.Abs(candidate.Bounds.X - other.Bounds.X) + Math.Abs(candidate.Bounds.Y - other.Bounds.Y)); if (reference is null) continue; found.Add((candidate.Shape, candidate.Id, candidate.Bounds, reference.Original)); references.Add(new CopyReference(candidate.Bounds, reference.Original)); occupied.Add(candidate.Bounds); remaining.Remove(candidate); foundMore = true; } } while (foundMore); var counters = layout.Slots.CountBy(slot => slot.Role).ToDictionary(); var copies = new List(); foreach (var copy in found.OrderBy(candidate => ReadingKeyOf(candidate.Bounds, slideHeight))) { var ordinal = counters.GetValueOrDefault(copy.Original.Role) + 1; counters[copy.Original.Role] = ordinal; copies.Add(new FoundSlotCopy(new TemplateSlotCopy(copy.Original.MovedTo(copy.Bounds) with { Ordinal = ordinal }, copy.Id), copy.Shape)); } return copies; } /// /// Whether a shape at the given place repeats the slot of a reference: with the size of the slot, /// in line with the reference. /// private static bool Repeats(SlotBounds bounds, CopyReference reference, long horizontalTolerance, long verticalTolerance) { var original = reference.Original.Bounds; var hasItsSize = Math.Abs(bounds.Height - original.Height) <= verticalTolerance && (reference.Original.Role is PlaceholderRole.LABEL || Math.Abs(bounds.Width - original.Width) <= horizontalTolerance); var isInLine = Math.Abs(bounds.X - reference.Bounds.X) <= horizontalTolerance || Math.Abs(bounds.Y - reference.Bounds.Y) <= verticalTolerance; return hasItsSize && isInLine; } /// /// Something a copy of a slot may line up with: the slot itself, or a copy of it found before. /// /// Where the slot or the copy sits on the slide. /// The slot of the layout which is repeated, where the slide puts it. private sealed record CopyReference(SlotBounds Bounds, TemplateSlot Original); /// /// A copy of a slot found on a slide, with the shape which is the copy. /// /// The copy, as the model knows it. /// The shape on the slide, whose text the copy holds. private sealed record FoundSlotCopy(TemplateSlotCopy Copy, P.Shape Shape); /// /// Adds the copies of slots to the slots of a slide, each one where a reader comes across it. /// /// /// The slots of the layout keep their order, so that a slide without copies offers its slots /// exactly as the layout does. A copy goes after the last slot a reader takes in before it. /// /// The slots of the layout, where the slide puts them. /// The slots the copies offer, in reading order. /// The height of a slide, in EMU. private static List InReadingOrderWith(IReadOnlyList slots, IEnumerable copies, long slideHeight) { var merged = slots.ToList(); foreach (var copy in copies) { var key = ReadingKeyOf(copy.Bounds, slideHeight); var position = merged.FindLastIndex(slot => ReadingKeyOf(slot.Bounds, slideHeight).CompareTo(key) <= 0); merged.Insert(position + 1, copy); } return merged; } /// /// The slots whose placeholders a slide moved or resized, with their place and capacity on the slide. /// /// /// Authors shrink a placeholder to make room for something of their own, such as a picture /// beside the text. The model then has to write for the room the slide leaves, not for the one /// the layout offers. Small shifts, as they happen when dragging a placeholder, do not count. /// /// The shapes of the slide which fill the slots of its layout. /// The layout of the slide. /// The width of a slide, in EMU. /// The height of a slide, in EMU. private static List MovedSlotsOf(IReadOnlyDictionary shapesBySlot, TemplateLayout layout, long slideWidth, long slideHeight) { var horizontalTolerance = (long)(slideWidth * SLOT_TOLERANCE); var verticalTolerance = (long)(slideHeight * SLOT_TOLERANCE); var movedSlots = new List(); foreach (var slot in layout.Slots) { if (shapesBySlot.TryGetValue(slot, out var shape) && BoundsOnSlideOf(slot, shape, horizontalTolerance, verticalTolerance) is { } bounds && !bounds.IsCloseTo(slot.Bounds, horizontalTolerance, verticalTolerance)) movedSlots.Add(slot.MovedTo(bounds)); } return movedSlots; } /// /// Where the shape filling a slot puts the slot on the slide. /// /// The place; null when the shape says nothing about it, e.g., because it takes the place of the layout. private static SlotBounds? BoundsOnSlideOf(TemplateSlot slot, OpenXmlElement shape, long horizontalTolerance, long verticalTolerance) => shape switch { P.Shape textShape => BoundsOf(textShape.ShapeProperties?.Transform2D?.Offset, textShape.ShapeProperties?.Transform2D?.Extents), // A table grows with its rows, so its frame tells where the slot starts, but not how far down it reaches: P.GraphicFrame frame => BoundsOf(frame.Transform?.Offset, frame.Transform?.Extents) is { } frameBounds ? frameBounds with { Height = Math.Max(frameBounds.Height, slot.Bounds.Bottom - frameBounds.Y) } : null, // A picture is fit into its slot, so one within the slot does not say that the slot moved: P.Picture picture => BoundsOf(picture.ShapeProperties?.Transform2D?.Offset, picture.ShapeProperties?.Transform2D?.Extents) is { } pictureBounds && !slot.Bounds.Contains(pictureBounds, horizontalTolerance, verticalTolerance) ? pictureBounds : null, _ => null, }; /// /// What a slide carries besides the shapes filling the slots of its layout and their copies, from back to front. /// /// /// Only what shows counts: footer, slide number and date belong to the presentation, a /// placeholder without text shows nothing, and neither does a shape without text, fill or line. /// The writer finds an element again by its ID, so an element whose ID another one shares on the /// slide is left out rather than risking the wrong one. PowerPoint itself never writes such IDs. /// /// The top-level elements of the slide. /// How often each shape ID occurs among them. /// The shapes filling the slots of its layout, and the copies of slots. private static List ElementsOf(IReadOnlyList topLevel, IReadOnlyDictionary idCounts, IReadOnlySet slotShapes) { var elements = new List(); foreach (var element in topLevel) { if (slotShapes.Contains(element) || TemplateSlideShapes.ShapeIdOf(element) is not { } id || idCounts[id] > 1 || KindOf(element) is not { } kind) continue; elements.Add(new TemplateSlideElement(elements.Count + 1, id, kind, ElementBoundsOf(element), ElementTextOf(element))); } return elements; } /// /// What kind of element a top-level element of a slide is. /// /// The kind; null when the element shows nothing worth naming, or belongs to the presentation as a whole. private static TemplateSlideElementKind? KindOf(OpenXmlElement element) { switch (element) { case P.Shape shape: var hasText = TextOf(shape).Length > 0; if (TemplateSlideShapes.PlaceholderOf(shape) is { } placeholder) return hasText && placeholder.Type?.InnerText is not ("ftr" or "sldNum" or "dt" or "hdr") ? TemplateSlideElementKind.TEXT_BOX : null; return hasText ? TemplateSlideElementKind.TEXT_BOX : IsVisible(shape) ? TemplateSlideElementKind.SHAPE : null; case P.Picture: return TemplateSlideElementKind.PICTURE; case P.GraphicFrame frame: var uri = frame.Graphic?.GraphicData?.Uri?.Value ?? string.Empty; if (uri.EndsWith("/table", StringComparison.Ordinal)) return TemplateSlideElementKind.TABLE; if (uri.Contains("chart", StringComparison.OrdinalIgnoreCase)) return TemplateSlideElementKind.CHART; return uri.EndsWith("/diagram", StringComparison.Ordinal) ? TemplateSlideElementKind.DIAGRAM : TemplateSlideElementKind.OBJECT; case P.GroupShape: return TemplateSlideElementKind.GROUP; case P.ConnectionShape: return TemplateSlideElementKind.LINE; case AlternateContent: return TemplateSlideElementKind.OBJECT; default: return null; } } /// /// Whether a shape without text shows anything: a fill or a line, of its own or from its style. /// private static bool IsVisible(P.Shape shape) { var properties = shape.ShapeProperties; var hasFill = properties?.GetFirstChild() is null && (properties?.Elements().Any(child => child is A.SolidFill or A.GradientFill or A.BlipFill or A.PatternFill or A.GroupFill) is true || shape.ShapeStyle?.FillReference?.Index?.Value > 0); var hasLine = properties?.GetFirstChild() is { } outline ? outline.GetFirstChild() is null : shape.ShapeStyle?.LineReference?.Index?.Value > 0; return hasFill || hasLine; } /// /// Where a top-level element of a slide sits. /// private static SlotBounds? ElementBoundsOf(OpenXmlElement element) => element switch { P.GraphicFrame frame => BoundsOf(frame.Transform?.Offset, frame.Transform?.Extents), P.GroupShape group => BoundsOf(group.GroupShapeProperties?.TransformGroup?.Offset, group.GroupShapeProperties?.TransformGroup?.Extents), _ => element.Descendants().FirstOrDefault() is { } transform ? BoundsOf(transform.Offset, transform.Extents) : null, }; /// /// The text of a top-level element of a slide: the rows of a table, or every paragraph it holds, those of a group included. /// /// /// Something wrapped for newer versions of PowerPoint comes with a fallback for older ones, which /// would repeat the text; only the first choice counts. /// private static string ElementTextOf(OpenXmlElement element) => element switch { P.GraphicFrame => TextOf(element), AlternateContent { FirstChild: { } choice } => ElementTextOf(choice), _ => string.Join('\n', element.Descendants().Select(TextOfParagraph).Where(text => text.Trim().Length > 0)).Trim(), }; /// /// The text of a placeholder: the paragraphs of a shape, or the rows of a table. /// private static string TextOf(OpenXmlElement placeholder) => placeholder switch { P.Shape shape => TextOfBody(shape.TextBody), P.GraphicFrame frame => string.Join('\n', frame.Descendants().Select(row => string.Join(" | ", row.Elements().Select(cell => TextOfBody(cell.TextBody).ReplaceLineEndings(" "))))).Trim(), _ => string.Empty, }; /// /// The text of a text body, one paragraph per line. /// /// The text body of a shape or of a table cell, which differ in type only. private static string TextOfBody(OpenXmlElement? textBody) => string.Join('\n', (textBody?.Elements() ?? []).Select(TextOfParagraph)).Trim(); private static string TextOfParagraph(A.Paragraph paragraph) { var text = new StringBuilder(); foreach (var element in paragraph.Elements()) { switch (element) { case A.Run run: text.Append(run.Text?.Text); break; // A field, e.g., a date, shows the text it last had: case A.Field field: text.Append(field.Text?.Text); break; case A.Break: text.Append('\n'); break; } } return text.ToString(); } }