namespace AIStudio.Tools.PowerPoint; /// /// Checks a slide deck the model wrote against the template it was written for. /// /// /// 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. /// internal static class SlideDeckSpecValidator { /// /// How far the estimated text may exceed the estimated capacity of a slot; at least one line more /// is always accepted. /// /// /// 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. /// private const double CAPACITY_TOLERANCE = 1.2; /// /// A table column narrower than this many characters is hardly readable. /// private const int MIN_COLUMN_CHARACTERS = 8; /// /// Each list level indents a paragraph by about this many characters. /// private const int INDENT_CHARACTERS_PER_LEVEL = 4; /// /// Checks a slide deck. /// /// The slide deck. /// The template whose layout families the deck uses. /// How many pictures the user attached for the slides, which the slides refer to by number; each goes on exactly one slide. /// How many slides the user asked for, or 0 when the model may choose. /// What the user chose for each slide of the template, by its number; null or empty when the deck uses no template slides. /// The layouts the user chose for kinds of slides; null when the model decides every layout. /// How many main points a slide may have at most, all its texts together, or 0 when the model may choose. /// The pictures of a picture library the slides may use, numbered after the pictures for the slides; null for no library. /// The issues, written for the model; empty when the deck fits. public static IReadOnlyList Validate(SlideDeckSpec spec, PresentationTemplate template, int pictureCount, int expectedSlideCount, IReadOnlyList? 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(); 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; } /// /// Checks that a picture of the library is used at most once, except for icons, and that no icon fills a backdrop. /// /// /// 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. /// private static void ValidateLibraryPictures(SlideDeckSpec spec, PresentationTemplate template, int pictureCount, LibraryPictures library, List issues) { if (library.Count == 0) return; var placed = new Dictionary>(); 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."); } } /// /// Checks that every picture the user attached for the slides is on exactly one slide. /// /// /// 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. /// private static void ValidateRequiredPictures(SlideDeckSpec spec, PresentationTemplate template, int pictureCount, List 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."); } } /// /// Checks that the slides a choice of the user adds are there, and that an imprint comes last. /// /// /// 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. /// private static void ValidateAddedSlides(SlideDeckSpec spec, PresentationTemplate template, ChosenLayouts chosen, List 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."); } /// /// Checks that a slide has no more main points than the user asked for, all its texts together. /// /// /// 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. /// private static void ValidateMainPoints(SlideSpec slide, int number, int maxMainPoints, List 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."); } /// /// Says whether a slide can be written at all. /// /// /// 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. /// /// The slide. /// The template whose layout families and slides the deck uses. /// What the user chose for each slide of the template, by its number. public static bool CanWrite(SlideSpec slide, PresentationTemplate template, IReadOnlyList 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; /// /// Says whether a template still offers what a deck was planned on. /// /// /// 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. /// /// The slide deck. /// The template as it is now. /// What the deck was planned on, as PresentationTemplate.DescribeBasisOf told it then. public static bool StillFits(SlideDeckSpec deck, PresentationTemplate template, IReadOnlyDictionary 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 uses, ChosenLayouts chosen, int pictureCount, List 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); } /// /// Checks that a new slide uses the layout the user chose for its kind, where its kind shows in the deck. /// /// /// 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. /// private static void ValidateChosenLayout(SlideSpec slide, int number, LayoutFamily family, ChosenLayouts chosen, List 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."); } /// /// 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. /// private static void ValidateTemplateSlideEntry(SlideSpec slide, int number, int templateSlideNumber, PresentationTemplate template, IReadOnlyList uses, int pictureCount, List 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); } /// /// 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. /// /// /// 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. /// private static void ValidateRemovals(SlideSpec slide, int number, TemplateSlide templateSlide, List 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."); } } /// /// Checks that every template slide the user chose appears exactly once, in the order of the /// template. /// /// /// References to slides the user did not choose are reported with the slide which makes them. /// private static void ValidateTemplateSlides(SlideDeckSpec spec, PresentationTemplate template, IReadOnlyList uses, List issues) { var references = spec.Slides .Select(slide => slide.TemplateSlide) .OfType() .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."); } /// /// The numbers of the template slides the user chose to keep or to edit, in the order of the template. /// private static List OfferedNumbersOf(IReadOnlyList uses, PresentationTemplate template) => Enumerable.Range(1, uses.Count) .Where(number => uses[number - 1] is not TemplateSlideUse.OMIT && template.FindSlide(number) is not null) .ToList(); /// /// Checks that the content of a slide fits the slots it goes into. /// /// The slide. /// The number of the slide in the deck. /// The ID of the layout family, which the issues name. /// The slots, as the layout offers them or as a template slide moved or copied them. /// How many pictures are attached. /// The issues found so far; new ones are added. private static void ValidateContent(SlideSpec slide, int number, string layoutId, IReadOnlyList slots, int pictureCount, List 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 slots, List 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); } /// /// Checks that the layout has a slot for every piece of content of one role. /// /// Whether the content fits, so that its slots can be checked one by one. private static bool ValidateCount(int count, PlaceholderRole role, int number, string layoutId, IReadOnlyList slots, List 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 paragraphs, TextCapacity capacity, string what, List 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 slots, List 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 slots, PlaceholderRole role, int ordinal) => slots.FirstOrDefault(slot => slot.Role == role && slot.Ordinal == ordinal); /// /// How many slots the entries take: all of them up to the last one with content. /// private static int CountUpToLastFilled(IReadOnlyList entries, Func 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"; }