using System.Text; namespace AIStudio.Tools.PowerPoint; internal sealed partial class PresentationTemplate { /// /// A slot at least this wide, as a share of the slide, spans the whole width. /// private const double FULL_WIDTH_SHARE = 0.7; /// /// A label this close to a text, picture or table, as a share of the slide height, belongs to it. /// private const double LABEL_DISTANCE_SHARE = 0.05; /// /// An element overlaps a slot when they share at least this share of the smaller one's area. /// /// /// Edges which merely touch, or a text box reaching a little into a slot, do not count: what the /// model should hear about is an element which a filled slot would cover, or which would cover it. /// private const double OVERLAP_AREA_SHARE = 0.2; /// /// An element covering at least this share of the slide is described as a background. /// private const double BACKGROUND_AREA_SHARE = 0.5; /// /// How much of the text of an element the model reads, which is enough to tell what it is. /// private const int MAX_EXCERPT_CHARACTERS = 80; /// /// Describes the layout families for the model, one line per family. /// /// /// The model never sees the slide in front of it, so the catalog says in words what a designer sees /// at a glance: where each slot sits, what a label belongs to, and how much text fits. The layout /// names stay as the template's author wrote them, since they often say what a layout is meant /// for, in whatever language the template speaks. They come from the template file, so the caller /// passes the catalog through the prompt injection filter. /// /// The catalog, e.g., - L3 "Inhalt/Content 1": title (up to 2 lines of about 48 characters); text 1 (full width, middle, up to 15 lines of about 85 characters). public string ToCatalogPrompt() { return string.Join('\n', this.Families.Select(family => $"- {this.DescribeFamily(family)}")); } /// /// Describes one layout family for the model: its ID, its name, and its slots. /// private string DescribeFamily(LayoutFamily family) { var description = new StringBuilder(); description.Append(family.Id).Append(" \"").Append(family.Name).Append('"'); if (PurposeOf(family.Variants[0].Layout.LayoutType) is { } purpose) description.Append(" (").Append(purpose).Append(')'); description.Append(": ").AppendJoin("; ", family.Slots.Select(slot => this.DescribeSlot(slot, family.Slots))); return description.ToString(); } /// /// Describes the template slides the user chose for the model, one line per slide. /// /// /// The model learns what each slide says, so that a new slide does not repeat a kept one, and /// which slots an edited slide offers, by the layout it shares with the catalog. Where an edited /// slide moved or resized a slot, the entry gives its place and capacity on the slide, since /// those of the layout would make the model write too much. A copy of a slot, e.g., a second /// heading with its text, is offered as one more slot of its kind, with its place and capacity /// as well. An edited slide also lists what it carries besides its slots, which the model may /// remove. Slides the user leaves out are not /// mentioned at all. The texts come from the template file, so the caller passes them through /// the prompt injection filter. /// /// What the user chose for each slide of the template, by its number. /// The list, e.g., - Template slide 3, to edit, with the slots of layout L3: title "News"; text 1 (left, middle, up to 6 lines of about 40 characters, on this slide) "AI Studio: / EINSTEIN:"; element 1 (picture, right, middle); empty when the user chose no slide. public string ToTemplateSlidesPrompt(IReadOnlyList uses) { return string.Join('\n', this.Slides .Select(slide => (Slide: slide, Use: uses.UseOf(slide.Number))) .Where(choice => choice.Use is not TemplateSlideUse.OMIT) .Select(choice => $"- {this.DescribeTemplateSlide(choice.Slide, choice.Use)}")); } /// /// Describes one template slide for the model, as one to keep or one to edit. /// private string DescribeTemplateSlide(TemplateSlide slide, TemplateSlideUse use) { var description = new StringBuilder(); description.Append("Template slide ").Append(slide.Number).Append(use is TemplateSlideUse.KEEP ? ", to keep unchanged" : ", to edit"); var family = this.FindFamily(slide.FamilyId ?? string.Empty); if (use is TemplateSlideUse.EDIT && family is not null) description.Append(", with the slots of layout ").Append(family.Id); var slots = family is not null ? slide.Slots : slide.Contents.Select(content => content.Slot).ToList(); var entries = use is TemplateSlideUse.EDIT && family is not null ? this.DescribeSlideToEdit(slide) : slide.Contents.Select(content => $"{NameOf(content.Slot, slots)} {DescribeContent(content)}").ToList(); description.Append(": "); if (entries.Count == 0) description.Append("no content"); else description.AppendJoin("; ", entries); return description.ToString(); } /// /// Describes what a deck was planned on: each layout and template slide it uses, as the model read it. /// /// /// A deck names layouts and template slides by their place in the template, e.g., L3 or template /// slide 2. When the template changes after planning, e.g., by a new layout in its slide master or /// by a deleted slide, the same place may hold another layout or slide. Comparing these /// descriptions tells such a change apart from one which leaves the deck as it was planned, e.g., /// other colors or another logo. /// /// The slide deck. /// The descriptions, by the ID of a layout or by "template slide" and its number; a layout or slide the template lacks is missing. public Dictionary DescribeBasisOf(SlideDeckSpec deck) { var basis = new Dictionary(StringComparer.Ordinal); foreach (var slide in deck.Slides) { if (slide.TemplateSlide is { } number && this.FindSlide(number) is { } templateSlide) basis[$"template slide {number}"] = this.DescribeTemplateSlide(templateSlide, slide.HasContent ? TemplateSlideUse.EDIT : TemplateSlideUse.KEEP); // A kept template slide takes nothing from its layout; any other slide fills the slots of one: if ((slide.TemplateSlide is null || slide.HasContent) && this.FamilyOf(slide) is { } family) basis[family.Id] = this.DescribeFamily(family); } return basis; } /// /// Describes a slide to edit: what each slot holds, and where the slide moved one or carries a /// copy of one, its place and capacity on the slide; then every element the slide carries /// besides its slots. /// /// The slide. /// One entry per slot which holds content, which the slide moved, or which a copy offers, in reading order, and one per element. private List DescribeSlideToEdit(TemplateSlide slide) { var slotsOnSlide = slide.Slots; var entries = new List(); foreach (var slot in slotsOnSlide) { var content = slide.Contents.FirstOrDefault(candidate => candidate.Slot.Role == slot.Role && candidate.Slot.Ordinal == slot.Ordinal); if (!slide.Moves(slot) && slide.FindCopy(slot.Role, slot.Ordinal) is null) { if (content is not null) entries.Add($"{NameOf(slot, slotsOnSlide)} {DescribeContent(content)}"); continue; } entries.Add(content is null ? this.DescribeSlot(slot, slotsOnSlide, "on this slide, empty") : $"{this.DescribeSlot(slot, slotsOnSlide, "on this slide")} {DescribeContent(content)}"); } entries.AddRange(slide.Elements.Select(element => this.DescribeElement(element, slotsOnSlide))); return entries; } /// /// Describes an element of a slide to edit: its kind, its place, the slots it overlaps, and the /// beginning of its text. /// /// /// The slots include those the slide leaves out, at the place of the layout: filling one of them /// puts a new shape there, on top of whatever the slide shows in its place. /// /// The element. /// The slots of the slide, where the slide puts them. /// The entry, e.g., element 2 (text box, right, middle, overlaps picture 1) "Image source". private string DescribeElement(TemplateSlideElement element, IReadOnlyList slotsOnSlide) { var details = new List { element.Kind.CatalogName() }; if (element.Bounds is { } bounds) { if (bounds.Area >= (double)this.SlideWidth * this.SlideHeight * BACKGROUND_AREA_SHARE) details.Add("covering most of the slide"); else { details.Add(this.DescribePlace(bounds)); var overlapped = slotsOnSlide.Where(slot => Overlaps(bounds, slot.Bounds)).Select(slot => NameOf(slot, slotsOnSlide)).ToList(); if (overlapped.Count > 0) details.Add($"overlaps {string.Join(" and ", overlapped)}"); } } var description = $"{element.Name} ({string.Join(", ", details)})"; if (element.Text.Length == 0) return description; var text = element.Text.ReplaceLineEndings(" / "); if (text.Length > MAX_EXCERPT_CHARACTERS) text = $"{text[..MAX_EXCERPT_CHARACTERS].TrimEnd()}…"; return $"{description} \"{text}\""; } /// /// Whether an element and a slot share enough of their areas to be in each other's way. /// private static bool Overlaps(SlotBounds element, SlotBounds slot) { // A straight line has no area, so it counts as one EMU thick: var thick = element with { Width = Math.Max(1, element.Width), Height = Math.Max(1, element.Height) }; var shared = thick.IntersectionArea(slot); return shared > 0 && shared >= OVERLAP_AREA_SHARE * Math.Min(thick.Area, slot.Area); } private static string DescribeContent(TemplateSlideContent content) => content.Slot.Role is PlaceholderRole.PICTURE ? "(a picture)" : $"\"{content.Text.ReplaceLineEndings(" / ")}\""; /// /// What the layout types a template may state mean; most templates state none. /// private static string? PurposeOf(string layoutType) => layoutType switch { "title" => "title slide", "secHead" => "section header", _ => null, }; /// /// Describes a slot for the model: its place, and how much text fits. /// /// The slot. /// All slots of the layout or the slide, which tell what a label belongs to. /// What the description adds at the end, e.g., "on this slide"; null for nothing. private string DescribeSlot(TemplateSlot slot, IReadOnlyList slots, string? note = null) { var details = new List(); switch (slot.Role) { case PlaceholderRole.LABEL: details.Add(this.DescribeLabelPlace(slot, slots)); break; case PlaceholderRole.TEXT or PlaceholderRole.PICTURE or PlaceholderRole.TABLE: details.Add(this.DescribePlace(slot.Bounds)); break; } if (slot.Role is PlaceholderRole.PICTURE) { details.Add(OrientationOf(slot.Bounds)); if (this.IsBackdrop(slot.Bounds)) details.Add("backdrop"); } if (slot.Capacity is { } capacity) details.Add(slot.Role is PlaceholderRole.TABLE ? $"up to {capacity.Lines} rows" : $"up to {capacity.Lines} {(capacity.Lines == 1 ? "line" : "lines")} of about {capacity.CharactersPerLine} characters"); if (note is not null) details.Add(note); return $"{NameOf(slot, slots)} ({string.Join(", ", details)})"; } /// /// How the model refers to a slot: a title alone is "title", while several of a kind are numbered. /// private static string NameOf(TemplateSlot slot, IReadOnlyList slots) { var isOnlyOne = slot.Role is (PlaceholderRole.TITLE or PlaceholderRole.SUBTITLE) && slots.Count(other => other.Role == slot.Role) == 1; return isOnlyOne ? slot.Role.CatalogName() : $"{slot.Role.CatalogName()} {slot.Ordinal}"; } private string DescribePlace(SlotBounds bounds) { var horizontal = bounds.Width >= this.SlideWidth * FULL_WIDTH_SHARE ? "full width" : bounds.CenterX < this.SlideWidth * 0.4 ? "left" : bounds.CenterX > this.SlideWidth * 0.6 ? "right" : "center"; var vertical = bounds.CenterY < this.SlideHeight / 3.0 ? "top" : bounds.CenterY > this.SlideHeight * 2 / 3.0 ? "bottom" : "middle"; return $"{horizontal}, {vertical}"; } /// /// Says what a label belongs to: the text, picture or table right below or right above it. /// /// /// A label above a text area is its heading, and a label below a picture its source or caption. /// A label with nothing next to it is described by its place, e.g., a chapter number in a corner. /// private string DescribeLabelPlace(TemplateSlot label, IReadOnlyList slots) { if (this.NeighborOf(label, slots, true) is { } below) return $"above {NameOf(below, slots)}"; return this.NeighborOf(label, slots, false) is { } above ? $"below {NameOf(above, slots)}" : this.DescribePlace(label.Bounds); } /// /// The text, picture or table right below or right above a label, which the label belongs to. /// /// /// A label right below one text and right above the next one heads the next one: on a slide /// stacking headings with their texts, a heading sits closer to the text before it than to its /// own, yet belongs to the text it introduces. Hence the caller asks for what lies below a label /// first. /// /// The label. /// All slots of the layout or the slide. /// Whether to look below the label rather than above it. /// The nearest slot on that side; null when there is none close enough. private TemplateSlot? NeighborOf(TemplateSlot label, IReadOnlyList slots, bool below) { var tolerance = this.SlideHeight * SLOT_TOLERANCE; var nearDistance = this.SlideHeight * LABEL_DISTANCE_SHARE; TemplateSlot? neighbor = null; var smallestGap = double.MaxValue; foreach (var target in slots) { if (target.Role is not (PlaceholderRole.TEXT or PlaceholderRole.PICTURE or PlaceholderRole.TABLE) || !target.Bounds.OverlapsHorizontally(label.Bounds)) continue; var gap = below ? target.Bounds.Y - label.Bounds.Bottom : label.Bounds.Y - target.Bounds.Bottom; if (gap >= -tolerance && gap <= nearDistance && Math.Abs(gap) < smallestGap) { smallestGap = Math.Abs(gap); neighbor = target; } } return neighbor; } private static string OrientationOf(SlotBounds bounds) { var ratio = (double)bounds.Width / Math.Max(1, bounds.Height); return ratio switch { > 1.2 => "landscape", < 0.8 => "portrait", _ => "square", }; } }