namespace AIStudio.Tools.PowerPoint; internal sealed partial class PresentationTemplate { /// /// Two layouts offer the same slots when every edge of every slot differs by no more than this /// share of the slide. /// private const double SLOT_TOLERANCE = 0.01; /// /// Groups the layouts with room for content into families of variants. /// /// /// Two layouts are variants of each other when they offer the same slots at the same places and /// their names differ in one word only. Same slots alone are not enough: "Table 1" and "Table 2" /// may well look alike to the reading code and still mean different things to the template's /// author. /// /// Templates put that word in front ("A Content 1", "B Content 1") or at the end ("Title blue", /// "Title green"). Which one a template uses is decided for the whole template, by the reading /// which finds more variants. Within a single template, a word in front and a word at the end /// never mean the same thing. /// private static GroupedLayouts GroupIntoFamilies(IReadOnlyList layouts, long slideWidth, long slideHeight) { var usableLayouts = layouts.Where(layout => layout.Slots.Any(slot => slot.Role.CarriesContent())).ToList(); var byFirstWord = Group(usableLayouts, VariantWord.FIRST, slideWidth, slideHeight); var byLastWord = Group(usableLayouts, VariantWord.LAST, slideWidth, slideHeight); var groups = CountVariants(byLastWord) > CountVariants(byFirstWord) ? byLastWord : byFirstWord; var families = new List(groups.Count); var variantLabels = new List(); foreach (var group in groups) { var id = $"L{families.Count + 1}"; if (group.Variants.Count == 1) { // Alone, the layout keeps its full name and needs no label: families.Add(new LayoutFamily(id, group.Variants[0].Layout.Name, [new LayoutVariant(null, group.Variants[0].Layout)])); continue; } families.Add(new LayoutFamily(id, group.Stem, group.Variants)); foreach (var variant in group.Variants) if (variant.Label is { } label && !variantLabels.Contains(label)) variantLabels.Add(label); } return new GroupedLayouts(families, variantLabels); } private static List Group(IReadOnlyList layouts, VariantWord variantWord, long slideWidth, long slideHeight) { var candidates = new List(); foreach (var layout in layouts) { if (!TrySplitName(layout.Name, variantWord, out var label, out var stem)) { candidates.Add(new FamilyCandidate(layout.Name, [new LayoutVariant(null, layout)])); continue; } var family = candidates.FirstOrDefault(candidate => candidate.Stem == stem && candidate.Variants[0].Label is not null && candidate.Variants.All(variant => variant.Label != label) && HaveSameSlots(candidate.Variants[0].Layout, layout, slideWidth, slideHeight)); if (family is null) candidates.Add(new FamilyCandidate(stem, [new LayoutVariant(label, layout)])); else family.Variants.Add(new LayoutVariant(label, layout)); } return candidates; } private static bool TrySplitName(string name, VariantWord variantWord, out string label, out string stem) { var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (words.Length < 2) { label = string.Empty; stem = string.Empty; return false; } if (variantWord is VariantWord.FIRST) { label = words[0]; stem = string.Join(' ', words[1..]); } else { label = words[^1]; stem = string.Join(' ', words[..^1]); } return true; } private static bool HaveSameSlots(TemplateLayout first, TemplateLayout second, long slideWidth, long slideHeight) { if (first.Slots.Count != second.Slots.Count) return false; var horizontalTolerance = (long)(slideWidth * SLOT_TOLERANCE); var verticalTolerance = (long)(slideHeight * SLOT_TOLERANCE); return first.Slots.Zip(second.Slots).All(pair => pair.First.Role == pair.Second.Role && pair.First.Bounds.IsCloseTo(pair.Second.Bounds, horizontalTolerance, verticalTolerance)); } /// /// How many layouts a grouping puts into families of more than one variant. /// private static int CountVariants(IEnumerable candidates) => candidates.Where(candidate => candidate.Variants.Count > 1).Sum(candidate => candidate.Variants.Count); /// /// Which word of a layout name tells the variants apart. /// private enum VariantWord { FIRST, LAST, } /// /// A family while it is being gathered. /// /// The name without the word which tells the variants apart. /// The variants found so far. private sealed record FamilyCandidate(string Stem, List Variants); /// /// The layouts with room for content, grouped into families of variants. /// /// The families, in the order of the template. /// The labels which tell the variants apart; empty when the template carries every layout once. private sealed record GroupedLayouts(IReadOnlyList Families, IReadOnlyList VariantLabels); }