Files
AI-Studio/app/MindWork AI Studio/Tools/PowerPoint/PresentationTemplate.Catalog.cs
T

368 lines
17 KiB
C#

using System.Text;
namespace AIStudio.Tools.PowerPoint;
internal sealed partial class PresentationTemplate
{
/// <summary>
/// A slot at least this wide, as a share of the slide, spans the whole width.
/// </summary>
private const double FULL_WIDTH_SHARE = 0.7;
/// <summary>
/// A label this close to a text, picture or table, as a share of the slide height, belongs to it.
/// </summary>
private const double LABEL_DISTANCE_SHARE = 0.05;
/// <summary>
/// An element overlaps a slot when they share at least this share of the smaller one's area.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private const double OVERLAP_AREA_SHARE = 0.2;
/// <summary>
/// An element covering at least this share of the slide is described as a background.
/// </summary>
private const double BACKGROUND_AREA_SHARE = 0.5;
/// <summary>
/// How much of the text of an element the model reads, which is enough to tell what it is.
/// </summary>
private const int MAX_EXCERPT_CHARACTERS = 80;
/// <summary>
/// Describes the layout families for the model, one line per family.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <returns>The catalog, e.g., <c>- 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)</c>.</returns>
public string ToCatalogPrompt()
{
return string.Join('\n', this.Families.Select(family => $"- {this.DescribeFamily(family)}"));
}
/// <summary>
/// Describes one layout family for the model: its ID, its name, and its slots.
/// </summary>
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();
}
/// <summary>
/// Describes the template slides the user chose for the model, one line per slide.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="uses">What the user chose for each slide of the template, by its number.</param>
/// <returns>The list, e.g., <c>- 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)</c>; empty when the user chose no slide.</returns>
public string ToTemplateSlidesPrompt(IReadOnlyList<TemplateSlideUse> 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)}"));
}
/// <summary>
/// Describes one template slide for the model, as one to keep or one to edit.
/// </summary>
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();
}
/// <summary>
/// Describes what a deck was planned on: each layout and template slide it uses, as the model read it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="deck">The slide deck.</param>
/// <returns>The descriptions, by the ID of a layout or by "template slide" and its number; a layout or slide the template lacks is missing.</returns>
public Dictionary<string, string> DescribeBasisOf(SlideDeckSpec deck)
{
var basis = new Dictionary<string, string>(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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="slide">The slide.</param>
/// <returns>One entry per slot which holds content, which the slide moved, or which a copy offers, in reading order, and one per element.</returns>
private List<string> DescribeSlideToEdit(TemplateSlide slide)
{
var slotsOnSlide = slide.Slots;
var entries = new List<string>();
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;
}
/// <summary>
/// Describes an element of a slide to edit: its kind, its place, the slots it overlaps, and the
/// beginning of its text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="element">The element.</param>
/// <param name="slotsOnSlide">The slots of the slide, where the slide puts them.</param>
/// <returns>The entry, e.g., <c>element 2 (text box, right, middle, overlaps picture 1) "Image source"</c>.</returns>
private string DescribeElement(TemplateSlideElement element, IReadOnlyList<TemplateSlot> slotsOnSlide)
{
var details = new List<string> { 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}\"";
}
/// <summary>
/// Whether an element and a slot share enough of their areas to be in each other's way.
/// </summary>
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(" / ")}\"";
/// <summary>
/// What the layout types a template may state mean; most templates state none.
/// </summary>
private static string? PurposeOf(string layoutType) => layoutType switch
{
"title" => "title slide",
"secHead" => "section header",
_ => null,
};
/// <summary>
/// Describes a slot for the model: its place, and how much text fits.
/// </summary>
/// <param name="slot">The slot.</param>
/// <param name="slots">All slots of the layout or the slide, which tell what a label belongs to.</param>
/// <param name="note">What the description adds at the end, e.g., "on this slide"; null for nothing.</param>
private string DescribeSlot(TemplateSlot slot, IReadOnlyList<TemplateSlot> slots, string? note = null)
{
var details = new List<string>();
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)})";
}
/// <summary>
/// How the model refers to a slot: a title alone is "title", while several of a kind are numbered.
/// </summary>
private static string NameOf(TemplateSlot slot, IReadOnlyList<TemplateSlot> 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}";
}
/// <summary>
/// Says what a label belongs to: the text, picture or table right below or right above it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private string DescribeLabelPlace(TemplateSlot label, IReadOnlyList<TemplateSlot> 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);
}
/// <summary>
/// The text, picture or table right below or right above a label, which the label belongs to.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="label">The label.</param>
/// <param name="slots">All slots of the layout or the slide.</param>
/// <param name="below">Whether to look below the label rather than above it.</param>
/// <returns>The nearest slot on that side; null when there is none close enough.</returns>
private TemplateSlot? NeighborOf(TemplateSlot label, IReadOnlyList<TemplateSlot> 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",
};
}
}