mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 14:47:21 +00:00
Added PowerPoint templates to the Slide Assistant (#1055)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
8c5cfa78b6
commit
6eb23ebbcb
136 files changed
+16535
-522
No files matched your search
@@ -0,0 +1,79 @@
|
||||
# AGENTS.md
|
||||
|
||||
These instructions cover the PowerPoint tools in `app/MindWork AI Studio/Tools/PowerPoint/`, with which
|
||||
the Slide Assistant (`app/MindWork AI Studio/Assistants/SlideBuilder/`) plans slides for the
|
||||
layouts of a template and writes PowerPoint files. They add to the `AGENTS.md` in the repository root and
|
||||
to `app/AGENTS.md`, which apply here as well.
|
||||
|
||||
## How a presentation comes about
|
||||
|
||||
1. `PresentationTemplate.Read` reads a .pptx or .potx with the Open XML SDK (`DocumentFormat.OpenXml`):
|
||||
its layouts, grouped into families of color variants, and the slides it carries. Without a template,
|
||||
`PresentationTemplate.CreateDefault` stands in, so that one prompt and one check serve both cases.
|
||||
2. `ToCatalogPrompt` and `ToTemplateSlidesPrompt` describe the layouts and the chosen template slides to
|
||||
the model in words: where each slot sits, what a label belongs to, and how much text fits.
|
||||
3. `SlideDeckSpec.TryParse` reads the JSON answer of the model leniently.
|
||||
4. `SlideDeckSpecValidator` checks the deck against the template. Its issues are written for the model,
|
||||
which gets one repair round.
|
||||
5. `SlideDeckSpec.ToMarkdownOutline` shows the result as an outline, and `PresentationWriter.WriteAsync`
|
||||
writes the file: a copy of the template, with its slides replaced. `PresentationFile.CreateAsync` makes
|
||||
that copy in the temporary folder and streams it into the target once it is complete.
|
||||
|
||||
## Rules which are easy to break
|
||||
|
||||
- **The validator is the only gate.** `PresentationWriter` relies on a deck which passed
|
||||
`SlideDeckSpecValidator`, checks nothing again, and throws when it meets what the validator should
|
||||
have caught. A new rule about what fits a template belongs into the validator, worded for the model.
|
||||
`SlideDeckSpecValidator.CanWrite` decides which issues leave a deck unusable at all.
|
||||
- **Slot names are a contract.** The model reads and writes slots and elements by names such as
|
||||
`title`, `text 2`, `label 1`, or `element 3`. The catalog (`PresentationTemplate.Catalog.cs`),
|
||||
`PlaceholderRoleExtensions.TryParseSlotName`, `TemplateSlide.FindElement`, the validator, the writer,
|
||||
and the outline all depend on them, so change them together. Slots are numbered per role in reading
|
||||
order; the copies of a slot on a template slide continue that numbering.
|
||||
- **No knowledge of a particular template.** Everything comes from the file: roles from the type and
|
||||
size of a placeholder, variants from the layout names, the design from masters and layouts. Never add
|
||||
the names, labels, or language of a corporate template to the code.
|
||||
- **Prompt texts stay in English, the user's texts do not.** Catalog, issues, and rules are written for
|
||||
the model. Every text the user reads, the outline included, goes through `T()` or `TB()`.
|
||||
- **Template texts are content of a file.** This holds for the names of the layouts as well as for the
|
||||
texts of the slides. They reach the model in the request, as `LAYOUTS` and `TEMPLATE_SLIDES`, never
|
||||
in the system prompt, and they pass `PromptInjectionGuardService` with the reporting scope of the
|
||||
request. The names of the attached pictures go with the request as well, as `PICTURES`.
|
||||
- **Pictures for the slides are not source material.** The user attaches them in a zone of their own;
|
||||
each goes on exactly one slide, which the validator checks. Images among the documents are source
|
||||
material, which the model reads and never places. A file is never both: the pictures for the slides
|
||||
win. Pictures go to the model as images only when it reads images; otherwise their names are all it has.
|
||||
- **A picture library offers names, never pictures.** `PictureLibrary` reads PNG and JPEG files of a
|
||||
folder, at most `MAX_LIBRARY_PICTURES`, sampled anew for every planning. Their numbers follow those
|
||||
of the pictures for the slides. The names are content of a folder which others may write to, so
|
||||
`PICTURE_LIBRARY` passes `PromptInjectionGuardService` like the texts of a template. A picture
|
||||
counts as an icon when its name or one of its folders, the library folder included, starts with
|
||||
"icon": icons may repeat, are never cropped, and never go into a backdrop
|
||||
(`PresentationTemplate.IsBackdrop`), which the catalog marks and the validator checks.
|
||||
- **Layout choices travel by name.** The user chooses layouts for kinds of slides (`LayoutPurpose`) by
|
||||
their names, which outlive sessions and new versions of a template. `ChosenLayouts` finds them in the
|
||||
template, and only their IDs go into the system prompt. The validator checks the two kinds which show
|
||||
in a deck, the title slide and the start of a chapter; every other choice is a rule for the model.
|
||||
A choice says which layout a kind uses, not whether the deck has one, except for the kinds with a
|
||||
`PromptPlacement`: a choice for them adds such a slide, and the validator checks that a new slide uses
|
||||
their layout and that an imprint comes last. Everything about a kind of slide lives in the file of its
|
||||
area, `LayoutPurposeExtensions.<Area>.cs`; a new kind goes there and into `LayoutPurpose`, within its area.
|
||||
- **New slides carry no formatting of their own.** They fill the placeholders of their layout and
|
||||
leave out every placeholder without content, so that no prompt text such as "Click to add text"
|
||||
remains. Only empty picture placeholders stay when the user asks for it
|
||||
(`PresentationWriteOptions.KeepEmptyPicturePlaceholders`), for pictures of their own.
|
||||
- **No copy of a file in memory.** Templates of organizations carry pictures and videos and easily reach
|
||||
a hundred megabytes, and AI Studio also runs on small devices. Read templates from their file, and
|
||||
let the writer edit a copy on disk. The Open XML SDK holds the compressed parts of the file in memory
|
||||
while it saves, which is all it costs; a `MemoryStream` with the whole file multiplies that.
|
||||
- **Write only into the file the user chose.** Inside the Flatpak, the save dialog grants access to
|
||||
exactly that file, so nothing may be created, renamed, or moved next to it. Temporary files go into
|
||||
the temporary folder, and the finished presentation is written over the chosen file, never moved there.
|
||||
- **Removing a shape removes what only it used:** its image, chart, or diagram parts, and the animations
|
||||
of its slide, see `PresentationWriter.RemoveShape`. Otherwise PowerPoint offers to repair the file.
|
||||
|
||||
## Tests
|
||||
|
||||
The tests in `app/Tests/Tools/PowerPoint/` build their templates in code with `TestTemplate`, so no
|
||||
binary template is checked in. A test which writes a file also runs the `OpenXmlValidator` over it,
|
||||
see `ValidationErrorsOf`.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// The layouts the user chose for kinds of slides, found in one template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user chooses layouts by their names, which stay valid across sessions, computers, and new
|
||||
/// versions of a template, while the IDs the model uses follow from the order of the layouts. A name
|
||||
/// the template does not have counts as no choice, just as a kind of slide the user left to the
|
||||
/// model. The rules for the model name the layouts by their IDs only, so that no text of the file
|
||||
/// reaches the system prompt.
|
||||
/// </remarks>
|
||||
/// <param name="Families">The family chosen for each kind of slide; a kind without a choice is missing.</param>
|
||||
internal sealed record ChosenLayouts(IReadOnlyDictionary<LayoutPurpose, LayoutFamily> Families)
|
||||
{
|
||||
/// <summary>
|
||||
/// No choices: the model decides every layout on its own.
|
||||
/// </summary>
|
||||
public static readonly ChosenLayouts NONE = new(new Dictionary<LayoutPurpose, LayoutFamily>());
|
||||
|
||||
/// <summary>
|
||||
/// Finds the chosen layouts in a template.
|
||||
/// </summary>
|
||||
/// <param name="template">The template the slides are planned for.</param>
|
||||
/// <param name="layoutNames">The name of the layout chosen for each kind of slide.</param>
|
||||
/// <returns>The choices the template can serve.</returns>
|
||||
public static ChosenLayouts Of(PresentationTemplate template, IReadOnlyDictionary<LayoutPurpose, string> layoutNames)
|
||||
{
|
||||
var families = new Dictionary<LayoutPurpose, LayoutFamily>();
|
||||
foreach (var (purpose, name) in layoutNames)
|
||||
if (template.FindFamilyByName(name) is { } family)
|
||||
families[purpose] = family;
|
||||
|
||||
return families.Count == 0 ? NONE : new ChosenLayouts(families);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The family chosen for a kind of slide.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
/// <returns>The family, or null when the model decides.</returns>
|
||||
public LayoutFamily? For(LayoutPurpose purpose) => this.Families.GetValueOrDefault(purpose);
|
||||
|
||||
/// <summary>
|
||||
/// The kinds of slides the presentation has to have, because the user chose a layout for them.
|
||||
/// </summary>
|
||||
public IEnumerable<LayoutPurpose> AddedSlides => Enum.GetValues<LayoutPurpose>().Where(purpose => purpose.ChoiceAddsSlide() && this.Families.ContainsKey(purpose));
|
||||
|
||||
/// <summary>
|
||||
/// Writes the choices as rules for the model, one line per kind of slide, in the order of the kinds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The first rule says which layout each kind of slide uses. The second one lists the slides the
|
||||
/// presentation has to have because of a choice, each with its place.
|
||||
/// </remarks>
|
||||
/// <returns>The rules, indented for the layout section of the system prompt; empty without choices.</returns>
|
||||
public string ToPromptRules()
|
||||
{
|
||||
if (this.Families.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var rules = new StringBuilder(" - The user chose layouts for some kinds of slides. A choice decides which layout such a slide uses, and it comes before every other rule on choosing layouts. Whether the presentation has such a slide follows from the content and the occasion, except for the slides the next rule requires. Vary the layouts only where no choice applies:");
|
||||
foreach (var purpose in Enum.GetValues<LayoutPurpose>())
|
||||
if (this.For(purpose) is { } family)
|
||||
rules.Append('\n').Append(" - ").Append(family.Id).Append(" for ").Append(purpose.PromptDescription()).Append('.');
|
||||
|
||||
var addedSlides = this.AddedSlides.ToList();
|
||||
if (addedSlides.Count > 0)
|
||||
{
|
||||
rules.Append('\n').Append(" - The presentation has at least one slide of each of these kinds, on its layout:");
|
||||
foreach (var purpose in addedSlides)
|
||||
rules.Append('\n').Append(" - ").Append(this.Families[purpose].Id).Append(": ").Append(purpose.PromptDescription()).Append(", ").Append(purpose.PromptPlacement()).Append('.');
|
||||
}
|
||||
|
||||
return rules.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Hands out the pictures a slide deck refers to by number.
|
||||
/// </summary>
|
||||
internal interface IPictureSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads a picture.
|
||||
/// </summary>
|
||||
/// <param name="number">The 1-based number the model used for the picture.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>The picture, or null when there is no such picture or it cannot be read.</returns>
|
||||
public Task<SlidePicture?> LoadAsync(int number, CancellationToken token);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Layouts which offer the same slots at the same places and differ only in their look.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Corporate templates often carry every layout in several colors. The model chooses what goes on a
|
||||
/// slide, never its color: it sees one entry per family, and the color is chosen for the whole
|
||||
/// presentation. Since all variants share the slots, slot n of one variant is slot n of every other
|
||||
/// one.
|
||||
/// </remarks>
|
||||
/// <param name="Id">The short ID the model uses to name the family, e.g., "L3".</param>
|
||||
/// <param name="Name">The name the layouts share, e.g., "Inhalt/Content 1".</param>
|
||||
/// <param name="Variants">The variants, in the order of the template; never empty.</param>
|
||||
internal sealed record LayoutFamily(string Id, string Name, IReadOnlyList<LayoutVariant> Variants)
|
||||
{
|
||||
/// <summary>
|
||||
/// The slots every variant of the family offers.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TemplateSlot> Slots => this.Variants[0].Layout.Slots;
|
||||
|
||||
/// <summary>
|
||||
/// The layout of the given variant, or the first one when the family has no such variant.
|
||||
/// </summary>
|
||||
/// <param name="variantLabel">The label of the variant, e.g., "B".</param>
|
||||
public TemplateLayout GetLayout(string? variantLabel) => (this.Variants.FirstOrDefault(variant => variant.Label == variantLabel) ?? this.Variants[0]).Layout;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// A kind of slide for which the user may choose the layout of the template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A choice says which layout a kind of slide uses, not whether the presentation has such a slide
|
||||
/// at all. The members are grouped by their LayoutPurposeArea, in the order of the areas, and within
|
||||
/// an area they roughly follow the course of a presentation; the forms list them in this order. A
|
||||
/// kind which comes as a single item or as several gets a member for each, because a template
|
||||
/// usually shows one item prominently and several as an overview. What the user and the model read
|
||||
/// about each member is in LayoutPurposeExtensions, one file per area.<br/><br/>
|
||||
/// Public, because the settings store the choices by these members. They are stored by name, so
|
||||
/// members may be added anywhere, but a renamed member loses the choices made for it.
|
||||
/// </remarks>
|
||||
public enum LayoutPurpose
|
||||
{
|
||||
//
|
||||
// Structure and framing:
|
||||
//
|
||||
TITLE,
|
||||
TABLE_OF_CONTENTS,
|
||||
AGENDA,
|
||||
SPEAKER,
|
||||
SPEAKERS,
|
||||
CHAPTER,
|
||||
CHAPTER_SUMMARY,
|
||||
Q_AND_A,
|
||||
ACKNOWLEDGEMENTS,
|
||||
REFERENCES,
|
||||
CLOSING,
|
||||
APPENDIX,
|
||||
IMPRINT,
|
||||
|
||||
//
|
||||
// General content:
|
||||
//
|
||||
CONTENT,
|
||||
LITTLE_CONTENT,
|
||||
MUCH_CONTENT,
|
||||
KEY_MESSAGE,
|
||||
EXECUTIVE_SUMMARY,
|
||||
CONTEXT,
|
||||
RETROSPECTIVE,
|
||||
PICTURE,
|
||||
PICTURES,
|
||||
TABLE,
|
||||
COMPARISON,
|
||||
PROS_AND_CONS,
|
||||
TIMELINE,
|
||||
PROCESS,
|
||||
QUOTE,
|
||||
QUOTES,
|
||||
CODE,
|
||||
FORMULA,
|
||||
FORMULAS,
|
||||
CHECKLIST,
|
||||
NOTE,
|
||||
NOTES,
|
||||
AUDIENCE_QUESTION,
|
||||
FEEDBACK,
|
||||
DISCLAIMER,
|
||||
SUMMARY,
|
||||
OUTLOOK,
|
||||
|
||||
//
|
||||
// Teaching and workshops:
|
||||
//
|
||||
INTRODUCTION_ROUND,
|
||||
EXPECTATIONS,
|
||||
GROUND_RULES,
|
||||
ORGANIZATIONAL_NOTES,
|
||||
LEARNING_OBJECTIVE,
|
||||
LEARNING_OBJECTIVES,
|
||||
EXAMPLE,
|
||||
EXAMPLES,
|
||||
STEP_BY_STEP,
|
||||
EXERCISE,
|
||||
EXERCISE_SOLUTION,
|
||||
GROUP_WORK,
|
||||
POLL,
|
||||
DISCUSSION_PROMPT,
|
||||
REFLECTION,
|
||||
BREAK,
|
||||
DAY_CHANGE,
|
||||
|
||||
//
|
||||
// Business and projects:
|
||||
//
|
||||
VISION,
|
||||
GOAL,
|
||||
GOALS,
|
||||
NARRATIVE,
|
||||
STORY,
|
||||
PROBLEM,
|
||||
PROBLEMS,
|
||||
OPPORTUNITY,
|
||||
OPPORTUNITIES,
|
||||
SWOT,
|
||||
USE_CASE,
|
||||
USE_CASES,
|
||||
USER_STORY,
|
||||
USER_STORIES,
|
||||
ACCEPTANCE_CRITERIA,
|
||||
KPI,
|
||||
KPIS,
|
||||
FORECAST,
|
||||
SCENARIO,
|
||||
SCENARIOS,
|
||||
RISK,
|
||||
RISKS,
|
||||
DEPENDENCY,
|
||||
DEPENDENCIES,
|
||||
RESOURCES,
|
||||
TEAM,
|
||||
PARTNERS,
|
||||
ROADMAP,
|
||||
MILESTONE,
|
||||
MILESTONES,
|
||||
WORK_PACKAGE,
|
||||
WORK_PACKAGES,
|
||||
PROJECT_STATUS,
|
||||
LESSONS_LEARNED,
|
||||
REASON,
|
||||
REASONS,
|
||||
RECOMMENDATION,
|
||||
RECOMMENDATIONS,
|
||||
DECISION_REQUEST,
|
||||
NEXT_STEPS,
|
||||
ACTION_ITEMS,
|
||||
CALL_TO_ACTION,
|
||||
|
||||
//
|
||||
// Science and research:
|
||||
//
|
||||
STATE_OF_RESEARCH,
|
||||
RESEARCH_GAP,
|
||||
RESEARCH_QUESTION,
|
||||
RESEARCH_QUESTIONS,
|
||||
DEFINITION,
|
||||
DEFINITIONS,
|
||||
THEOREM,
|
||||
PROOF,
|
||||
ASSUMPTION,
|
||||
ASSUMPTIONS,
|
||||
DELIMITATION,
|
||||
DELIMITATIONS,
|
||||
THESIS,
|
||||
THESES,
|
||||
HYPOTHESIS,
|
||||
HYPOTHESES,
|
||||
ARGUMENT_CHAIN,
|
||||
COUNTERARGUMENT,
|
||||
COUNTERARGUMENTS,
|
||||
METHODOLOGY,
|
||||
STUDY_DESIGN,
|
||||
EXPERIMENTAL_SETUP,
|
||||
DATA,
|
||||
RESULT,
|
||||
RESULTS,
|
||||
INTERPRETATION,
|
||||
LIMITATIONS,
|
||||
CONTRIBUTION,
|
||||
FUNDING,
|
||||
|
||||
//
|
||||
// Medicine:
|
||||
//
|
||||
PATIENT_CASE,
|
||||
MEDICAL_HISTORY,
|
||||
CLINICAL_FINDINGS,
|
||||
IMAGING,
|
||||
LAB_VALUES,
|
||||
DIAGNOSIS,
|
||||
DIFFERENTIAL_DIAGNOSES,
|
||||
THERAPY,
|
||||
MEDICATION,
|
||||
COURSE_OF_DISEASE,
|
||||
PROGNOSIS,
|
||||
ADVERSE_EVENTS,
|
||||
GUIDELINE_RECOMMENDATION,
|
||||
STUDY_POPULATION,
|
||||
ENDPOINTS,
|
||||
ETHICS_APPROVAL,
|
||||
|
||||
//
|
||||
// Law:
|
||||
//
|
||||
FACTS_OF_THE_CASE,
|
||||
LEGAL_QUESTION,
|
||||
LEGAL_QUESTIONS,
|
||||
LEGAL_PROVISION,
|
||||
LEGAL_PROVISIONS,
|
||||
EXAMINATION_SCHEME,
|
||||
DISPUTE_OF_OPINIONS,
|
||||
POSITIONS_OF_THE_PARTIES,
|
||||
PIECE_OF_EVIDENCE,
|
||||
PIECES_OF_EVIDENCE,
|
||||
RULING,
|
||||
RULINGS,
|
||||
LEGAL_COMMENTARY,
|
||||
LEGAL_COMMENTARIES,
|
||||
CONTRACT_CLAUSE,
|
||||
CONTRACT_CLAUSES,
|
||||
LEGAL_CONSEQUENCES,
|
||||
DEADLINES,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// An area of kinds of slides, by which the forms group the choices of layouts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The members follow the order in which the forms list the areas, and the kinds of slides in
|
||||
/// LayoutPurpose follow the same order.
|
||||
/// </remarks>
|
||||
public enum LayoutPurposeArea
|
||||
{
|
||||
/// <summary>
|
||||
/// The slides which frame a presentation and structure its course, e.g., the title slide, chapters, or the imprint.
|
||||
/// </summary>
|
||||
FRAMING,
|
||||
|
||||
/// <summary>
|
||||
/// Content of every kind of presentation, e.g., a key message, a comparison, or pictures.
|
||||
/// </summary>
|
||||
GENERAL,
|
||||
|
||||
/// <summary>
|
||||
/// Teaching, training, and workshops, e.g., learning objectives or exercises.
|
||||
/// </summary>
|
||||
TEACHING,
|
||||
|
||||
/// <summary>
|
||||
/// Business and projects, e.g., risks, KPIs, or milestones.
|
||||
/// </summary>
|
||||
BUSINESS,
|
||||
|
||||
/// <summary>
|
||||
/// Science and research, e.g., hypotheses, methodology, or results.
|
||||
/// </summary>
|
||||
SCIENCE,
|
||||
|
||||
/// <summary>
|
||||
/// Medicine, e.g., patient cases, findings, or therapies.
|
||||
/// </summary>
|
||||
MEDICINE,
|
||||
|
||||
/// <summary>
|
||||
/// Law, e.g., the facts of a case, legal provisions, or court rulings.
|
||||
/// </summary>
|
||||
LAW,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What the user reads about an area of kinds of slides.
|
||||
/// </summary>
|
||||
internal static class LayoutPurposeAreaExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => PluginSystem.I18N.I.T(fallbackEN, typeof(LayoutPurposeAreaExtensions).Namespace, nameof(LayoutPurposeAreaExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// The name of the area, as the forms show it above its kinds of slides.
|
||||
/// </summary>
|
||||
/// <param name="area">The area.</param>
|
||||
public static string Label(this LayoutPurposeArea area) => area switch
|
||||
{
|
||||
LayoutPurposeArea.FRAMING => TB("Structure and framing"),
|
||||
LayoutPurposeArea.GENERAL => TB("General content"),
|
||||
LayoutPurposeArea.TEACHING => TB("Teaching and workshops"),
|
||||
LayoutPurposeArea.BUSINESS => TB("Business and projects"),
|
||||
LayoutPurposeArea.SCIENCE => TB("Science and research"),
|
||||
LayoutPurposeArea.MEDICINE => TB("Medicine"),
|
||||
LayoutPurposeArea.LAW => TB("Law"),
|
||||
|
||||
_ => TB("Other kinds of slides"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static LayoutPurposeInfo? DescribeBusiness(LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.VISION => Business(TB("Layout for the vision"), TB("Which slide layout should a vision use?"), "a vision, i.e., the picture of where to go"),
|
||||
LayoutPurpose.GOAL => Business(TB("Layout for one goal"), TB("Which slide layout should a single goal use, shown prominently?"), "a single goal, shown prominently"),
|
||||
LayoutPurpose.GOALS => Business(TB("Layout for several goals"), TB("Which slide layout should several goals use, shown together?"), "several goals, shown together"),
|
||||
LayoutPurpose.NARRATIVE => Business(TB("Layout for the narrative"), TB("Which slide layout should a narrative use, which frames, e.g., a project?"), "a narrative which frames, e.g., a project"),
|
||||
LayoutPurpose.STORY => Business(TB("Layout for stories"), TB("Which slide layout should a story use, e.g., in the storytelling of a keynote?"), "a story, e.g., in the storytelling of a keynote"),
|
||||
LayoutPurpose.PROBLEM => Business(TB("Layout for one problem"), TB("Which slide layout should a single problem use, shown prominently?"), "a single problem, shown prominently"),
|
||||
LayoutPurpose.PROBLEMS => Business(TB("Layout for several problems"), TB("Which slide layout should several problems use, shown together?"), "several problems, shown together"),
|
||||
LayoutPurpose.OPPORTUNITY => Business(TB("Layout for one opportunity"), TB("Which slide layout should a single opportunity use, shown prominently?"), "a single opportunity, shown prominently"),
|
||||
LayoutPurpose.OPPORTUNITIES => Business(TB("Layout for several opportunities"), TB("Which slide layout should several opportunities use, shown together?"), "several opportunities, shown together"),
|
||||
LayoutPurpose.SWOT => Business(TB("Layout for SWOT analyses"), TB("Which slide layout should a SWOT analysis use?"), "a SWOT analysis of strengths, weaknesses, opportunities, and threats"),
|
||||
LayoutPurpose.USE_CASE => Business(TB("Layout for one use case"), TB("Which slide layout should a single use case use, shown prominently?"), "a single use case, shown prominently"),
|
||||
LayoutPurpose.USE_CASES => Business(TB("Layout for several use cases"), TB("Which slide layout should several use cases use, shown together?"), "several use cases, shown together"),
|
||||
LayoutPurpose.USER_STORY => Business(TB("Layout for one user story"), TB("Which slide layout should a single user story use, shown prominently?"), "a single user story, shown prominently"),
|
||||
LayoutPurpose.USER_STORIES => Business(TB("Layout for several user stories"), TB("Which slide layout should several user stories use, shown together?"), "several user stories, shown together"),
|
||||
LayoutPurpose.ACCEPTANCE_CRITERIA => Business(TB("Layout for acceptance criteria"), TB("Which slide layout should acceptance criteria use?"), "acceptance criteria"),
|
||||
LayoutPurpose.KPI => Business(TB("Layout for one KPI"), TB("Which slide layout should a single key figure use, shown prominently?"), "a single key figure (KPI), shown prominently"),
|
||||
LayoutPurpose.KPIS => Business(TB("Layout for an overview of KPIs"), TB("Which slide layout should an overview of several key figures use?"), "an overview of several key figures (KPIs)"),
|
||||
LayoutPurpose.FORECAST => Business(TB("Layout for forecasts"), TB("Which slide layout should a forecast use?"), "a forecast"),
|
||||
LayoutPurpose.SCENARIO => Business(TB("Layout for one scenario"), TB("Which slide layout should a single scenario use, shown prominently?"), "a single scenario, shown prominently"),
|
||||
LayoutPurpose.SCENARIOS => Business(TB("Layout for several scenarios"), TB("Which slide layout should several scenarios use, shown together?"), "several scenarios, shown together"),
|
||||
LayoutPurpose.RISK => Business(TB("Layout for one risk"), TB("Which slide layout should a single risk use, shown prominently?"), "a single risk, shown prominently"),
|
||||
LayoutPurpose.RISKS => Business(TB("Layout for several risks"), TB("Which slide layout should several risks use, shown together?"), "several risks, shown together"),
|
||||
LayoutPurpose.DEPENDENCY => Business(TB("Layout for one dependency"), TB("Which slide layout should a single dependency and its countermeasures use?"), "a single dependency and its countermeasures, shown prominently"),
|
||||
LayoutPurpose.DEPENDENCIES => Business(TB("Layout for several dependencies"), TB("Which slide layout should several dependencies and their countermeasures use?"), "several dependencies and their countermeasures, shown together"),
|
||||
LayoutPurpose.RESOURCES => Business(TB("Layout for resources"), TB("Which slide layout should resources use, e.g., costs, people, or machines?"), "resources, e.g., costs, people, or machines"),
|
||||
LayoutPurpose.TEAM => Business(TB("Layout for the team"), TB("Which slide layout should a slide use which presents a team?"), "a slide which presents a team"),
|
||||
LayoutPurpose.PARTNERS => Business(TB("Layout for partners"), TB("Which slide layout should a slide use which presents partners, e.g., with their logos?"), "a slide which presents partners, e.g., with their logos"),
|
||||
LayoutPurpose.ROADMAP => Business(TB("Layout for roadmaps"), TB("Which slide layout should a roadmap use?"), "a roadmap"),
|
||||
LayoutPurpose.MILESTONE => Business(TB("Layout for one milestone"), TB("Which slide layout should a single milestone use, e.g., with a report?"), "a single milestone, e.g., with a report or a description"),
|
||||
LayoutPurpose.MILESTONES => Business(TB("Layout for an overview of milestones"), TB("Which slide layout should an overview of several milestones use?"), "an overview of several milestones"),
|
||||
LayoutPurpose.WORK_PACKAGE => Business(TB("Layout for one work package"), TB("Which slide layout should a single work package use, e.g., in a project report?"), "a single work package, e.g., in a project report"),
|
||||
LayoutPurpose.WORK_PACKAGES => Business(TB("Layout for an overview of work packages"), TB("Which slide layout should an overview of several work packages use?"), "an overview of several work packages"),
|
||||
LayoutPurpose.PROJECT_STATUS => Business(TB("Layout for the project status"), TB("Which slide layout should the status of a project use, e.g., as a traffic light?"), "the status of a project, e.g., as a traffic light"),
|
||||
LayoutPurpose.LESSONS_LEARNED => Business(TB("Layout for lessons learned"), TB("Which slide layout should lessons learned use?"), "lessons learned"),
|
||||
LayoutPurpose.REASON => Business(TB("Layout for one reason"), TB("Which slide layout should a single reason use, shown prominently?"), "a single reason or justification, shown prominently"),
|
||||
LayoutPurpose.REASONS => Business(TB("Layout for several reasons"), TB("Which slide layout should several reasons use, shown together?"), "several reasons or justifications, shown together"),
|
||||
LayoutPurpose.RECOMMENDATION => Business(TB("Layout for one recommendation"), TB("Which slide layout should a single recommendation use, shown prominently?"), "a single recommendation, shown prominently"),
|
||||
LayoutPurpose.RECOMMENDATIONS => Business(TB("Layout for several recommendations"), TB("Which slide layout should several recommendations use, shown together?"), "several recommendations, shown together"),
|
||||
LayoutPurpose.DECISION_REQUEST => Business(TB("Layout for decision requests"), TB("Which slide layout should a decision use which the audience is asked to make?"), "a decision which the audience is asked to make"),
|
||||
LayoutPurpose.NEXT_STEPS => Business(TB("Layout for next steps"), TB("Which slide layout should the next steps use?"), "the next steps"),
|
||||
LayoutPurpose.ACTION_ITEMS => Business(TB("Layout for to-dos"), TB("Which slide layout should to-dos use, e.g., with who does them and by when?"), "to-dos, i.e., open tasks, e.g., with who does them and by when"),
|
||||
LayoutPurpose.CALL_TO_ACTION => Business(TB("Layout for calls to action"), TB("Which slide layout should a call to action use?"), "a call to action"),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static LayoutPurposeInfo Business(string label, string question, string promptDescription) => new(LayoutPurposeArea.BUSINESS, label, question, promptDescription);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static LayoutPurposeInfo? DescribeFraming(LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.TITLE => Framing(TB("Layout for the title slide"), TB("Which slide layout should the title slide use?"), "the title slide, which is the first slide"),
|
||||
LayoutPurpose.TABLE_OF_CONTENTS => Framing(TB("Layout for the table of contents"), TB("Which slide layout should the table of contents use, which lists the topics of the presentation?"), "the table of contents, which lists the topics or chapters of the presentation without times"),
|
||||
LayoutPurpose.AGENDA => Framing(TB("Layout for the agenda"), TB("Which slide layout should an agenda with times and rooms use, e.g., of a workshop?"), "an agenda with times, and with rooms where the content names them, e.g., of a workshop; only where the content gives such a schedule, never with times of your own"),
|
||||
LayoutPurpose.SPEAKER => Framing(TB("Layout for introducing one speaker"), TB("Which slide layout should the introduction of a single speaker use?"), "a slide which introduces a single speaker"),
|
||||
LayoutPurpose.SPEAKERS => Framing(TB("Layout for introducing several speakers"), TB("Which slide layout should the introduction of several speakers use?"), "a slide which introduces several speakers together"),
|
||||
LayoutPurpose.CHAPTER => Framing(TB("Layout for chapters"), TB("Which slide layout should open each chapter?"), "each slide which starts a chapter; it opens the chapter, and the content of the chapter follows on the next slides"),
|
||||
LayoutPurpose.CHAPTER_SUMMARY => Framing(TB("Layout for interim summaries"), TB("Which slide layout should an interim summary at the end of a chapter use?"), "an interim summary at the end of a chapter"),
|
||||
LayoutPurpose.Q_AND_A => Framing(TB("Layout for “Any questions?”"), TB("Which slide layout should a slide use which invites the questions of the audience?"), "a slide which invites the questions of the audience"),
|
||||
LayoutPurpose.ACKNOWLEDGEMENTS => Framing(TB("Layout for acknowledgements"), TB("Which slide layout should the acknowledgements use?"), "acknowledgements, which thank the people or organizations who contributed"),
|
||||
LayoutPurpose.REFERENCES => Framing(TB("Layout for sources and literature"), TB("Which slide layout should the sources and the literature use?"), "the sources and the literature"),
|
||||
LayoutPurpose.CLOSING => Framing(TB("Layout for the closing slide"), TB("Which slide layout should the closing slide use, e.g., with thanks and contact details?"), "the closing slide, e.g., with thanks and contact details"),
|
||||
LayoutPurpose.APPENDIX => Framing(TB("Layout for the appendix"), TB("Which slide layout should open an appendix with backup slides?"), "the slide which opens an appendix with backup slides after the closing slide"),
|
||||
LayoutPurpose.IMPRINT => Framing(TB("Layout for the imprint"), TB("Which slide layout should the imprint use, e.g., with authors, organization, and date?"), "the imprint, e.g., with the authors, the organization, and the date"),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static LayoutPurposeInfo Framing(string label, string question, string promptDescription) => new(LayoutPurposeArea.FRAMING, label, question, promptDescription);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static LayoutPurposeInfo? DescribeGeneral(LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.CONTENT => General(TB("Default layout for content"), TB("Which slide layout should ordinary slides with content use by default?"), "ordinary slides with content; use another layout only where a slide needs other slots, e.g., for a picture or a table, or where another choice here applies"),
|
||||
LayoutPurpose.LITTLE_CONTENT => General(TB("Layout for little content"), TB("Which slide layout should slides with little content use, e.g., a short statement?"), "slides with little content, e.g., a short statement, where no other choice here applies"),
|
||||
LayoutPurpose.MUCH_CONTENT => General(TB("Layout for much content"), TB("Which slide layout should slides with much content use, e.g., many points or a longer explanation?"), "slides with much content, e.g., many points or a longer explanation"),
|
||||
LayoutPurpose.KEY_MESSAGE => General(TB("Layout for a key message"), TB("Which slide layout should a key message use, which states the one thing to remember?"), "a key message, which states the one thing the audience should remember"),
|
||||
LayoutPurpose.EXECUTIVE_SUMMARY => General(TB("Layout for the executive summary"), TB("Which slide layout should an executive summary use, which sums up the presentation for decision makers?"), "an executive summary, which sums up the whole presentation for decision makers, usually at its start"),
|
||||
LayoutPurpose.CONTEXT => General(TB("Layout for the context"), TB("Which slide layout should the context or the starting point use?"), "the context or the starting point of the topic"),
|
||||
LayoutPurpose.RETROSPECTIVE => General(TB("Layout for a look back"), TB("Which slide layout should a look back on where we come from use?"), "a look back on what happened so far, i.e., where we come from"),
|
||||
LayoutPurpose.PICTURE => General(TB("Layout for one picture"), TB("Which slide layout should a single picture use, shown prominently, e.g., a screenshot, a photo, or a sketch?"), "a single attached picture, shown prominently, e.g., a screenshot, a photo, or a sketch"),
|
||||
LayoutPurpose.PICTURES => General(TB("Layout for several pictures"), TB("Which slide layout should several pictures use, shown together?"), "several attached pictures, shown together"),
|
||||
LayoutPurpose.TABLE => General(TB("Layout for tables"), TB("Which slide layout should a slide with a table use?"), "a slide with a table"),
|
||||
LayoutPurpose.COMPARISON => General(TB("Layout for comparisons"), TB("Which slide layout should comparisons use, e.g., before and after, or two options?"), "comparisons, e.g., before and after, or two options side by side"),
|
||||
LayoutPurpose.PROS_AND_CONS => General(TB("Layout for pros and cons"), TB("Which slide layout should pros and cons use?"), "the pros and cons of one thing"),
|
||||
LayoutPurpose.TIMELINE => General(TB("Layout for timelines"), TB("Which slide layout should timelines use, e.g., the course of a project?"), "timelines, e.g., the course of a project"),
|
||||
LayoutPurpose.PROCESS => General(TB("Layout for processes"), TB("Which slide layout should processes use, e.g., the steps of a workflow?"), "processes, e.g., the steps of a workflow"),
|
||||
LayoutPurpose.QUOTE => General(TB("Layout for one quote"), TB("Which slide layout should a single quote use, shown prominently?"), "a single quote, shown prominently"),
|
||||
LayoutPurpose.QUOTES => General(TB("Layout for several quotes"), TB("Which slide layout should several quotes use, shown together?"), "several quotes, shown together"),
|
||||
LayoutPurpose.CODE => General(TB("Layout for source code"), TB("Which slide layout should source code use?"), "source code"),
|
||||
LayoutPurpose.FORMULA => General(TB("Layout for one formula"), TB("Which slide layout should a single formula use, shown prominently?"), "a single formula, shown prominently, e.g., with the meaning of its symbols"),
|
||||
LayoutPurpose.FORMULAS => General(TB("Layout for several formulas"), TB("Which slide layout should several formulas use, shown together?"), "several formulas, shown together, e.g., the steps of a derivation"),
|
||||
LayoutPurpose.CHECKLIST => General(TB("Layout for checklists"), TB("Which slide layout should a checklist use?"), "a checklist"),
|
||||
LayoutPurpose.NOTE => General(TB("Layout for one note"), TB("Which slide layout should a single note or remark use, shown prominently?"), "a single note or remark on the content, shown prominently"),
|
||||
LayoutPurpose.NOTES => General(TB("Layout for several notes"), TB("Which slide layout should several notes or remarks use, shown together?"), "several notes or remarks on the content, shown together"),
|
||||
LayoutPurpose.AUDIENCE_QUESTION => General(TB("Layout for questions to the audience"), TB("Which slide layout should questions to the audience during the presentation use?"), "questions to the audience during the presentation"),
|
||||
LayoutPurpose.FEEDBACK => General(TB("Layout for feedback"), TB("Which slide layout should feedback use, e.g., of customers or users?"), "feedback, e.g., of customers or users"),
|
||||
LayoutPurpose.DISCLAIMER => General(TB("Layout for disclaimers"), TB("Which slide layout should conflicts of interest and disclaimers use?"), "conflicts of interest and disclaimers"),
|
||||
LayoutPurpose.SUMMARY => General(TB("Layout for the summary"), TB("Which slide layout should a summary or a conclusion use?"), "a summary or a conclusion"),
|
||||
LayoutPurpose.OUTLOOK => General(TB("Layout for the outlook"), TB("Which slide layout should an outlook on what comes next use?"), "an outlook on what comes next"),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static LayoutPurposeInfo General(string label, string question, string promptDescription) => new(LayoutPurposeArea.GENERAL, label, question, promptDescription);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static LayoutPurposeInfo? DescribeLaw(LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.FACTS_OF_THE_CASE => Law(TB("Layout for the facts of the case"), TB("Which slide layout should the facts of a legal case use?"), "the facts of a legal case"),
|
||||
LayoutPurpose.LEGAL_QUESTION => Law(TB("Layout for one legal question"), TB("Which slide layout should a single legal question use, shown prominently?"), "a single legal question, shown prominently"),
|
||||
LayoutPurpose.LEGAL_QUESTIONS => Law(TB("Layout for several legal questions"), TB("Which slide layout should several legal questions use, shown together?"), "several legal questions, shown together"),
|
||||
LayoutPurpose.LEGAL_PROVISION => Law(TB("Layout for one legal provision"), TB("Which slide layout should a single legal provision use, e.g., a quoted section of a law?"), "a single legal provision, e.g., a quoted section of a law, shown prominently"),
|
||||
LayoutPurpose.LEGAL_PROVISIONS => Law(TB("Layout for several legal provisions"), TB("Which slide layout should several legal provisions use, shown together?"), "several legal provisions, shown together"),
|
||||
LayoutPurpose.EXAMINATION_SCHEME => Law(TB("Layout for examination schemes"), TB("Which slide layout should a scheme use by which a legal question is examined step by step?"), "a scheme by which a legal question is examined step by step"),
|
||||
LayoutPurpose.DISPUTE_OF_OPINIONS => Law(TB("Layout for disputes of opinions"), TB("Which slide layout should a dispute of legal opinions use, e.g., the prevailing and a minority opinion?"), "a dispute of legal opinions, e.g., the prevailing and a minority opinion"),
|
||||
LayoutPurpose.POSITIONS_OF_THE_PARTIES => Law(TB("Layout for the positions of the parties"), TB("Which slide layout should the positions of the parties use, e.g., of plaintiff and defendant?"), "the positions of the parties, e.g., of plaintiff and defendant"),
|
||||
LayoutPurpose.PIECE_OF_EVIDENCE => Law(TB("Layout for one piece of evidence"), TB("Which slide layout should a single piece of evidence use, shown prominently?"), "a single piece of evidence in a legal case, shown prominently"),
|
||||
LayoutPurpose.PIECES_OF_EVIDENCE => Law(TB("Layout for several pieces of evidence"), TB("Which slide layout should several pieces of evidence use, shown together?"), "several pieces of evidence in a legal case, shown together"),
|
||||
LayoutPurpose.RULING => Law(TB("Layout for one ruling"), TB("Which slide layout should a single court ruling use, e.g., with court, date, case number, and headnote?"), "a single court ruling, shown prominently, e.g., with court, date, case number, and headnote"),
|
||||
LayoutPurpose.RULINGS => Law(TB("Layout for several rulings"), TB("Which slide layout should several court rulings use, shown together?"), "several court rulings, shown together"),
|
||||
LayoutPurpose.LEGAL_COMMENTARY => Law(TB("Layout for one commentary"), TB("Which slide layout should a single passage of a legal commentary use, e.g., of an annotated code, shown prominently?"), "a single passage of a legal commentary, e.g., of an annotated code or statute, or of other legal literature, shown prominently with its citation"),
|
||||
LayoutPurpose.LEGAL_COMMENTARIES => Law(TB("Layout for several commentaries"), TB("Which slide layout should several passages of legal commentaries or other legal literature use, shown together?"), "several passages of legal commentaries, e.g., of annotated codes or statutes, or of other legal literature, shown together with their citations"),
|
||||
LayoutPurpose.CONTRACT_CLAUSE => Law(TB("Layout for one contract clause"), TB("Which slide layout should a single contract clause use, shown prominently?"), "a single contract clause, shown prominently"),
|
||||
LayoutPurpose.CONTRACT_CLAUSES => Law(TB("Layout for several contract clauses"), TB("Which slide layout should several contract clauses use, shown together?"), "several contract clauses, shown together"),
|
||||
LayoutPurpose.LEGAL_CONSEQUENCES => Law(TB("Layout for legal consequences"), TB("Which slide layout should legal consequences use?"), "the legal consequences"),
|
||||
LayoutPurpose.DEADLINES => Law(TB("Layout for deadlines"), TB("Which slide layout should deadlines use, e.g., for an appeal?"), "deadlines, e.g., for an appeal"),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static LayoutPurposeInfo Law(string label, string question, string promptDescription) => new(LayoutPurposeArea.LAW, label, question, promptDescription);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static LayoutPurposeInfo? DescribeMedicine(LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.PATIENT_CASE => Medicine(TB("Layout for patient cases"), TB("Which slide layout should the presentation of a patient case use?"), "the presentation of a patient case, without anything which identifies the patient"),
|
||||
LayoutPurpose.MEDICAL_HISTORY => Medicine(TB("Layout for the medical history"), TB("Which slide layout should the medical history of a patient use?"), "the medical history of a patient"),
|
||||
LayoutPurpose.CLINICAL_FINDINGS => Medicine(TB("Layout for clinical findings"), TB("Which slide layout should clinical findings use, e.g., of a physical examination?"), "clinical findings, e.g., of a physical examination"),
|
||||
LayoutPurpose.IMAGING => Medicine(TB("Layout for imaging"), TB("Which slide layout should imaging use, e.g., an X-ray, CT, or MRI with its findings?"), "imaging, e.g., an X-ray, CT, or MRI with its findings"),
|
||||
LayoutPurpose.LAB_VALUES => Medicine(TB("Layout for lab values"), TB("Which slide layout should lab values use, e.g., with their reference ranges?"), "lab values, e.g., with their reference ranges"),
|
||||
LayoutPurpose.DIAGNOSIS => Medicine(TB("Layout for the diagnosis"), TB("Which slide layout should the diagnosis use?"), "the diagnosis"),
|
||||
LayoutPurpose.DIFFERENTIAL_DIAGNOSES => Medicine(TB("Layout for differential diagnoses"), TB("Which slide layout should differential diagnoses use?"), "the differential diagnoses, i.e., what else the findings could point to"),
|
||||
LayoutPurpose.THERAPY => Medicine(TB("Layout for the therapy"), TB("Which slide layout should the therapy or the treatment plan use?"), "the therapy or the treatment plan"),
|
||||
LayoutPurpose.MEDICATION => Medicine(TB("Layout for medication"), TB("Which slide layout should medication use, e.g., with agent, dose, and duration?"), "medication, e.g., with agent, dose, and duration"),
|
||||
LayoutPurpose.COURSE_OF_DISEASE => Medicine(TB("Layout for the course of a disease"), TB("Which slide layout should the course of a disease use?"), "the course of a disease or of its treatment"),
|
||||
LayoutPurpose.PROGNOSIS => Medicine(TB("Layout for the prognosis"), TB("Which slide layout should the prognosis use?"), "the prognosis"),
|
||||
LayoutPurpose.ADVERSE_EVENTS => Medicine(TB("Layout for adverse events"), TB("Which slide layout should side effects and adverse events use?"), "side effects and adverse events"),
|
||||
LayoutPurpose.GUIDELINE_RECOMMENDATION => Medicine(TB("Layout for guideline recommendations"), TB("Which slide layout should a recommendation of a guideline use, e.g., with its level of evidence?"), "a recommendation of a clinical guideline, e.g., with its level of evidence and its grade of recommendation"),
|
||||
LayoutPurpose.STUDY_POPULATION => Medicine(TB("Layout for the study population"), TB("Which slide layout should the study population use, e.g., with inclusion and exclusion criteria?"), "the study population, e.g., with inclusion and exclusion criteria or a flow diagram"),
|
||||
LayoutPurpose.ENDPOINTS => Medicine(TB("Layout for endpoints"), TB("Which slide layout should the endpoints of a study use?"), "the endpoints of a study, e.g., the primary and the secondary ones"),
|
||||
LayoutPurpose.ETHICS_APPROVAL => Medicine(TB("Layout for the ethics approval"), TB("Which slide layout should the approval of an ethics committee use?"), "the approval of an ethics committee"),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static LayoutPurposeInfo Medicine(string label, string question, string promptDescription) => new(LayoutPurposeArea.MEDICINE, label, question, promptDescription);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static LayoutPurposeInfo? DescribeScience(LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.STATE_OF_RESEARCH => Science(TB("Layout for the state of research"), TB("Which slide layout should the state of research use?"), "the state of research"),
|
||||
LayoutPurpose.RESEARCH_GAP => Science(TB("Layout for the research gap"), TB("Which slide layout should the research gap use?"), "the research gap, i.e., what the state of research leaves open"),
|
||||
LayoutPurpose.RESEARCH_QUESTION => Science(TB("Layout for one research question"), TB("Which slide layout should a single research question use, shown prominently?"), "a single research question, shown prominently"),
|
||||
LayoutPurpose.RESEARCH_QUESTIONS => Science(TB("Layout for several research questions"), TB("Which slide layout should several research questions use, shown together?"), "several research questions, shown together"),
|
||||
LayoutPurpose.DEFINITION => Science(TB("Layout for one definition"), TB("Which slide layout should the definition of a single term use?"), "the definition of a single term, shown prominently"),
|
||||
LayoutPurpose.DEFINITIONS => Science(TB("Layout for several definitions"), TB("Which slide layout should the definitions of several terms use?"), "the definitions of several terms, shown together"),
|
||||
LayoutPurpose.THEOREM => Science(TB("Layout for theorems"), TB("Which slide layout should a theorem use, e.g., in mathematics?"), "a theorem, e.g., in mathematics, with its premises and its claim"),
|
||||
LayoutPurpose.PROOF => Science(TB("Layout for proofs"), TB("Which slide layout should the proof of a theorem use?"), "the proof of a theorem, step by step"),
|
||||
LayoutPurpose.ASSUMPTION => Science(TB("Layout for one assumption"), TB("Which slide layout should a single assumption use, shown prominently?"), "a single assumption, shown prominently"),
|
||||
LayoutPurpose.ASSUMPTIONS => Science(TB("Layout for several assumptions"), TB("Which slide layout should several assumptions use, shown together?"), "several assumptions, shown together"),
|
||||
LayoutPurpose.DELIMITATION => Science(TB("Layout for one delimitation"), TB("Which slide layout should a single delimitation use, i.e., what the work leaves out?"), "a single delimitation, i.e., what the work deliberately leaves out, shown prominently"),
|
||||
LayoutPurpose.DELIMITATIONS => Science(TB("Layout for several delimitations"), TB("Which slide layout should several delimitations use, shown together?"), "several delimitations, i.e., what the work deliberately leaves out, shown together"),
|
||||
LayoutPurpose.THESIS => Science(TB("Layout for one thesis"), TB("Which slide layout should a single thesis or central claim use, shown prominently?"), "a single thesis or central claim, which the work argues for, shown prominently"),
|
||||
LayoutPurpose.THESES => Science(TB("Layout for several theses"), TB("Which slide layout should several theses or central claims use, shown together?"), "several theses or central claims, which the work argues for, shown together"),
|
||||
LayoutPurpose.HYPOTHESIS => Science(TB("Layout for one hypothesis"), TB("Which slide layout should a single hypothesis use, shown prominently?"), "a single hypothesis, which the work tests, shown prominently"),
|
||||
LayoutPurpose.HYPOTHESES => Science(TB("Layout for several hypotheses"), TB("Which slide layout should several hypotheses use, shown together?"), "several hypotheses, which the work tests, shown together"),
|
||||
LayoutPurpose.ARGUMENT_CHAIN => Science(TB("Layout for chains of arguments"), TB("Which slide layout should a chain of arguments use?"), "a chain of arguments"),
|
||||
LayoutPurpose.COUNTERARGUMENT => Science(TB("Layout for one counterargument"), TB("Which slide layout should a single counterargument use, shown prominently?"), "a single counterargument, shown prominently"),
|
||||
LayoutPurpose.COUNTERARGUMENTS => Science(TB("Layout for several counterarguments"), TB("Which slide layout should several counterarguments use, shown together?"), "several counterarguments, shown together"),
|
||||
LayoutPurpose.METHODOLOGY => Science(TB("Layout for the methodology"), TB("Which slide layout should the methodology use?"), "the methodology"),
|
||||
LayoutPurpose.STUDY_DESIGN => Science(TB("Layout for the study design"), TB("Which slide layout should the design of a study use?"), "the design of a study"),
|
||||
LayoutPurpose.EXPERIMENTAL_SETUP => Science(TB("Layout for the experimental setup"), TB("Which slide layout should the experimental setup use?"), "the experimental setup"),
|
||||
LayoutPurpose.DATA => Science(TB("Layout for data"), TB("Which slide layout should a description of the data use, e.g., of a dataset?"), "a description of the data, e.g., of a dataset"),
|
||||
LayoutPurpose.RESULT => Science(TB("Layout for one result"), TB("Which slide layout should a single result use, shown prominently?"), "a single result, shown prominently"),
|
||||
LayoutPurpose.RESULTS => Science(TB("Layout for several results"), TB("Which slide layout should several results use, shown together?"), "several results, shown together"),
|
||||
LayoutPurpose.INTERPRETATION => Science(TB("Layout for interpretations"), TB("Which slide layout should an interpretation of the results use?"), "an interpretation of the results"),
|
||||
LayoutPurpose.LIMITATIONS => Science(TB("Layout for limitations"), TB("Which slide layout should the limitations of the work use?"), "the limitations of the work"),
|
||||
LayoutPurpose.CONTRIBUTION => Science(TB("Layout for the contribution"), TB("Which slide layout should the contribution of the work use, i.e., what is new?"), "the contribution of the work, i.e., what is new about it"),
|
||||
LayoutPurpose.FUNDING => Science(TB("Layout for funding notes"), TB("Which slide layout should a note on the funding of the work use?"), "a note on who funded the work"),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static LayoutPurposeInfo Science(string label, string question, string promptDescription) => new(LayoutPurposeArea.SCIENCE, label, question, promptDescription);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static LayoutPurposeInfo? DescribeTeaching(LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.INTRODUCTION_ROUND => Teaching(TB("Layout for introduction rounds"), TB("Which slide layout should a round use in which the participants introduce themselves?"), "a round in which the participants introduce themselves"),
|
||||
LayoutPurpose.EXPECTATIONS => Teaching(TB("Layout for expectations"), TB("Which slide layout should collecting the expectations of the participants use?"), "collecting the expectations of the participants"),
|
||||
LayoutPurpose.GROUND_RULES => Teaching(TB("Layout for ground rules"), TB("Which slide layout should the ground rules for working together use?"), "the ground rules for working together"),
|
||||
LayoutPurpose.ORGANIZATIONAL_NOTES => Teaching(TB("Layout for organizational notes"), TB("Which slide layout should organizational notes use, e.g., on rooms or Wi-Fi?"), "organizational notes, e.g., on rooms or Wi-Fi"),
|
||||
LayoutPurpose.LEARNING_OBJECTIVE => Teaching(TB("Layout for one learning objective"), TB("Which slide layout should a single learning objective use, shown prominently?"), "a single learning objective, shown prominently"),
|
||||
LayoutPurpose.LEARNING_OBJECTIVES => Teaching(TB("Layout for several learning objectives"), TB("Which slide layout should several learning objectives use, shown together?"), "several learning objectives, shown together"),
|
||||
LayoutPurpose.EXAMPLE => Teaching(TB("Layout for one example"), TB("Which slide layout should a single example or case study use?"), "a single example or case study"),
|
||||
LayoutPurpose.EXAMPLES => Teaching(TB("Layout for several examples"), TB("Which slide layout should several examples use, shown together?"), "several examples, shown together"),
|
||||
LayoutPurpose.STEP_BY_STEP => Teaching(TB("Layout for step-by-step explanations"), TB("Which slide layout should a step-by-step explanation use?"), "a step-by-step explanation"),
|
||||
LayoutPurpose.EXERCISE => Teaching(TB("Layout for exercises"), TB("Which slide layout should an exercise use, with its task, time frame, and expected result?"), "an exercise with its task, its time frame, and the expected result"),
|
||||
LayoutPurpose.EXERCISE_SOLUTION => Teaching(TB("Layout for solutions of exercises"), TB("Which slide layout should the solution of an exercise use?"), "the solution of an exercise"),
|
||||
LayoutPurpose.GROUP_WORK => Teaching(TB("Layout for group work"), TB("Which slide layout should group work use, e.g., the groups and their tasks?"), "group work, e.g., the groups and their tasks"),
|
||||
LayoutPurpose.POLL => Teaching(TB("Layout for polls"), TB("Which slide layout should a poll, a vote, or a knowledge check use?"), "a poll, a vote, or a knowledge check"),
|
||||
LayoutPurpose.DISCUSSION_PROMPT => Teaching(TB("Layout for discussion prompts"), TB("Which slide layout should a prompt for a discussion use?"), "a prompt which starts a discussion"),
|
||||
LayoutPurpose.REFLECTION => Teaching(TB("Layout for reflection"), TB("Which slide layout should reflecting on and recording the results use?"), "reflecting on and recording the results"),
|
||||
LayoutPurpose.BREAK => Teaching(TB("Layout for breaks"), TB("Which slide layout should a break use?"), "a break, only where the schedule of the content has one"),
|
||||
LayoutPurpose.DAY_CHANGE => Teaching(TB("Layout for the change of days"), TB("Which slide layout should the end of a day or the start of the next use in a workshop of several days?"), "the end of one day or the start of the next in a workshop of several days"),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static LayoutPurposeInfo Teaching(string label, string question, string promptDescription) => new(LayoutPurposeArea.TEACHING, label, question, promptDescription);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What the user and the model read about the kinds of slides the user may choose layouts for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each area of kinds of slides describes its members in a file of its own, one line per kind, so
|
||||
/// that everything about a kind stays in one place.
|
||||
/// </remarks>
|
||||
internal static partial class LayoutPurposeExtensions
|
||||
{
|
||||
private static string TB(string fallbackEN) => PluginSystem.I18N.I.T(fallbackEN, typeof(LayoutPurposeExtensions).Namespace, nameof(LayoutPurposeExtensions));
|
||||
|
||||
/// <summary>
|
||||
/// What the forms offer for a kind of slide the user leaves to the LLM.
|
||||
/// </summary>
|
||||
public static string NoPreference => TB("No preference (the LLM decides)");
|
||||
|
||||
/// <summary>
|
||||
/// The area the forms list a kind of slide in.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
public static LayoutPurposeArea Area(this LayoutPurpose purpose) => Describe(purpose).Area;
|
||||
|
||||
/// <summary>
|
||||
/// The short name of the choice, e.g., "Layout for chapters".
|
||||
/// </summary>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
public static string Label(this LayoutPurpose purpose) => Describe(purpose).Label;
|
||||
|
||||
/// <summary>
|
||||
/// The question the choice answers, which says which slides it applies to.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
public static string Question(this LayoutPurpose purpose) => Describe(purpose).Question;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the slides of this kind for the model, so that it knows which of its slides a choice applies to.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
/// <returns>The description, e.g., "timelines, e.g., the course of a project".</returns>
|
||||
public static string PromptDescription(this LayoutPurpose purpose) => Describe(purpose).PromptDescription;
|
||||
|
||||
/// <summary>
|
||||
/// Whether choosing a layout for this kind of slide also puts such a slide into the presentation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For most kinds, a choice says only which layout such a slide uses, and whether the
|
||||
/// presentation has one follows from its content. The slides which frame a presentation are
|
||||
/// different: whoever chooses a layout for the table of contents or the imprint expects them.
|
||||
/// </remarks>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
public static bool ChoiceAddsSlide(this LayoutPurpose purpose) => purpose.PromptPlacement() is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Where a slide goes which a choice puts into the presentation, and what it holds, for the model.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
/// <returns>The rule, or null when a choice adds no slide of this kind.</returns>
|
||||
public static string? PromptPlacement(this LayoutPurpose purpose) => purpose switch
|
||||
{
|
||||
LayoutPurpose.TABLE_OF_CONTENTS => "right after the title slide",
|
||||
LayoutPurpose.Q_AND_A => "near the end, before the closing slide; where the occasion suits it, e.g., in a lecture or a workshop, also at the end of chapters, then naming the topic of the chapter",
|
||||
LayoutPurpose.ACKNOWLEDGEMENTS => "near the end; where thanks concern a single chapter, also at the end of that chapter",
|
||||
LayoutPurpose.REFERENCES => "near the end; it lists only the sources the content names, or else the attached documents, and never a source of your own",
|
||||
LayoutPurpose.CLOSING => "at the end, before an appendix and the imprint",
|
||||
LayoutPurpose.IMPRINT => "as the very last slide; it holds only what the request, the content, or the profile state, e.g., the title, the authors, the organization, and today's date, and it leaves a slot empty rather than inventing anything",
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The help below the choice: its question, and whether a choice adds such a slide.
|
||||
/// </summary>
|
||||
/// <param name="purpose">The kind of slide.</param>
|
||||
public static string Help(this LayoutPurpose purpose) => purpose.ChoiceAddsSlide()
|
||||
? $"{purpose.Question()} {TB("Choosing a layout also adds such a slide to the presentation.")}"
|
||||
: purpose.Question();
|
||||
|
||||
private static LayoutPurposeInfo Describe(LayoutPurpose purpose) =>
|
||||
DescribeFraming(purpose)
|
||||
?? DescribeGeneral(purpose)
|
||||
?? DescribeTeaching(purpose)
|
||||
?? DescribeBusiness(purpose)
|
||||
?? DescribeScience(purpose)
|
||||
?? DescribeMedicine(purpose)
|
||||
?? DescribeLaw(purpose)
|
||||
?? throw new ArgumentOutOfRangeException(nameof(purpose), purpose, "There is no description for this kind of slide.");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Everything known about one kind of slide.
|
||||
/// </summary>
|
||||
/// <param name="Area">The area the forms list the kind of slide in.</param>
|
||||
/// <param name="Label">The short name of the choice, e.g., "Layout for chapters", for the user.</param>
|
||||
/// <param name="Question">The question the choice answers, which says which slides it applies to, for the user.</param>
|
||||
/// <param name="PromptDescription">What the slides of this kind are, in English, for the model.</param>
|
||||
internal readonly record struct LayoutPurposeInfo(LayoutPurposeArea Area, string Label, string Question, string PromptDescription);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// One variant of a layout family, e.g., the green version of a content layout.
|
||||
/// </summary>
|
||||
/// <param name="Label">The part of the layout name which tells the variants apart, e.g., "B"; null for a family of a single layout.</param>
|
||||
/// <param name="Layout">The layout.</param>
|
||||
internal sealed record LayoutVariant(string? Label, TemplateLayout Layout);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// A picture of a picture library, which the LLM may choose by its name.
|
||||
/// </summary>
|
||||
/// <param name="FilePath">The full path of the file.</param>
|
||||
/// <param name="Name">The path below the library folder, with forward slashes, e.g., "icons/rocket.png"; this is what the LLM reads.</param>
|
||||
/// <param name="IsIcon">Whether the picture is an icon, because its name or the name of one of its folders starts with "icon".</param>
|
||||
internal sealed record LibraryPicture(string FilePath, string Name, bool IsIcon);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// The pictures of a picture library which a slide deck may use, by their numbers, which follow those of the pictures for the slides.
|
||||
/// </summary>
|
||||
/// <param name="Count">How many pictures of the library the LLM may choose from.</param>
|
||||
/// <param name="IconNumbers">The numbers of those which are icons.</param>
|
||||
internal sealed record LibraryPictures(int Count, IReadOnlySet<int> IconNumbers)
|
||||
{
|
||||
/// <summary>
|
||||
/// No library at all.
|
||||
/// </summary>
|
||||
public static readonly LibraryPictures NONE = new(0, new HashSet<int>());
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a folder of pictures and icons, which the LLM may choose from by their names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The LLM reads only the names, never the pictures: a library holds hundreds or thousands of them.
|
||||
/// Only PNG and JPEG pictures count, since the writer can place nothing else; many icons come as SVG,
|
||||
/// which is why the other files are counted, so that the user learns about them. Hidden files and
|
||||
/// folders are left out, as everywhere in AI Studio.
|
||||
/// </remarks>
|
||||
internal static class PictureLibrary
|
||||
{
|
||||
/// <summary>
|
||||
/// The most pictures of a library the LLM reads about in one request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every name costs tokens in every request, and 5,000 names come to about 50,000 tokens. A
|
||||
/// larger library is sampled at random, anew for every request, so that every picture has the
|
||||
/// same chance rather than those which the file system happens to list first.
|
||||
/// </remarks>
|
||||
public const int MAX_LIBRARY_PICTURES = 5_000;
|
||||
|
||||
/// <summary>
|
||||
/// What the name of an icon, or of one of the folders it is in, starts with, in any case.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The library folder counts as well, so that a folder of icons, such as "Icons", makes all of
|
||||
/// its pictures icons. The folders above the library do not count: they say where the library
|
||||
/// lies, not what it holds.
|
||||
/// </remarks>
|
||||
public const string ICON_PREFIX = "icon";
|
||||
|
||||
private static readonly HashSet<string> PICTURE_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg" };
|
||||
|
||||
/// <summary>
|
||||
/// Reads a library on a thread of its own, since a folder on a network drive may take a while.
|
||||
/// </summary>
|
||||
/// <param name="folder">The library folder.</param>
|
||||
/// <param name="random">Where the sample of a large library comes from.</param>
|
||||
/// <param name="maxPictures">The most pictures the sample holds, e.g., MAX_LIBRARY_PICTURES, or 0 to count the pictures only.</param>
|
||||
/// <param name="reportInaccessibleFolder">Told about every folder which could not be read.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>What the library offers.</returns>
|
||||
public static Task<PictureLibraryContent> ReadAsync(string folder, Random random, int maxPictures, Action<string, Exception>? reportInaccessibleFolder, CancellationToken token) => Task.Run(() => Read(folder, random, maxPictures, reportInaccessibleFolder, token), token);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a library.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sample is drawn while the folder is read, by reservoir sampling: the first pictures fill
|
||||
/// the sample, and each later one replaces a random member of it with the chance that keeps every
|
||||
/// picture equally likely. So the list of all pictures is never held in memory.
|
||||
/// </remarks>
|
||||
/// <param name="folder">The library folder.</param>
|
||||
/// <param name="random">Where the sample of a large library comes from.</param>
|
||||
/// <param name="maxPictures">The most pictures the sample holds.</param>
|
||||
/// <param name="reportInaccessibleFolder">Told about every folder which could not be read.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>What the library offers.</returns>
|
||||
public static PictureLibraryContent Read(string folder, Random random, int maxPictures, Action<string, Exception>? reportInaccessibleFolder, CancellationToken token)
|
||||
{
|
||||
var sample = new List<LibraryPicture>();
|
||||
var isIconFolder = StartsWithIconPrefix(Path.GetFileName(Path.TrimEndingDirectorySeparator(folder)));
|
||||
var pictureCount = 0;
|
||||
var iconCount = 0;
|
||||
var skippedFileCount = 0;
|
||||
foreach (var path in DirectoryFileTraversal.EnumerateFiles(folder, true, false, reportInaccessibleDirectory: reportInaccessibleFolder))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!PICTURE_EXTENSIONS.Contains(Path.GetExtension(path)))
|
||||
{
|
||||
skippedFileCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = Path.GetRelativePath(folder, path).Replace(Path.DirectorySeparatorChar, '/');
|
||||
var picture = new LibraryPicture(path, name, isIconFolder || IsIcon(name));
|
||||
pictureCount++;
|
||||
if (picture.IsIcon)
|
||||
iconCount++;
|
||||
|
||||
if (sample.Count < maxPictures)
|
||||
sample.Add(picture);
|
||||
else if (random.NextInt64(pictureCount) is var slot && slot < maxPictures)
|
||||
sample[(int)slot] = picture;
|
||||
}
|
||||
|
||||
sample.Sort((first, second) => StringComparer.OrdinalIgnoreCase.Compare(first.Name, second.Name));
|
||||
return new PictureLibraryContent(sample, pictureCount, iconCount, skippedFileCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a picture is an icon by its own path: its name or the name of one of its folders below the library starts with "icon".
|
||||
/// </summary>
|
||||
/// <param name="name">The path of the picture below the library folder, with forward slashes.</param>
|
||||
private static bool IsIcon(string name) => name.Split('/').Any(StartsWithIconPrefix);
|
||||
|
||||
private static bool StartsWithIconPrefix(string name) => name.StartsWith(ICON_PREFIX, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What a picture library offers for one request.
|
||||
/// </summary>
|
||||
/// <param name="Pictures">The pictures the LLM may choose from, sorted by name: all of them, or a random sample of a large library.</param>
|
||||
/// <param name="PictureCount">How many pictures the folder holds in all.</param>
|
||||
/// <param name="IconCount">How many of them are icons.</param>
|
||||
/// <param name="SkippedFileCount">How many files the folder holds which are neither PNG nor JPEG, e.g., SVG icons.</param>
|
||||
internal sealed record PictureLibraryContent(IReadOnlyList<LibraryPicture> Pictures, int PictureCount, int IconCount, int SkippedFileCount)
|
||||
{
|
||||
/// <summary>
|
||||
/// No library at all.
|
||||
/// </summary>
|
||||
public static readonly PictureLibraryContent NONE = new([], 0, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the folder holds more pictures than the LLM reads about, so that it gets a random sample.
|
||||
/// </summary>
|
||||
public bool IsSampled => this.PictureCount > this.Pictures.Count;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What a placeholder of a slide layout is for, as far as filling it is concerned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Templates rarely say what a layout is for: most of them leave the layout type at "custom". So the
|
||||
/// role is read from the placeholder itself, its type and its size, and a text placeholder too small
|
||||
/// to hold more than a line becomes a label rather than a text.
|
||||
/// </remarks>
|
||||
internal enum PlaceholderRole
|
||||
{
|
||||
/// <summary>
|
||||
/// A placeholder we cannot fill, e.g., a chart or a media placeholder.
|
||||
/// </summary>
|
||||
UNSUPPORTED = 0,
|
||||
|
||||
TITLE,
|
||||
SUBTITLE,
|
||||
|
||||
/// <summary>
|
||||
/// A text area large enough for paragraphs or bullet points.
|
||||
/// </summary>
|
||||
TEXT,
|
||||
|
||||
/// <summary>
|
||||
/// A small text, e.g., a heading above a text area, the source of a picture, or a chapter number.
|
||||
/// </summary>
|
||||
LABEL,
|
||||
|
||||
PICTURE,
|
||||
TABLE,
|
||||
|
||||
FOOTER,
|
||||
SLIDE_NUMBER,
|
||||
DATE,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static class PlaceholderRoleExtensions
|
||||
{
|
||||
private static readonly PlaceholderRole[] SLOT_ROLES = [PlaceholderRole.TITLE, PlaceholderRole.SUBTITLE, PlaceholderRole.TEXT, PlaceholderRole.LABEL, PlaceholderRole.PICTURE, PlaceholderRole.TABLE];
|
||||
|
||||
/// <summary>
|
||||
/// Reads the name of a slot as the catalog gives it to the model, e.g., "text 2" or "title".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The catalog leaves out the number where a layout has a single slot of a role, as it does for
|
||||
/// the title; the first slot is meant then. Case and spaces do not matter.
|
||||
/// </remarks>
|
||||
/// <param name="name">The name.</param>
|
||||
/// <param name="role">The role of the slot.</param>
|
||||
/// <param name="ordinal">The number of the slot among the slots of its role, starting at 1.</param>
|
||||
/// <returns>Whether the name is the name of a slot.</returns>
|
||||
public static bool TryParseSlotName(string name, out PlaceholderRole role, out int ordinal)
|
||||
{
|
||||
role = PlaceholderRole.UNSUPPORTED;
|
||||
ordinal = 0;
|
||||
var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (words.Length is 0 or > 2)
|
||||
return false;
|
||||
|
||||
foreach (var candidate in SLOT_ROLES)
|
||||
{
|
||||
if (!string.Equals(words[0], candidate.CatalogName(), StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var number = 1;
|
||||
if (words.Length == 2 && (!int.TryParse(words[1], NumberStyles.None, CultureInfo.InvariantCulture, out number) || number < 1))
|
||||
return false;
|
||||
|
||||
role = candidate;
|
||||
ordinal = number;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether content of the presentation goes into a placeholder of this role.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Footer, slide number and date are filled as well, but by the presentation as a whole rather
|
||||
/// than by a single slide, so they are no slots of a layout.
|
||||
/// </remarks>
|
||||
public static bool IsSlot(this PlaceholderRole role) => role switch
|
||||
{
|
||||
PlaceholderRole.TITLE => true,
|
||||
PlaceholderRole.SUBTITLE => true,
|
||||
PlaceholderRole.TEXT => true,
|
||||
PlaceholderRole.LABEL => true,
|
||||
PlaceholderRole.PICTURE => true,
|
||||
PlaceholderRole.TABLE => true,
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether a placeholder of this role carries the content of a slide, rather than describing it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A layout offering nothing but a title and some labels has no room for content. Such layouts are
|
||||
/// samples or blank pages, and they are left out of the catalog.
|
||||
/// </remarks>
|
||||
public static bool CarriesContent(this PlaceholderRole role) => role switch
|
||||
{
|
||||
PlaceholderRole.SUBTITLE => true,
|
||||
PlaceholderRole.TEXT => true,
|
||||
PlaceholderRole.PICTURE => true,
|
||||
PlaceholderRole.TABLE => true,
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// How the role is called in the catalog the model reads.
|
||||
/// </summary>
|
||||
public static string CatalogName(this PlaceholderRole role) => role switch
|
||||
{
|
||||
PlaceholderRole.TITLE => "title",
|
||||
PlaceholderRole.SUBTITLE => "subtitle",
|
||||
PlaceholderRole.TEXT => "text",
|
||||
PlaceholderRole.LABEL => "label",
|
||||
PlaceholderRole.PICTURE => "picture",
|
||||
PlaceholderRole.TABLE => "table",
|
||||
|
||||
_ => "unsupported",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a presentation file from a template and a slide deck.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The presentation comes about in a copy of the template in the temporary folder, and only the
|
||||
/// complete presentation is streamed into the file the user chose. Nothing is created next to that
|
||||
/// file: inside the Flatpak, the save dialog grants access to exactly the chosen file, and to nothing
|
||||
/// next to it. A failure before the presentation is complete therefore leaves the target untouched,
|
||||
/// and neither the template nor the presentation has to be held in memory.
|
||||
/// </remarks>
|
||||
internal static class PresentationFile
|
||||
{
|
||||
/// <summary>
|
||||
/// The folder below the temporary folder of the system, where the presentations come about.
|
||||
/// </summary>
|
||||
private const string TEMPORARY_DIRECTORY = "mindwork-ai-studio-presentations";
|
||||
|
||||
/// <summary>
|
||||
/// Writes the slide deck into a copy of the template, and the result into the target.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The template is read once more rather than reused: it may have changed since the slides were
|
||||
/// planned, and the writer has to see the layouts of the very file it edits.
|
||||
/// </remarks>
|
||||
/// <param name="templatePath">The path of the template, a .pptx or a .potx.</param>
|
||||
/// <param name="targetPath">The path of the file the user chose. It may exist; the save dialog asked about overwriting it.</param>
|
||||
/// <param name="deck">The slide deck, as checked against the template when it was planned.</param>
|
||||
/// <param name="basis">What the deck was planned on, as PresentationTemplate.DescribeBasisOf told it then.</param>
|
||||
/// <param name="options">What the user chose for the presentation as a whole.</param>
|
||||
/// <param name="pictures">Where the pictures the deck refers to come from.</param>
|
||||
/// <param name="logger">Where to report a temporary file which cannot be removed.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>Whether the presentation was written, and which pictures it lacks.</returns>
|
||||
public static async Task<PresentationFileResult> CreateAsync(string templatePath, string targetPath, SlideDeckSpec deck, IReadOnlyDictionary<string, string> basis, PresentationWriteOptions options, IPictureSource pictures, ILogger logger, CancellationToken token)
|
||||
{
|
||||
var temporaryDirectory = Path.Join(Path.GetTempPath(), TEMPORARY_DIRECTORY);
|
||||
Directory.CreateDirectory(temporaryDirectory);
|
||||
|
||||
var temporaryPath = Path.Join(temporaryDirectory, $"{Guid.NewGuid():N}.pptx");
|
||||
try
|
||||
{
|
||||
await using (var templateFile = new FileStream(templatePath, FileMode.Open, FileAccess.Read, PresentationTemplate.FILE_SHARE))
|
||||
await using (var copy = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
await templateFile.CopyToAsync(copy, token);
|
||||
|
||||
PresentationWriteResult written;
|
||||
await using (var presentationFile = new FileStream(temporaryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
var currentTemplate = PresentationTemplate.Read(presentationFile);
|
||||
presentationFile.Position = 0;
|
||||
if (!SlideDeckSpecValidator.StillFits(deck, currentTemplate, basis))
|
||||
return PresentationFileResult.TEMPLATE_CHANGED;
|
||||
|
||||
written = await PresentationWriter.WriteAsync(presentationFile, currentTemplate, deck, options, pictures, token);
|
||||
}
|
||||
|
||||
await CopyIntoTargetAsync(temporaryPath, targetPath, logger, token);
|
||||
return new PresentationFileResult(true, written.SkippedPictures);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteQuietly(temporaryPath, logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams the complete presentation into the file the user chose.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The file is written over rather than removed and created anew, which the Flatpak would not
|
||||
/// allow. Once writing has begun, a failure removes the file: a half-written presentation is
|
||||
/// worse than none. A file which cannot even be opened, e.g., because PowerPoint shows it, stays
|
||||
/// as it is.
|
||||
/// </remarks>
|
||||
private static async Task CopyIntoTargetAsync(string presentationPath, string targetPath, ILogger logger, CancellationToken token)
|
||||
{
|
||||
await using var presentation = new FileStream(presentationPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var target = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
try
|
||||
{
|
||||
await using (target)
|
||||
await presentation.CopyToAsync(target, token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
DeleteQuietly(targetPath, logger);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a file which is of no use anymore, and only reports when that fails.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A copy of the template left behind would pile up in the temporary folder with every attempt.
|
||||
/// </remarks>
|
||||
private static void DeleteQuietly(string path, ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogWarning(e, "The file '{FilePath}' cannot be deleted.", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What became of creating a presentation file.
|
||||
/// </summary>
|
||||
/// <param name="IsWritten">Whether the presentation was written. It is not when the template changed since the slides were planned, and they no longer fit it; the target stays untouched then.</param>
|
||||
/// <param name="SkippedPictures">The numbers of the pictures which could not be placed: unreadable, or in a format PowerPoint does not show. Their slots stay empty.</param>
|
||||
internal sealed record PresentationFileResult(bool IsWritten, IReadOnlyList<int> SkippedPictures)
|
||||
{
|
||||
/// <summary>
|
||||
/// The template changed since the slides were planned, and they no longer fit it. Nothing was written.
|
||||
/// </summary>
|
||||
public static readonly PresentationFileResult TEMPLATE_CHANGED = new(false, []);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal sealed partial class PresentationTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the catalog the Slide Assistant works with when no template was chosen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without a template, the Slide Assistant writes an outline only. It still asks the model for the
|
||||
/// same JSON, against these few layouts of a plain 16:9 presentation, so that one prompt and one
|
||||
/// check serve both cases. Places and font sizes are those of a new presentation in PowerPoint.
|
||||
/// </remarks>
|
||||
/// <returns>The catalog.</returns>
|
||||
public static PresentationTemplate CreateDefault()
|
||||
{
|
||||
var title = new SlotBounds(838_200, 365_125, 10_515_600, 1_325_563);
|
||||
var body = new SlotBounds(838_200, 1_825_625, 10_515_600, 4_351_338);
|
||||
var left = new SlotBounds(838_200, 1_825_625, 5_181_600, 4_351_338);
|
||||
var right = new SlotBounds(6_172_200, 1_825_625, 5_181_600, 4_351_338);
|
||||
|
||||
TemplateLayout[] layouts =
|
||||
[
|
||||
DefaultLayout(1, "Title Slide", "title",
|
||||
(PlaceholderRole.TITLE, new SlotBounds(1_524_000, 1_122_363, 9_144_000, 2_387_600), 6000),
|
||||
(PlaceholderRole.SUBTITLE, new SlotBounds(1_524_000, 3_602_038, 9_144_000, 1_655_762), 2400)),
|
||||
|
||||
DefaultLayout(2, "Section Header", "secHead",
|
||||
(PlaceholderRole.TITLE, new SlotBounds(831_850, 1_709_738, 10_515_600, 2_852_737), 6000),
|
||||
(PlaceholderRole.SUBTITLE, new SlotBounds(831_850, 4_589_463, 10_515_600, 1_500_187), 2400)),
|
||||
|
||||
DefaultLayout(3, "Title and Content", "obj",
|
||||
(PlaceholderRole.TITLE, title, 4400),
|
||||
(PlaceholderRole.TEXT, body, 2800)),
|
||||
|
||||
DefaultLayout(4, "Two Content", "twoObj",
|
||||
(PlaceholderRole.TITLE, title, 4400),
|
||||
(PlaceholderRole.TEXT, left, 2800),
|
||||
(PlaceholderRole.TEXT, right, 2800)),
|
||||
|
||||
DefaultLayout(5, "Content with Picture", "cust",
|
||||
(PlaceholderRole.TITLE, title, 4400),
|
||||
(PlaceholderRole.TEXT, left, 2800),
|
||||
(PlaceholderRole.PICTURE, right, DEFAULT_FONT_SIZE)),
|
||||
|
||||
DefaultLayout(6, "Table", "tbl",
|
||||
(PlaceholderRole.TITLE, title, 4400),
|
||||
(PlaceholderRole.TABLE, body, DEFAULT_FONT_SIZE)),
|
||||
];
|
||||
|
||||
var families = layouts.Select((layout, index) => new LayoutFamily($"L{index + 1}", layout.Name, [new LayoutVariant(null, layout)])).ToList();
|
||||
return new PresentationTemplate(DEFAULT_SLIDE_WIDTH, DEFAULT_SLIDE_HEIGHT, families, [], []);
|
||||
}
|
||||
|
||||
private static TemplateLayout DefaultLayout(int number, string name, string layoutType, params (PlaceholderRole Role, SlotBounds Bounds, int FontSize)[] slots)
|
||||
{
|
||||
var templateSlots = slots.Select(slot => new TemplateSlot(slot.Role, 0, slot.Bounds, DefaultPlaceholderTypeOf(slot.Role), null, CapacityOf(slot.Role, slot.Bounds, slot.FontSize, InsetsOf(null))));
|
||||
return new TemplateLayout(name, new Uri($"/ppt/slideLayouts/slideLayout{number}.xml", UriKind.Relative), layoutType, InReadingOrder(templateSlots, DEFAULT_SLIDE_HEIGHT));
|
||||
}
|
||||
|
||||
private static string DefaultPlaceholderTypeOf(PlaceholderRole role) => role switch
|
||||
{
|
||||
PlaceholderRole.TITLE => "title",
|
||||
PlaceholderRole.SUBTITLE => "subTitle",
|
||||
PlaceholderRole.PICTURE => "pic",
|
||||
PlaceholderRole.TABLE => "tbl",
|
||||
|
||||
_ => "body",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal sealed partial class PresentationTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Two layouts offer the same slots when every edge of every slot differs by no more than this
|
||||
/// share of the slide.
|
||||
/// </summary>
|
||||
private const double SLOT_TOLERANCE = 0.01;
|
||||
|
||||
/// <summary>
|
||||
/// Groups the layouts with room for content into families of variants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static GroupedLayouts GroupIntoFamilies(IReadOnlyList<TemplateLayout> 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<LayoutFamily>(groups.Count);
|
||||
var variantLabels = new List<string>();
|
||||
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<FamilyCandidate> Group(IReadOnlyList<TemplateLayout> layouts, VariantWord variantWord, long slideWidth, long slideHeight)
|
||||
{
|
||||
var candidates = new List<FamilyCandidate>();
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many layouts a grouping puts into families of more than one variant.
|
||||
/// </summary>
|
||||
private static int CountVariants(IEnumerable<FamilyCandidate> candidates) => candidates.Where(candidate => candidate.Variants.Count > 1).Sum(candidate => candidate.Variants.Count);
|
||||
|
||||
/// <summary>
|
||||
/// Which word of a layout name tells the variants apart.
|
||||
/// </summary>
|
||||
private enum VariantWord
|
||||
{
|
||||
FIRST,
|
||||
LAST,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A family while it is being gathered.
|
||||
/// </summary>
|
||||
/// <param name="Stem">The name without the word which tells the variants apart.</param>
|
||||
/// <param name="Variants">The variants found so far.</param>
|
||||
private sealed record FamilyCandidate(string Stem, List<LayoutVariant> Variants);
|
||||
|
||||
/// <summary>
|
||||
/// The layouts with room for content, grouped into families of variants.
|
||||
/// </summary>
|
||||
/// <param name="Families">The families, in the order of the template.</param>
|
||||
/// <param name="VariantLabels">The labels which tell the variants apart; empty when the template carries every layout once.</param>
|
||||
private sealed record GroupedLayouts(IReadOnlyList<LayoutFamily> Families, IReadOnlyList<string> VariantLabels);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal sealed partial class PresentationTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// The slide size PowerPoint assumes when a file states none: 16:9, 13.333 by 7.5 inches.
|
||||
/// </summary>
|
||||
private const long DEFAULT_SLIDE_WIDTH = 12_192_000;
|
||||
|
||||
private const long DEFAULT_SLIDE_HEIGHT = 6_858_000;
|
||||
|
||||
/// <summary>
|
||||
/// 18 pt, in the hundredths of a point the file uses. PowerPoint's own default.
|
||||
/// </summary>
|
||||
private const int DEFAULT_FONT_SIZE = 1800;
|
||||
|
||||
private const double EMU_PER_POINT = 12_700;
|
||||
|
||||
/// <summary>
|
||||
/// The space a text box keeps free at its left and right edges unless it says otherwise: 0.1 inch.
|
||||
/// </summary>
|
||||
private const long DEFAULT_HORIZONTAL_INSET = 91_440;
|
||||
|
||||
/// <summary>
|
||||
/// The space a text box keeps free at its top and bottom edges unless it says otherwise: 0.05 inch.
|
||||
/// </summary>
|
||||
private const long DEFAULT_VERTICAL_INSET = 45_720;
|
||||
|
||||
/// <summary>
|
||||
/// A line of text takes about 1.2 times the font size.
|
||||
/// </summary>
|
||||
private const double LINE_HEIGHT_FACTOR = 1.2;
|
||||
|
||||
/// <summary>
|
||||
/// An average character takes about half the font size in width.
|
||||
/// </summary>
|
||||
private const double CHARACTER_WIDTH_FACTOR = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Text placeholders covering less than this share of the slide are labels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A heading above a text area, the source of a picture or a chapter number cover one or two
|
||||
/// percent of a slide; the smallest real text area, a quarter of a slide split into a grid,
|
||||
/// covers more than five.
|
||||
/// </remarks>
|
||||
private const double LABEL_AREA_SHARE = 0.05;
|
||||
|
||||
/// <summary>
|
||||
/// Slots whose top edges lie within one row of this many rows per slide are read as one row.
|
||||
/// </summary>
|
||||
private const int READING_ROWS = 50;
|
||||
|
||||
/// <summary>
|
||||
/// How a template file is opened for reading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Other programs may go on writing or deleting the file meanwhile. PowerPoint keeps a file open
|
||||
/// for writing as long as it shows it, and a template which is still open there has to be
|
||||
/// readable all the same.
|
||||
/// </remarks>
|
||||
public const FileShare FILE_SHARE = FileShare.ReadWrite | FileShare.Delete;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the template file at the given path, as Read does with a stream.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Throws when the file is missing, cannot be opened, or is no presentation at all; the caller
|
||||
/// decides what the user hears about it.
|
||||
/// </remarks>
|
||||
/// <param name="path">The path of the template file.</param>
|
||||
/// <returns>The template.</returns>
|
||||
public static PresentationTemplate ReadFile(string path)
|
||||
{
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FILE_SHARE);
|
||||
return Read(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the layouts of a template, and the slides it carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Works for presentations (.pptx) and templates (.potx) alike. The slides the file contains are
|
||||
/// read with what they hold, yet a presentation written later starts without them: in a template
|
||||
/// they are mostly samples or hints for the user, and only those the user chooses are kept. The
|
||||
/// Open XML SDK throws when the file is no presentation at all.
|
||||
/// </remarks>
|
||||
/// <param name="stream">The file, opened for reading.</param>
|
||||
/// <returns>The template.</returns>
|
||||
public static PresentationTemplate Read(Stream stream)
|
||||
{
|
||||
using var document = PresentationDocument.Open(stream, false);
|
||||
var presentationPart = document.PresentationPart ?? throw new InvalidDataException("The file contains no presentation.");
|
||||
var presentation = presentationPart.Presentation ?? throw new InvalidDataException("The file contains no presentation.");
|
||||
var slideWidth = presentation.SlideSize?.Cx?.Value ?? DEFAULT_SLIDE_WIDTH;
|
||||
var slideHeight = presentation.SlideSize?.Cy?.Value ?? DEFAULT_SLIDE_HEIGHT;
|
||||
var defaultFontSize = FontSizeOf(presentation.DefaultTextStyle?.Level1ParagraphProperties) ?? DEFAULT_FONT_SIZE;
|
||||
|
||||
var layouts = new List<TemplateLayout>();
|
||||
foreach (var masterId in presentation.SlideMasterIdList?.Elements<P.SlideMasterId>() ?? [])
|
||||
{
|
||||
if (masterId.RelationshipId?.Value is not { } masterRelationshipId || !presentationPart.TryGetPartById(masterRelationshipId, out var masterCandidate) || masterCandidate is not SlideMasterPart { SlideMaster: { } slideMaster } masterPart)
|
||||
continue;
|
||||
|
||||
var master = MasterOf(slideMaster, defaultFontSize);
|
||||
foreach (var layoutId in slideMaster.SlideLayoutIdList?.Elements<P.SlideLayoutId>() ?? [])
|
||||
{
|
||||
if (layoutId.RelationshipId?.Value is not { } layoutRelationshipId || !masterPart.TryGetPartById(layoutRelationshipId, out var layoutCandidate) || layoutCandidate is not SlideLayoutPart { SlideLayout: { } slideLayout } layoutPart)
|
||||
continue;
|
||||
|
||||
layouts.Add(ReadLayout(slideLayout, layoutPart.Uri, master, slideWidth, slideHeight));
|
||||
}
|
||||
}
|
||||
|
||||
var (families, variantLabels) = GroupIntoFamilies(layouts, slideWidth, slideHeight);
|
||||
var slides = ReadSlides(presentationPart, presentation, layouts, families, slideWidth, slideHeight);
|
||||
return new PresentationTemplate(slideWidth, slideHeight, families, variantLabels, slides);
|
||||
}
|
||||
|
||||
private static MasterStyles MasterOf(P.SlideMaster master, int defaultFontSize)
|
||||
{
|
||||
var placeholders = new Dictionary<string, PlaceholderInfo>(StringComparer.Ordinal);
|
||||
foreach (var placeholder in PlaceholdersOf(master.CommonSlideData))
|
||||
placeholders.TryAdd(MasterTypeOf(placeholder.Type), placeholder);
|
||||
|
||||
var titleFontSize = FontSizeOf(master.TextStyles?.TitleStyle?.Level1ParagraphProperties) ?? defaultFontSize;
|
||||
var bodyFontSize = FontSizeOf(master.TextStyles?.BodyStyle?.Level1ParagraphProperties) ?? defaultFontSize;
|
||||
return new MasterStyles(placeholders, titleFontSize, bodyFontSize, defaultFontSize);
|
||||
}
|
||||
|
||||
private static TemplateLayout ReadLayout(P.SlideLayout layout, Uri partUri, MasterStyles master, long slideWidth, long slideHeight)
|
||||
{
|
||||
var slideArea = (double)slideWidth * slideHeight;
|
||||
var slots = new List<TemplateSlot>();
|
||||
foreach (var placeholder in PlaceholdersOf(layout.CommonSlideData))
|
||||
{
|
||||
//
|
||||
// A layout often leaves out where its title goes, because the slide master already
|
||||
// says so. PowerPoint then takes the place from the master, and so do we:
|
||||
//
|
||||
master.Placeholders.TryGetValue(MasterTypeOf(placeholder.Type), out var inherited);
|
||||
if ((placeholder.Bounds ?? inherited?.Bounds) is not { } bounds)
|
||||
continue;
|
||||
|
||||
var role = RoleOf(placeholder.Type, bounds, slideArea);
|
||||
if (!role.IsSlot())
|
||||
continue;
|
||||
|
||||
//
|
||||
// A table takes its text from the table style, which starts out at the default
|
||||
// size of the presentation rather than at the size of the body text:
|
||||
//
|
||||
var fontSize = role switch
|
||||
{
|
||||
PlaceholderRole.TABLE => master.DefaultFontSize,
|
||||
PlaceholderRole.TITLE => placeholder.FontSize ?? inherited?.FontSize ?? master.TitleFontSize,
|
||||
_ => placeholder.FontSize ?? inherited?.FontSize ?? master.BodyFontSize,
|
||||
};
|
||||
|
||||
slots.Add(new TemplateSlot(role, 0, bounds, placeholder.Type, placeholder.Index, CapacityOf(role, bounds, fontSize, placeholder.Insets)));
|
||||
}
|
||||
|
||||
return new TemplateLayout(
|
||||
layout.CommonSlideData?.Name?.Value ?? string.Empty,
|
||||
partUri,
|
||||
layout.Type?.InnerText ?? string.Empty,
|
||||
InReadingOrder(slots, slideHeight));
|
||||
}
|
||||
|
||||
private static IEnumerable<PlaceholderInfo> PlaceholdersOf(P.CommonSlideData? commonSlideData)
|
||||
{
|
||||
foreach (var element in commonSlideData?.ShapeTree?.Elements() ?? [])
|
||||
{
|
||||
P.PlaceholderShape? placeholder;
|
||||
SlotBounds? bounds;
|
||||
P.TextBody? textBody = null;
|
||||
switch (element)
|
||||
{
|
||||
case P.Shape shape:
|
||||
placeholder = shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape;
|
||||
bounds = BoundsOf(shape.ShapeProperties?.Transform2D?.Offset, shape.ShapeProperties?.Transform2D?.Extents);
|
||||
textBody = shape.TextBody;
|
||||
break;
|
||||
|
||||
case P.Picture picture:
|
||||
placeholder = picture.NonVisualPictureProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape;
|
||||
bounds = BoundsOf(picture.ShapeProperties?.Transform2D?.Offset, picture.ShapeProperties?.Transform2D?.Extents);
|
||||
break;
|
||||
|
||||
case P.GraphicFrame frame:
|
||||
placeholder = frame.NonVisualGraphicFrameProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape;
|
||||
bounds = BoundsOf(frame.Transform?.Offset, frame.Transform?.Extents);
|
||||
break;
|
||||
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placeholder is null)
|
||||
continue;
|
||||
|
||||
yield return new PlaceholderInfo(
|
||||
// A placeholder without a type is an object placeholder, according to the standard:
|
||||
placeholder.Type?.InnerText ?? "obj",
|
||||
placeholder.Index?.Value,
|
||||
bounds,
|
||||
FontSizeOf(textBody?.ListStyle?.Level1ParagraphProperties),
|
||||
InsetsOf(textBody?.BodyProperties));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type of the master placeholder a layout placeholder of the given type takes its defaults from.
|
||||
/// </summary>
|
||||
private static string MasterTypeOf(string placeholderType) => placeholderType switch
|
||||
{
|
||||
"title" or "ctrTitle" => "title",
|
||||
"dt" => "dt",
|
||||
"ftr" => "ftr",
|
||||
"sldNum" => "sldNum",
|
||||
|
||||
_ => "body",
|
||||
};
|
||||
|
||||
private static PlaceholderRole RoleOf(string placeholderType, SlotBounds bounds, double slideArea) => placeholderType switch
|
||||
{
|
||||
"title" or "ctrTitle" => PlaceholderRole.TITLE,
|
||||
"subTitle" => PlaceholderRole.SUBTITLE,
|
||||
"pic" => PlaceholderRole.PICTURE,
|
||||
"tbl" => PlaceholderRole.TABLE,
|
||||
"ftr" => PlaceholderRole.FOOTER,
|
||||
"sldNum" => PlaceholderRole.SLIDE_NUMBER,
|
||||
"dt" => PlaceholderRole.DATE,
|
||||
"body" or "obj" => bounds.Area < slideArea * LABEL_AREA_SHARE ? PlaceholderRole.LABEL : PlaceholderRole.TEXT,
|
||||
|
||||
_ => PlaceholderRole.UNSUPPORTED,
|
||||
};
|
||||
|
||||
private static TextCapacity? CapacityOf(PlaceholderRole role, SlotBounds bounds, int fontSize, TextInsets insets)
|
||||
{
|
||||
if (role is PlaceholderRole.PICTURE)
|
||||
return null;
|
||||
|
||||
var fontHeight = fontSize / 100.0 * EMU_PER_POINT;
|
||||
var lineHeight = fontHeight * LINE_HEIGHT_FACTOR;
|
||||
var width = (double)bounds.Width;
|
||||
var height = (double)bounds.Height;
|
||||
if (role is PlaceholderRole.TABLE)
|
||||
{
|
||||
// A table row keeps the default cell margins above and below its text:
|
||||
lineHeight += 2 * DEFAULT_VERTICAL_INSET;
|
||||
}
|
||||
else
|
||||
{
|
||||
width -= insets.Left + insets.Right;
|
||||
height -= insets.Top + insets.Bottom;
|
||||
}
|
||||
|
||||
var lines = (int)(height / lineHeight);
|
||||
var charactersPerLine = (int)(width / (fontHeight * CHARACTER_WIDTH_FACTOR));
|
||||
return new TextCapacity(Math.Max(1, lines), Math.Max(1, charactersPerLine));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the slots into the order a reader takes them in, and numbers the slots of each role.
|
||||
/// </summary>
|
||||
private static List<TemplateSlot> InReadingOrder(IEnumerable<TemplateSlot> slots, long slideHeight)
|
||||
{
|
||||
var ordered = slots
|
||||
.OrderBy(slot => ReadingKeyOf(slot.Bounds, slideHeight))
|
||||
.ToList();
|
||||
|
||||
var counters = new Dictionary<PlaceholderRole, int>();
|
||||
for (var index = 0; index < ordered.Count; index++)
|
||||
{
|
||||
var ordinal = counters.GetValueOrDefault(ordered[index].Role) + 1;
|
||||
counters[ordered[index].Role] = ordinal;
|
||||
ordered[index] = ordered[index] with { Ordinal = ordinal };
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a reader takes something in: by its row first, then from left to right.
|
||||
/// </summary>
|
||||
private static ReadingPosition ReadingKeyOf(SlotBounds bounds, long slideHeight) => new((int)Math.Round(bounds.Y * (double)READING_ROWS / slideHeight), bounds.X);
|
||||
|
||||
private static SlotBounds? BoundsOf(A.Offset? offset, A.Extents? extents)
|
||||
{
|
||||
if (offset?.X?.Value is not { } x || offset.Y?.Value is not { } y || extents?.Cx?.Value is not { } width || extents.Cy?.Value is not { } height)
|
||||
return null;
|
||||
|
||||
return new SlotBounds(x, y, width, height);
|
||||
}
|
||||
|
||||
private static int? FontSizeOf(A.Level1ParagraphProperties? properties) => properties?.GetFirstChild<A.DefaultRunProperties>()?.FontSize?.Value;
|
||||
|
||||
private static TextInsets InsetsOf(A.BodyProperties? bodyProperties) => new(
|
||||
bodyProperties?.LeftInset?.Value ?? DEFAULT_HORIZONTAL_INSET,
|
||||
bodyProperties?.TopInset?.Value ?? DEFAULT_VERTICAL_INSET,
|
||||
bodyProperties?.RightInset?.Value ?? DEFAULT_HORIZONTAL_INSET,
|
||||
bodyProperties?.BottomInset?.Value ?? DEFAULT_VERTICAL_INSET);
|
||||
|
||||
/// <summary>
|
||||
/// A placeholder as the slide, the layout or the slide master states it, before anything is inherited.
|
||||
/// </summary>
|
||||
private sealed record PlaceholderInfo(string Type, uint? Index, SlotBounds? Bounds, int? FontSize, TextInsets Insets);
|
||||
|
||||
/// <summary>
|
||||
/// What the layouts of one slide master inherit from it: its placeholders, keyed by the type
|
||||
/// layout placeholders look them up with, and its font sizes.
|
||||
/// </summary>
|
||||
private sealed record MasterStyles(IReadOnlyDictionary<string, PlaceholderInfo> Placeholders, int TitleFontSize, int BodyFontSize, int DefaultFontSize);
|
||||
|
||||
private readonly record struct TextInsets(long Left, long Top, long Right, long Bottom);
|
||||
|
||||
/// <summary>
|
||||
/// Where a reader comes across something on a slide, which orders the slots.
|
||||
/// </summary>
|
||||
/// <param name="Row">The row, of the rows a slide is divided into for reading.</param>
|
||||
/// <param name="X">The left edge, in EMU.</param>
|
||||
private readonly record struct ReadingPosition(int Row, long X) : IComparable<ReadingPosition>
|
||||
{
|
||||
public int CompareTo(ReadingPosition other) => this.Row != other.Row ? this.Row.CompareTo(other.Row) : this.X.CompareTo(other.X);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
using System.Text;
|
||||
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal sealed partial class PresentationTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the slides of a template with what their slots hold.
|
||||
/// </summary>
|
||||
/// <param name="presentationPart">The presentation part, which holds the slides.</param>
|
||||
/// <param name="presentation">The presentation, which lists the slides in their order.</param>
|
||||
/// <param name="layouts">Every layout of the template, including those without room for content.</param>
|
||||
/// <param name="families">The layout families the model plans with.</param>
|
||||
/// <param name="slideWidth">The width of a slide, in EMU.</param>
|
||||
/// <param name="slideHeight">The height of a slide, in EMU.</param>
|
||||
/// <returns>The slides, numbered as PowerPoint counts them.</returns>
|
||||
private static List<TemplateSlide> ReadSlides(PresentationPart presentationPart, P.Presentation presentation, IReadOnlyList<TemplateLayout> layouts, IReadOnlyList<LayoutFamily> families, long slideWidth, long slideHeight)
|
||||
{
|
||||
var layoutsByUri = new Dictionary<Uri, TemplateLayout>();
|
||||
foreach (var layout in layouts)
|
||||
layoutsByUri.TryAdd(layout.PartUri, layout);
|
||||
|
||||
var familyIdsByLayoutUri = new Dictionary<Uri, string>();
|
||||
foreach (var family in families)
|
||||
foreach (var variant in family.Variants)
|
||||
familyIdsByLayoutUri.TryAdd(variant.Layout.PartUri, family.Id);
|
||||
|
||||
var slides = new List<TemplateSlide>();
|
||||
foreach (var slideId in presentation.SlideIdList?.Elements<P.SlideId>() ?? [])
|
||||
{
|
||||
if (slideId.RelationshipId?.Value is not { } relationshipId || !presentationPart.TryGetPartById(relationshipId, out var slideCandidate) || slideCandidate is not SlidePart { Slide: { } slide } slidePart)
|
||||
continue;
|
||||
|
||||
var layoutUri = slidePart.SlideLayoutPart?.Uri;
|
||||
var topLevel = slide.CommonSlideData?.ShapeTree?.Elements().ToList() ?? [];
|
||||
var idCounts = topLevel.Select(TemplateSlideShapes.ShapeIdOf).OfType<uint>().CountBy(id => id).ToDictionary();
|
||||
var slotShapes = new HashSet<OpenXmlElement>();
|
||||
List<TemplateSlideContent> contents = [];
|
||||
List<TemplateSlot> movedSlots = [];
|
||||
List<TemplateSlot> slots = [];
|
||||
List<TemplateSlotCopy> slotCopies = [];
|
||||
if (layoutUri is not null && layoutsByUri.TryGetValue(layoutUri, out var slideLayout))
|
||||
{
|
||||
var shapesBySlot = TemplateSlideShapes.SlotShapesOf(slide.CommonSlideData?.ShapeTree, slideLayout);
|
||||
movedSlots = MovedSlotsOf(shapesBySlot, slideLayout, slideWidth, slideHeight);
|
||||
var slotsOnSlide = slideLayout.Slots.Select(slot => movedSlots.FirstOrDefault(moved => moved.Role == slot.Role && moved.Ordinal == slot.Ordinal) ?? slot).ToList();
|
||||
var copies = SlotCopiesOf(topLevel, idCounts, shapesBySlot, slideLayout, slotsOnSlide, slideWidth, slideHeight);
|
||||
slotCopies = copies.Select(found => found.Copy).ToList();
|
||||
slots = InReadingOrderWith(slotsOnSlide, slotCopies.Select(copy => copy.Slot), slideHeight);
|
||||
|
||||
// What fills a slot, by the name the model knows it by:
|
||||
var filledSlots = shapesBySlot.ToDictionary(pair => (pair.Key.Role, pair.Key.Ordinal), pair => (Slot: pair.Key, Shape: pair.Value));
|
||||
foreach (var (copy, shape) in copies)
|
||||
filledSlots[(copy.Slot.Role, copy.Slot.Ordinal)] = (copy.Slot, shape);
|
||||
|
||||
contents = ContentsOf(slots, filledSlots);
|
||||
slotShapes = filledSlots.Values.Select(filled => filled.Shape).ToHashSet();
|
||||
}
|
||||
|
||||
var title = contents.FirstOrDefault(content => content.Slot.Role is PlaceholderRole.TITLE)?.Text ?? string.Empty;
|
||||
slides.Add(new TemplateSlide(
|
||||
slides.Count + 1,
|
||||
slidePart.Uri,
|
||||
layoutUri,
|
||||
layoutUri is null ? null : familyIdsByLayoutUri.GetValueOrDefault(layoutUri),
|
||||
title.ReplaceLineEndings(" "),
|
||||
contents,
|
||||
movedSlots,
|
||||
slots,
|
||||
slotCopies,
|
||||
ElementsOf(topLevel, idCounts, slotShapes),
|
||||
slide.Show?.Value == false));
|
||||
}
|
||||
|
||||
return slides;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the slots of a slide hold, in reading order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Placeholders which fill no slot, such as the footer, and slots left empty are left out. A
|
||||
/// picture counts as content even though it has no text, and a table which is no placeholder
|
||||
/// as the content of the table slot, see TemplateSlideShapes.SlotShapesOf.
|
||||
/// </remarks>
|
||||
/// <param name="slots">The slots the slide offers, in reading order.</param>
|
||||
/// <param name="filledSlots">The shapes filling the slots, by role and number: the slot of the layout, or the copy, and its shape.</param>
|
||||
private static List<TemplateSlideContent> ContentsOf(IReadOnlyList<TemplateSlot> slots, IReadOnlyDictionary<(PlaceholderRole Role, int Ordinal), (TemplateSlot Slot, OpenXmlElement Shape)> filledSlots)
|
||||
{
|
||||
var contents = new List<TemplateSlideContent>();
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
if (!filledSlots.TryGetValue((slot.Role, slot.Ordinal), out var filled))
|
||||
continue;
|
||||
|
||||
var text = TextOf(filled.Shape);
|
||||
if (text.Length > 0 || filled.Shape is P.Picture)
|
||||
contents.Add(new TemplateSlideContent(filled.Slot, text));
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the shapes of a slide which repeat a text or a label slot of its layout, e.g., a second
|
||||
/// heading with its text, which the author copied from the placeholders of the slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// PowerPoint turns a copied placeholder into a text box which keeps the look of the placeholder,
|
||||
/// so what tells a copy is its geometry: it has the height of the slot it repeats, a text also
|
||||
/// its width, and it lines up with the slot or with another copy of it, below it in a column or
|
||||
/// beside it in a row. A label may be narrower or wider, since authors fit a heading to its text.
|
||||
/// The name of the shape would tell as well, but it depends on the language of PowerPoint, and
|
||||
/// other programs do not keep it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A copy overlaps no slot and no other copy: a text box lying on a slot is in the way of it, so it
|
||||
/// stays an element, which the model may remove. A second placeholder of a slot whose first one is
|
||||
/// taken may be a copy as well. Size is compared with the slot itself, so that a long row of
|
||||
/// copies does not drift by a tolerance per step.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="topLevel">The top-level elements of the slide.</param>
|
||||
/// <param name="idCounts">How often each shape ID occurs among them; a copy is found again by its ID, which therefore has to be unique.</param>
|
||||
/// <param name="shapesBySlot">The shapes of the slide which fill the slots of its layout.</param>
|
||||
/// <param name="layout">The layout of the slide.</param>
|
||||
/// <param name="slotsOnSlide">The slots of the layout, where the slide puts them; a slot the slide leaves out at the place of the layout.</param>
|
||||
/// <param name="slideWidth">The width of a slide, in EMU.</param>
|
||||
/// <param name="slideHeight">The height of a slide, in EMU.</param>
|
||||
/// <returns>The copies with their shapes, in reading order, numbered after the slots of the layout.</returns>
|
||||
private static List<FoundSlotCopy> SlotCopiesOf(IReadOnlyList<OpenXmlElement> topLevel, IReadOnlyDictionary<uint, int> idCounts, IReadOnlyDictionary<TemplateSlot, OpenXmlElement> shapesBySlot, TemplateLayout layout, IReadOnlyList<TemplateSlot> slotsOnSlide, long slideWidth, long slideHeight)
|
||||
{
|
||||
var horizontalTolerance = (long)(slideWidth * SLOT_TOLERANCE);
|
||||
var verticalTolerance = (long)(slideHeight * SLOT_TOLERANCE);
|
||||
|
||||
// What a copy may repeat: a text or a label the slide carries, and every copy found so far:
|
||||
var references = slotsOnSlide
|
||||
.Where(slot => slot.Role is (PlaceholderRole.TEXT or PlaceholderRole.LABEL) && shapesBySlot.Keys.Any(filled => filled.Role == slot.Role && filled.Ordinal == slot.Ordinal))
|
||||
.Select(slot => new CopyReference(slot.Bounds, slot))
|
||||
.ToList();
|
||||
|
||||
if (references.Count == 0)
|
||||
return [];
|
||||
|
||||
var slotShapes = shapesBySlot.Values.ToHashSet();
|
||||
var candidates = new List<(P.Shape Shape, uint Id, SlotBounds Bounds)>();
|
||||
foreach (var element in topLevel)
|
||||
{
|
||||
if (element is not P.Shape shape || slotShapes.Contains(shape) || TemplateSlideShapes.ShapeIdOf(shape) is not { } id || idCounts[id] > 1 || TextOf(shape).Length == 0)
|
||||
continue;
|
||||
|
||||
if (TemplateSlideShapes.PlaceholderOf(shape) is { } placeholder && layout.SlotOf(placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value)?.Role is not (PlaceholderRole.TEXT or PlaceholderRole.LABEL))
|
||||
continue;
|
||||
|
||||
if (BoundsOf(shape.ShapeProperties?.Transform2D?.Offset, shape.ShapeProperties?.Transform2D?.Extents) is { } bounds)
|
||||
candidates.Add((shape, id, bounds));
|
||||
}
|
||||
|
||||
var occupied = slotsOnSlide.Select(slot => slot.Bounds).ToList();
|
||||
var remaining = candidates.OrderBy(candidate => ReadingKeyOf(candidate.Bounds, slideHeight)).ToList();
|
||||
var found = new List<(P.Shape Shape, uint Id, SlotBounds Bounds, TemplateSlot Original)>();
|
||||
bool foundMore;
|
||||
do
|
||||
{
|
||||
foundMore = false;
|
||||
foreach (var candidate in remaining.ToList())
|
||||
{
|
||||
if (occupied.Any(bounds => Overlaps(candidate.Bounds, bounds)))
|
||||
{
|
||||
remaining.Remove(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
var reference = references
|
||||
.Where(other => Repeats(candidate.Bounds, other, horizontalTolerance, verticalTolerance))
|
||||
.MinBy(other => Math.Abs(candidate.Bounds.X - other.Bounds.X) + Math.Abs(candidate.Bounds.Y - other.Bounds.Y));
|
||||
|
||||
if (reference is null)
|
||||
continue;
|
||||
|
||||
found.Add((candidate.Shape, candidate.Id, candidate.Bounds, reference.Original));
|
||||
references.Add(new CopyReference(candidate.Bounds, reference.Original));
|
||||
occupied.Add(candidate.Bounds);
|
||||
remaining.Remove(candidate);
|
||||
foundMore = true;
|
||||
}
|
||||
} while (foundMore);
|
||||
|
||||
var counters = layout.Slots.CountBy(slot => slot.Role).ToDictionary();
|
||||
var copies = new List<FoundSlotCopy>();
|
||||
foreach (var copy in found.OrderBy(candidate => ReadingKeyOf(candidate.Bounds, slideHeight)))
|
||||
{
|
||||
var ordinal = counters.GetValueOrDefault(copy.Original.Role) + 1;
|
||||
counters[copy.Original.Role] = ordinal;
|
||||
copies.Add(new FoundSlotCopy(new TemplateSlotCopy(copy.Original.MovedTo(copy.Bounds) with { Ordinal = ordinal }, copy.Id), copy.Shape));
|
||||
}
|
||||
|
||||
return copies;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a shape at the given place repeats the slot of a reference: with the size of the slot,
|
||||
/// in line with the reference.
|
||||
/// </summary>
|
||||
private static bool Repeats(SlotBounds bounds, CopyReference reference, long horizontalTolerance, long verticalTolerance)
|
||||
{
|
||||
var original = reference.Original.Bounds;
|
||||
var hasItsSize = Math.Abs(bounds.Height - original.Height) <= verticalTolerance && (reference.Original.Role is PlaceholderRole.LABEL || Math.Abs(bounds.Width - original.Width) <= horizontalTolerance);
|
||||
var isInLine = Math.Abs(bounds.X - reference.Bounds.X) <= horizontalTolerance || Math.Abs(bounds.Y - reference.Bounds.Y) <= verticalTolerance;
|
||||
return hasItsSize && isInLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something a copy of a slot may line up with: the slot itself, or a copy of it found before.
|
||||
/// </summary>
|
||||
/// <param name="Bounds">Where the slot or the copy sits on the slide.</param>
|
||||
/// <param name="Original">The slot of the layout which is repeated, where the slide puts it.</param>
|
||||
private sealed record CopyReference(SlotBounds Bounds, TemplateSlot Original);
|
||||
|
||||
/// <summary>
|
||||
/// A copy of a slot found on a slide, with the shape which is the copy.
|
||||
/// </summary>
|
||||
/// <param name="Copy">The copy, as the model knows it.</param>
|
||||
/// <param name="Shape">The shape on the slide, whose text the copy holds.</param>
|
||||
private sealed record FoundSlotCopy(TemplateSlotCopy Copy, P.Shape Shape);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the copies of slots to the slots of a slide, each one where a reader comes across it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The slots of the layout keep their order, so that a slide without copies offers its slots
|
||||
/// exactly as the layout does. A copy goes after the last slot a reader takes in before it.
|
||||
/// </remarks>
|
||||
/// <param name="slots">The slots of the layout, where the slide puts them.</param>
|
||||
/// <param name="copies">The slots the copies offer, in reading order.</param>
|
||||
/// <param name="slideHeight">The height of a slide, in EMU.</param>
|
||||
private static List<TemplateSlot> InReadingOrderWith(IReadOnlyList<TemplateSlot> slots, IEnumerable<TemplateSlot> copies, long slideHeight)
|
||||
{
|
||||
var merged = slots.ToList();
|
||||
foreach (var copy in copies)
|
||||
{
|
||||
var key = ReadingKeyOf(copy.Bounds, slideHeight);
|
||||
var position = merged.FindLastIndex(slot => ReadingKeyOf(slot.Bounds, slideHeight).CompareTo(key) <= 0);
|
||||
merged.Insert(position + 1, copy);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The slots whose placeholders a slide moved or resized, with their place and capacity on the slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Authors shrink a placeholder to make room for something of their own, such as a picture
|
||||
/// beside the text. The model then has to write for the room the slide leaves, not for the one
|
||||
/// the layout offers. Small shifts, as they happen when dragging a placeholder, do not count.
|
||||
/// </remarks>
|
||||
/// <param name="shapesBySlot">The shapes of the slide which fill the slots of its layout.</param>
|
||||
/// <param name="layout">The layout of the slide.</param>
|
||||
/// <param name="slideWidth">The width of a slide, in EMU.</param>
|
||||
/// <param name="slideHeight">The height of a slide, in EMU.</param>
|
||||
private static List<TemplateSlot> MovedSlotsOf(IReadOnlyDictionary<TemplateSlot, OpenXmlElement> shapesBySlot, TemplateLayout layout, long slideWidth, long slideHeight)
|
||||
{
|
||||
var horizontalTolerance = (long)(slideWidth * SLOT_TOLERANCE);
|
||||
var verticalTolerance = (long)(slideHeight * SLOT_TOLERANCE);
|
||||
var movedSlots = new List<TemplateSlot>();
|
||||
foreach (var slot in layout.Slots)
|
||||
{
|
||||
if (shapesBySlot.TryGetValue(slot, out var shape) && BoundsOnSlideOf(slot, shape, horizontalTolerance, verticalTolerance) is { } bounds && !bounds.IsCloseTo(slot.Bounds, horizontalTolerance, verticalTolerance))
|
||||
movedSlots.Add(slot.MovedTo(bounds));
|
||||
}
|
||||
|
||||
return movedSlots;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the shape filling a slot puts the slot on the slide.
|
||||
/// </summary>
|
||||
/// <returns>The place; null when the shape says nothing about it, e.g., because it takes the place of the layout.</returns>
|
||||
private static SlotBounds? BoundsOnSlideOf(TemplateSlot slot, OpenXmlElement shape, long horizontalTolerance, long verticalTolerance) => shape switch
|
||||
{
|
||||
P.Shape textShape => BoundsOf(textShape.ShapeProperties?.Transform2D?.Offset, textShape.ShapeProperties?.Transform2D?.Extents),
|
||||
|
||||
// A table grows with its rows, so its frame tells where the slot starts, but not how far down it reaches:
|
||||
P.GraphicFrame frame => BoundsOf(frame.Transform?.Offset, frame.Transform?.Extents) is { } frameBounds
|
||||
? frameBounds with { Height = Math.Max(frameBounds.Height, slot.Bounds.Bottom - frameBounds.Y) }
|
||||
: null,
|
||||
|
||||
// A picture is fit into its slot, so one within the slot does not say that the slot moved:
|
||||
P.Picture picture => BoundsOf(picture.ShapeProperties?.Transform2D?.Offset, picture.ShapeProperties?.Transform2D?.Extents) is { } pictureBounds && !slot.Bounds.Contains(pictureBounds, horizontalTolerance, verticalTolerance)
|
||||
? pictureBounds
|
||||
: null,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What a slide carries besides the shapes filling the slots of its layout and their copies, from back to front.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only what shows counts: footer, slide number and date belong to the presentation, a
|
||||
/// placeholder without text shows nothing, and neither does a shape without text, fill or line.
|
||||
/// The writer finds an element again by its ID, so an element whose ID another one shares on the
|
||||
/// slide is left out rather than risking the wrong one. PowerPoint itself never writes such IDs.
|
||||
/// </remarks>
|
||||
/// <param name="topLevel">The top-level elements of the slide.</param>
|
||||
/// <param name="idCounts">How often each shape ID occurs among them.</param>
|
||||
/// <param name="slotShapes">The shapes filling the slots of its layout, and the copies of slots.</param>
|
||||
private static List<TemplateSlideElement> ElementsOf(IReadOnlyList<OpenXmlElement> topLevel, IReadOnlyDictionary<uint, int> idCounts, IReadOnlySet<OpenXmlElement> slotShapes)
|
||||
{
|
||||
var elements = new List<TemplateSlideElement>();
|
||||
foreach (var element in topLevel)
|
||||
{
|
||||
if (slotShapes.Contains(element) || TemplateSlideShapes.ShapeIdOf(element) is not { } id || idCounts[id] > 1 || KindOf(element) is not { } kind)
|
||||
continue;
|
||||
|
||||
elements.Add(new TemplateSlideElement(elements.Count + 1, id, kind, ElementBoundsOf(element), ElementTextOf(element)));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What kind of element a top-level element of a slide is.
|
||||
/// </summary>
|
||||
/// <returns>The kind; null when the element shows nothing worth naming, or belongs to the presentation as a whole.</returns>
|
||||
private static TemplateSlideElementKind? KindOf(OpenXmlElement element)
|
||||
{
|
||||
switch (element)
|
||||
{
|
||||
case P.Shape shape:
|
||||
var hasText = TextOf(shape).Length > 0;
|
||||
if (TemplateSlideShapes.PlaceholderOf(shape) is { } placeholder)
|
||||
return hasText && placeholder.Type?.InnerText is not ("ftr" or "sldNum" or "dt" or "hdr") ? TemplateSlideElementKind.TEXT_BOX : null;
|
||||
|
||||
return hasText ? TemplateSlideElementKind.TEXT_BOX : IsVisible(shape) ? TemplateSlideElementKind.SHAPE : null;
|
||||
|
||||
case P.Picture:
|
||||
return TemplateSlideElementKind.PICTURE;
|
||||
|
||||
case P.GraphicFrame frame:
|
||||
var uri = frame.Graphic?.GraphicData?.Uri?.Value ?? string.Empty;
|
||||
if (uri.EndsWith("/table", StringComparison.Ordinal))
|
||||
return TemplateSlideElementKind.TABLE;
|
||||
|
||||
if (uri.Contains("chart", StringComparison.OrdinalIgnoreCase))
|
||||
return TemplateSlideElementKind.CHART;
|
||||
|
||||
return uri.EndsWith("/diagram", StringComparison.Ordinal) ? TemplateSlideElementKind.DIAGRAM : TemplateSlideElementKind.OBJECT;
|
||||
|
||||
case P.GroupShape:
|
||||
return TemplateSlideElementKind.GROUP;
|
||||
|
||||
case P.ConnectionShape:
|
||||
return TemplateSlideElementKind.LINE;
|
||||
|
||||
case AlternateContent:
|
||||
return TemplateSlideElementKind.OBJECT;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a shape without text shows anything: a fill or a line, of its own or from its style.
|
||||
/// </summary>
|
||||
private static bool IsVisible(P.Shape shape)
|
||||
{
|
||||
var properties = shape.ShapeProperties;
|
||||
var hasFill = properties?.GetFirstChild<A.NoFill>() is null &&
|
||||
(properties?.Elements().Any(child => child is A.SolidFill or A.GradientFill or A.BlipFill or A.PatternFill or A.GroupFill) is true || shape.ShapeStyle?.FillReference?.Index?.Value > 0);
|
||||
|
||||
var hasLine = properties?.GetFirstChild<A.Outline>() is { } outline
|
||||
? outline.GetFirstChild<A.NoFill>() is null
|
||||
: shape.ShapeStyle?.LineReference?.Index?.Value > 0;
|
||||
|
||||
return hasFill || hasLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a top-level element of a slide sits.
|
||||
/// </summary>
|
||||
private static SlotBounds? ElementBoundsOf(OpenXmlElement element) => element switch
|
||||
{
|
||||
P.GraphicFrame frame => BoundsOf(frame.Transform?.Offset, frame.Transform?.Extents),
|
||||
P.GroupShape group => BoundsOf(group.GroupShapeProperties?.TransformGroup?.Offset, group.GroupShapeProperties?.TransformGroup?.Extents),
|
||||
|
||||
_ => element.Descendants<A.Transform2D>().FirstOrDefault() is { } transform ? BoundsOf(transform.Offset, transform.Extents) : null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The text of a top-level element of a slide: the rows of a table, or every paragraph it holds, those of a group included.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Something wrapped for newer versions of PowerPoint comes with a fallback for older ones, which
|
||||
/// would repeat the text; only the first choice counts.
|
||||
/// </remarks>
|
||||
private static string ElementTextOf(OpenXmlElement element) => element switch
|
||||
{
|
||||
P.GraphicFrame => TextOf(element),
|
||||
AlternateContent { FirstChild: { } choice } => ElementTextOf(choice),
|
||||
|
||||
_ => string.Join('\n', element.Descendants<A.Paragraph>().Select(TextOfParagraph).Where(text => text.Trim().Length > 0)).Trim(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The text of a placeholder: the paragraphs of a shape, or the rows of a table.
|
||||
/// </summary>
|
||||
private static string TextOf(OpenXmlElement placeholder) => placeholder switch
|
||||
{
|
||||
P.Shape shape => TextOfBody(shape.TextBody),
|
||||
P.GraphicFrame frame => string.Join('\n', frame.Descendants<A.TableRow>().Select(row => string.Join(" | ", row.Elements<A.TableCell>().Select(cell => TextOfBody(cell.TextBody).ReplaceLineEndings(" "))))).Trim(),
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The text of a text body, one paragraph per line.
|
||||
/// </summary>
|
||||
/// <param name="textBody">The text body of a shape or of a table cell, which differ in type only.</param>
|
||||
private static string TextOfBody(OpenXmlElement? textBody) => string.Join('\n', (textBody?.Elements<A.Paragraph>() ?? []).Select(TextOfParagraph)).Trim();
|
||||
|
||||
private static string TextOfParagraph(A.Paragraph paragraph)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
foreach (var element in paragraph.Elements())
|
||||
{
|
||||
switch (element)
|
||||
{
|
||||
case A.Run run:
|
||||
text.Append(run.Text?.Text);
|
||||
break;
|
||||
|
||||
// A field, e.g., a date, shows the text it last had:
|
||||
case A.Field field:
|
||||
text.Append(field.Text?.Text);
|
||||
break;
|
||||
|
||||
case A.Break:
|
||||
text.Append('\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What a PowerPoint template offers to build slides from: its layouts, grouped into families.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read once from the template the user chose. The model gets the families as a catalog and picks
|
||||
/// one for every slide; the presentation is then written from the layouts of the same template. The
|
||||
/// corporate design therefore comes from the template alone, and nothing about any particular
|
||||
/// template is written into the code.
|
||||
/// </remarks>
|
||||
internal sealed partial class PresentationTemplate
|
||||
{
|
||||
/// <summary>
|
||||
/// A picture slot covering at least this share of the slide is a backdrop, which a picture fills.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A backdrop, such as the picture of a chapter slide, looks broken with a margin, so a picture is
|
||||
/// cropped to fill it. A smaller slot often gets a diagram or a screenshot, which must not lose its
|
||||
/// edges, so a picture is scaled to fit inside and keeps all of its content. An icon is never
|
||||
/// cropped and never goes into a backdrop: it would fill the slide as a giant.
|
||||
/// </remarks>
|
||||
public const double BACKDROP_AREA_SHARE = 0.5;
|
||||
|
||||
private PresentationTemplate(long slideWidth, long slideHeight, IReadOnlyList<LayoutFamily> families, IReadOnlyList<string> variantLabels, IReadOnlyList<TemplateSlide> slides)
|
||||
{
|
||||
this.SlideWidth = slideWidth;
|
||||
this.SlideHeight = slideHeight;
|
||||
this.Families = families;
|
||||
this.VariantLabels = variantLabels;
|
||||
this.Slides = slides;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The width of a slide, in EMU.
|
||||
/// </summary>
|
||||
public long SlideWidth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The height of a slide, in EMU.
|
||||
/// </summary>
|
||||
public long SlideHeight { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The layout families which have room for content, in the order of the template.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LayoutFamily> Families { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The labels which tell the variants of the families apart, e.g., "A", "B" and "C"; empty when
|
||||
/// the template carries every layout once.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> VariantLabels { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The slides the template carries, in the order of the file; empty for a template without slides.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TemplateSlide> Slides { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Finds a family by the ID the model used for it.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID, e.g., "L3".</param>
|
||||
/// <returns>The family, or null when there is none with this ID.</returns>
|
||||
public LayoutFamily? FindFamily(string id) => this.Families.FirstOrDefault(family => string.Equals(family.Id, id.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Finds a family by the name the user chose it by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The name is compared exactly, as the template's author wrote it. A template may give two
|
||||
/// layouts the same name, e.g., in two slide masters; the first one in the order of the template
|
||||
/// counts then.
|
||||
/// </remarks>
|
||||
/// <param name="name">The name, e.g., "Inhalt/Content 1".</param>
|
||||
/// <returns>The family, or null when there is none with this name.</returns>
|
||||
public LayoutFamily? FindFamilyByName(string name) => name.Length == 0 ? null : this.Families.FirstOrDefault(family => string.Equals(family.Name, name, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// The names of the families, each one once, in the order of the template, e.g., for the user to choose a layout by.
|
||||
/// </summary>
|
||||
public IEnumerable<string> FamilyNames => this.Families.Select(family => family.Name).Distinct(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a picture slot in these bounds is a backdrop, see BACKDROP_AREA_SHARE.
|
||||
/// </summary>
|
||||
public bool IsBackdrop(SlotBounds bounds) => bounds.Area >= (double)this.SlideWidth * this.SlideHeight * BACKDROP_AREA_SHARE;
|
||||
|
||||
/// <summary>
|
||||
/// Finds a slide of the template by its number.
|
||||
/// </summary>
|
||||
/// <param name="number">The number, starting at 1.</param>
|
||||
/// <returns>The slide, or null when the template has no slide with this number.</returns>
|
||||
public TemplateSlide? FindSlide(int number) => number >= 1 && number <= this.Slides.Count ? this.Slides[number - 1] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the family a slide of a deck is written on: that of the template slide it refers to, or the one it names.
|
||||
/// </summary>
|
||||
/// <param name="slide">The slide of the deck.</param>
|
||||
/// <returns>The family, or null when there is none, e.g., because the template slide is on a layout without room for content.</returns>
|
||||
public LayoutFamily? FamilyOf(SlideSpec slide) => slide.TemplateSlide is { } number
|
||||
? this.FindSlide(number)?.FamilyId is { } familyId ? this.FindFamily(familyId) : null
|
||||
: this.FindFamily(slide.Layout);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What the user chose for the presentation as a whole, beyond the slides themselves.
|
||||
/// </summary>
|
||||
/// <param name="VariantLabel">The variant all slides are written in, e.g., "A"; null for the first one of the template.</param>
|
||||
/// <param name="FooterText">The text of the footer on every slide whose layout has one; empty for no footer.</param>
|
||||
/// <param name="KeepEmptyPicturePlaceholders">Whether a new slide keeps the picture placeholders of its layout which no picture fills, so that the user can insert pictures of their own.</param>
|
||||
internal sealed record PresentationWriteOptions(string? VariantLabel, string FooterText, bool KeepEmptyPicturePlaceholders);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What happened while writing a presentation, beyond writing it.
|
||||
/// </summary>
|
||||
/// <param name="SkippedPictures">The numbers of the pictures which could not be placed: unreadable, or in a format PowerPoint does not show. Their slots stay empty.</param>
|
||||
internal sealed record PresentationWriteResult(IReadOnlyList<int> SkippedPictures);
|
||||
@@ -0,0 +1,60 @@
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
using P14 = DocumentFormat.OpenXml.Office2010.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class PresentationWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// The extension of the presentation which holds its sections, as PowerPoint 2010 introduced them.
|
||||
/// </summary>
|
||||
private const string SECTION_LIST_URI = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}";
|
||||
|
||||
private static void RemoveSections(P.Presentation presentation)
|
||||
{
|
||||
if (presentation.PresentationExtensionList is not { } extensions)
|
||||
return;
|
||||
|
||||
foreach (var extension in extensions.Elements<P.PresentationExtension>().Where(candidate => candidate.Uri?.Value == SECTION_LIST_URI).ToList())
|
||||
extension.Remove();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes every chapter a section, so that the slide sorter of PowerPoint shows the outline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The slides before the first chapter, such as the title slide, form a section named after the
|
||||
/// presentation. A deck without chapters gets no sections at all.
|
||||
/// </remarks>
|
||||
/// <param name="presentation">The presentation.</param>
|
||||
/// <param name="template">The template, which names the template slides of the deck.</param>
|
||||
/// <param name="presentationTitle">The title of the presentation, which names the section before the first chapter.</param>
|
||||
/// <param name="slides">The slides of the deck, in the order they were written.</param>
|
||||
/// <param name="slideIds">The IDs of these slides, in the same order.</param>
|
||||
private static void WriteSections(P.Presentation presentation, PresentationTemplate template, string presentationTitle, IReadOnlyList<SlideSpec> slides, IReadOnlyList<uint> slideIds)
|
||||
{
|
||||
if (!slides.Any(slide => slide.StartsChapter))
|
||||
return;
|
||||
|
||||
var sections = new P14.SectionList();
|
||||
P14.SectionSlideIdList? currentSection = null;
|
||||
for (var index = 0; index < slides.Count; index++)
|
||||
{
|
||||
var slide = slides[index];
|
||||
if (currentSection is null || slide.StartsChapter)
|
||||
{
|
||||
var title = TitleOf(slide, template);
|
||||
var name = slide.StartsChapter && title.Length > 0 ? title : presentationTitle;
|
||||
currentSection = new P14.SectionSlideIdList();
|
||||
sections.Append(new P14.Section(currentSection) { Name = name, Id = NewSectionId() });
|
||||
}
|
||||
|
||||
currentSection.Append(new P14.SectionSlideIdListEntry { Id = slideIds[index] });
|
||||
}
|
||||
|
||||
var extensions = presentation.PresentationExtensionList ??= new P.PresentationExtensionList();
|
||||
extensions.InsertAt(new P.PresentationExtension(sections) { Uri = SECTION_LIST_URI }, 0);
|
||||
}
|
||||
|
||||
private static string NewSectionId() => $"{{{Guid.NewGuid().ToString().ToUpperInvariant()}}}";
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class PresentationWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// A crop of the whole picture, in the thousandths of a percent the file format uses.
|
||||
/// </summary>
|
||||
private const double WHOLE_PICTURE = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// The height of a table row, 0.4 inch. PowerPoint grows a row whose text needs more.
|
||||
/// </summary>
|
||||
private const long TABLE_ROW_HEIGHT = 370_840;
|
||||
|
||||
private const string TABLE_GRAPHIC_DATA_URI = "http://schemas.openxmlformats.org/drawingml/2006/table";
|
||||
|
||||
/// <summary>
|
||||
/// Any fixed ID will do for the field: PowerPoint fills in the number when it shows the slide.
|
||||
/// </summary>
|
||||
private const string SLIDE_NUMBER_FIELD_ID = "{B6F15528-21DE-4FAA-801E-634DDDAF4B2B}";
|
||||
|
||||
private static P.ShapeTree ShapeTreeOf(SlidePart slidePart, SlideContent content)
|
||||
{
|
||||
var placeholders = LayoutPlaceholders.Of(content.LayoutElement);
|
||||
var shapeTree = NewShapeTree();
|
||||
var slide = content.Slide;
|
||||
var nextId = 2U;
|
||||
foreach (var slot in content.Layout.Slots)
|
||||
{
|
||||
var index = slot.Ordinal - 1;
|
||||
var placeholder = placeholders.For(slot);
|
||||
OpenXmlElement? shape = slot.Role switch
|
||||
{
|
||||
PlaceholderRole.TITLE when slide.Title.Length > 0 => TextShape(nextId, $"Title {nextId}", placeholder, ParagraphsOf(slide.Title), false),
|
||||
PlaceholderRole.SUBTITLE when slide.Subtitle.Length > 0 => TextShape(nextId, $"Subtitle {nextId}", placeholder, ParagraphsOf(slide.Subtitle), false),
|
||||
PlaceholderRole.TEXT when index < slide.Texts.Count && slide.Texts[index].Paragraphs.Count > 0 => TextShape(nextId, $"Text {nextId}", placeholder, slide.Texts[index].Paragraphs.Select(ParagraphOf), true),
|
||||
PlaceholderRole.LABEL when index < slide.Labels.Count && slide.Labels[index].Length > 0 => TextShape(nextId, $"Label {nextId}", placeholder, ParagraphsOf(slide.Labels[index]), false),
|
||||
PlaceholderRole.PICTURE when index < slide.Pictures.Count && content.Pictures.TryGetValue(slide.Pictures[index], out var picture) => PictureShape(nextId, placeholder, slidePart, picture, slot.Bounds, slot.Bounds.Area >= content.SlideArea * PresentationTemplate.BACKDROP_AREA_SHARE),
|
||||
PlaceholderRole.PICTURE when content.KeepEmptyPicturePlaceholders => EmptyPlaceholderShape(nextId, $"Picture Placeholder {nextId}", placeholder),
|
||||
PlaceholderRole.TABLE when index == 0 && slide.Table is { Headers.Count: > 0 } table => TableShape(nextId, placeholder, table, slot.Bounds, content.TableStyleId),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (shape is null)
|
||||
continue;
|
||||
|
||||
shapeTree.Append(shape);
|
||||
nextId++;
|
||||
}
|
||||
|
||||
AppendFooterAndSlideNumber(shapeTree, placeholders, content.FooterText, nextId);
|
||||
return shapeTree;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shape tree of a slide, with no shapes in it yet.
|
||||
/// </summary>
|
||||
private static P.ShapeTree NewShapeTree() => new(
|
||||
new P.NonVisualGroupShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = 1U, Name = string.Empty },
|
||||
new P.NonVisualGroupShapeDrawingProperties(),
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
new P.GroupShapeProperties(new A.TransformGroup(
|
||||
new A.Offset { X = 0, Y = 0 },
|
||||
new A.Extents { Cx = 0, Cy = 0 },
|
||||
new A.ChildOffset { X = 0, Y = 0 },
|
||||
new A.ChildExtents { Cx = 0, Cy = 0 })));
|
||||
|
||||
/// <summary>
|
||||
/// Adds the footer and the slide number, as far as the layout has places for them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Footer and slide number appear only on slides which carry their placeholders, even when the
|
||||
/// layout has them.
|
||||
/// </remarks>
|
||||
/// <param name="shapeTree">The shape tree of the slide.</param>
|
||||
/// <param name="placeholders">The placeholders of the slide's layout.</param>
|
||||
/// <param name="footerText">The footer text; empty for no footer.</param>
|
||||
/// <param name="nextId">The next free shape ID of the slide.</param>
|
||||
private static void AppendFooterAndSlideNumber(P.ShapeTree shapeTree, LayoutPlaceholders placeholders, string footerText, uint nextId)
|
||||
{
|
||||
if (footerText.Length > 0 && placeholders.Footer is { } footer)
|
||||
{
|
||||
shapeTree.Append(TextShape(nextId, $"Footer {nextId}", Clone(footer), ParagraphsOf(footerText), false));
|
||||
nextId++;
|
||||
}
|
||||
|
||||
if (placeholders.SlideNumber is { } slideNumber)
|
||||
shapeTree.Append(TextShape(nextId, $"Slide Number {nextId}", Clone(slideNumber), [new A.Paragraph(new A.Field(new A.Text("‹#›")) { Id = SLIDE_NUMBER_FIELD_ID, Type = "slidenum" })], false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A text placeholder. It states no place and no formatting, so both come from the layout.
|
||||
/// </summary>
|
||||
private static P.Shape TextShape(uint id, string name, P.PlaceholderShape placeholder, IEnumerable<A.Paragraph> paragraphs, bool shrinkOnOverflow)
|
||||
{
|
||||
var textBody = new P.TextBody(
|
||||
shrinkOnOverflow ? new A.BodyProperties(new A.NormalAutoFit()) : new A.BodyProperties(),
|
||||
new A.ListStyle());
|
||||
|
||||
textBody.Append(paragraphs);
|
||||
return new P.Shape(
|
||||
new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = name },
|
||||
new P.NonVisualShapeDrawingProperties(new A.ShapeLocks { NoGrouping = true }),
|
||||
new P.ApplicationNonVisualDrawingProperties(placeholder)),
|
||||
new P.ShapeProperties(),
|
||||
textBody);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A placeholder without content. PowerPoint shows the prompt text of the layout in it while
|
||||
/// editing, and nothing in the slide show.
|
||||
/// </summary>
|
||||
private static P.Shape EmptyPlaceholderShape(uint id, string name, P.PlaceholderShape placeholder) => new(
|
||||
new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = name },
|
||||
new P.NonVisualShapeDrawingProperties(new A.ShapeLocks { NoGrouping = true }),
|
||||
new P.ApplicationNonVisualDrawingProperties(placeholder)),
|
||||
new P.ShapeProperties());
|
||||
|
||||
private static P.Picture PictureShape(uint id, P.PlaceholderShape placeholder, SlidePart slidePart, SlidePicture picture, SlotBounds bounds, bool isBackdrop)
|
||||
{
|
||||
var imagePart = slidePart.AddImagePart(picture.ContentType);
|
||||
using (var data = new MemoryStream(picture.Data))
|
||||
imagePart.FeedData(data);
|
||||
|
||||
var blipFill = new P.BlipFill(new A.Blip { Embed = slidePart.GetIdOfPart(imagePart) });
|
||||
var shapeProperties = new P.ShapeProperties();
|
||||
// An icon keeps its edges even in a backdrop, which the validator keeps it out of:
|
||||
if (isBackdrop && !picture.IsIcon)
|
||||
{
|
||||
// The picture is cropped for these bounds, which a slide of the template may have moved away from the layout's:
|
||||
blipFill.Append(CropToFill(picture, bounds));
|
||||
shapeProperties.Transform2D = new A.Transform2D(new A.Offset { X = bounds.X, Y = bounds.Y }, new A.Extents { Cx = bounds.Width, Cy = bounds.Height });
|
||||
}
|
||||
else
|
||||
shapeProperties.Transform2D = FitInside(picture, bounds);
|
||||
|
||||
blipFill.Append(new A.Stretch(new A.FillRectangle()));
|
||||
return new P.Picture(
|
||||
new P.NonVisualPictureProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Picture {id}" },
|
||||
new P.NonVisualPictureDrawingProperties(new A.PictureLocks { NoGrouping = true, NoChangeAspect = true }),
|
||||
new P.ApplicationNonVisualDrawingProperties(placeholder)),
|
||||
blipFill,
|
||||
shapeProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crops the picture evenly on two sides, so that it fills the slot without being distorted.
|
||||
/// </summary>
|
||||
private static A.SourceRectangle CropToFill(SlidePicture picture, SlotBounds bounds)
|
||||
{
|
||||
var slotRatio = (double)bounds.Width / bounds.Height;
|
||||
var pictureRatio = (double)picture.Width / picture.Height;
|
||||
var crop = new A.SourceRectangle();
|
||||
if (pictureRatio > slotRatio)
|
||||
{
|
||||
var side = (int)Math.Round((1 - slotRatio / pictureRatio) / 2 * WHOLE_PICTURE);
|
||||
crop.Left = side;
|
||||
crop.Right = side;
|
||||
}
|
||||
else if (pictureRatio < slotRatio)
|
||||
{
|
||||
var side = (int)Math.Round((1 - pictureRatio / slotRatio) / 2 * WHOLE_PICTURE);
|
||||
crop.Top = side;
|
||||
crop.Bottom = side;
|
||||
}
|
||||
|
||||
return crop;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scales the picture to the largest size which fits into the slot, centered in it.
|
||||
/// </summary>
|
||||
private static A.Transform2D FitInside(SlidePicture picture, SlotBounds bounds)
|
||||
{
|
||||
var scale = Math.Min((double)bounds.Width / picture.Width, (double)bounds.Height / picture.Height);
|
||||
var width = (long)Math.Round(picture.Width * scale);
|
||||
var height = (long)Math.Round(picture.Height * scale);
|
||||
return new A.Transform2D(
|
||||
new A.Offset { X = bounds.X + (bounds.Width - width) / 2, Y = bounds.Y + (bounds.Height - height) / 2 },
|
||||
new A.Extents { Cx = width, Cy = height });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A table in the table slot, in the default table style of the template, with a header row.
|
||||
/// </summary>
|
||||
private static P.GraphicFrame TableShape(uint id, P.PlaceholderShape placeholder, SlideTableSpec table, SlotBounds bounds, string? tableStyleId)
|
||||
{
|
||||
var columns = table.Headers.Count;
|
||||
var columnWidth = bounds.Width / columns;
|
||||
var properties = new A.TableProperties { FirstRow = true, BandRow = true };
|
||||
if (tableStyleId is not null)
|
||||
properties.Append(new A.TableStyleId(tableStyleId));
|
||||
|
||||
var grid = new A.TableGrid();
|
||||
for (var column = 0; column < columns; column++)
|
||||
{
|
||||
// The last column takes what the division left over, so the table spans the slot exactly:
|
||||
grid.Append(new A.GridColumn { Width = column < columns - 1 ? columnWidth : bounds.Width - columnWidth * (columns - 1) });
|
||||
}
|
||||
|
||||
var drawingTable = new A.Table(properties, grid);
|
||||
drawingTable.Append(TableRowOf(table.Headers, columns));
|
||||
foreach (var row in table.Rows)
|
||||
drawingTable.Append(TableRowOf(row, columns));
|
||||
|
||||
return new P.GraphicFrame(
|
||||
new P.NonVisualGraphicFrameProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Table {id}" },
|
||||
new P.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoGrouping = true }),
|
||||
new P.ApplicationNonVisualDrawingProperties(placeholder)),
|
||||
new P.Transform(
|
||||
new A.Offset { X = bounds.X, Y = bounds.Y },
|
||||
new A.Extents { Cx = bounds.Width, Cy = TABLE_ROW_HEIGHT * (table.Rows.Count + 1) }),
|
||||
new A.Graphic(new A.GraphicData(drawingTable) { Uri = TABLE_GRAPHIC_DATA_URI }));
|
||||
}
|
||||
|
||||
private static A.TableRow TableRowOf(IReadOnlyList<string> cells, int columns)
|
||||
{
|
||||
var row = new A.TableRow { Height = TABLE_ROW_HEIGHT };
|
||||
for (var column = 0; column < columns; column++)
|
||||
{
|
||||
var text = column < cells.Count ? XmlSafe(cells[column]) : string.Empty;
|
||||
var paragraph = text.Length > 0 ? new A.Paragraph(new A.Run(new A.Text(text))) : new A.Paragraph();
|
||||
row.Append(new A.TableCell(new A.TextBody(new A.BodyProperties(), new A.ListStyle(), paragraph), new A.TableCellProperties()));
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static A.Paragraph ParagraphOf(SlideParagraph paragraph)
|
||||
{
|
||||
var result = new A.Paragraph();
|
||||
if (paragraph.Level > 0)
|
||||
result.Append(new A.ParagraphProperties { Level = paragraph.Level });
|
||||
|
||||
result.Append(new A.Run(new A.Text(XmlSafe(paragraph.Text))));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One paragraph per line of a title, subtitle, label or footer.
|
||||
/// </summary>
|
||||
private static List<A.Paragraph> ParagraphsOf(string text) => text
|
||||
.ReplaceLineEndings("\n")
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(line => ParagraphOf(new SlideParagraph(line, 0)))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Leaves out the characters XML cannot carry, which a model may produce now and then. The file
|
||||
/// could not be saved with them.
|
||||
/// </summary>
|
||||
private static string XmlSafe(string text)
|
||||
{
|
||||
if (text.All(XmlConvert.IsXmlChar))
|
||||
return text;
|
||||
|
||||
var result = new StringBuilder(text.Length);
|
||||
for (var index = 0; index < text.Length; index++)
|
||||
{
|
||||
if (XmlConvert.IsXmlChar(text[index]))
|
||||
result.Append(text[index]);
|
||||
else if (index + 1 < text.Length && XmlConvert.IsXmlSurrogatePair(text[index + 1], text[index]))
|
||||
result.Append(text[index]).Append(text[++index]);
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of a layout placeholder, for a slide to refer to it.
|
||||
/// </summary>
|
||||
private static P.PlaceholderShape Clone(P.PlaceholderShape placeholder)
|
||||
{
|
||||
var clone = (P.PlaceholderShape)placeholder.CloneNode(true);
|
||||
|
||||
// A prompt of its own means something on a layout only:
|
||||
clone.HasCustomPrompt = null;
|
||||
return clone;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The placeholders of a layout, which the placeholders of a slide refer to by type and index.
|
||||
/// </summary>
|
||||
private sealed class LayoutPlaceholders
|
||||
{
|
||||
private readonly Dictionary<(string Type, uint? Index), P.PlaceholderShape> byTypeAndIndex = new();
|
||||
|
||||
public P.PlaceholderShape? Footer { get; private set; }
|
||||
|
||||
public P.PlaceholderShape? SlideNumber { get; private set; }
|
||||
|
||||
public P.PlaceholderShape? Date { get; private set; }
|
||||
|
||||
public static LayoutPlaceholders Of(P.SlideLayout? layout)
|
||||
{
|
||||
var placeholders = new LayoutPlaceholders();
|
||||
foreach (var placeholder in layout?.CommonSlideData?.ShapeTree?.Descendants<P.PlaceholderShape>() ?? [])
|
||||
{
|
||||
// The same reading as the template's, so that a slot finds its placeholder again:
|
||||
var type = placeholder.Type?.InnerText ?? "obj";
|
||||
placeholders.byTypeAndIndex.TryAdd((type, placeholder.Index?.Value), placeholder);
|
||||
if (type == "ftr")
|
||||
placeholders.Footer ??= placeholder;
|
||||
else if (type == "sldNum")
|
||||
placeholders.SlideNumber ??= placeholder;
|
||||
else if (type == "dt")
|
||||
placeholders.Date ??= placeholder;
|
||||
}
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The placeholder a slot refers to: a copy of the layout's, or a new one with the same type and index.
|
||||
/// </summary>
|
||||
public P.PlaceholderShape For(TemplateSlot slot)
|
||||
{
|
||||
if (this.byTypeAndIndex.TryGetValue((slot.PlaceholderType, slot.PlaceholderIndex), out var placeholder))
|
||||
return Clone(placeholder);
|
||||
|
||||
var created = new P.PlaceholderShape();
|
||||
if (slot.PlaceholderType != "obj")
|
||||
created.Type = new P.PlaceholderValues(slot.PlaceholderType);
|
||||
|
||||
if (slot.PlaceholderIndex is { } index)
|
||||
created.Index = index;
|
||||
|
||||
return created;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using System.Globalization;
|
||||
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static partial class PresentationWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// The namespace of the attributes by which the XML of a part refers to other parts.
|
||||
/// </summary>
|
||||
private const string RELATIONSHIPS_NAMESPACE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
/// <summary>
|
||||
/// Where the template slides the deck refers to are stored inside the file.
|
||||
/// </summary>
|
||||
private static HashSet<Uri> TemplateSlidePartUrisOf(PresentationTemplate template, IEnumerable<SlideSpec> slides) => slides
|
||||
.Select(slide => slide.TemplateSlide is { } number ? template.FindSlide(number)?.PartUri : null)
|
||||
.OfType<Uri>()
|
||||
.ToHashSet();
|
||||
|
||||
/// <summary>
|
||||
/// The kept slide part of a template slide, which the presentation lists once only.
|
||||
/// </summary>
|
||||
/// <param name="keptSlideParts">The slides the deck keeps, by where they are stored.</param>
|
||||
/// <param name="usedTemplateSlides">Where the template slides written so far are stored; the slide is added.</param>
|
||||
/// <param name="templateSlide">The template slide.</param>
|
||||
private static SlidePart TemplateSlidePartOf(Dictionary<Uri, SlidePart> keptSlideParts, HashSet<Uri> usedTemplateSlides, TemplateSlide templateSlide)
|
||||
{
|
||||
if (!keptSlideParts.TryGetValue(templateSlide.PartUri, out var slidePart))
|
||||
throw new InvalidOperationException($"The template has no slide at '{templateSlide.PartUri}'. The template was read from another file than the one being written.");
|
||||
|
||||
if (!usedTemplateSlides.Add(templateSlide.PartUri))
|
||||
throw new InvalidOperationException($"The slide deck uses template slide {templateSlide.Number} more than once. Check the deck before writing it.");
|
||||
|
||||
return slidePart;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes a template slide the deck keeps look like the slides around it: in the color variant
|
||||
/// of the presentation, and with its footer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything else stays as the author of the template made it, pictures and notes included.
|
||||
/// The slide shows even when the template hides it, since the user chose it for the presentation.
|
||||
/// </remarks>
|
||||
/// <param name="slidePart">The slide part of the template slide.</param>
|
||||
/// <param name="templateSlide">The template slide, as read from the template.</param>
|
||||
/// <param name="template">The template.</param>
|
||||
/// <param name="layoutParts">The layouts of the document being written, by where they are stored.</param>
|
||||
/// <param name="variantLabel">The color variant of the presentation; null when the template has no variants.</param>
|
||||
/// <param name="footerText">The footer text; empty to keep the footer of the template slide.</param>
|
||||
/// <returns>The layout the slide is based on now; null when its layout belongs to no family, which leaves the slide without slots to fill.</returns>
|
||||
private static TemplateLayout? AdaptTemplateSlide(SlidePart slidePart, TemplateSlide templateSlide, PresentationTemplate template, Dictionary<Uri, SlideLayoutPart> layoutParts, string? variantLabel, string footerText)
|
||||
{
|
||||
var slide = slidePart.Slide ?? throw new InvalidDataException($"The template slide {templateSlide.Number} has no content.");
|
||||
slide.Show = null;
|
||||
|
||||
TemplateLayout? layout = null;
|
||||
if (templateSlide.FamilyId is { } familyId && template.FindFamily(familyId) is { } family && family.Variants.FirstOrDefault(variant => variant.Layout.PartUri == templateSlide.LayoutPartUri)?.Layout is { } currentLayout)
|
||||
{
|
||||
layout = family.GetLayout(variantLabel);
|
||||
if (layout.PartUri != currentLayout.PartUri)
|
||||
MoveToLayout(slidePart, slide, currentLayout, layout, layoutParts);
|
||||
}
|
||||
|
||||
if (footerText.Length > 0)
|
||||
ReplaceFooter(slidePart, slide, footerText);
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the content the model wrote for a template slide into the slots of that slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only slots with content change; every other one keeps what the template slide holds. A text
|
||||
/// takes the formatting the slide gave the old one. A picture or a table replaces the old one
|
||||
/// as a whole, since nothing of its old shape fits the new content, and a picture nobody shows
|
||||
/// any more leaves the file. This holds for a table which is no placeholder as well, so that no
|
||||
/// row of the old table shows below a shorter new one. A new picture or table goes where the
|
||||
/// slide put the old one, should it have moved it away from where the layout puts it. A slot the
|
||||
/// slide does not carry gets its placeholder from the layout, as on a slide written anew. A copy
|
||||
/// of a slot takes its new text like a text slot, in the formatting of its old one.
|
||||
///
|
||||
/// What the slide removes goes first, so that the slots are filled on what is left.
|
||||
/// </remarks>
|
||||
/// <param name="slidePart">The slide part of the template slide, already on the layout of SlideContent.Layout.</param>
|
||||
/// <param name="templateSlide">The template slide, as read from the template, which knows where it moved its slots, which copies of slots and which elements it carries.</param>
|
||||
/// <param name="content">What goes on the slide.</param>
|
||||
private static void EditTemplateSlide(SlidePart slidePart, TemplateSlide templateSlide, SlideContent content)
|
||||
{
|
||||
if (slidePart.Slide?.CommonSlideData?.ShapeTree is not { } shapeTree)
|
||||
return;
|
||||
|
||||
RemoveFromTemplateSlide(slidePart, shapeTree, templateSlide, content);
|
||||
|
||||
// Which shape fills which slot, by the same reading as the template's:
|
||||
var shapesBySlot = TemplateSlideShapes.SlotShapesOf(shapeTree, content.Layout);
|
||||
|
||||
var layoutPlaceholders = LayoutPlaceholders.Of(content.LayoutElement);
|
||||
var slide = content.Slide;
|
||||
foreach (var slot in content.Layout.Slots)
|
||||
{
|
||||
var index = slot.Ordinal - 1;
|
||||
var existing = shapesBySlot.GetValueOrDefault(slot);
|
||||
var placeholder = existing is not null && TemplateSlideShapes.PlaceholderOf(existing) is { } existingPlaceholder
|
||||
? (P.PlaceholderShape)existingPlaceholder.CloneNode(true)
|
||||
: layoutPlaceholders.For(slot);
|
||||
|
||||
var paragraphs = ParagraphsFor(slot, slide);
|
||||
OpenXmlElement? replacement;
|
||||
if (paragraphs is not null)
|
||||
{
|
||||
if (existing is P.Shape shape)
|
||||
{
|
||||
ReplaceParagraphs(shape, paragraphs);
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = NextShapeIdOf(shapeTree);
|
||||
var name = slot.Role switch
|
||||
{
|
||||
PlaceholderRole.TITLE => "Title",
|
||||
PlaceholderRole.SUBTITLE => "Subtitle",
|
||||
PlaceholderRole.LABEL => "Label",
|
||||
|
||||
_ => "Text",
|
||||
};
|
||||
|
||||
replacement = TextShape(id, $"{name} {id}", placeholder, paragraphs, slot.Role is PlaceholderRole.TEXT);
|
||||
}
|
||||
else
|
||||
{
|
||||
var bounds = templateSlide.OnSlide(slot).Bounds;
|
||||
replacement = slot.Role switch
|
||||
{
|
||||
PlaceholderRole.PICTURE when index < slide.Pictures.Count && content.Pictures.TryGetValue(slide.Pictures[index], out var picture) => PictureShape(NextShapeIdOf(shapeTree), placeholder, slidePart, picture, bounds, bounds.Area >= content.SlideArea * PresentationTemplate.BACKDROP_AREA_SHARE),
|
||||
PlaceholderRole.TABLE when index == 0 && slide.Table is { Headers.Count: > 0 } table => TableShape(NextShapeIdOf(shapeTree), placeholder, table, bounds, content.TableStyleId),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
if (replacement is null)
|
||||
continue;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
shapeTree.Append(replacement);
|
||||
continue;
|
||||
}
|
||||
|
||||
// In the place of the old one, so that what lay above it still does:
|
||||
existing.InsertAfterSelf(replacement);
|
||||
RemoveShape(slidePart, existing);
|
||||
|
||||
//
|
||||
// A free table may stand in for an empty placeholder of its slot. The new table fills
|
||||
// that placeholder now, so the empty one would only fill the slot a second time:
|
||||
//
|
||||
if (TemplateSlideShapes.PlaceholderOf(existing) is null)
|
||||
{
|
||||
foreach (var emptyPlaceholder in shapeTree.Elements().Where(element => element != replacement && TemplateSlideShapes.PlaceholderOf(element) is { } other && content.Layout.SlotOf(other.Type?.InnerText ?? "obj", other.Index?.Value) == slot).ToList())
|
||||
RemoveShape(slidePart, emptyPlaceholder);
|
||||
}
|
||||
}
|
||||
|
||||
// A copy of a slot keeps its shape, and with it the look of the slot it repeats:
|
||||
foreach (var copy in templateSlide.SlotCopies)
|
||||
{
|
||||
if (ParagraphsFor(copy.Slot, slide) is { } paragraphs && ShapeWithId(shapeTree, copy.ShapeId) is P.Shape shape)
|
||||
ReplaceParagraphs(shape, paragraphs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paragraphs the slide writes into a text slot.
|
||||
/// </summary>
|
||||
/// <returns>The paragraphs; null when the slot is no text slot, or the slide leaves it empty.</returns>
|
||||
private static List<A.Paragraph>? ParagraphsFor(TemplateSlot slot, SlideSpec slide)
|
||||
{
|
||||
var index = slot.Ordinal - 1;
|
||||
return slot.Role switch
|
||||
{
|
||||
PlaceholderRole.TITLE when slide.Title.Length > 0 => ParagraphsOf(slide.Title),
|
||||
PlaceholderRole.SUBTITLE when slide.Subtitle.Length > 0 => ParagraphsOf(slide.Subtitle),
|
||||
PlaceholderRole.TEXT when index < slide.Texts.Count && slide.Texts[index].Paragraphs.Count > 0 => slide.Texts[index].Paragraphs.Select(ParagraphOf).ToList(),
|
||||
PlaceholderRole.LABEL when index < slide.Labels.Count && slide.Labels[index].Length > 0 => ParagraphsOf(slide.Labels[index]),
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top-level shape of a slide with the given ID.
|
||||
/// </summary>
|
||||
/// <returns>The shape; null when the slide no longer has it.</returns>
|
||||
private static OpenXmlElement? ShapeWithId(P.ShapeTree shapeTree, uint shapeId) => shapeTree.Elements().FirstOrDefault(candidate => TemplateSlideShapes.ShapeIdOf(candidate) == shapeId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes what an edited slide no longer shows: the elements, the slot contents and the copies of slots it names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The deck was checked against the template as it was read, so every name is known. A name
|
||||
/// which finds nothing, because the file changed in between, is passed over: there is nothing
|
||||
/// left to remove.
|
||||
/// </remarks>
|
||||
private static void RemoveFromTemplateSlide(SlidePart slidePart, P.ShapeTree shapeTree, TemplateSlide templateSlide, SlideContent content)
|
||||
{
|
||||
if (content.Slide.Removals.Count == 0)
|
||||
return;
|
||||
|
||||
var shapesBySlot = TemplateSlideShapes.SlotShapesOf(shapeTree, content.Layout);
|
||||
var shapes = new List<OpenXmlElement>();
|
||||
foreach (var name in content.Slide.Removals)
|
||||
{
|
||||
if (templateSlide.FindElement(name) is { } element)
|
||||
{
|
||||
if (ShapeWithId(shapeTree, element.ShapeId) is { } elementShape)
|
||||
shapes.Add(elementShape);
|
||||
}
|
||||
else if (PlaceholderRoleExtensions.TryParseSlotName(name, out var role, out var ordinal) && role is not PlaceholderRole.TITLE)
|
||||
{
|
||||
if (content.Layout.Slots.FirstOrDefault(candidate => candidate.Role == role && candidate.Ordinal == ordinal) is { } slot)
|
||||
{
|
||||
if (shapesBySlot.TryGetValue(slot, out var slotShape))
|
||||
shapes.Add(slotShape);
|
||||
}
|
||||
else if (templateSlide.FindCopy(role, ordinal) is { } copy && ShapeWithId(shapeTree, copy.ShapeId) is { } copyShape)
|
||||
shapes.Add(copyShape);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var shape in shapes.Distinct())
|
||||
RemoveShape(slidePart, shape);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a shape from a slide, together with the parts only it used.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A shape refers to parts of the file by relationship IDs: a picture to its image, a chart to
|
||||
/// its data, a diagram to its drawing. A part which nothing else on the slide refers to leaves
|
||||
/// the slide; the file drops it when no other slide refers to it either. An animation of the
|
||||
/// shape would point into nothing afterwards, which PowerPoint repairs by dropping every
|
||||
/// animation of the slide, so that is done here right away.
|
||||
/// </remarks>
|
||||
private static void RemoveShape(SlidePart slidePart, OpenXmlElement shape)
|
||||
{
|
||||
var relationshipIds = RelationshipIdsOf(shape);
|
||||
var shapeIds = shape.Descendants<P.NonVisualDrawingProperties>().Select(properties => properties.Id?.Value.ToString(CultureInfo.InvariantCulture)).OfType<string>().ToHashSet();
|
||||
shape.Remove();
|
||||
|
||||
var slide = slidePart.Slide;
|
||||
var stillUsed = slide is null ? new HashSet<string>() : RelationshipIdsOf(slide);
|
||||
foreach (var relationshipId in relationshipIds.Where(id => !stillUsed.Contains(id)))
|
||||
{
|
||||
if (slidePart.TryGetPartById(relationshipId, out var part))
|
||||
slidePart.DeletePart(part);
|
||||
}
|
||||
|
||||
if (slide?.Timing?.Descendants().Any(element => element.GetAttributes().Any(attribute => attribute.LocalName == "spid" && shapeIds.Contains(attribute.Value ?? string.Empty))) is true)
|
||||
slide.Timing = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The relationship IDs an element and everything within it refer to.
|
||||
/// </summary>
|
||||
private static HashSet<string> RelationshipIdsOf(OpenXmlElement element) => element.Descendants()
|
||||
.Prepend(element)
|
||||
.SelectMany(candidate => candidate.GetAttributes())
|
||||
.Where(attribute => attribute.NamespaceUri == RELATIONSHIPS_NAMESPACE)
|
||||
.Select(attribute => attribute.Value)
|
||||
.OfType<string>()
|
||||
.ToHashSet();
|
||||
|
||||
/// <summary>
|
||||
/// Moves a slide to the layout of the same family in another color variant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The variants of a family offer the same slots at the same places, yet not necessarily under
|
||||
/// the same placeholder indices. Every placeholder of the slide is therefore linked anew: a slot
|
||||
/// to the slot at the same position in reading order, and footer, slide number and date to
|
||||
/// those of the new layout. The old layout stays in the file, since its master still lists it.
|
||||
/// </remarks>
|
||||
private static void MoveToLayout(SlidePart slidePart, P.Slide slide, TemplateLayout currentLayout, TemplateLayout targetLayout, Dictionary<Uri, SlideLayoutPart> layoutParts)
|
||||
{
|
||||
var targetLayoutPart = LayoutPartOf(layoutParts, targetLayout);
|
||||
var targetPlaceholders = LayoutPlaceholders.Of(targetLayoutPart.SlideLayout);
|
||||
foreach (var placeholder in slide.CommonSlideData?.ShapeTree?.Descendants<P.PlaceholderShape>().ToList() ?? [])
|
||||
{
|
||||
var type = placeholder.Type?.InnerText ?? "obj";
|
||||
if (currentLayout.SlotOf(type, placeholder.Index?.Value) is { } slot)
|
||||
{
|
||||
var position = PositionOf(currentLayout.Slots, slot);
|
||||
if (position < targetLayout.Slots.Count)
|
||||
LinkTo(placeholder, slot, targetLayout.Slots[position]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetPlaceholder = type switch
|
||||
{
|
||||
"ftr" => targetPlaceholders.Footer,
|
||||
"sldNum" => targetPlaceholders.SlideNumber,
|
||||
"dt" => targetPlaceholders.Date,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (targetPlaceholder is not null)
|
||||
SetIndex(placeholder, targetPlaceholder.Index?.Value);
|
||||
}
|
||||
|
||||
if (slidePart.SlideLayoutPart is { } currentLayoutPart)
|
||||
slidePart.DeletePart(currentLayoutPart);
|
||||
|
||||
slidePart.AddPart(targetLayoutPart);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Links a placeholder of a slide to the slot of another layout which takes the place of its slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The type changes only where the two layouts spell it differently: a slide often states no
|
||||
/// type at all, which is fine as long as the index matches.
|
||||
/// </remarks>
|
||||
private static void LinkTo(P.PlaceholderShape placeholder, TemplateSlot slot, TemplateSlot targetSlot)
|
||||
{
|
||||
SetIndex(placeholder, targetSlot.PlaceholderIndex);
|
||||
if (targetSlot.PlaceholderType == slot.PlaceholderType)
|
||||
return;
|
||||
|
||||
if (targetSlot.PlaceholderType == "obj")
|
||||
placeholder.Type = null;
|
||||
else
|
||||
placeholder.Type = new P.PlaceholderValues(targetSlot.PlaceholderType);
|
||||
}
|
||||
|
||||
private static void SetIndex(P.PlaceholderShape placeholder, uint? index)
|
||||
{
|
||||
if (index is { } value)
|
||||
placeholder.Index = value;
|
||||
else
|
||||
placeholder.Index = null;
|
||||
}
|
||||
|
||||
private static int PositionOf(IReadOnlyList<TemplateSlot> slots, TemplateSlot slot)
|
||||
{
|
||||
for (var position = 0; position < slots.Count; position++)
|
||||
if (slots[position] == slot)
|
||||
return position;
|
||||
|
||||
return slots.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the footer of the presentation into the footer of a template slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A template slide without a footer gets one from its layout, like every slide written anew.
|
||||
/// </remarks>
|
||||
private static void ReplaceFooter(SlidePart slidePart, P.Slide slide, string footerText)
|
||||
{
|
||||
if (slide.CommonSlideData?.ShapeTree is not { } shapeTree)
|
||||
return;
|
||||
|
||||
var footers = shapeTree.Elements<P.Shape>().Where(shape => TemplateSlideShapes.PlaceholderOf(shape)?.Type?.InnerText == "ftr").ToList();
|
||||
foreach (var footer in footers)
|
||||
ReplaceParagraphs(footer, ParagraphsOf(footerText));
|
||||
|
||||
if (footers.Count == 0 && LayoutPlaceholders.Of(slidePart.SlideLayoutPart?.SlideLayout).Footer is { } layoutFooter)
|
||||
{
|
||||
var id = NextShapeIdOf(shapeTree);
|
||||
shapeTree.Append(TextShape(id, $"Footer {id}", Clone(layoutFooter), ParagraphsOf(footerText), false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the text of a shape on a template slide, keeping how the slide formats it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The body properties and the list style stay. A new paragraph takes the formatting of the first
|
||||
/// old paragraph on its list level: its paragraph properties, e.g., a bullet its author turned
|
||||
/// off, and the properties of its first run, e.g., a size or a color picked by hand. A paragraph
|
||||
/// without text keeps the latter in its end mark, which is where an author leaves them on a slide
|
||||
/// waiting for text.
|
||||
/// </remarks>
|
||||
private static void ReplaceParagraphs(P.Shape shape, IEnumerable<A.Paragraph> paragraphs)
|
||||
{
|
||||
var textBody = shape.TextBody ??= new P.TextBody(new A.BodyProperties(), new A.ListStyle());
|
||||
var formats = new Dictionary<int, (A.ParagraphProperties? Paragraph, A.RunProperties? Run)>();
|
||||
foreach (var oldParagraph in textBody.Elements<A.Paragraph>().ToList())
|
||||
{
|
||||
formats.TryAdd(LevelOf(oldParagraph), (oldParagraph.ParagraphProperties, RunPropertiesOf(oldParagraph)));
|
||||
oldParagraph.Remove();
|
||||
}
|
||||
|
||||
foreach (var paragraph in paragraphs)
|
||||
{
|
||||
if (formats.TryGetValue(LevelOf(paragraph), out var format))
|
||||
{
|
||||
if (format.Paragraph is not null)
|
||||
paragraph.ParagraphProperties = (A.ParagraphProperties)format.Paragraph.CloneNode(true);
|
||||
|
||||
if (format.Run is not null)
|
||||
foreach (var run in paragraph.Elements<A.Run>().Where(run => run.RunProperties is null))
|
||||
run.RunProperties = (A.RunProperties)format.Run.CloneNode(true);
|
||||
}
|
||||
|
||||
textBody.Append(paragraph);
|
||||
}
|
||||
}
|
||||
|
||||
private static int LevelOf(A.Paragraph paragraph) => paragraph.ParagraphProperties?.Level?.Value ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// How a paragraph formats its text: the properties of its first run, or of its end mark when it has no text.
|
||||
/// </summary>
|
||||
private static A.RunProperties? RunPropertiesOf(A.Paragraph paragraph)
|
||||
{
|
||||
if (paragraph.Elements<A.Run>().FirstOrDefault()?.RunProperties is { } runProperties)
|
||||
return runProperties;
|
||||
|
||||
if (paragraph.GetFirstChild<A.EndParagraphRunProperties>() is not { } endProperties)
|
||||
return null;
|
||||
|
||||
// Both carry the same properties of a text, under another name:
|
||||
var converted = new A.RunProperties(endProperties.ChildElements.Select(child => child.CloneNode(true)));
|
||||
converted.SetAttributes(endProperties.GetAttributes());
|
||||
return converted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The next free shape ID of a slide.
|
||||
/// </summary>
|
||||
private static uint NextShapeIdOf(P.ShapeTree shapeTree) => shapeTree.Descendants<P.NonVisualDrawingProperties>().Select(properties => properties.Id?.Value ?? 0U).DefaultIfEmpty(1U).Max() + 1;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a presentation from a template and a checked slide deck.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The presentation is the template itself, with its slides replaced: masters, layouts, theme, fonts
|
||||
/// and table styles stay exactly as their author made them. Every new slide is based on a layout of
|
||||
/// the template and fills that layout's placeholders, without any formatting of its own, so the
|
||||
/// corporate design applies to everything written here. Slots without content get no placeholder
|
||||
/// at all, which leaves no prompt text such as "Click to add text" behind. Picture slots are the
|
||||
/// exception when the user asks for it: an empty one keeps its placeholder, which shows the prompt
|
||||
/// text of the layout while editing and nothing in the slide show, so that the user can insert a
|
||||
/// picture of their own.
|
||||
///
|
||||
/// The template slides the deck refers to stay, with their pictures and notes. They take the color
|
||||
/// variant and the footer of the presentation, so that they look like the slides around them. A
|
||||
/// slide the model edited takes its new content into its slots, and keeps the rest.
|
||||
///
|
||||
/// The deck has to pass the SlideDeckSpecValidator first; what fits the template is not
|
||||
/// checked again here.
|
||||
/// </remarks>
|
||||
internal static partial class PresentationWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// The smallest slide ID PowerPoint accepts.
|
||||
/// </summary>
|
||||
private const uint FIRST_SLIDE_ID = 256;
|
||||
|
||||
/// <summary>
|
||||
/// The picture formats PowerPoint shows everywhere. WebP, for instance, only newer versions do.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> SUPPORTED_PICTURE_TYPES = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Writes the presentation into a copy of the template, which it edits in place.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The caller copies the template, e.g., into a temporary file as PresentationFile does, so that the
|
||||
/// template stays untouched and no further copy of the file is held in memory. The Open XML SDK
|
||||
/// still holds the compressed parts of the whole file in memory while it saves: measured with a
|
||||
/// template of 150 MB, editing a copy on disk allocated about 150 MB, while copying the file into
|
||||
/// memory streams, as this writer did before, allocated about 810 MB.
|
||||
/// </remarks>
|
||||
/// <param name="presentationFile">A copy of the template, a .pptx or a .potx, positioned at its start; readable, writable, and seekable.</param>
|
||||
/// <param name="template">The template as read from the same file.</param>
|
||||
/// <param name="deck">The slide deck, already checked against the template.</param>
|
||||
/// <param name="options">What the user chose for the presentation as a whole.</param>
|
||||
/// <param name="pictures">Where the pictures the deck refers to come from.</param>
|
||||
/// <param name="token">The cancellation token.</param>
|
||||
/// <returns>What happened beyond writing.</returns>
|
||||
public static async Task<PresentationWriteResult> WriteAsync(Stream presentationFile, PresentationTemplate template, SlideDeckSpec deck, PresentationWriteOptions options, IPictureSource pictures, CancellationToken token)
|
||||
{
|
||||
//
|
||||
// Loading the pictures is the only part which waits. The document is edited in one go
|
||||
// afterwards, since the Open XML SDK works synchronously:
|
||||
//
|
||||
var (loadedPictures, skippedPictures) = await LoadPicturesAsync(deck, pictures, token);
|
||||
|
||||
using (var document = PresentationDocument.Open(presentationFile, true))
|
||||
{
|
||||
// A template (.potx) becomes a presentation; everything else about it stays:
|
||||
if (document.DocumentType != PresentationDocumentType.Presentation)
|
||||
document.ChangeDocumentType(PresentationDocumentType.Presentation);
|
||||
|
||||
var presentationPart = document.PresentationPart ?? throw new InvalidDataException("The file contains no presentation.");
|
||||
var presentation = presentationPart.Presentation ?? throw new InvalidDataException("The file contains no presentation.");
|
||||
|
||||
var slides = deck.Slides;
|
||||
var keptSlideParts = RemoveSlides(presentationPart, presentation, TemplateSlidePartUrisOf(template, slides));
|
||||
|
||||
var layoutParts = presentationPart.SlideMasterParts.SelectMany(master => master.SlideLayoutParts).ToDictionary(part => part.Uri);
|
||||
var tableStyleId = presentationPart.TableStylesPart?.TableStyleList?.Default?.Value;
|
||||
var slideIdList = presentation.SlideIdList ??= new P.SlideIdList();
|
||||
var variantLabel = VariantLabelOf(template, options);
|
||||
var slideIds = new List<uint>(slides.Count);
|
||||
var usedTemplateSlides = new HashSet<Uri>();
|
||||
var slideArea = (double)template.SlideWidth * template.SlideHeight;
|
||||
for (var index = 0; index < slides.Count; index++)
|
||||
{
|
||||
var slide = slides[index];
|
||||
SlidePart slidePart;
|
||||
if (slide.TemplateSlide is { } number)
|
||||
{
|
||||
var templateSlide = template.FindSlide(number) ?? throw new InvalidOperationException($"The slide deck uses template slide {number}, which the template does not have. Check the deck before writing it.");
|
||||
slidePart = TemplateSlidePartOf(keptSlideParts, usedTemplateSlides, templateSlide);
|
||||
var layout = AdaptTemplateSlide(slidePart, templateSlide, template, layoutParts, variantLabel, options.FooterText);
|
||||
if (slide.HasContent)
|
||||
{
|
||||
if (layout is null)
|
||||
throw new InvalidOperationException($"The slide deck writes content into template slide {number}, whose layout has no slots for it. Check the deck before writing it.");
|
||||
|
||||
EditTemplateSlide(slidePart, templateSlide, new SlideContent(slide, layout, slidePart.SlideLayoutPart?.SlideLayout, loadedPictures, tableStyleId, options.FooterText, slideArea, options.KeepEmptyPicturePlaceholders));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var family = template.FindFamily(slide.Layout) ?? throw new InvalidOperationException($"The slide deck uses the layout '{slide.Layout}', which the template does not have. Check the deck before writing it.");
|
||||
var layout = family.GetLayout(variantLabel);
|
||||
var layoutPart = LayoutPartOf(layoutParts, layout);
|
||||
slidePart = presentationPart.AddNewPart<SlidePart>();
|
||||
slidePart.AddPart(layoutPart);
|
||||
var content = new SlideContent(slide, layout, layoutPart.SlideLayout, loadedPictures, tableStyleId, options.FooterText, slideArea, options.KeepEmptyPicturePlaceholders);
|
||||
slidePart.Slide = new P.Slide(
|
||||
new P.CommonSlideData(ShapeTreeOf(slidePart, content)),
|
||||
new P.ColorMapOverride(new A.MasterColorMapping()));
|
||||
}
|
||||
|
||||
var slideId = FIRST_SLIDE_ID + (uint)index;
|
||||
slideIdList.Append(new P.SlideId { Id = slideId, RelationshipId = presentationPart.GetIdOfPart(slidePart) });
|
||||
slideIds.Add(slideId);
|
||||
}
|
||||
|
||||
WriteSections(presentation, template, deck.Title, slides, slideIds);
|
||||
}
|
||||
|
||||
return new PresentationWriteResult(skippedPictures);
|
||||
}
|
||||
|
||||
private static SlideLayoutPart LayoutPartOf(Dictionary<Uri, SlideLayoutPart> layoutParts, TemplateLayout layout) => layoutParts.TryGetValue(layout.PartUri, out var layoutPart)
|
||||
? layoutPart
|
||||
: throw new InvalidOperationException($"The template has no layout at '{layout.PartUri}'. The template was read from another file than the one being written.");
|
||||
|
||||
private static async Task<LoadedPictures> LoadPicturesAsync(SlideDeckSpec deck, IPictureSource pictures, CancellationToken token)
|
||||
{
|
||||
var loaded = new Dictionary<int, SlidePicture>();
|
||||
var skipped = new List<int>();
|
||||
foreach (var number in deck.Slides.SelectMany(slide => slide.Pictures).Distinct())
|
||||
{
|
||||
var picture = await pictures.LoadAsync(number, token);
|
||||
if (picture is { Width: > 0, Height: > 0 } && SUPPORTED_PICTURE_TYPES.Contains(picture.ContentType))
|
||||
loaded[number] = picture;
|
||||
else
|
||||
skipped.Add(number);
|
||||
}
|
||||
|
||||
return new LoadedPictures(loaded, skipped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the slides of the template, which are samples or hints for the user, except those the deck keeps.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whatever lists slides by their IDs has to go with them, or PowerPoint finds references to
|
||||
/// nothing and offers to repair the file: custom shows and sections. Sections are written anew
|
||||
/// for the new slides. The kept slides lose their place in the list as well, and get it back in
|
||||
/// the order of the deck.
|
||||
/// </remarks>
|
||||
/// <param name="presentationPart">The presentation part, which holds the slides.</param>
|
||||
/// <param name="presentation">The presentation, which lists the slides.</param>
|
||||
/// <param name="keptSlides">Where the slides the deck keeps are stored inside the file.</param>
|
||||
/// <returns>The slides the deck keeps, by where they are stored.</returns>
|
||||
private static Dictionary<Uri, SlidePart> RemoveSlides(PresentationPart presentationPart, P.Presentation presentation, IReadOnlySet<Uri> keptSlides)
|
||||
{
|
||||
var keptSlideParts = new Dictionary<Uri, SlidePart>();
|
||||
foreach (var slideId in presentation.SlideIdList?.Elements<P.SlideId>().ToList() ?? [])
|
||||
{
|
||||
if (slideId.RelationshipId?.Value is { } relationshipId && presentationPart.TryGetPartById(relationshipId, out var slidePart))
|
||||
{
|
||||
if (slidePart is SlidePart keptSlidePart && keptSlides.Contains(keptSlidePart.Uri))
|
||||
keptSlideParts.TryAdd(keptSlidePart.Uri, keptSlidePart);
|
||||
else
|
||||
presentationPart.DeletePart(slidePart);
|
||||
}
|
||||
|
||||
slideId.Remove();
|
||||
}
|
||||
|
||||
presentation.CustomShowList = null;
|
||||
RemoveSections(presentation);
|
||||
return keptSlideParts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The title of a slide of the deck: its own, or that of the template slide it keeps.
|
||||
/// </summary>
|
||||
private static string TitleOf(SlideSpec slide, PresentationTemplate template) => slide.Title.Length > 0
|
||||
? slide.Title
|
||||
: slide.TemplateSlide is { } number ? template.FindSlide(number)?.Title ?? string.Empty : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Says which variant all slides are written in: the one the user chose, or the first one of
|
||||
/// the template when it has no such variant.
|
||||
/// </summary>
|
||||
/// <returns>The label of the variant; null when the template carries every layout once.</returns>
|
||||
private static string? VariantLabelOf(PresentationTemplate template, PresentationWriteOptions options) =>
|
||||
options.VariantLabel is { } label && template.VariantLabels.Contains(label)
|
||||
? label
|
||||
: template.VariantLabels.FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Everything needed to fill the shape tree of one slide.
|
||||
/// </summary>
|
||||
/// <param name="Slide">What goes on the slide.</param>
|
||||
/// <param name="Layout">The layout the slide is based on, as read from the template.</param>
|
||||
/// <param name="LayoutElement">The same layout in the document being written, whose placeholders the slide refers to.</param>
|
||||
/// <param name="Pictures">The loaded pictures, by their numbers.</param>
|
||||
/// <param name="TableStyleId">The default table style of the template, if it states one.</param>
|
||||
/// <param name="FooterText">The footer text; empty for no footer.</param>
|
||||
/// <param name="SlideArea">The area of a slide, in square EMU.</param>
|
||||
/// <param name="KeepEmptyPicturePlaceholders">Whether a new slide keeps the picture placeholders which no picture fills.</param>
|
||||
private sealed record SlideContent(SlideSpec Slide, TemplateLayout Layout, P.SlideLayout? LayoutElement, IReadOnlyDictionary<int, SlidePicture> Pictures, string? TableStyleId, string FooterText, double SlideArea, bool KeepEmptyPicturePlaceholders);
|
||||
|
||||
/// <summary>
|
||||
/// The pictures a slide deck refers to, as far as they could be loaded.
|
||||
/// </summary>
|
||||
/// <param name="Pictures">The loaded pictures, by their numbers.</param>
|
||||
/// <param name="Skipped">The numbers of the pictures which cannot be read, or which come in a format PowerPoint does not show.</param>
|
||||
private sealed record LoadedPictures(IReadOnlyDictionary<int, SlidePicture> Pictures, IReadOnlyList<int> Skipped);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the attached pictures through the runtime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The runtime checks that each file is the image its extension claims, and scales large pictures
|
||||
/// down, so that a presentation full of photos stays small enough to send by mail.
|
||||
/// </remarks>
|
||||
/// <param name="rustService">The runtime.</param>
|
||||
/// <param name="picturePaths">The paths of the pictures the slides use, by the numbers the model gave them.</param>
|
||||
/// <param name="iconNumbers">The numbers of the pictures which are icons of a picture library.</param>
|
||||
internal sealed class RustPictureSource(RustService rustService, IReadOnlyDictionary<int, string> picturePaths, IReadOnlySet<int> iconNumbers) : IPictureSource
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SlidePicture?> LoadAsync(int number, CancellationToken token)
|
||||
{
|
||||
if (!picturePaths.TryGetValue(number, out var picturePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var prepared = await rustService.PrepareImageAsync(picturePath, true, token);
|
||||
var dataStart = prepared.DataUrl.IndexOf(',');
|
||||
if (dataStart < 0)
|
||||
return null;
|
||||
|
||||
return new SlidePicture(Convert.FromBase64String(prepared.DataUrl[(dataStart + 1)..]), prepared.MimeType, (int)prepared.Width, (int)prepared.Height, iconNumbers.Contains(number));
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
// The slot stays empty, and the writer reports the picture as skipped:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal sealed partial record SlideDeckSpec
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SlideDeckSpec).Namespace, nameof(SlideDeckSpec));
|
||||
|
||||
/// <summary>
|
||||
/// Writes the slide deck as a Markdown outline, which the user reads before creating the file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The outline is what the Slide Assistant always produced, and it keeps working wherever Markdown
|
||||
/// does: copying it, exporting it to Word, or taking it into a chat. Slides starting a chapter
|
||||
/// get a larger heading. Within a slide, the content follows the reading order of its layout, so
|
||||
/// a heading above a text comes before that text, and the source of a picture after the picture.
|
||||
///
|
||||
/// A slide taken from the template says so, and shows what it keeps of the template: all of it
|
||||
/// when kept unchanged, and every slot the model neither filled nor removed when edited. Such
|
||||
/// notes of the outline itself are in the language of the app, since the user reads them.
|
||||
/// </remarks>
|
||||
/// <param name="template">The template the deck was written for, which gives the reading order and the template slides.</param>
|
||||
/// <param name="pictureNames">The names of the pictures the slides use, by the numbers the model gave them.</param>
|
||||
/// <returns>The outline.</returns>
|
||||
public string ToMarkdownOutline(PresentationTemplate template, IReadOnlyDictionary<int, string> pictureNames)
|
||||
{
|
||||
var outline = new StringBuilder();
|
||||
outline.Append("# ").Append(this.Title).Append('\n');
|
||||
foreach (var slide in this.Slides)
|
||||
{
|
||||
outline.Append("\n***\n");
|
||||
var templateSlide = slide.TemplateSlide is { } number ? template.FindSlide(number) : null;
|
||||
var title = slide.Title.Length > 0 ? slide.Title : templateSlide?.Title ?? string.Empty;
|
||||
if (title.Length > 0)
|
||||
outline.Append('\n').Append(slide.StartsChapter ? "## " : "### ").Append(title).Append('\n');
|
||||
|
||||
if (templateSlide is null)
|
||||
{
|
||||
foreach (var slot in template.FindFamily(slide.Layout)?.Slots ?? [])
|
||||
AppendSlot(outline, slide, slot, pictureNames);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var templateNote = slide.HasContent ? TB("Slide {0} of the template, edited") : TB("Slide {0} of the template, unchanged");
|
||||
outline.Append('\n').Append('*').Append(string.Format(templateNote, templateSlide.Number)).Append("*\n");
|
||||
|
||||
// A slide on a layout without room for content shows what it holds all the same:
|
||||
var slots = template.FamilyOf(slide) is not null ? templateSlide.Slots : templateSlide.Contents.Select(content => content.Slot).ToList();
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
if (AppendSlot(outline, slide, slot, pictureNames))
|
||||
continue;
|
||||
|
||||
if (!slide.Removes(slot) && templateSlide.Contents.FirstOrDefault(content => content.Slot.Role == slot.Role && content.Slot.Ordinal == slot.Ordinal) is { } templateContent)
|
||||
AppendTemplateContent(outline, templateContent);
|
||||
}
|
||||
}
|
||||
|
||||
return outline.ToString().TrimEnd('\n');
|
||||
}
|
||||
|
||||
/// <returns>Whether the slide has content for the slot, which was appended.</returns>
|
||||
private static bool AppendSlot(StringBuilder outline, SlideSpec slide, TemplateSlot slot, IReadOnlyDictionary<int, string> pictureNames)
|
||||
{
|
||||
var index = slot.Ordinal - 1;
|
||||
switch (slot.Role)
|
||||
{
|
||||
case PlaceholderRole.SUBTITLE when slide.Subtitle.Length > 0:
|
||||
outline.Append('\n').Append('_').Append(slide.Subtitle).Append("_\n");
|
||||
return true;
|
||||
|
||||
case PlaceholderRole.TEXT when index < slide.Texts.Count && slide.Texts[index].Paragraphs.Count > 0:
|
||||
outline.Append('\n');
|
||||
foreach (var paragraph in slide.Texts[index].Paragraphs)
|
||||
outline.Append(' ', 2 * paragraph.Level).Append("- ").Append(paragraph.Text).Append('\n');
|
||||
|
||||
return true;
|
||||
|
||||
case PlaceholderRole.LABEL when index < slide.Labels.Count && slide.Labels[index].Length > 0:
|
||||
outline.Append('\n').Append('_').Append(slide.Labels[index]).Append("_\n");
|
||||
return true;
|
||||
|
||||
case PlaceholderRole.PICTURE when index < slide.Pictures.Count:
|
||||
var number = slide.Pictures[index];
|
||||
var name = pictureNames.TryGetValue(number, out var pictureName) ? pictureName : $"#{number}";
|
||||
outline.Append('\n').Append('*').Append(string.Format(TB("Picture: {0}"), name)).Append("*\n");
|
||||
return true;
|
||||
|
||||
case PlaceholderRole.TABLE when index == 0 && slide.Table is { Headers.Count: > 0 } table:
|
||||
outline.Append('\n');
|
||||
AppendTableRow(outline, table.Headers);
|
||||
AppendTableRow(outline, table.Headers.Select(_ => "---").ToList());
|
||||
foreach (var row in table.Rows)
|
||||
AppendTableRow(outline, row);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows what a slot of a template slide holds, where the deck keeps it as it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The title is the heading of the slide already. The rows of a table become list items, since
|
||||
/// the template slide tells their cells apart only by " | ".
|
||||
/// </remarks>
|
||||
private static void AppendTemplateContent(StringBuilder outline, TemplateSlideContent content)
|
||||
{
|
||||
switch (content.Slot.Role)
|
||||
{
|
||||
case PlaceholderRole.SUBTITLE or PlaceholderRole.LABEL:
|
||||
outline.Append('\n').Append('_').Append(content.Text.ReplaceLineEndings(" ")).Append("_\n");
|
||||
break;
|
||||
|
||||
case PlaceholderRole.TEXT or PlaceholderRole.TABLE:
|
||||
outline.Append('\n');
|
||||
foreach (var line in content.Text.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0))
|
||||
outline.Append("- ").Append(line).Append('\n');
|
||||
|
||||
break;
|
||||
|
||||
case PlaceholderRole.PICTURE:
|
||||
outline.Append('\n').Append('*').Append(TB("Picture of the template")).Append("*\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendTableRow(StringBuilder outline, IReadOnlyList<string> cells)
|
||||
{
|
||||
outline.Append('|');
|
||||
foreach (var cell in cells)
|
||||
outline.Append(' ').Append(cell.ReplaceLineEndings(" ").Replace("|", "\\|")).Append(" |");
|
||||
|
||||
outline.Append('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Assistants.Builder;
|
||||
// ReSharper disable UnusedAutoPropertyAccessor.Local
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal sealed partial record SlideDeckSpec
|
||||
{
|
||||
private static readonly JsonSerializerOptions JSON_OPTIONS = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
MaxDepth = 16,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The deepest list level a paragraph may have; PowerPoint knows nine, starting at 0.
|
||||
/// </summary>
|
||||
private const int MAX_PARAGRAPH_LEVEL = 8;
|
||||
|
||||
/// <summary>
|
||||
/// What models put in front of a bullet point although the template draws the bullets.
|
||||
/// </summary>
|
||||
private static readonly string[] BULLET_PREFIXES = ["- ", "* ", "• ", "– "];
|
||||
|
||||
/// <summary>
|
||||
/// Reads the slide deck out of a model answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The JSON may be wrapped in prose or in a code fence. A text slot may be written as a list of
|
||||
/// paragraphs or as a single string, and a paragraph as a string or as an object with a text and a
|
||||
/// level. A string holding line breaks counts as one paragraph per line.
|
||||
/// </remarks>
|
||||
/// <param name="modelResponse">The answer of the model.</param>
|
||||
/// <param name="spec">The slide deck, or an empty one when reading failed.</param>
|
||||
/// <param name="issue">Why reading failed, written for the model; empty on success.</param>
|
||||
/// <returns>Whether the answer could be read.</returns>
|
||||
public static bool TryParse(string modelResponse, out SlideDeckSpec spec, out string issue)
|
||||
{
|
||||
spec = new SlideDeckSpec(string.Empty, []);
|
||||
issue = string.Empty;
|
||||
|
||||
var json = LuaResponse.ExtractJson(modelResponse);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
issue = "The answer contains no complete JSON object.";
|
||||
return false;
|
||||
}
|
||||
|
||||
DeckDto? deck;
|
||||
try
|
||||
{
|
||||
deck = JsonSerializer.Deserialize<DeckDto>(json, JSON_OPTIONS);
|
||||
}
|
||||
catch (JsonException e)
|
||||
{
|
||||
issue = $"The JSON object cannot be read: {e.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deck is null)
|
||||
{
|
||||
issue = "The JSON object is empty.";
|
||||
return false;
|
||||
}
|
||||
|
||||
spec = new SlideDeckSpec(Clean(deck.Title), (deck.Slides ?? []).OfType<SlideDto>().Select(ToSlide).ToList());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SlideSpec ToSlide(SlideDto slide) => new(
|
||||
Clean(slide.Layout),
|
||||
slide.StartsChapter ?? false,
|
||||
Clean(slide.Title),
|
||||
Clean(slide.Subtitle),
|
||||
(slide.Texts ?? []).Select(ToTextBlock).ToList(),
|
||||
(slide.Labels ?? []).Select(TextOf).ToList(),
|
||||
slide.Pictures ?? [],
|
||||
slide.Table is null ? null : ToTable(slide.Table),
|
||||
slide.TemplateSlide)
|
||||
{
|
||||
Removals = ToRemovals(slide.Remove),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The names of what a slide removes from its template slide: a list of names, or a single one,
|
||||
/// each named once.
|
||||
/// </summary>
|
||||
private static List<string> ToRemovals(JsonElement? remove)
|
||||
{
|
||||
List<JsonElement> names = remove switch
|
||||
{
|
||||
{ ValueKind: JsonValueKind.Array } list => list.EnumerateArray().ToList(),
|
||||
{ } single => [single],
|
||||
|
||||
_ => [],
|
||||
};
|
||||
|
||||
return names.Select(TextOf).Where(name => name.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static SlideTextBlock ToTextBlock(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => new SlideTextBlock(element.EnumerateArray().SelectMany(ToParagraphs).ToList()),
|
||||
|
||||
_ => new SlideTextBlock(ToParagraphs(element).ToList()),
|
||||
};
|
||||
|
||||
private static IEnumerable<SlideParagraph> ToParagraphs(JsonElement element)
|
||||
{
|
||||
var level = 0;
|
||||
var text = TextOf(element);
|
||||
if (element.ValueKind is JsonValueKind.Object)
|
||||
{
|
||||
text = element.TryGetProperty("text", out var textElement) ? TextOf(textElement) : string.Empty;
|
||||
if (element.TryGetProperty("level", out var levelElement) && levelElement.ValueKind is JsonValueKind.Number && levelElement.TryGetInt32(out var parsedLevel))
|
||||
level = Math.Clamp(parsedLevel, 0, MAX_PARAGRAPH_LEVEL);
|
||||
}
|
||||
|
||||
foreach (var line in text.ReplaceLineEndings("\n").Split('\n'))
|
||||
{
|
||||
var paragraph = WithoutBullet(line.Trim());
|
||||
if (paragraph.Length > 0)
|
||||
yield return new SlideParagraph(paragraph, level);
|
||||
}
|
||||
}
|
||||
|
||||
private static SlideTableSpec ToTable(TableDto table) => new(
|
||||
(table.Headers ?? []).Select(TextOf).ToList(),
|
||||
(table.Rows ?? []).OfType<List<JsonElement>>().Select(row => (IReadOnlyList<string>)row.Select(TextOf).ToList()).ToList());
|
||||
|
||||
/// <summary>
|
||||
/// The text of a JSON value, whatever kind of value the model chose: a number in a table cell
|
||||
/// is as good as a string.
|
||||
/// </summary>
|
||||
private static string TextOf(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => Clean(element.GetString()),
|
||||
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.GetRawText(),
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => string.Empty,
|
||||
|
||||
_ => element.GetRawText(),
|
||||
};
|
||||
|
||||
private static string WithoutBullet(string text)
|
||||
{
|
||||
foreach (var prefix in BULLET_PREFIXES)
|
||||
if (text.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return text[prefix.Length..].TrimStart();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string Clean(string? text) => text?.Trim() ?? string.Empty;
|
||||
|
||||
//
|
||||
// What the model writes, before anything is checked. Every value may be missing or null.
|
||||
//
|
||||
|
||||
private sealed class DeckDto
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
|
||||
public List<SlideDto?>? Slides { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SlideDto
|
||||
{
|
||||
public int? TemplateSlide { get; set; }
|
||||
|
||||
public string? Layout { get; set; }
|
||||
|
||||
public bool? StartsChapter { get; set; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Subtitle { get; set; }
|
||||
|
||||
public List<JsonElement>? Texts { get; set; }
|
||||
|
||||
public List<JsonElement>? Labels { get; set; }
|
||||
|
||||
public List<int>? Pictures { get; set; }
|
||||
|
||||
public TableDto? Table { get; set; }
|
||||
|
||||
public JsonElement? Remove { get; set; }
|
||||
}
|
||||
|
||||
private sealed class TableDto
|
||||
{
|
||||
public List<JsonElement>? Headers { get; set; }
|
||||
|
||||
public List<List<JsonElement>?>? Rows { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// A presentation as the model describes it, slide by slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The model writes JSON, which is read leniently into this shape: whatever the model may write in
|
||||
/// several ways, such as a number in a table cell, is accepted, and every missing value becomes
|
||||
/// empty. Whether the slides fit the template is a separate question, which
|
||||
/// SlideDeckSpecValidator answers.
|
||||
/// </remarks>
|
||||
/// <param name="Title">The title of the presentation.</param>
|
||||
/// <param name="Slides">The slides, in order.</param>
|
||||
internal sealed partial record SlideDeckSpec(string Title, IReadOnlyList<SlideSpec> Slides);
|
||||
@@ -0,0 +1,521 @@
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// One paragraph of a text slot: a bullet point, or a sub-point of one.
|
||||
/// </summary>
|
||||
/// <param name="Text">The text, without a bullet character; the template draws the bullets.</param>
|
||||
/// <param name="Level">The list level, 0 for a top-level point.</param>
|
||||
internal readonly record struct SlideParagraph(string Text, int Level);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// A picture, ready to be placed on a slide.
|
||||
/// </summary>
|
||||
/// <param name="Data">The encoded image.</param>
|
||||
/// <param name="ContentType">The MIME type of the encoded image, e.g., "image/png".</param>
|
||||
/// <param name="Width">The width in pixels.</param>
|
||||
/// <param name="Height">The height in pixels.</param>
|
||||
/// <param name="IsIcon">Whether the picture is an icon of a picture library, which is never cropped.</param>
|
||||
internal sealed record SlidePicture(byte[] Data, string ContentType, int Width, int Height, bool IsIcon = false);
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// One slide as the model describes it: a layout family of the template, and the content of its slots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The lists fill the slots of their role in reading order: the first text goes into "text 1" of the
|
||||
/// catalog, the second into "text 2", and so on. An empty entry keeps its slot empty, and the
|
||||
/// entries after it keep their slots. A slot left without content is removed from the slide, so
|
||||
/// that no prompt text of the template stays behind. Only a picture slot may stay, when the user
|
||||
/// asks for it, see PresentationWriteOptions.KeepEmptyPicturePlaceholders.
|
||||
///
|
||||
/// A slide may refer to a slide of the template instead of a layout. Without content, it keeps that
|
||||
/// slide as it is. With content, the content replaces what the slots of that slide hold, and every
|
||||
/// slot it leaves empty keeps the content of the template, unless the slide removes it. What the
|
||||
/// template slide carries besides its slots stays as well, unless the slide removes it. Where the
|
||||
/// template slide carries copies of a slot, e.g., a second heading with its text, the numbers go on
|
||||
/// through them: "label 2" is the first copy of a layout with a single label.
|
||||
/// </remarks>
|
||||
/// <param name="Layout">The ID of the layout family, e.g., "L3"; not used when the slide refers to a template slide, which keeps its own layout.</param>
|
||||
/// <param name="StartsChapter">Whether a new chapter starts with this slide, which opens a section of the presentation.</param>
|
||||
/// <param name="Title">The title; empty when the slide has none.</param>
|
||||
/// <param name="Subtitle">The subtitle; empty when the slide has none.</param>
|
||||
/// <param name="Texts">The content of the text slots.</param>
|
||||
/// <param name="Labels">The content of the label slots.</param>
|
||||
/// <param name="Pictures">The pictures for the picture slots, as 1-based numbers of the attached pictures.</param>
|
||||
/// <param name="Table">The content of the table slot, if any.</param>
|
||||
/// <param name="TemplateSlide">The number of the template slide the slide keeps or edits; null for a new slide.</param>
|
||||
internal sealed record SlideSpec(string Layout, bool StartsChapter, string Title, string Subtitle, IReadOnlyList<SlideTextBlock> Texts, IReadOnlyList<string> Labels, IReadOnlyList<int> Pictures, SlideTableSpec? Table, int? TemplateSlide = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// What the slide removes from the template slide it edits, by the names the model read: slots,
|
||||
/// e.g., "label 1", and elements, e.g., "element 2". Empty for every other slide.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Removals { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the slide brings content of its own, rather than keeping a template slide as it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Removing something changes a template slide as well.
|
||||
/// </remarks>
|
||||
public bool HasContent =>
|
||||
this.Title.Length > 0 ||
|
||||
this.Subtitle.Length > 0 ||
|
||||
this.Texts.Any(text => text.Paragraphs.Count > 0) ||
|
||||
this.Labels.Any(label => label.Length > 0) ||
|
||||
this.Pictures.Count > 0 ||
|
||||
this.Table is not null ||
|
||||
this.Removals.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the slide has content for a slot.
|
||||
/// </summary>
|
||||
/// <param name="role">The role of the slot.</param>
|
||||
/// <param name="ordinal">The number of the slot among the slots of its role, starting at 1.</param>
|
||||
public bool Fills(PlaceholderRole role, int ordinal) => role switch
|
||||
{
|
||||
PlaceholderRole.TITLE => this.Title.Length > 0,
|
||||
PlaceholderRole.SUBTITLE => this.Subtitle.Length > 0,
|
||||
PlaceholderRole.TEXT => ordinal >= 1 && ordinal <= this.Texts.Count && this.Texts[ordinal - 1].Paragraphs.Count > 0,
|
||||
PlaceholderRole.LABEL => ordinal >= 1 && ordinal <= this.Labels.Count && this.Labels[ordinal - 1].Length > 0,
|
||||
PlaceholderRole.PICTURE => ordinal >= 1 && ordinal <= this.Pictures.Count,
|
||||
PlaceholderRole.TABLE => ordinal == 1 && this.Table is { Headers.Count: > 0 },
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the slide removes the content a slot of its template slide holds.
|
||||
/// </summary>
|
||||
/// <param name="slot">The slot.</param>
|
||||
public bool Removes(TemplateSlot slot) => this.Removals.Any(name => PlaceholderRoleExtensions.TryParseSlotName(name, out var role, out var ordinal) && role == slot.Role && ordinal == slot.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// The content of a table slot.
|
||||
/// </summary>
|
||||
/// <param name="Headers">The column headers, which also give the number of columns.</param>
|
||||
/// <param name="Rows">The rows below the header, each with one text per column.</param>
|
||||
internal sealed record SlideTableSpec(IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// The content of one text slot.
|
||||
/// </summary>
|
||||
/// <param name="Paragraphs">The paragraphs, in order.</param>
|
||||
internal sealed record SlideTextBlock(IReadOnlyList<SlideParagraph> Paragraphs);
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Where a placeholder sits on the slide, in English Metric Units (EMU).
|
||||
/// </summary>
|
||||
/// <param name="X">The distance of the left edge from the left edge of the slide.</param>
|
||||
/// <param name="Y">The distance of the top edge from the top edge of the slide.</param>
|
||||
/// <param name="Width">The width.</param>
|
||||
/// <param name="Height">The height.</param>
|
||||
internal readonly record struct SlotBounds(long X, long Y, long Width, long Height)
|
||||
{
|
||||
public long Right => this.X + this.Width;
|
||||
|
||||
public long Bottom => this.Y + this.Height;
|
||||
|
||||
public long CenterX => this.X + this.Width / 2;
|
||||
|
||||
public long CenterY => this.Y + this.Height / 2;
|
||||
|
||||
public double Area => (double)this.Width * this.Height;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the two areas share a part of their horizontal extent.
|
||||
/// </summary>
|
||||
public bool OverlapsHorizontally(SlotBounds other) => this.X < other.Right && other.X < this.Right;
|
||||
|
||||
/// <summary>
|
||||
/// The area both share; 0 when they do not overlap.
|
||||
/// </summary>
|
||||
public double IntersectionArea(SlotBounds other)
|
||||
{
|
||||
var width = Math.Min(this.Right, other.Right) - Math.Max(this.X, other.X);
|
||||
var height = Math.Min(this.Bottom, other.Bottom) - Math.Max(this.Y, other.Y);
|
||||
return width > 0 && height > 0 ? (double)width * height : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the other area lies within this one, give or take the given tolerance on each edge.
|
||||
/// </summary>
|
||||
public bool Contains(SlotBounds other, long horizontalTolerance, long verticalTolerance) =>
|
||||
other.X >= this.X - horizontalTolerance &&
|
||||
other.Right <= this.Right + horizontalTolerance &&
|
||||
other.Y >= this.Y - verticalTolerance &&
|
||||
other.Bottom <= this.Bottom + verticalTolerance;
|
||||
|
||||
/// <summary>
|
||||
/// Whether both areas are the same, give or take the given tolerance on each edge.
|
||||
/// </summary>
|
||||
public bool IsCloseTo(SlotBounds other, long horizontalTolerance, long verticalTolerance) =>
|
||||
Math.Abs(this.X - other.X) <= horizontalTolerance &&
|
||||
Math.Abs(this.Width - other.Width) <= horizontalTolerance &&
|
||||
Math.Abs(this.Y - other.Y) <= verticalTolerance &&
|
||||
Math.Abs(this.Height - other.Height) <= verticalTolerance;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Counts the slides an answer has started while it still streams in, without reading it as JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A slide starts with the key of its layout, or with the key of the template slide it is taken
|
||||
/// from. JSON escapes the quotation marks within a text, so no text of a slide looks like such a
|
||||
/// key. The answer grows by thousands of characters, and the count runs with every rendering, so
|
||||
/// each count starts where the last one ended. One counter belongs to one answer.
|
||||
/// </remarks>
|
||||
public sealed class StreamedSlideCounter
|
||||
{
|
||||
private static readonly string[] SLIDE_KEYS = ["\"layout\"", "\"template_slide\""];
|
||||
|
||||
private int countedLength;
|
||||
private int countedSlides;
|
||||
|
||||
/// <summary>
|
||||
/// Counts the slides which the answer has started so far.
|
||||
/// </summary>
|
||||
/// <param name="answerSoFar">The answer as far as it has streamed in, which only grows.</param>
|
||||
/// <returns>How many slides the answer has started.</returns>
|
||||
public int Count(string answerSoFar)
|
||||
{
|
||||
// An answer which got shorter was replaced; it is counted anew:
|
||||
if (answerSoFar.Length < this.countedLength)
|
||||
{
|
||||
this.countedLength = 0;
|
||||
this.countedSlides = 0;
|
||||
}
|
||||
|
||||
foreach (var key in SLIDE_KEYS)
|
||||
{
|
||||
//
|
||||
// A key which the last count saw only in part ends after the end of what it counted,
|
||||
// so the count goes back by less than one key:
|
||||
//
|
||||
var index = Math.Max(0, this.countedLength - key.Length + 1);
|
||||
while ((index = answerSoFar.IndexOf(key, index, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
this.countedSlides++;
|
||||
index += key.Length;
|
||||
}
|
||||
}
|
||||
|
||||
this.countedLength = answerSoFar.Length;
|
||||
return this.countedSlides;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// One slide layout of a template.
|
||||
/// </summary>
|
||||
/// <param name="Name">The name the template gives the layout.</param>
|
||||
/// <param name="PartUri">Where the layout is stored inside the file, which identifies it when the presentation is written.</param>
|
||||
/// <param name="LayoutType">The layout type as the file spells it, e.g., "title" or "secHead"; empty when the file states none.</param>
|
||||
/// <param name="Slots">The slots in reading order: from top to bottom, and from left to right within a row.</param>
|
||||
internal sealed record TemplateLayout(string Name, Uri PartUri, string LayoutType, IReadOnlyList<TemplateSlot> Slots)
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the slot a placeholder of a slide based on this layout fills.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PowerPoint links the placeholders of a slide to those of its layout by their index, and a
|
||||
/// placeholder without an index, such as the title, by its type. A slide often states the type
|
||||
/// less precisely than its layout does, e.g., none at all for a body, so the index wins.
|
||||
/// </remarks>
|
||||
/// <param name="placeholderType">The placeholder type as the slide spells it; "obj" when it states none.</param>
|
||||
/// <param name="placeholderIndex">The placeholder index the slide states, if any.</param>
|
||||
/// <returns>The slot, or null when the placeholder fills none, e.g., because it is the footer.</returns>
|
||||
public TemplateSlot? SlotOf(string placeholderType, uint? placeholderIndex)
|
||||
{
|
||||
if (placeholderIndex is { } index && this.Slots.FirstOrDefault(slot => slot.PlaceholderIndex == index) is { } slotByIndex)
|
||||
return slotByIndex;
|
||||
|
||||
var typeGroup = TypeGroupOf(placeholderType);
|
||||
return this.Slots.FirstOrDefault(slot => (slot.PlaceholderIndex is null || placeholderIndex is null) && TypeGroupOf(slot.PlaceholderType) == typeGroup);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type a placeholder is matched by: a title is a title, whether it is centered or not.
|
||||
/// </summary>
|
||||
private static string TypeGroupOf(string placeholderType) => placeholderType == "ctrTitle" ? "title" : placeholderType;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// One slide a template carries, which the presentation may keep as it is or have the model edit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A template made for PowerPoint's "New" dialog carries samples or hints, which a presentation
|
||||
/// leaves out. A presentation used as a template, though, often carries slides worth keeping: the
|
||||
/// title slide, a picture opening each chapter, the closing slide. Which ones the presentation
|
||||
/// keeps is up to the user; what a slide holds tells the model what it is about.
|
||||
/// </remarks>
|
||||
/// <param name="Number">The number of the slide in the template, starting at 1, as PowerPoint counts it; the model refers to the slide by it.</param>
|
||||
/// <param name="PartUri">Where the slide is stored inside the file, which identifies it when the presentation is written.</param>
|
||||
/// <param name="LayoutPartUri">Where the layout of the slide is stored; null when the slide names none.</param>
|
||||
/// <param name="FamilyId">The ID of the layout family the slide is based on; null when its layout belongs to none, e.g., because it has no room for content.</param>
|
||||
/// <param name="Title">The title of the slide on a single line; empty when it has none.</param>
|
||||
/// <param name="Contents">What the slots of the slide hold, in the order of <paramref name="Slots"/>, a table which is no placeholder and the copies of slots included. Slots left empty are missing.</param>
|
||||
/// <param name="MovedSlots">The slots whose placeholders the slide moved or resized, e.g., to make room for a picture of its own, with their place and capacity on the slide; empty when every placeholder stays where the layout puts it.</param>
|
||||
/// <param name="Slots">The slots the slide offers, in reading order: those of its layout, where the slide puts them, and the copies the slide carries; empty when the layout of the slide is unknown.</param>
|
||||
/// <param name="SlotCopies">The shapes which repeat a slot of the layout, e.g., a second heading with its text, numbered after the slots of the layout; empty when the slide carries none.</param>
|
||||
/// <param name="Elements">What the slide carries besides the slots of its layout and their copies, from back to front.</param>
|
||||
/// <param name="IsHidden">Whether the slide is hidden in the slide show.</param>
|
||||
internal sealed record TemplateSlide(int Number, Uri PartUri, Uri? LayoutPartUri, string? FamilyId, string Title, IReadOnlyList<TemplateSlideContent> Contents, IReadOnlyList<TemplateSlot> MovedSlots, IReadOnlyList<TemplateSlot> Slots, IReadOnlyList<TemplateSlotCopy> SlotCopies, IReadOnlyList<TemplateSlideElement> Elements, bool IsHidden)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the model can edit the slide, which takes a layout with room for content.
|
||||
/// </summary>
|
||||
public bool IsEditable => this.FamilyId is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Finds an element of the slide by the name the model used for it.
|
||||
/// </summary>
|
||||
/// <param name="name">The name, e.g., "element 2"; a bare number is understood as well.</param>
|
||||
/// <returns>The element, or null when the slide has no element of that name.</returns>
|
||||
public TemplateSlideElement? FindElement(string name)
|
||||
{
|
||||
var number = name.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) switch
|
||||
{
|
||||
[var single] => single,
|
||||
[var word, var single] when string.Equals(word, "element", StringComparison.OrdinalIgnoreCase) => single,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return int.TryParse(number, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)
|
||||
? this.Elements.FirstOrDefault(element => element.Number == parsed)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where a slot of the slide's layout family sits on this slide, and how much fits there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The slot is matched by its role and number, which every variant of a family shares, so this
|
||||
/// works whichever color the slide takes in the presentation.
|
||||
/// </remarks>
|
||||
/// <param name="slot">A slot of the family, or of any of its layouts.</param>
|
||||
/// <returns>The slot as the slide moved it, or the given one when the slide keeps it where the layout puts it.</returns>
|
||||
public TemplateSlot OnSlide(TemplateSlot slot) => this.MovedSlots.FirstOrDefault(moved => moved.Role == slot.Role && moved.Ordinal == slot.Ordinal) ?? slot;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the slide moved or resized the placeholder of a slot.
|
||||
/// </summary>
|
||||
public bool Moves(TemplateSlot slot) => this.MovedSlots.Any(moved => moved.Role == slot.Role && moved.Ordinal == slot.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the copy which offers a slot of the slide.
|
||||
/// </summary>
|
||||
/// <param name="role">The role of the slot.</param>
|
||||
/// <param name="ordinal">The number of the slot among the slots of its role, starting at 1.</param>
|
||||
/// <returns>The copy, or null when the slot is one of the layout, or the slide has none of that number.</returns>
|
||||
public TemplateSlotCopy? FindCopy(PlaceholderRole role, int ordinal) => this.SlotCopies.FirstOrDefault(copy => copy.Slot.Role == role && copy.Slot.Ordinal == ordinal);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What one slot of a template slide holds.
|
||||
/// </summary>
|
||||
/// <param name="Slot">The slot, as the layout of the slide offers it.</param>
|
||||
/// <param name="Text">The text, one paragraph per line; empty for a picture. A table gives one row per line, with " | " between its cells.</param>
|
||||
internal sealed record TemplateSlideContent(TemplateSlot Slot, string Text);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Something a slide of a template carries besides the placeholders of its layout, e.g., a picture
|
||||
/// or a text box its author placed by hand.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The model knows a template slide by the slots of its layout, which it fills. Everything else on
|
||||
/// the slide stays unless the model removes it, so the model learns what there is and where:
|
||||
/// a sample picture, a hint to name the source of a picture, or a chart in the way of a slot.
|
||||
/// </remarks>
|
||||
/// <param name="Number">The number of the element on its slide, starting at 1, from back to front; the model refers to it as "element 1".</param>
|
||||
/// <param name="ShapeId">The ID of the shape on the slide, which finds the element again when the presentation is written.</param>
|
||||
/// <param name="Kind">What kind of element it is.</param>
|
||||
/// <param name="Bounds">Where it sits on the slide; null when the file does not say.</param>
|
||||
/// <param name="Text">Its text, one paragraph per line; empty when it has none.</param>
|
||||
internal sealed record TemplateSlideElement(int Number, uint ShapeId, TemplateSlideElementKind Kind, SlotBounds? Bounds, string Text)
|
||||
{
|
||||
/// <summary>
|
||||
/// How the model refers to the element.
|
||||
/// </summary>
|
||||
public string Name => $"element {this.Number}";
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What kind of element a slide of a template carries besides the placeholders of its layout.
|
||||
/// </summary>
|
||||
internal enum TemplateSlideElementKind
|
||||
{
|
||||
/// <summary>
|
||||
/// A shape with text, e.g., a text box naming the source of a picture.
|
||||
/// </summary>
|
||||
TEXT_BOX,
|
||||
|
||||
/// <summary>
|
||||
/// A shape without text which shows something all the same, e.g., a colored rectangle.
|
||||
/// </summary>
|
||||
SHAPE,
|
||||
|
||||
/// <summary>
|
||||
/// A line or a connector.
|
||||
/// </summary>
|
||||
LINE,
|
||||
|
||||
PICTURE,
|
||||
TABLE,
|
||||
CHART,
|
||||
|
||||
/// <summary>
|
||||
/// A SmartArt graphic.
|
||||
/// </summary>
|
||||
DIAGRAM,
|
||||
|
||||
/// <summary>
|
||||
/// Anything else, e.g., an embedded object, a video, or an equation.
|
||||
/// </summary>
|
||||
OBJECT,
|
||||
|
||||
/// <summary>
|
||||
/// Shapes grouped into one, which count as one element.
|
||||
/// </summary>
|
||||
GROUP,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static class TemplateSlideElementKindExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// How the kind is called in the list of template slides the model reads.
|
||||
/// </summary>
|
||||
public static string CatalogName(this TemplateSlideElementKind kind) => kind switch
|
||||
{
|
||||
TemplateSlideElementKind.TEXT_BOX => "text box",
|
||||
TemplateSlideElementKind.SHAPE => "shape",
|
||||
TemplateSlideElementKind.LINE => "line",
|
||||
TemplateSlideElementKind.PICTURE => "picture",
|
||||
TemplateSlideElementKind.TABLE => "table",
|
||||
TemplateSlideElementKind.CHART => "chart",
|
||||
TemplateSlideElementKind.DIAGRAM => "diagram",
|
||||
TemplateSlideElementKind.GROUP => "group",
|
||||
|
||||
_ => "object",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using DocumentFormat.OpenXml;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Which shapes of a slide fill which slots of its layout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reading a template and editing one of its slides ask the same question, so they share the answer:
|
||||
/// what the model is told a slot holds is what a new content replaces.
|
||||
/// </remarks>
|
||||
internal static class TemplateSlideShapes
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the shapes of a slide which fill the slots of its layout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A placeholder fills the slot of its type and index; of two placeholders for one slot, the
|
||||
/// first counts, while the second may be read as a copy of the slot, as a text box copied from
|
||||
/// the placeholder is. A table slot whose placeholder holds no table, or which the slide leaves out,
|
||||
/// takes a table which is no placeholder at all: an author who pastes a table onto the slide,
|
||||
/// or whose table lost the link to its placeholder on the way, still means it as the table of
|
||||
/// the slide. Such tables fill these table slots in the order of the slide.
|
||||
/// </remarks>
|
||||
/// <param name="shapeTree">The shape tree of the slide.</param>
|
||||
/// <param name="layout">The layout the slide is based on.</param>
|
||||
/// <returns>The top-level shape filling each slot; slots the slide leaves out are missing.</returns>
|
||||
public static Dictionary<TemplateSlot, OpenXmlElement> SlotShapesOf(P.ShapeTree? shapeTree, TemplateLayout layout)
|
||||
{
|
||||
var elements = shapeTree?.Elements().ToList() ?? [];
|
||||
var shapesBySlot = new Dictionary<TemplateSlot, OpenXmlElement>();
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (PlaceholderOf(element) is { } placeholder && layout.SlotOf(placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value) is { } slot)
|
||||
shapesBySlot.TryAdd(slot, element);
|
||||
}
|
||||
|
||||
var freeTables = new Queue<OpenXmlElement>(elements.Where(element => PlaceholderOf(element) is null && HoldsTable(element)));
|
||||
foreach (var slot in layout.Slots.Where(slot => slot.Role is PlaceholderRole.TABLE && !(shapesBySlot.GetValueOrDefault(slot) is { } shape && HoldsTable(shape))))
|
||||
{
|
||||
if (!freeTables.TryDequeue(out var table))
|
||||
break;
|
||||
|
||||
shapesBySlot[slot] = table;
|
||||
}
|
||||
|
||||
return shapesBySlot;
|
||||
}
|
||||
|
||||
private static bool HoldsTable(OpenXmlElement element) => element is P.GraphicFrame frame && frame.Graphic?.GraphicData?.GetFirstChild<A.Table>() is not null;
|
||||
|
||||
/// <summary>
|
||||
/// The ID of a top-level element of a slide: a shape, a picture, a graphic frame, a group, a
|
||||
/// connector, or something wrapped for newer versions of PowerPoint.
|
||||
/// </summary>
|
||||
/// <returns>The ID, or null for anything else, e.g., the properties of the shape tree itself.</returns>
|
||||
public static uint? ShapeIdOf(OpenXmlElement element) => element switch
|
||||
{
|
||||
P.Shape or P.Picture or P.GraphicFrame or P.GroupShape or P.ConnectionShape or AlternateContent => element.Descendants<P.NonVisualDrawingProperties>().FirstOrDefault()?.Id?.Value,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The placeholder a shape, a picture or a table of a slide fills; null for anything else.
|
||||
/// </summary>
|
||||
public static P.PlaceholderShape? PlaceholderOf(OpenXmlElement element) => element switch
|
||||
{
|
||||
P.Shape shape => shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape,
|
||||
P.Picture picture => picture.NonVisualPictureProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape,
|
||||
P.GraphicFrame frame => frame.NonVisualGraphicFrameProperties?.ApplicationNonVisualDrawingProperties?.PlaceholderShape,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// What the presentation does with one slide of the template, as the user chose it.
|
||||
/// </summary>
|
||||
public enum TemplateSlideUse
|
||||
{
|
||||
/// <summary>
|
||||
/// The presentation leaves the slide out, as it does with the samples and hints of a template.
|
||||
/// </summary>
|
||||
OMIT = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The presentation keeps the slide as it is, only in its color variant and with its footer.
|
||||
/// </summary>
|
||||
KEEP,
|
||||
|
||||
/// <summary>
|
||||
/// The model writes new content into the slots of the slide, which keeps its design and every slot the model leaves empty.
|
||||
/// </summary>
|
||||
EDIT,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
internal static class TemplateSlideUseListExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// What the user chose for one slide of the template.
|
||||
/// </summary>
|
||||
/// <param name="uses">What the user chose for each slide of the template, by its number.</param>
|
||||
/// <param name="number">The number of the slide, starting at 1.</param>
|
||||
/// <returns>The choice; TemplateSlideUse.OMIT for a slide the list does not cover.</returns>
|
||||
public static TemplateSlideUse UseOf(this IReadOnlyList<TemplateSlideUse> uses, int number) => number >= 1 && number <= uses.Count ? uses[number - 1] : TemplateSlideUse.OMIT;
|
||||
|
||||
/// <summary>
|
||||
/// What the presentation does with each slide of a template, by the choices for some of its slides.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A slide without a choice is left out, as by default. A choice to edit a slide which the model
|
||||
/// cannot edit keeps the slide unchanged instead: such a choice may come from an organization,
|
||||
/// which the user cannot overrule, and it would block the form. A choice for a slide the template
|
||||
/// does not have is passed over.
|
||||
/// </remarks>
|
||||
/// <param name="choices">The choices by the number of the slide, starting at 1.</param>
|
||||
/// <param name="template">The template whose slides the choices number.</param>
|
||||
/// <returns>A choice for each slide of the template, in its order.</returns>
|
||||
public static List<TemplateSlideUse> ToTemplateSlideUses(this IReadOnlyDictionary<int, TemplateSlideUse> choices, PresentationTemplate template) => template.Slides
|
||||
.Select(slide => choices.TryGetValue(slide.Number, out var use) ? use is TemplateSlideUse.EDIT && !slide.IsEditable ? TemplateSlideUse.KEEP : use : TemplateSlideUse.OMIT)
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// One placeholder of a slide layout which the content of a slide goes into.
|
||||
/// </summary>
|
||||
/// <param name="Role">What the slot is for.</param>
|
||||
/// <param name="Ordinal">The number of the slot among the slots of the same role, starting at 1, in reading order.</param>
|
||||
/// <param name="Bounds">Where the slot sits, inherited from the slide master when the layout does not say.</param>
|
||||
/// <param name="PlaceholderType">The placeholder type as the file spells it, e.g., "body" or "pic"; "obj" when the file states none.</param>
|
||||
/// <param name="PlaceholderIndex">The placeholder index, which links a placeholder of a slide to the one of its layout.</param>
|
||||
/// <param name="Capacity">How much text fits; missing for pictures.</param>
|
||||
internal sealed record TemplateSlot(PlaceholderRole Role, int Ordinal, SlotBounds Bounds, string PlaceholderType, uint? PlaceholderIndex, TextCapacity? Capacity)
|
||||
{
|
||||
/// <summary>
|
||||
/// The slot as the layout offers it, which a slot a slide moved or copied is measured against;
|
||||
/// null for a slot of the layout itself.
|
||||
/// </summary>
|
||||
public TemplateSlot? LayoutSlot { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The slot where a slide moved or resized its placeholder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The capacity grows and shrinks with the size, since the slide keeps the font. Insets stay as
|
||||
/// they are on a smaller slot, so the estimate runs a little high there; the check of the
|
||||
/// model's answer accepts a line more anyway. The capacity is always scaled from the slot of the
|
||||
/// layout, so that a slot moved twice, e.g., resized by its slide and then grown into room the
|
||||
/// slide frees, is not rounded down twice.
|
||||
/// </remarks>
|
||||
/// <param name="bounds">Where the slide puts the placeholder.</param>
|
||||
public TemplateSlot MovedTo(SlotBounds bounds)
|
||||
{
|
||||
var layoutSlot = this.LayoutSlot ?? this;
|
||||
return this with
|
||||
{
|
||||
Bounds = bounds,
|
||||
Capacity = layoutSlot.Capacity is { } capacity
|
||||
? new TextCapacity(
|
||||
Math.Max(1, (int)(capacity.Lines * (double)bounds.Height / Math.Max(1, layoutSlot.Bounds.Height))),
|
||||
Math.Max(1, (int)(capacity.CharactersPerLine * (double)bounds.Width / Math.Max(1, layoutSlot.Bounds.Width))))
|
||||
: null,
|
||||
LayoutSlot = layoutSlot,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// A shape of a template slide which repeats a slot of its layout, e.g., a second heading with its
|
||||
/// text, which the author copied from the placeholders of the slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PowerPoint turns a copied placeholder into a text box which keeps the look of the placeholder,
|
||||
/// but no longer belongs to the layout. The slide still means it as one more slot of the same kind,
|
||||
/// so the model fills or removes it like any slot, just as a table pasted onto a slide counts as the
|
||||
/// table of the slide.
|
||||
/// </remarks>
|
||||
/// <param name="Slot">The slot the copy offers: the role of the slot it repeats, the next number of that role, and the place and capacity of the copy.</param>
|
||||
/// <param name="ShapeId">The ID of the shape on the slide, which finds the copy again when the presentation is written.</param>
|
||||
internal sealed record TemplateSlotCopy(TemplateSlot Slot, uint ShapeId);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace AIStudio.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// How much text fits into a slot, estimated from its size and its font size.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An estimate on purpose: the real width of a line depends on the font and the words. It tells the
|
||||
/// model how much to write, and the check of its answer how much is clearly too much.
|
||||
/// </remarks>
|
||||
/// <param name="Lines">How many lines fit, or how many rows for a table.</param>
|
||||
/// <param name="CharactersPerLine">How many characters fit into one line, across the whole width.</param>
|
||||
internal readonly record struct TextCapacity(int Lines, int CharactersPerLine);
|
||||
Reference in new issue
Block a user