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

521 lines
27 KiB
C#

namespace AIStudio.Tools.PowerPoint;
/// <summary>
/// Checks a slide deck the model wrote against the template it was written for.
/// </summary>
/// <remarks>
/// Every issue is written for the model: the Slide Assistant sends the issues back in a repair
/// round, so each one names the slide and the slot, and says what to do instead. What fits the
/// template is checked here and nowhere else, so that the writer of the file can rely on it.
/// </remarks>
internal static class SlideDeckSpecValidator
{
/// <summary>
/// How far the estimated text may exceed the estimated capacity of a slot; at least one line more
/// is always accepted.
/// </summary>
/// <remarks>
/// Both are rough estimates, and the template shrinks text which runs a little long. Only text
/// clearly beyond the slot is sent back. The extra line keeps a two-line title in a box made for
/// one from being rejected, which PowerPoint simply sets a little smaller.
/// </remarks>
private const double CAPACITY_TOLERANCE = 1.2;
/// <summary>
/// A table column narrower than this many characters is hardly readable.
/// </summary>
private const int MIN_COLUMN_CHARACTERS = 8;
/// <summary>
/// Each list level indents a paragraph by about this many characters.
/// </summary>
private const int INDENT_CHARACTERS_PER_LEVEL = 4;
/// <summary>
/// Checks a slide deck.
/// </summary>
/// <param name="spec">The slide deck.</param>
/// <param name="template">The template whose layout families the deck uses.</param>
/// <param name="pictureCount">How many pictures the user attached for the slides, which the slides refer to by number; each goes on exactly one slide.</param>
/// <param name="expectedSlideCount">How many slides the user asked for, or 0 when the model may choose.</param>
/// <param name="templateSlideUses">What the user chose for each slide of the template, by its number; null or empty when the deck uses no template slides.</param>
/// <param name="chosenLayouts">The layouts the user chose for kinds of slides; null when the model decides every layout.</param>
/// <param name="maxMainPointsPerSlide">How many main points a slide may have at most, all its texts together, or 0 when the model may choose.</param>
/// <param name="libraryPictures">The pictures of a picture library the slides may use, numbered after the pictures for the slides; null for no library.</param>
/// <returns>The issues, written for the model; empty when the deck fits.</returns>
public static IReadOnlyList<string> Validate(SlideDeckSpec spec, PresentationTemplate template, int pictureCount, int expectedSlideCount, IReadOnlyList<TemplateSlideUse>? templateSlideUses = null, ChosenLayouts? chosenLayouts = null, int maxMainPointsPerSlide = 0, LibraryPictures? libraryPictures = null)
{
var uses = templateSlideUses ?? [];
var chosen = chosenLayouts ?? ChosenLayouts.NONE;
var library = libraryPictures ?? LibraryPictures.NONE;
var issues = new List<string>();
if (spec.Title.Length == 0)
issues.Add("The presentation has no title.");
if (spec.Slides.Count == 0)
{
issues.Add("The presentation has no slides.");
return issues;
}
if (expectedSlideCount > 0 && spec.Slides.Count != expectedSlideCount)
issues.Add($"The presentation has {spec.Slides.Count} slides, but exactly {expectedSlideCount} were asked for.");
for (var index = 0; index < spec.Slides.Count; index++)
{
ValidateSlide(spec.Slides[index], index + 1, template, uses, chosen, pictureCount + library.Count, issues);
ValidateMainPoints(spec.Slides[index], index + 1, maxMainPointsPerSlide, issues);
}
ValidateTemplateSlides(spec, template, uses, issues);
ValidateAddedSlides(spec, template, chosen, issues);
ValidateRequiredPictures(spec, template, pictureCount, issues);
ValidateLibraryPictures(spec, template, pictureCount, library, issues);
return issues;
}
/// <summary>
/// Checks that a picture of the library is used at most once, except for icons, and that no icon fills a backdrop.
/// </summary>
/// <remarks>
/// The same photo twice looks like an oversight, while the same icon may well mark, e.g., every
/// risk. An icon in a backdrop would cover the slide as a giant, so it belongs into a smaller
/// picture slot; a large one is fine, e.g., for a central icon next to a research question.
/// </remarks>
private static void ValidateLibraryPictures(SlideDeckSpec spec, PresentationTemplate template, int pictureCount, LibraryPictures library, List<string> issues)
{
if (library.Count == 0)
return;
var placed = new Dictionary<int, List<int>>();
for (var index = 0; index < spec.Slides.Count; index++)
{
var slide = spec.Slides[index];
var number = index + 1;
var pictureSlots = template.FamilyOf(slide)?.Slots.Where(slot => slot.Role is PlaceholderRole.PICTURE).ToList() ?? [];
var templateSlide = slide.TemplateSlide is { } templateSlideNumber ? template.FindSlide(templateSlideNumber) : null;
for (var position = 0; position < slide.Pictures.Count; position++)
{
var picture = slide.Pictures[position];
if (!placed.TryGetValue(picture, out var slides))
placed[picture] = slides = [];
slides.Add(number);
if (!library.IconNumbers.Contains(picture) || pictureSlots.FirstOrDefault(candidate => candidate.Ordinal == position + 1) is not { } slot)
continue;
var bounds = templateSlide?.OnSlide(slot).Bounds ?? slot.Bounds;
if (template.IsBackdrop(bounds))
issues.Add($"Slide {number} puts icon {picture} into picture {slot.Ordinal}, which is a backdrop. Put icons only into picture slots which are no backdrop, or choose another picture.");
}
}
foreach (var (picture, slides) in placed)
{
if (slides.Count > 1 && picture > pictureCount && picture <= pictureCount + library.Count && !library.IconNumbers.Contains(picture))
issues.Add($"Picture {picture} of PICTURE_LIBRARY is on slides {string.Join(", ", slides)}. Use a picture of the library at most once; only icons may repeat.");
}
}
/// <summary>
/// Checks that every picture the user attached for the slides is on exactly one slide.
/// </summary>
/// <remarks>
/// The user attached these pictures for the presentation to show them, once each. A template
/// without a single picture slot leaves no place for them, which no repair round can change.
/// </remarks>
private static void ValidateRequiredPictures(SlideDeckSpec spec, PresentationTemplate template, int pictureCount, List<string> issues)
{
if (pictureCount == 0 || !template.Families.Any(family => family.Slots.Any(slot => slot.Role is PlaceholderRole.PICTURE)))
return;
for (var picture = 1; picture <= pictureCount; picture++)
{
var slides = spec.Slides
.Select((slide, index) => (Slide: slide, Number: index + 1))
.Where(candidate => candidate.Slide.Pictures.Contains(picture))
.Select(candidate => candidate.Number)
.ToList();
if (slides.Count == 0)
issues.Add($"Picture {picture} is on no slide. Place every picture of PICTURES on exactly one slide with a picture slot.");
else if (slides.Count > 1)
issues.Add($"Picture {picture} is on slides {string.Join(", ", slides)}. Place it on exactly one of them.");
}
}
/// <summary>
/// Checks that the slides a choice of the user adds are there, and that an imprint comes last.
/// </summary>
/// <remarks>
/// The check goes by layout: a new slide on the layout chosen for the kind counts as such a slide.
/// It cannot tell the kinds apart when the user chose one layout for two of them, which then
/// share their slides.
/// </remarks>
private static void ValidateAddedSlides(SlideDeckSpec spec, PresentationTemplate template, ChosenLayouts chosen, List<string> issues)
{
var newSlideFamilyIds = spec.Slides.Select(slide => slide.TemplateSlide is null ? template.FindFamily(slide.Layout)?.Id : null).ToList();
foreach (var purpose in chosen.AddedSlides)
{
var familyId = chosen.Families[purpose].Id;
if (!newSlideFamilyIds.Contains(familyId))
issues.Add($"The presentation has no slide on layout {familyId} for {purpose.PromptDescription()}. Add one {purpose.PromptPlacement()}.");
}
if (chosen.For(LayoutPurpose.IMPRINT) is { } imprintFamily && newSlideFamilyIds.Contains(imprintFamily.Id) && newSlideFamilyIds[^1] != imprintFamily.Id)
issues.Add($"The imprint on layout {imprintFamily.Id} is not the last slide. Move it to the end of the presentation.");
}
/// <summary>
/// Checks that a slide has no more main points than the user asked for, all its texts together.
/// </summary>
/// <remarks>
/// Main points are the paragraphs on the top level; a sub-point belongs to its main point. A kept
/// template slide has no texts of its own, so it never has too many.
/// </remarks>
private static void ValidateMainPoints(SlideSpec slide, int number, int maxMainPoints, List<string> issues)
{
if (maxMainPoints <= 0)
return;
var mainPoints = slide.Texts.Sum(text => text.Paragraphs.Count(paragraph => paragraph.Level == 0));
if (mainPoints > maxMainPoints)
issues.Add($"Slide {number} has {mainPoints} main points, but at most {maxMainPoints} per slide were asked for, all its texts together. Merge or shorten them, or move some to another slide.");
}
/// <summary>
/// Says whether a slide can be written at all.
/// </summary>
/// <remarks>
/// A slide on a layout the template does not have, or one referring to a template slide the user
/// did not choose, cannot be written. Any other issue, such as a text which runs long, leaves a
/// slide worth writing and looking at.
/// </remarks>
/// <param name="slide">The slide.</param>
/// <param name="template">The template whose layout families and slides the deck uses.</param>
/// <param name="templateSlideUses">What the user chose for each slide of the template, by its number.</param>
public static bool CanWrite(SlideSpec slide, PresentationTemplate template, IReadOnlyList<TemplateSlideUse> templateSlideUses) => slide.TemplateSlide is { } number
? templateSlideUses.UseOf(number) is not TemplateSlideUse.OMIT && template.FindSlide(number) is not null && (!slide.HasContent || template.FamilyOf(slide) is not null)
: template.FindFamily(slide.Layout) is not null;
/// <summary>
/// Says whether a template still offers what a deck was planned on.
/// </summary>
/// <remarks>
/// The template is read once more for writing, and the user may have changed it since the deck
/// was planned. A deck whose layouts or template slides changed would land on other ones, or
/// could not be written at all, so it has to be planned anew.
/// </remarks>
/// <param name="deck">The slide deck.</param>
/// <param name="template">The template as it is now.</param>
/// <param name="basis">What the deck was planned on, as PresentationTemplate.DescribeBasisOf told it then.</param>
public static bool StillFits(SlideDeckSpec deck, PresentationTemplate template, IReadOnlyDictionary<string, string> basis)
{
var current = template.DescribeBasisOf(deck);
return current.Count == basis.Count && current.All(entry => basis.TryGetValue(entry.Key, out var planned) && planned == entry.Value);
}
private static void ValidateSlide(SlideSpec slide, int number, PresentationTemplate template, IReadOnlyList<TemplateSlideUse> uses, ChosenLayouts chosen, int pictureCount, List<string> issues)
{
if (slide.TemplateSlide is { } templateSlideNumber)
{
ValidateTemplateSlideEntry(slide, number, templateSlideNumber, template, uses, pictureCount, issues);
return;
}
if (slide.Removals.Count > 0)
issues.Add($"Slide {number} is a new slide, which has nothing to remove. Leave out \"remove\".");
var family = template.FindFamily(slide.Layout);
if (family is null)
{
issues.Add($"Slide {number} uses the layout \"{slide.Layout}\", which does not exist. Use one of {string.Join(", ", template.Families.Select(candidate => candidate.Id))}.");
return;
}
ValidateChosenLayout(slide, number, family, chosen, issues);
ValidateContent(slide, number, family.Id, family.Slots, pictureCount, issues);
}
/// <summary>
/// Checks that a new slide uses the layout the user chose for its kind, where its kind shows in the deck.
/// </summary>
/// <remarks>
/// Only two kinds show: the first slide is the title slide, and a slide which starts a chapter
/// says so. Whether a slide is, e.g., a timeline is for the model to judge, so those choices are
/// rules in the prompt only. The title slide wins over a chapter on the first slide, so that the
/// two checks never contradict each other. A template slide keeps its own layout and is not
/// checked at all.
/// </remarks>
private static void ValidateChosenLayout(SlideSpec slide, int number, LayoutFamily family, ChosenLayouts chosen, List<string> issues)
{
if (number == 1)
{
if (chosen.For(LayoutPurpose.TITLE) is { } titleFamily && titleFamily.Id != family.Id)
issues.Add($"Slide 1 is the title slide, but uses layout {family.Id}. The title slide uses layout {titleFamily.Id}.");
return;
}
if (slide.StartsChapter && chosen.For(LayoutPurpose.CHAPTER) is { } chapterFamily && chapterFamily.Id != family.Id)
issues.Add($"Slide {number} starts a chapter, but uses layout {family.Id}. Chapters start with a slide on layout {chapterFamily.Id}; put their content on the slides that follow.");
}
/// <summary>
/// Checks a slide which refers to a template slide: a kept one takes no content, an edited one
/// needs some, and it has to fit the slots of the template slide where the slide puts them.
/// </summary>
private static void ValidateTemplateSlideEntry(SlideSpec slide, int number, int templateSlideNumber, PresentationTemplate template, IReadOnlyList<TemplateSlideUse> uses, int pictureCount, List<string> issues)
{
var use = uses.UseOf(templateSlideNumber);
if (use is TemplateSlideUse.OMIT || template.FindSlide(templateSlideNumber) is not { } templateSlide)
{
var offered = OfferedNumbersOf(uses, template);
issues.Add(offered.Count > 0
? $"Slide {number} refers to template slide {templateSlideNumber}, which is not offered. Refer only to template slides {string.Join(", ", offered)}."
: $"Slide {number} refers to template slide {templateSlideNumber}, but no template slides are offered. Leave out \"template_slide\".");
return;
}
if (use is TemplateSlideUse.KEEP)
{
if (slide.HasContent)
issues.Add($"Slide {number} keeps template slide {templateSlideNumber} unchanged, so it takes no content. Write only \"template_slide\", and \"starts_chapter\" where it opens a chapter.");
return;
}
if (!slide.HasContent)
{
issues.Add($"Slide {number} is to edit template slide {templateSlideNumber}, but writes no content. Write the new content of the slide, or what it removes.");
return;
}
if (template.FamilyOf(slide) is not { } family)
{
issues.Add($"Template slide {templateSlideNumber} has no slots to fill. Keep it unchanged: write only \"template_slide\".");
return;
}
ValidateContent(slide, number, family.Id, templateSlide.Slots, pictureCount, issues);
ValidateRemovals(slide, number, templateSlide, issues);
}
/// <summary>
/// Checks what an edited slide removes from its template slide: slots, copies of slots, and
/// elements the slide has, and neither the title nor a slot the slide fills anew.
/// </summary>
/// <remarks>
/// The title stays, since it names the slide in the outline and in the sections of the
/// presentation; an edited slide changes it by writing a new one.
/// </remarks>
private static void ValidateRemovals(SlideSpec slide, int number, TemplateSlide templateSlide, List<string> issues)
{
foreach (var name in slide.Removals)
{
if (templateSlide.FindElement(name) is not null)
continue;
if (!PlaceholderRoleExtensions.TryParseSlotName(name, out var role, out var ordinal) || SlotOf(templateSlide.Slots, role, ordinal) is null)
{
issues.Add($"Slide {number} removes \"{name}\", which template slide {templateSlide.Number} does not have. Remove only the slots and elements its entry in TEMPLATE_SLIDES names, e.g., \"element 1\" or \"label 1\".");
continue;
}
if (role is PlaceholderRole.TITLE)
issues.Add($"Slide {number} removes the title of template slide {templateSlide.Number}. Write a new title instead, or keep the old one.");
else if (slide.Fills(role, ordinal))
issues.Add($"Slide {number} fills {name} and removes it as well. Either fill it or remove it.");
}
}
/// <summary>
/// Checks that every template slide the user chose appears exactly once, in the order of the
/// template.
/// </summary>
/// <remarks>
/// References to slides the user did not choose are reported with the slide which makes them.
/// </remarks>
private static void ValidateTemplateSlides(SlideDeckSpec spec, PresentationTemplate template, IReadOnlyList<TemplateSlideUse> uses, List<string> issues)
{
var references = spec.Slides
.Select(slide => slide.TemplateSlide)
.OfType<int>()
.Where(number => uses.UseOf(number) is not TemplateSlideUse.OMIT && template.FindSlide(number) is not null)
.ToList();
foreach (var number in OfferedNumbersOf(uses, template))
{
var count = references.Count(reference => reference == number);
if (count == 0)
{
issues.Add(uses.UseOf(number) is TemplateSlideUse.KEEP
? $"Template slide {number} is missing. Add it at its place as {{ \"template_slide\": {number} }}."
: $"Template slide {number} is missing. Add it at its place with \"template_slide\": {number} and its new content.");
}
else if (count > 1)
issues.Add($"Template slide {number} appears {count} times. Use every template slide exactly once.");
}
var order = references.Distinct().ToList();
if (!order.SequenceEqual(order.Order()))
issues.Add($"The template slides are out of order. Keep them in the order {string.Join(", ", order.Order())}, and put new slides before, between, or after them.");
}
/// <summary>
/// The numbers of the template slides the user chose to keep or to edit, in the order of the template.
/// </summary>
private static List<int> OfferedNumbersOf(IReadOnlyList<TemplateSlideUse> uses, PresentationTemplate template) => Enumerable.Range(1, uses.Count)
.Where(number => uses[number - 1] is not TemplateSlideUse.OMIT && template.FindSlide(number) is not null)
.ToList();
/// <summary>
/// Checks that the content of a slide fits the slots it goes into.
/// </summary>
/// <param name="slide">The slide.</param>
/// <param name="number">The number of the slide in the deck.</param>
/// <param name="layoutId">The ID of the layout family, which the issues name.</param>
/// <param name="slots">The slots, as the layout offers them or as a template slide moved or copied them.</param>
/// <param name="pictureCount">How many pictures are attached.</param>
/// <param name="issues">The issues found so far; new ones are added.</param>
private static void ValidateContent(SlideSpec slide, int number, string layoutId, IReadOnlyList<TemplateSlot> slots, int pictureCount, List<string> issues)
{
ValidateSingleText(slide.Title, PlaceholderRole.TITLE, number, layoutId, slots, issues);
ValidateSingleText(slide.Subtitle, PlaceholderRole.SUBTITLE, number, layoutId, slots, issues);
//
// The n-th text goes into "text n", and an empty entry keeps its slot empty: a model may
// well want the source below a picture without a heading above the text. Only empty
// entries at the end take no slot:
//
var textCount = CountUpToLastFilled(slide.Texts, text => text.Paragraphs.Count > 0);
if (ValidateCount(textCount, PlaceholderRole.TEXT, number, layoutId, slots, issues))
{
for (var index = 0; index < textCount; index++)
{
if (SlotOf(slots, PlaceholderRole.TEXT, index + 1)?.Capacity is { } capacity)
ValidateCapacity(slide.Texts[index].Paragraphs, capacity, $"Slide {number}: text {index + 1}", issues);
}
}
var labelCount = CountUpToLastFilled(slide.Labels, label => label.Length > 0);
if (ValidateCount(labelCount, PlaceholderRole.LABEL, number, layoutId, slots, issues))
{
for (var index = 0; index < labelCount; index++)
{
if (slide.Labels[index].Length > 0 && SlotOf(slots, PlaceholderRole.LABEL, index + 1)?.Capacity is { } capacity)
ValidateCapacity([new SlideParagraph(slide.Labels[index], 0)], capacity, $"Slide {number}: label {index + 1}", issues);
}
}
if (ValidateCount(slide.Pictures.Count, PlaceholderRole.PICTURE, number, layoutId, slots, issues))
{
foreach (var picture in slide.Pictures.Where(candidate => candidate < 1 || candidate > pictureCount))
{
issues.Add(pictureCount == 0
? $"Slide {number} refers to picture {picture}, but no pictures are attached. Leave \"pictures\" empty."
: $"Slide {number} refers to picture {picture}, but only pictures 1 to {pictureCount} are attached.");
}
}
if (slide.Table is { } table)
ValidateTable(table, number, layoutId, slots, issues);
}
private static void ValidateSingleText(string text, PlaceholderRole role, int number, string layoutId, IReadOnlyList<TemplateSlot> slots, List<string> issues)
{
if (text.Length == 0)
return;
var slot = SlotOf(slots, role, 1);
if (slot is null)
{
issues.Add($"Slide {number} has a {role.CatalogName()}, but layout {layoutId} has none. Choose a layout with a {role.CatalogName()}, or leave it empty.");
return;
}
if (slot.Capacity is { } capacity)
ValidateCapacity([new SlideParagraph(text, 0)], capacity, $"Slide {number}: the {role.CatalogName()}", issues);
}
/// <summary>
/// Checks that the layout has a slot for every piece of content of one role.
/// </summary>
/// <returns>Whether the content fits, so that its slots can be checked one by one.</returns>
private static bool ValidateCount(int count, PlaceholderRole role, int number, string layoutId, IReadOnlyList<TemplateSlot> slots, List<string> issues)
{
var slotCount = slots.Count(slot => slot.Role == role);
if (count <= slotCount)
return true;
issues.Add(slotCount == 0
? $"Slide {number} has {count} {PluralOf(role, count)}, but layout {layoutId} has no {role.CatalogName()}. Choose a layout which has one."
: $"Slide {number} has {count} {PluralOf(role, count)}, but layout {layoutId} has only {slotCount}. Merge them, or choose a layout with more.");
return false;
}
private static void ValidateCapacity(IEnumerable<SlideParagraph> paragraphs, TextCapacity capacity, string what, List<string> issues)
{
var lines = paragraphs.Sum(paragraph => LinesOf(paragraph, capacity.CharactersPerLine));
if (lines > AcceptedLinesOf(capacity))
issues.Add($"{what} needs about {lines} lines, but only about {capacity.Lines} fit. Shorten it, or spread the content over more slides.");
}
private static void ValidateTable(SlideTableSpec table, int number, string layoutId, IReadOnlyList<TemplateSlot> slots, List<string> issues)
{
if (SlotOf(slots, PlaceholderRole.TABLE, 1) is not { } slot)
{
issues.Add($"Slide {number} has a table, but layout {layoutId} has none. Choose a layout with a table.");
return;
}
var columns = table.Headers.Count;
if (columns == 0)
{
issues.Add($"Slide {number}: the table has no headers. Name every column.");
return;
}
for (var index = 0; index < table.Rows.Count; index++)
if (table.Rows[index].Count != columns)
issues.Add($"Slide {number}: row {index + 1} of the table has {table.Rows[index].Count} cells, but the table has {columns} columns.");
if (slot.Capacity is not { } capacity)
return;
var maxColumns = Math.Max(1, capacity.CharactersPerLine / MIN_COLUMN_CHARACTERS);
if (columns > maxColumns)
issues.Add($"Slide {number}: the table has {columns} columns, but only about {maxColumns} fit. Leave out or merge columns.");
// The header takes a row as well:
var rows = table.Rows.Count + 1;
if (rows > AcceptedLinesOf(capacity))
issues.Add($"Slide {number}: the table has {rows} rows including the header, but only about {capacity.Lines} fit. Split it across slides.");
}
private static TemplateSlot? SlotOf(IReadOnlyList<TemplateSlot> slots, PlaceholderRole role, int ordinal) => slots.FirstOrDefault(slot => slot.Role == role && slot.Ordinal == ordinal);
/// <summary>
/// How many slots the entries take: all of them up to the last one with content.
/// </summary>
private static int CountUpToLastFilled<T>(IReadOnlyList<T> entries, Func<T, bool> isFilled)
{
for (var index = entries.Count - 1; index >= 0; index--)
if (isFilled(entries[index]))
return index + 1;
return 0;
}
private static double AcceptedLinesOf(TextCapacity capacity) => Math.Max(capacity.Lines * CAPACITY_TOLERANCE, capacity.Lines + 1);
private static int LinesOf(SlideParagraph paragraph, int charactersPerLine)
{
var width = Math.Max(MIN_COLUMN_CHARACTERS, charactersPerLine - paragraph.Level * INDENT_CHARACTERS_PER_LEVEL);
return Math.Max(1, (int)Math.Ceiling(paragraph.Text.Length / (double)width));
}
private static string PluralOf(PlaceholderRole role, int count) => count == 1 ? role.CatalogName() : $"{role.CatalogName()}s";
}