Added PowerPoint templates to the Slide Assistant (#1055)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Sabrina-devopsandThorsten Sommer authored and GitHub committed 2026-10-25 18:52:39 +01:00
1 parent 8c5cfa78b6
commit 6eb23ebbcb
136 files changed
+16535 -522

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ Therefore, before you plan, read, or change anything in one of these areas, read
| anything under `app/MindWork AI Studio/Models/` or `app/Tests/Models/` | `app/MindWork AI Studio/Models/AGENTS.md` |
| anything under `app/MindWork AI Studio/Tools/Services/Indexing/`, or `DataSourceEmbeddingService` | `app/MindWork AI Studio/Tools/Services/Indexing/AGENTS.md` |
| anything under `app/MindWork AI Studio/Tools/Databases/` | `app/MindWork AI Studio/Tools/Databases/AGENTS.md` |
| anything under `app/MindWork AI Studio/Tools/PowerPoint/` or `app/Tests/Tools/PowerPoint/`, or the PowerPoint part of the Slide Assistant | `app/MindWork AI Studio/Tools/PowerPoint/AGENTS.md` |
This file keeps only what applies to the whole repository. `app/AGENTS.md` keeps what applies to all of the
.NET app, such as the rules for code which calls into one of the areas below it. Add instructions about a
+8
View File
@@ -136,6 +136,14 @@ active language plugin. Storage sizes are the exception: they keep using the byt
**Area guide:** `app/MindWork AI Studio/Tools/Databases/AGENTS.md`, with both stores and what to keep in
mind when working on them, EF Core migrations included.
## Slide Assistant and PowerPoint
The Slide Assistant plans slides for the layouts of a PowerPoint template and writes the
presentation with the Open XML SDK (`DocumentFormat.OpenXml`), in `app/MindWork AI Studio/Tools/PowerPoint/`.
**Area guide:** `app/MindWork AI Studio/Tools/PowerPoint/AGENTS.md`, with how a presentation comes about
and the rules which are easy to break.
## Enterprise IT Support
AI Studio supports centralized configuration for enterprise environments:
@@ -69,7 +69,17 @@
@if (this.ShowDedicatedProgress && this.IsProcessing)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-6" />
@if (string.IsNullOrWhiteSpace(this.ProgressText))
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-6" />
}
else
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-2" />
<MudText Typo="Typo.body2" Class="mb-6">
@this.ProgressText
</MudText>
}
}
<div id="@RESULT_DIV_ID" class="mr-2 mt-3">
@@ -107,6 +107,11 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected virtual bool ShowProfileSelection => true;
protected virtual bool ShowDedicatedProgress => false;
/// <summary>
/// What the assistant is doing right now, shown below the dedicated progress bar; empty for no text.
/// </summary>
protected virtual string ProgressText => string.Empty;
protected virtual bool ShowSendTo => true;
@@ -124,6 +129,15 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected virtual bool ShowReset => true;
/// <summary>
/// Whether the system prompt starts with the current date and time.
/// </summary>
/// <remarks>
/// Off for most assistants, whose results do not depend on the date. An assistant which writes
/// the date into its result, or plans around it, turns this on rather than guessing the date.
/// </remarks>
protected virtual bool IncludeDateTime => false;
protected virtual string? SendToChatVisibleUserPromptPrefix => null;
protected virtual string? SendToChatVisibleUserPromptContent => null;
@@ -385,7 +399,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
{
this.ChatThread = new()
{
IncludeDateTime = false,
IncludeDateTime = this.IncludeDateTime,
SelectedProvider = this.ProviderSettings.Id,
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
SystemPrompt = this.SystemPrompt,
@@ -403,7 +417,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
var chatId = Guid.NewGuid();
this.ChatThread = new()
{
IncludeDateTime = false,
IncludeDateTime = this.IncludeDateTime,
SelectedProvider = this.ProviderSettings.Id,
SelectedProfile = this.AllowProfiles ? this.CurrentProfile.Id : Profile.NO_PROFILE.Id,
SystemPrompt = this.SystemPrompt,
@@ -527,10 +541,46 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
InitialRemoteWait = true,
};
//
// The stream calls this for every chunk and waits for it. A checkpoint captures the whole
// state of the assistant and makes the main layout render as well, so both happen only now
// and then. When the stream is done, both happen in any case:
//
var lastRendering = DateTimeOffset.MinValue;
var lastCheckpoint = DateTimeOffset.MinValue;
string? lastProgressText = null;
aiText.StreamingEvent = async () =>
{
await this.CheckpointAssistantSession();
await this.RefreshAssistantUIAsync();
var now = DateTimeOffset.Now;
if (now - lastCheckpoint >= STREAMING_CHECKPOINT_MIN_TIME)
{
lastCheckpoint = now;
await this.CheckpointAssistantSession();
}
if (now - lastRendering < STREAMING_RENDER_MIN_TIME)
return;
lastRendering = now;
if (!hideContentFromUser)
{
await this.RefreshAssistantUIAsync();
return;
}
//
// A hidden answer shows nothing but the progress text, so the whole form renders only
// when that text changed. Thinking models stream for minutes before they write a word:
//
await this.RefreshAssistantUIAsync(() =>
{
var progressText = this.ProgressText;
if (progressText == lastProgressText)
return false;
lastProgressText = progressText;
return true;
});
};
aiText.StreamingDone = async () =>
@@ -995,15 +1045,20 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
/// <summary>
/// Refreshes the component when it is still mounted.
/// </summary>
/// <param name="hasChanged">Decides whether anything visible changed, asked on the thread of the renderer, so it may read what the rendering reads; null to render in any case.</param>
/// <returns>A task that completes after the renderer was notified.</returns>
protected async Task RefreshAssistantUIAsync()
protected async Task RefreshAssistantUIAsync(Func<bool>? hasChanged = null)
{
if (this.isDisposed)
return;
try
{
await this.InvokeAsync(this.StateHasChanged);
await this.InvokeAsync(() =>
{
if (hasChanged is null || hasChanged())
this.StateHasChanged();
});
}
catch (InvalidOperationException)
{
@@ -12,7 +12,22 @@ public abstract class AssistantLowerBase : MSGComponentBase
internal const string RESULT_DIV_ID = "assistantResult";
internal const string BEFORE_RESULT_DIV_ID = "beforeAssistantResult";
internal const string AFTER_RESULT_DIV_ID = "afterAssistantResult";
/// <summary>
/// The minimum time between two renderings of the assistant while an answer streams in.
/// </summary>
/// <remarks>
/// The stream waits for each rendering, and an assistant renders its whole form, which may hold
/// more than a hundred fields. Ten renderings a second still read as a live stream.
/// </remarks>
private protected static readonly TimeSpan STREAMING_RENDER_MIN_TIME = TimeSpan.FromMilliseconds(100);
/// <summary>
/// The minimum time between two checkpoints of the assistant session while an answer streams in,
/// as in the chat.
/// </summary>
private protected static readonly TimeSpan STREAMING_CHECKPOINT_MIN_TIME = TimeSpan.FromSeconds(3);
protected static readonly AssistantSessionStateKey<AIStudio.Settings.Provider> PROVIDER_SETTINGS_STATE_KEY = new(nameof(ProviderSettings));
protected static readonly AssistantSessionStateKey<bool> INPUT_IS_VALID_STATE_KEY = new(nameof(InputIsValid));
protected static readonly AssistantSessionStateKey<Profile> CURRENT_PROFILE_STATE_KEY = new(nameof(CurrentProfile));
@@ -43,44 +43,32 @@
</MudPaper>
}
<MudExpansionPanels Dense="@true" Elevation="0" Class="mb-3 rounded">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Tune" Class="mr-3"/>
<MudText Typo="Typo.button">
@T("Advanced Options")
</MudText>
</div>
</TitleContent>
<ChildContent>
@* A launcher shows this field inside its own frame above, next to the chat settings
it belongs with. *@
@if (!this.createChatLauncher)
<CompactExpansionPanel HeaderText="@T("Advanced Options")" HeaderIcon="@Icons.Material.Filled.Tune" Class="mb-3 rounded">
@* A launcher shows this field inside its own frame above, next to the chat settings
it belongs with. *@
@if (!this.createChatLauncher)
{
<MudTextField T="string" @bind-Text="@this.assistantName" AdornmentIcon="@Icons.Material.Filled.Assistant" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Display Name (Optional)")" Placeholder="@T("Meeting Task Extractor")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
}
<EnumSelection T="AssistantCategory" NameFunc="@(category => category.NameSelecting())" @bind-Value="@this.selectedCategory" ValidateSelection="@this.ValidatingCategory" Icon="@Icons.Material.Filled.Category" IconSize="Size.Small" Label="@T("Category (Optional)")" AllowOther="@true" OtherValue="AssistantCategory.OTHER" @bind-OtherInput="@this.customCategory" ValidateOther="@this.ValidateCustomCategory" LabelOther="@T("Custom assistant category")" />
@if (!this.createChatLauncher)
{
<MudTextField T="string" @bind-Text="@this.typicalInput" AdornmentIcon="@Icons.Material.Filled.Login" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Typical input (Optional)")" Placeholder="@T("What users provide, e.g. text, notes, files, or a URL")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="8" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudTextField T="string" @bind-Text="@this.expectedOutput" AdornmentIcon="@Icons.Material.Filled.Logout" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Expected output (Optional)")" Placeholder="@T("What users should get, e.g. a summary or checklist")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="8" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudSelect T="AssistantComponentType" Label="@T("Input and UI components (Optional)")" MultiSelection="@true" @bind-SelectedValues="@this.selectedAssistantComponents" MultiSelectionTextFunc="@this.GetSelectedAssistantComponentText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ViewDay" IconSize="Size.Small">
@foreach (var component in ASSISTANT_COMPONENT_OPTIONS)
{
<MudTextField T="string" @bind-Text="@this.assistantName" AdornmentIcon="@Icons.Material.Filled.Assistant" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Display Name (Optional)")" Placeholder="@T("Meeting Task Extractor")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudSelectItem T="AssistantComponentType" Value="@component">
@component.GetDisplayName()
</MudSelectItem>
}
<EnumSelection T="AssistantCategory" NameFunc="@(category => category.NameSelecting())" @bind-Value="@this.selectedCategory" ValidateSelection="@this.ValidatingCategory" Icon="@Icons.Material.Filled.Category" IconSize="Size.Small" Label="@T("Category (Optional)")" AllowOther="@true" OtherValue="AssistantCategory.OTHER" @bind-OtherInput="@this.customCategory" ValidateOther="@this.ValidateCustomCategory" LabelOther="@T("Custom assistant category")" />
@if (!this.createChatLauncher)
{
<MudTextField T="string" @bind-Text="@this.typicalInput" AdornmentIcon="@Icons.Material.Filled.Login" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Typical input (Optional)")" Placeholder="@T("What users provide, e.g. text, notes, files, or a URL")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="8" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudTextField T="string" @bind-Text="@this.expectedOutput" AdornmentIcon="@Icons.Material.Filled.Logout" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Expected output (Optional)")" Placeholder="@T("What users should get, e.g. a summary or checklist")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="8" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudSelect T="AssistantComponentType" Label="@T("Input and UI components (Optional)")" MultiSelection="@true" @bind-SelectedValues="@this.selectedAssistantComponents" MultiSelectionTextFunc="@this.GetSelectedAssistantComponentText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ViewDay" IconSize="Size.Small">
@foreach (var component in ASSISTANT_COMPONENT_OPTIONS)
{
<MudSelectItem T="AssistantComponentType" Value="@component">
@component.GetDisplayName()
</MudSelectItem>
}
</MudSelect>
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.NameSelectingOptional())" @bind-Value="@this.selectedOutputLanguage" Icon="@Icons.Material.Filled.Translate" IconSize="Size.Small" Label="@T("(Optional) Output language")" AllowOther="@true" OtherValue="CommonLanguages.OTHER" @bind-OtherInput="@this.customOutputLanguage" ValidateOther="@this.ValidateCustomOutputLanguage" LabelOther="@T("Custom output language")" />
<MudSwitch T="bool" @bind-Value="@this.allowGeneratedAssistantProfiles" Label="@T("Allow AI Studio profiles")" LabelPlacement="Placement.End" Color="Color.Primary" Class="mb-3"/>
<MudTextField T="string" @bind-Text="@this.extraRules" AdornmentIcon="@Icons.Material.Filled.Rule" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Additional rules (Optional)")" Placeholder="@T("What to avoid or consider, e.g. do not invent missing facts")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudTextField T="string" @bind-Text="@this.exampleRequest" AdornmentIcon="@Icons.Material.Filled.Lightbulb" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Example prompt (Optional)")" Placeholder="@T("An expected user prompt, e.g. summarize this document")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
}
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
</MudSelect>
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.NameSelectingOptional())" @bind-Value="@this.selectedOutputLanguage" Icon="@Icons.Material.Filled.Translate" IconSize="Size.Small" Label="@T("(Optional) Output language")" AllowOther="@true" OtherValue="CommonLanguages.OTHER" @bind-OtherInput="@this.customOutputLanguage" ValidateOther="@this.ValidateCustomOutputLanguage" LabelOther="@T("Custom output language")" />
<MudSwitch T="bool" @bind-Value="@this.allowGeneratedAssistantProfiles" Label="@T("Allow AI Studio profiles")" LabelPlacement="Placement.End" Color="Color.Primary" Class="mb-3"/>
<MudTextField T="string" @bind-Text="@this.extraRules" AdornmentIcon="@Icons.Material.Filled.Rule" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Additional rules (Optional)")" Placeholder="@T("What to avoid or consider, e.g. do not invent missing facts")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudTextField T="string" @bind-Text="@this.exampleRequest" AdornmentIcon="@Icons.Material.Filled.Lightbulb" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Example prompt (Optional)")" Placeholder="@T("An expected user prompt, e.g. summarize this document")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="3" AutoGrow="@true" MaxLines="10" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
}
</CompactExpansionPanel>
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>
<MudAlert Severity="Severity.Info" Square="true" Class="mb-2 mt-n1" Style="width: max-content">@this.HighPerformanceLLMInfo</MudAlert>
@@ -122,21 +110,9 @@ else
@code {
private protected override RenderFragment? BelowSubmitContent => this.step is BuilderStep.DONE && !string.IsNullOrWhiteSpace(this.generatedLuaAssistant)
? @<MudStack Spacing="3" Class="mb-3">
<MudExpansionPanels Dense="@true" Elevation="0" Class="rounded">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Code" Class="mr-3" Color="Color.Primary"/>
<MudText Typo="Typo.button">
@T("Generated Lua plugin")
</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@this.generatedLuaAssistant" ReadOnly="true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/>
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
<CompactExpansionPanel HeaderText="@T("Generated Lua plugin")" HeaderIcon="@Icons.Material.Filled.Code" IconColor="Color.Primary" Class="rounded">
<MudTextField T="string" Text="@this.generatedLuaAssistant" ReadOnly="true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/>
</CompactExpansionPanel>
<MudStepperWithoutActions @bind-ActiveIndex="@this.stepperIndex" Class="mb-3">
<ChildContent>
@@ -163,8 +163,8 @@ internal sealed partial class LuaResponse
/// Reads the first complete JSON object out of a model answer that may carry text around it.
/// </summary>
/// <remarks>
/// Shared with the launcher texts response, which is a different shape but arrives the same
/// way, wrapped in whatever prose the model felt like adding.
/// Shared with the launcher texts response and the slide deck of the Slide Assistant, which are
/// different shapes but arrive the same way, wrapped in whatever prose the model felt like adding.
/// </remarks>
internal static string ExtractJson(string input)
{
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The occasion of a presentation, which shapes how the LLM structures it.
/// </summary>
/// <remarks>
/// The list covers the common occasions only; OTHER takes any other one as text.
/// </remarks>
public enum PresentationOccasion
{
UNSPECIFIED = 0,
TALK,
KEYNOTE,
CONFERENCE_TALK,
LECTURE,
TRAINING,
WORKSHOP,
MULTI_DAY_WORKSHOP,
THESIS_DEFENSE,
RESEARCH_RESULTS,
PROJECT_REPORT,
MANAGEMENT_REPORT,
PITCH,
PRODUCT_PRESENTATION,
KICK_OFF,
TEAM_MEETING,
OTHER,
}
@@ -0,0 +1,56 @@
namespace AIStudio.Assistants.SlideBuilder;
public static class PresentationOccasionExtensions
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(PresentationOccasionExtensions).Namespace, nameof(PresentationOccasionExtensions));
public static string Name(this PresentationOccasion occasion) => occasion switch
{
PresentationOccasion.UNSPECIFIED => TB("Unspecified occasion"),
PresentationOccasion.TALK => TB("Talk"),
PresentationOccasion.KEYNOTE => TB("Keynote"),
PresentationOccasion.CONFERENCE_TALK => TB("Conference talk"),
PresentationOccasion.LECTURE => TB("Lecture"),
PresentationOccasion.TRAINING => TB("Training"),
PresentationOccasion.WORKSHOP => TB("Workshop of one day"),
PresentationOccasion.MULTI_DAY_WORKSHOP => TB("Workshop of several days"),
PresentationOccasion.THESIS_DEFENSE => TB("Defense of a thesis or dissertation"),
PresentationOccasion.RESEARCH_RESULTS => TB("Presentation of research results"),
PresentationOccasion.PROJECT_REPORT => TB("Project or status report"),
PresentationOccasion.MANAGEMENT_REPORT => TB("Report to management"),
PresentationOccasion.PITCH => TB("Pitch"),
PresentationOccasion.PRODUCT_PRESENTATION => TB("Product presentation"),
PresentationOccasion.KICK_OFF => TB("Kick-off"),
PresentationOccasion.TEAM_MEETING => TB("Team meeting"),
PresentationOccasion.OTHER => TB("Other occasion"),
_ => TB("Unspecified occasion"),
};
/// <summary>
/// Tells the model what the occasion means for the structure of the presentation.
/// </summary>
/// <param name="occasion">The occasion.</param>
/// <param name="customOccasion">The occasion the user described, for OTHER.</param>
public static string Prompt(this PresentationOccasion occasion, string customOccasion) => occasion switch
{
PresentationOccasion.TALK => "The presentation accompanies a talk. Structure it for a speaker who talks the audience through it.",
PresentationOccasion.KEYNOTE => "The presentation accompanies a keynote. Favor few words per slide, strong key messages, and a story which carries the audience.",
PresentationOccasion.CONFERENCE_TALK => "The presentation accompanies a talk at a conference. Keep to the essentials of a short time slot, and end with the key messages and room for questions.",
PresentationOccasion.LECTURE => "The presentation accompanies a lecture. Teach step by step, with examples, and where it suits, end chapters with an interim summary or with room for questions.",
PresentationOccasion.TRAINING => "The presentation accompanies a training. Teach practical skills step by step, with examples and exercises where the content offers them.",
PresentationOccasion.WORKSHOP => "The presentation accompanies a workshop of one day. Plan for interaction, e.g., exercises, group work, or discussions, and for the breaks the schedule of the content has.",
PresentationOccasion.MULTI_DAY_WORKSHOP => "The presentation accompanies a workshop of several days. Plan for interaction, mark where a day ends and the next one starts, and look back at the start of each day, following the schedule of the content.",
PresentationOccasion.THESIS_DEFENSE => "The presentation defends a thesis or a dissertation before a committee. Present the research questions, the method, the results, and the contribution precisely, and anticipate critical questions.",
PresentationOccasion.RESEARCH_RESULTS => "The presentation presents research results. Present the questions, the method, the results, and their meaning precisely and comprehensibly.",
PresentationOccasion.PROJECT_REPORT => "The presentation reports on a project. Present the status, the progress, the problems, and the next steps clearly.",
PresentationOccasion.MANAGEMENT_REPORT => "The presentation reports to management and may prepare a decision. Lead with the key messages, keep details short, and state clearly what is to be decided.",
PresentationOccasion.PITCH => "The presentation pitches an idea, a product, or an offer. Lead with the benefit for the audience, and end with a clear call to action.",
PresentationOccasion.PRODUCT_PRESENTATION => "The presentation presents a product. Show what it does and what it brings the audience, with examples.",
PresentationOccasion.KICK_OFF => "The presentation opens a project at its kick-off. Present the goals, the team, the plan, and how the work is organized.",
PresentationOccasion.TEAM_MEETING => "The presentation serves a team meeting. Keep it short and practical, with clear points and next steps.",
PresentationOccasion.OTHER => $"The presentation is for this occasion: {customOccasion}. Structure it accordingly.",
_ => "No particular occasion is given. Structure the presentation by its content.",
};
}
@@ -1,4 +1,7 @@
@attribute [Route(Routes.ASSISTANT_SLIDE_BUILDER)]
@using AIStudio.Settings.DataModel
@using AIStudio.Tools.PowerPoint
@using AIStudio.Tools.Rust
@inherits AssistantBaseCore<AIStudio.Dialogs.Settings.SettingsDialogSlideBuilder>
<MudText Typo="Typo.h5" Class="mb-1 mt-3"> @T("Content to derive slide from")</MudText>
@@ -7,8 +10,43 @@
</MudJustifiedText>
<MudTextField T="string" @bind-Text="@this.inputContent" Validation="@this.ValidatingContext" Adornment="Adornment.Start" Lines="6" MaxLines="12" AutoGrow="@false" Label="@T("Text content")" Variant="Variant.Outlined" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudText Typo="Typo.h6" Class="mb-1 mt-1"> @T("Attach documents")</MudText>
<AttachDocuments Name="Documents for input" @bind-DocumentPaths="@this.loadedDocumentPaths" OnChange="@this.OnDocumentsChanged" CatchAllDocuments="true" UseSmallForm="false" Provider="@this.ProviderSettings"/>
@* Side by side where the window is wide enough; each column ends with its zone, so both zones line up below explanations of different length: *@
<MudGrid Class="mt-1">
<MudItem xs="12" lg="6" Class="d-flex flex-column">
<MudText Typo="Typo.h6" Class="mb-1"> @T("Attach documents")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2 flex-grow-1">
@T("Documents and images attached here are source material: the LLM reads them, but puts none of them on a slide.")
</MudJustifiedText>
<AttachDocuments Name="Documents for input" @bind-DocumentPaths="@this.loadedDocumentPaths" OnChange="@this.OnDocumentsChanged" CatchAllDocuments="true" UseSmallForm="false" Provider="@this.ProviderSettings"/>
</MudItem>
<MudItem xs="12" lg="6" Class="d-flex flex-column">
<MudText Typo="Typo.h6" Class="mb-1">@T("Pictures for the slides")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2 flex-grow-1">
@T("Attach the pictures which the presentation must show, e.g., photos, diagrams, or screenshots. Each of them goes on exactly one slide with room for a picture. Only PNG and JPEG pictures are possible. An LLM which can read images sees them; any other LLM knows only their file names, so clear names help.")
</MudJustifiedText>
<AttachDocuments Name="Pictures for the slides" @bind-DocumentPaths="@this.slidePictures" OnChange="@this.OnSlidePicturesChanged" CatchAllDocuments="false" UseSmallForm="false" AllowedFileTypes="@([FileTypes.SLIDE_PICTURE])" Provider="@this.ProviderSettings"/>
</MudItem>
</MudGrid>
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Picture library")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@SlideAssistantTexts.WhatAPictureLibraryOffers
</MudJustifiedText>
<SelectDirectory Directory="@this.pictureLibraryPath" DirectoryChanged="@this.OnPictureLibraryChanged" Label="@T("(Optional) Picture library")" DirectoryDialogTitle="@T("Select a picture library")" Validation="@this.ValidatePictureLibrary" Disabled="@this.IsPictureLibraryLocked" EnableDragDrop="true"/>
@if (this.IsLockedPictureLibraryUnavailable)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">@T("The picture library of your organization is not available right now, e.g., because its network drive cannot be reached. Until it is, the assistant plans the slides without it.")</MudAlert>
}
@if (this.PictureLibraryHint is { Length: > 0 } pictureLibraryHint)
{
<MudText Typo="Typo.body2" Class="mb-3">@pictureLibraryHint</MudText>
}
@if (!string.IsNullOrWhiteSpace(this.pictureLibraryPath) && !this.IsPictureLibraryLocked)
{
<MudButton StartIcon="@Icons.Material.Filled.FolderOff" Variant="Variant.Outlined" OnClick="@this.UseNoPictureLibrary" Class="mb-3">
@T("Use no picture library")
</MudButton>
}
<MudText Typo="Typo.h5" Class="mb-3 mt-6"> @T("Details about the desired presentation")</MudText>
@@ -22,11 +60,11 @@
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("You might want to specify important aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.")
</MudJustifiedText>
<MudTextField T="string" AutoGrow="true" Lines="3" @bind-Text="@this.importantAspects" class="mb-1" Label="@T("(Optional) Important Aspects")" HelperText="@T("(Optional) Specify aspects that the LLM should consider when creating the slides. For example, the use of emojis or specific topics that should be highlighted.")" ShrinkLabel="true" Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.List" Adornment="Adornment.Start" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudTextField T="string" AutoGrow="true" Lines="3" @bind-Text="@this.importantAspects" class="mb-1" Label="@T("(Optional) Important Aspects")" ShrinkLabel="true" Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.List" Adornment="Adornment.Start" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Extent of the planned presentation")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("Please specify the extent of the planned presentation. This can be the number of slides, the number of bullet points per slide, or the time specification for the presentation. This will help the LLM to create a presentation that fits your needs. Leave the default values if you don't have specific requirements regarding the extent of the presentation. You might only want to specify one of these parameters, for example the time specification, and leave the others at their default values.")
@T("Please specify the extent of the planned presentation. This can be the number of slides, the maximum number of bullet points per slide, or the time specification for the presentation. This will help the LLM to create a presentation that fits your needs. Leave the default values if you don't have specific requirements regarding the extent of the presentation. You might only want to specify one of these parameters, for example the time specification, and leave the others at their default values.")
</MudJustifiedText>
<MudGrid>
<MudItem xs="4">
@@ -39,10 +77,11 @@
<MudItem xs="4">
<MudNumericField @bind-Value="@this.numberOfBulletPoints"
Label="@T("Number of bullet points")"
Label="@T("Maximum number of bullet points per slide")"
Variant="Variant.Outlined"
Min="0"
Max="7"
Max="@DataSlideBuilder.MAX_BULLET_POINTS_PER_SLIDE"
Disabled="@this.IsBulletPointMaximumLocked"
Class="mb-3" />
</MudItem>
@@ -58,6 +97,12 @@
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Language")</MudText>
<EnumSelection T="CommonLanguages" NameFunc="@(language => language.Name())" @bind-Value="@this.selectedTargetLanguage" Icon="@Icons.Material.Filled.Translate" Label="@T("Target language")" AllowOther="@true" @bind-OtherInput="@this.customTargetLanguage" OtherValue="CommonLanguages.OTHER" LabelOther="@T("Custom target language")" ValidateOther="@this.ValidateCustomLanguage" />
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Occasion")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("The occasion helps the LLM to structure the presentation. A lecture, for example, may close its chapters with an interim summary or with room for questions, and a report to management leads with the key messages.")
</MudJustifiedText>
<EnumSelection T="PresentationOccasion" NameFunc="@(occasion => occasion.Name())" @bind-Value="@this.selectedOccasion" Icon="@Icons.Material.Filled.Event" Label="@T("Occasion of the presentation")" AllowOther="@true" @bind-OtherInput="@this.customOccasion" OtherValue="PresentationOccasion.OTHER" LabelOther="@T("Other occasion")" ValidateOther="@this.ValidateCustomOccasion" />
<MudText Typo="Typo.h6" Class="mb-1 mt-3"> @T("Audience")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("Please specify the audience for the planned presentation. This will help the LLM to create a presentation that fits your needs. You can specify the audience profile, the age group, organizational level, and the expertise. You don't have to specify all of these parameters, for example you might only want to specify the audience profile and leave the other parameters at their default values.")
@@ -66,4 +111,77 @@
<EnumSelection T="AudienceAgeGroup" NameFunc="@(ageGroup => ageGroup.Name())" @bind-Value="@this.selectedAudienceAgeGroup" Icon="@Icons.Material.Filled.Cake" Label="@T("Audience age group")" />
<EnumSelection T="AudienceOrganizationalLevel" NameFunc="@(level => level.Name())" @bind-Value="@this.selectedAudienceOrganizationalLevel" Icon="@Icons.Material.Filled.AccountTree" Label="@T("Audience organizational level")" />
<EnumSelection T="AudienceExpertise" NameFunc="@(expertise => expertise.Name())" @bind-Value="@this.selectedAudienceExpertise" Icon="@Icons.Material.Filled.School" Label="@T("Audience expertise")" />
<MudText Typo="Typo.h5" Class="mb-1 mt-6"> @T("PowerPoint template")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@T("Choose a PowerPoint presentation or template (.pptx, .potx) whose layouts the slides should use. The LLM then plans every slide for one of these layouts, and you can create a PowerPoint file (.pptx) in the design of your template, which other programs such as LibreOffice Impress open as well. OpenDocument presentations (.odp, .otp) cannot serve as a template yet. Without a template, you get an outline, which you can copy or export.")
</MudJustifiedText>
<MudJustifiedText Typo="Typo.body1" Class="mb-2">
@SlideAssistantTexts.WhatTheLLMKnowsOfLayouts
</MudJustifiedText>
<SelectFile File="@this.templatePath" FileChanged="@this.OnTemplateChanged" Label="@T("PowerPoint template")" FileDialogTitle="@T("Select a PowerPoint template")" Filter="@([FileTypes.POWER_POINT_TEMPLATE])" Validation="@this.ValidateTemplate" Disabled="@this.IsTemplateLocked" EnableDragDrop="true"/>
@if (this.IsLockedTemplateUnavailable)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">@T("The PowerPoint template of your organization is not available right now, e.g., because its network drive cannot be reached. Until it is, the assistant plans an outline only.")</MudAlert>
}
@if (!string.IsNullOrWhiteSpace(this.templatePath) && !this.IsTemplateLocked)
{
<MudButton StartIcon="@Icons.Material.Filled.LayersClear" Variant="Variant.Outlined" OnClick="@this.UseNoTemplate" Class="mb-3">
@T("Use no template")
</MudButton>
}
@* The select renders its items again on its own, e.g., while the form validates, when the template may be gone already: *@
@if (this.template is { } loadedTemplate)
{
<CompactExpansionPanel HeaderText="@T("Advanced Options")" HeaderIcon="@Icons.Material.Filled.Tune" Class="mb-3 rounded" @bind-Expanded="@this.advancedOptionsExpanded">
@if (loadedTemplate.VariantLabels.Count > 0)
{
<MudSelect T="string" @bind-Value="@this.variantLabel" Label="@T("Color variant")" HelperText="@T("Your template offers its layouts in several colors. Choose one for the presentation.")" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Palette" Class="mb-3">
@foreach (var label in loadedTemplate.VariantLabels)
{
<MudSelectItem T="string" Value="@label">@label</MudSelectItem>
}
</MudSelect>
}
<MudTextField T="string" @bind-Text="@this.footerText" Label="@T("(Optional) Footer")" HelperText="@T("Shown on every slide whose layout has a footer, e.g., your name, organization, and date.")" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ShortText" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudSwitch T="bool" @bind-Value="@this.keepEmptyPicturePlaceholders" Color="Color.Primary" Class="mx-1">
@T("Keep empty picture placeholders")
</MudSwitch>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@SlideAssistantTexts.WhatKeepingEmptyPicturePlaceholdersDoes
</MudJustifiedText>
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Layouts for kinds of slides")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@SlideAssistantTexts.WhatALayoutChoiceDoes
</MudJustifiedText>
<LayoutChoicePanels IsChosen="@(purpose => this.LayoutNameOf(purpose).Length > 0)">
<ChoiceContent Context="purpose">
<MudSelect T="string" Value="@this.LayoutNameOf(purpose)" ValueChanged="@(name => this.SetLayoutName(purpose, name))" Disabled="@this.AreLayoutsLocked" Label="@purpose.Label()" HelperText="@purpose.Help()" Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.ViewQuilt" Class="mb-3">
<MudSelectItem T="string" Value="@string.Empty">@LayoutPurposeExtensions.NoPreference</MudSelectItem>
@foreach (var layoutName in loadedTemplate.FamilyNames)
{
<MudSelectItem T="string" Value="@layoutName">@layoutName</MudSelectItem>
}
</MudSelect>
</ChoiceContent>
</LayoutChoicePanels>
@if (loadedTemplate.Slides.Count > 0)
{
<MudText Typo="Typo.h6" Class="mb-1 mt-3">@T("Slides of the template")</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@SlideAssistantTexts.WhatHappensToTemplateSlides
</MudJustifiedText>
@foreach (var templateSlide in loadedTemplate.Slides)
{
<EnumSelection T="TemplateSlideUse" NameFunc="@(use => use.Name())" Value="@this.TemplateSlideUseOf(templateSlide.Number)" ValueChanged="@(use => this.SetTemplateSlideUse(templateSlide.Number, use))" ValidateSelection="@(use => this.ValidateTemplateSlideUse(templateSlide, use))" Disabled="@this.AreTemplateSlideUsesLocked" Icon="@Icons.Material.Filled.Slideshow" Label="@templateSlide.Label()"/>
}
}
</CompactExpansionPanel>
}
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>
@@ -0,0 +1,257 @@
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
using AIStudio.Tools.Security;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The part of the Slide Assistant which offers the LLM a folder of pictures and icons to choose from.
/// </summary>
/// <remarks>
/// The LLM reads only the names of the pictures, never the pictures themselves, and need not use
/// any of them; see PictureLibrary. The library is read anew for each planning, so that a large
/// one offers another random sample each time, and once whenever the user chooses a folder, to tell
/// how many pictures it holds.
/// </remarks>
public partial class SlideAssistant
{
[Inject]
private ThreadSafeRandom RandomSource { get; init; } = null!;
private string pictureLibraryPath = string.Empty;
private static readonly AssistantSessionStateKey<string> PICTURE_LIBRARY_PATH_STATE_KEY = new(nameof(pictureLibraryPath));
/// <summary>
/// How many pictures the chosen library holds, for the hint below the field; it holds no sample.
/// </summary>
private PictureLibraryContent pictureLibraryOverview = PictureLibraryContent.NONE;
private bool isReadingPictureLibrary;
private CancellationTokenSource? pictureLibraryReading;
/// <summary>
/// Whether the chosen library folder exists, as its last count found.
/// </summary>
/// <remarks>
/// The form asks this with every rendering, and an unreachable network drive takes seconds to
/// answer, so the folder is checked only when it changes, and away from the rendering.
/// </remarks>
private bool isPictureLibraryAvailable;
/// <summary>
/// What the library offers to the planning under way, while the system prompt is written for it.
/// </summary>
private PictureLibraryContent planningLibrary = PictureLibraryContent.NONE;
/// <summary>
/// Whether the organization dictates the picture library.
/// </summary>
/// <remarks>
/// Like the template, the library is a preselection, so a lock binds only while preselection is enabled.
/// </remarks>
private bool IsPictureLibraryLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedPictureLibraryPath, out var meta) && meta.IsLocked;
/// <summary>
/// Whether the picture library of the organization is locked, but cannot be read right now, e.g.,
/// because its network drive cannot be reached.
/// </summary>
/// <remarks>
/// The user cannot choose another folder then, so it must not block the form. The assistant plans
/// without a library instead, and the form says why.
/// </remarks>
private bool IsLockedPictureLibraryUnavailable => this.IsPictureLibraryLocked && this.pictureLibraryPath.Length > 0 && !this.isReadingPictureLibrary && !this.isPictureLibraryAvailable;
private async Task OnPictureLibraryChanged(string path)
{
this.pictureLibraryPath = path;
await this.ReadPictureLibraryOverviewAsync();
if (this.Form is not null)
await this.Form.Validate();
}
private async Task UseNoPictureLibrary() => await this.OnPictureLibraryChanged(string.Empty);
private string? ValidatePictureLibrary(string path)
{
// The warning below the field explains a locked folder, since the user cannot change it, and
// while the folder is still counted, it is too early to tell:
if (string.IsNullOrWhiteSpace(path) || this.IsPictureLibraryLocked || this.isReadingPictureLibrary)
return null;
return this.isPictureLibraryAvailable ? null : T("The folder does not exist.");
}
/// <summary>
/// Counts the pictures of the chosen library without waiting for it, e.g., after the library was preselected or restored.
/// </summary>
private void StartCountingPictureLibrary() => this.ReadPictureLibraryOverviewAsync().Observe($"{nameof(SlideAssistant)}: counting the pictures of the library");
/// <summary>
/// Counts the pictures of the chosen library for the hint, and cancels a count still running for another folder.
/// </summary>
private async Task ReadPictureLibraryOverviewAsync()
{
if (this.pictureLibraryReading is { } previousReading)
{
await previousReading.CancelAsync();
previousReading.Dispose();
}
this.pictureLibraryOverview = PictureLibraryContent.NONE;
this.isPictureLibraryAvailable = false;
var path = this.pictureLibraryPath;
if (path.Length == 0)
{
this.pictureLibraryReading = null;
return;
}
var reading = new CancellationTokenSource();
this.pictureLibraryReading = reading;
this.isReadingPictureLibrary = true;
this.StateHasChanged();
try
{
var isAvailable = await FolderExistsAsync(path, reading.Token);
var overview = isAvailable ? await PictureLibrary.ReadAsync(path, this.RandomSource, 0, this.ReportInaccessibleLibraryFolder, reading.Token) : PictureLibraryContent.NONE;
if (!reading.IsCancellationRequested)
{
this.isPictureLibraryAvailable = isAvailable;
this.pictureLibraryOverview = overview;
}
}
catch (OperationCanceledException)
{
// Another folder was chosen meanwhile, and its count is under way.
}
catch (Exception e)
{
this.Logger.LogWarning(e, "The picture library '{FolderPath}' cannot be read.", path);
}
finally
{
// A count which started without a user's action, e.g., for a preselected folder, ends
// without a rendering of its own:
if (ReferenceEquals(this.pictureLibraryReading, reading))
{
this.isReadingPictureLibrary = false;
this.StateHasChanged();
}
}
}
/// <summary>
/// Reads the library for a planning, with a new random sample when it is large.
/// </summary>
/// <returns>What the library offers; nothing when the user chose no library or it cannot be read.</returns>
private async Task<PictureLibraryContent> ReadPictureLibraryForPlanningAsync()
{
var path = this.pictureLibraryPath;
if (path.Length == 0)
return PictureLibraryContent.NONE;
var token = this.CancellationTokenSource?.Token ?? CancellationToken.None;
try
{
if (!await FolderExistsAsync(path, token))
{
this.Logger.LogWarning("The picture library '{FolderPath}' is not available, so the slides are planned without it.", path);
return PictureLibraryContent.NONE;
}
return await PictureLibrary.ReadAsync(path, this.RandomSource, PictureLibrary.MAX_LIBRARY_PICTURES, this.ReportInaccessibleLibraryFolder, token);
}
catch (Exception e) when (e is not OperationCanceledException)
{
this.Logger.LogWarning(e, "The picture library '{FolderPath}' cannot be read, so the slides are planned without it.", path);
return PictureLibraryContent.NONE;
}
}
/// <summary>
/// Whether a folder exists, asked on a thread of its own, since an unreachable network drive takes seconds to answer.
/// </summary>
private static Task<bool> FolderExistsAsync(string path, CancellationToken token) => Task.Run(() => Directory.Exists(path), token);
private void ReportInaccessibleLibraryFolder(string folderPath, Exception e) => this.Logger.LogWarning(e, "The folder '{FolderPath}' of the picture library cannot be read.", folderPath);
/// <summary>
/// What the chosen library holds, below the field; empty while there is nothing to tell.
/// </summary>
private string PictureLibraryHint
{
get
{
if (this.isReadingPictureLibrary)
return T("Counting the pictures of the folder...");
if (!this.isPictureLibraryAvailable)
return string.Empty;
var library = this.pictureLibraryOverview;
var hints = new List<string>
{
library.PictureCount == 0
? T("The folder holds no PNG or JPEG pictures.")
: string.Format(T("{0} pictures, {1} of them icons."), library.PictureCount.CompactCount(), library.IconCount.CompactCount()),
};
if (library.SkippedFileCount > 0)
hints.Add(string.Format(T("{0} other files are left out, because they are neither PNG nor JPEG."), library.SkippedFileCount.CompactCount()));
if (library.PictureCount > PictureLibrary.MAX_LIBRARY_PICTURES)
hints.Add(string.Format(T("For each planning, the LLM gets a random {0} of them."), SlideAssistantTexts.MaxLibraryPictures));
return string.Join(" ", hints);
}
}
/// <summary>
/// The pictures of the library by their numbers, which follow those of the pictures for the slides, for the user's request.
/// </summary>
/// <remarks>
/// The names come from a folder, which may be a network drive anybody can write to, so they pass
/// the prompt injection filter like the texts of a template.
/// </remarks>
/// <param name="guardService">The prompt injection filter.</param>
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
/// <param name="library">What the library offers for this request.</param>
/// <param name="firstNumber">The number of the first picture of the library.</param>
/// <param name="folderPath">The library folder.</param>
/// <returns>The section of the request; empty without pictures in the library.</returns>
private static async Task<string> PromptPictureLibraryAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PictureLibraryContent library, int firstNumber, string folderPath)
{
if (library.Pictures.Count == 0)
return string.Empty;
var list = string.Join(Environment.NewLine, library.Pictures.Select((picture, index) => $"- Picture {firstNumber + index}: {picture.Name}{(picture.IsIcon ? " (icon)" : string.Empty)}"));
var sanitizedList = await guardService.SanitizeAsync(list, PromptInjectionSource.FileContent(folderPath), promptInjectionScope);
return $"""
# PICTURE_LIBRARY
{sanitizedList}
""";
}
/// <summary>
/// The rules for the pictures of the library; empty without them.
/// </summary>
private string PromptPictureLibraryRules() => this.planningLibrary.Pictures.Count == 0
? string.Empty
: """
- PICTURE_LIBRARY lists further pictures by their numbers and names; you know only their names. Fill the picture slots of your new slides from it: wherever a name fits the content of a slide, place that picture there by its number. Leave a picture slot empty only when no name fits. A picture which is not an icon appears at most once.
- Pictures marked "(icon)" are icons. An icon may appear on several slides, e.g., to mark each risk the same way, and suits picture slots of any size, except those marked as backdrop.
""";
/// <inheritdoc />
protected override void DisposeResources()
{
this.pictureLibraryReading?.Cancel();
this.pictureLibraryReading?.Dispose();
this.pictureLibraryReading = null;
base.DisposeResources();
}
}
@@ -0,0 +1,592 @@
using System.Globalization;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Security;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The part of the Slide Assistant which works with PowerPoint: the template, the slides the model
/// plans for its layouts, and the file written from them.
/// </summary>
public partial class SlideAssistant
{
/// <summary>
/// The layouts of a plain presentation, which the model plans with when no template was chosen.
/// </summary>
private static readonly PresentationTemplate DEFAULT_CATALOG = PresentationTemplate.CreateDefault();
/// <summary>
/// How many issues the repair request names at most.
/// </summary>
/// <remarks>
/// A model handed fifty issues fixes the first few and loses track of the rest; a dozen names
/// what is wrong in kind, and the model finds the like on its own.
/// </remarks>
private const int MAX_REPORTED_ISSUES = 12;
private string templatePath = string.Empty;
private string footerText = string.Empty;
private bool keepEmptyPicturePlaceholders;
private string? variantLabel;
private PresentationTemplate? template;
private SlideDeckSpec? slideDeck;
private bool isWritingPresentation;
/// <summary>
/// Whether the options of the template are open: its color variant, the footer, and its slides.
/// </summary>
private bool advancedOptionsExpanded;
/// <summary>
/// What the user chose for each slide of the template, by its number.
/// </summary>
private List<TemplateSlideUse> templateSlideUses = [];
/// <summary>
/// The name of the layout the user chose for each kind of slide; a kind left to the LLM is missing.
/// </summary>
/// <remarks>
/// Names stay when the user chooses another template, so that they apply again once a template
/// with these layouts is back. A name the current template does not have counts as no choice.
/// </remarks>
private readonly Dictionary<LayoutPurpose, string> layoutNames = new();
/// <summary>
/// The answer the slide deck was read from, which the session keeps to restore the deck.
/// </summary>
private string slideDeckAnswer = string.Empty;
/// <summary>
/// The template the slide deck was planned for; empty when it was planned without one.
/// </summary>
private string slideDeckTemplatePath = string.Empty;
/// <summary>
/// The pictures the slide deck may use, by the numbers the model gave them: every picture for the
/// slides, and those of the library which the deck uses, not the thousands it was offered.
/// </summary>
private readonly Dictionary<int, string> slideDeckPicturePaths = new();
/// <summary>
/// The numbers of the pictures in slideDeckPicturePaths which are icons.
/// </summary>
private readonly HashSet<int> slideDeckIconNumbers = [];
/// <summary>
/// What the slide deck was planned on: the layouts and template slides it uses, as the model read them.
/// </summary>
/// <remarks>
/// The template may change before the user saves the presentation, see SlideDeckSpecValidator.StillFits.
/// </remarks>
private readonly Dictionary<string, string> slideDeckBasis = new(StringComparer.Ordinal);
private static readonly AssistantSessionStateKey<string> TEMPLATE_PATH_STATE_KEY = new(nameof(templatePath));
private static readonly AssistantSessionStateKey<string> FOOTER_TEXT_STATE_KEY = new(nameof(footerText));
private static readonly AssistantSessionStateKey<bool> KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY = new(nameof(keepEmptyPicturePlaceholders));
private static readonly AssistantSessionStateKey<string> VARIANT_LABEL_STATE_KEY = new(nameof(variantLabel));
private static readonly AssistantSessionStateKey<bool> ADVANCED_OPTIONS_EXPANDED_STATE_KEY = new(nameof(advancedOptionsExpanded));
private static readonly AssistantSessionStateKey<List<TemplateSlideUse>> TEMPLATE_SLIDE_USES_STATE_KEY = new(nameof(templateSlideUses));
private static readonly AssistantSessionStateKey<Dictionary<LayoutPurpose, string>> LAYOUT_NAMES_STATE_KEY = new(nameof(layoutNames));
private static readonly AssistantSessionStateKey<string> SLIDE_DECK_ANSWER_STATE_KEY = new(nameof(slideDeckAnswer));
private static readonly AssistantSessionStateKey<string> SLIDE_DECK_TEMPLATE_PATH_STATE_KEY = new(nameof(slideDeckTemplatePath));
private static readonly AssistantSessionStateKey<Dictionary<int, string>> SLIDE_DECK_PICTURE_PATHS_STATE_KEY = new(nameof(slideDeckPicturePaths));
private static readonly AssistantSessionStateKey<HashSet<int>> SLIDE_DECK_ICON_NUMBERS_STATE_KEY = new(nameof(slideDeckIconNumbers));
private static readonly AssistantSessionStateKey<Dictionary<string, string>> SLIDE_DECK_BASIS_STATE_KEY = new(nameof(slideDeckBasis));
/// <summary>
/// The layouts the model plans with: those of the chosen template, or those of a plain presentation.
/// </summary>
private PresentationTemplate Catalog => this.template ?? DEFAULT_CATALOG;
/// <summary>
/// Whether the organization dictates the template.
/// </summary>
/// <remarks>
/// The template is a preselection, so a lock binds only while preselection is enabled. Without
/// preselection, the assistant starts without a template, and the user may choose one.
/// </remarks>
private bool IsTemplateLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.TemplatePath, out var meta) && meta.IsLocked;
/// <summary>
/// Whether the template of the organization is locked, but cannot be read right now, e.g.,
/// because its network drive cannot be reached.
/// </summary>
/// <remarks>
/// The user cannot choose another template then, so it must not block the form. The assistant
/// plans an outline instead, as without a template, and the form says why.
/// </remarks>
private bool IsLockedTemplateUnavailable => this.IsTemplateLocked && !string.IsNullOrWhiteSpace(this.templatePath) && this.template is null;
/// <summary>
/// Whether the organization locked the layouts for kinds of slides.
/// </summary>
/// <remarks>
/// Like the template, the layouts are a preselection, so a lock binds only while preselection is enabled.
/// </remarks>
private bool AreLayoutsLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedLayouts, out var meta) && meta.IsLocked;
/// <summary>
/// Whether the organization locked what the presentation does with the slides of the template.
/// </summary>
/// <remarks>
/// The lock binds only for the preselected template, whose slides the choices number. For any
/// other template, the user chooses freely.
/// </remarks>
private bool AreTemplateSlideUsesLocked => this.IsPreselectedTemplate && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, out var meta) && meta.IsLocked;
/// <summary>
/// Whether preselection is enabled and the chosen template is the preselected one.
/// </summary>
private bool IsPreselectedTemplate => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && this.template is not null && string.Equals(this.templatePath, this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath, StringComparison.Ordinal);
/// <summary>
/// What the user chose for the slides of the template, as the model and the check of its answer see it; empty without a template.
/// </summary>
private IReadOnlyList<TemplateSlideUse> ChosenTemplateSlideUses => this.template is null ? [] : this.templateSlideUses.ToList();
private bool HasChosenTemplateSlides => this.template is not null && this.templateSlideUses.Any(use => use is not TemplateSlideUse.OMIT);
/// <summary>
/// The layouts the user chose for kinds of slides, as the current template offers them; none without a template.
/// </summary>
private ChosenLayouts ChosenLayoutsOfTemplate => this.template is null ? ChosenLayouts.NONE : ChosenLayouts.Of(this.template, this.layoutNames);
/// <summary>
/// How many slides the user asked for, or 0 when the model may choose.
/// </summary>
/// <remarks>
/// A number of slides wins over a duration. A duration means one slide per one and a half
/// minutes, as the form has always counted it.
/// </remarks>
private int ExpectedSlideCount => this.numberOfSheets > 0
? this.numberOfSheets
: (int)Math.Round(this.timeSpecification / 1.5);
private bool PlannedForCurrentTemplate => this.template is not null && this.slideDeckTemplatePath.Length > 0 && string.Equals(this.slideDeckTemplatePath, this.templatePath, StringComparison.Ordinal);
private bool CanCreatePresentation => this.slideDeck is not null && this.PlannedForCurrentTemplate && !this.isWritingPresentation && !this.IsProcessing;
private string CreatePresentationTooltip
{
get
{
if (this.slideDeck is null)
return T("Please plan the slides first.");
if (this.template is null)
return T("Please choose a PowerPoint template to create a file from the slides.");
if (!this.PlannedForCurrentTemplate)
return T("The slides were planned for another template. Please plan them again.");
return T("Creates a PowerPoint file in the design of your template.");
}
}
/// <summary>
/// Reads the layouts of the chosen template.
/// </summary>
/// <remarks>
/// A template which cannot be read leaves no template at all. The form then says why, and
/// nothing is planned for layouts nobody can write.
/// </remarks>
private void LoadTemplate()
{
this.template = null;
if (string.IsNullOrWhiteSpace(this.templatePath))
return;
if (!File.Exists(this.templatePath))
{
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' does not exist.", this.templatePath);
return;
}
try
{
this.template = PresentationTemplate.ReadFile(this.templatePath);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "The PowerPoint template '{TemplatePath}' cannot be read.", this.templatePath);
return;
}
// A variant of another template means nothing for this one, nor do choices for its slides:
if (this.variantLabel is null || !this.template.VariantLabels.Contains(this.variantLabel))
this.variantLabel = this.template.VariantLabels.FirstOrDefault();
if (this.templateSlideUses.Count != this.template.Slides.Count)
this.templateSlideUses = DefaultTemplateSlideUsesOf(this.template);
}
private async Task OnTemplateChanged(string path)
{
this.templatePath = path;
this.templateSlideUses = [];
this.LoadTemplate();
this.ApplyPreselectedTemplateSlideUses();
if (this.Form is not null)
await this.Form.Validate();
}
/// <summary>
/// What the presentation does with the slides of a template unless the user says otherwise.
/// </summary>
/// <remarks>
/// The presentation leaves every slide out. A template serves its design first, and the slides
/// of a file are as often samples and hints as an earlier presentation: whoever wants one of them
/// in the new presentation says so.
/// </remarks>
private static List<TemplateSlideUse> DefaultTemplateSlideUsesOf(PresentationTemplate template) => Enumerable.Repeat(TemplateSlideUse.OMIT, template.Slides.Count).ToList();
/// <summary>
/// Applies what the settings preselect for the slides of the template, as long as it is the preselected template.
/// </summary>
/// <remarks>
/// The choices number the slides of the preselected template, so they mean nothing for another
/// one. A choice which the template cannot follow, e.g., for a slide it lost since, is logged.
/// </remarks>
private void ApplyPreselectedTemplateSlideUses()
{
if (!this.IsPreselectedTemplate || this.template is null)
return;
var choices = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTemplateSlideUses;
this.templateSlideUses = choices.ToTemplateSlideUses(this.template);
foreach (var (number, use) in choices)
if (this.templateSlideUses.UseOf(number) != use)
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' cannot follow the preselected use '{Use}' of its slide {Number}: it has no such slide, or the slide has no placeholders for content.", this.templatePath, use, number);
}
private TemplateSlideUse TemplateSlideUseOf(int number) => this.templateSlideUses.UseOf(number);
private void SetTemplateSlideUse(int number, TemplateSlideUse use)
{
if (number >= 1 && number <= this.templateSlideUses.Count)
this.templateSlideUses[number - 1] = use;
}
private string? ValidateTemplateSlideUse(TemplateSlide slide, TemplateSlideUse use)
{
if (use is TemplateSlideUse.EDIT && !slide.IsEditable)
return T("The LLM cannot edit this slide, because its layout has no placeholders for content. Please keep it unchanged or leave it out.");
return null;
}
/// <summary>
/// The name of the layout chosen for a kind of slide, as the form shows it.
/// </summary>
/// <returns>The name, or empty when the LLM decides, which it also does when the template has no layout of this name.</returns>
private string LayoutNameOf(LayoutPurpose purpose) => this.template is not null && this.layoutNames.TryGetValue(purpose, out var name) && this.template.FindFamilyByName(name) is not null ? name : string.Empty;
private void SetLayoutName(LayoutPurpose purpose, string name)
{
if (name.Length == 0)
this.layoutNames.Remove(purpose);
else
this.layoutNames[purpose] = name;
}
/// <summary>
/// The rules for the layouts the user chose; empty when the LLM decides every layout.
/// </summary>
private string PromptChosenLayouts() => this.ChosenLayoutsOfTemplate.ToPromptRules();
private async Task UseNoTemplate() => await this.OnTemplateChanged(string.Empty);
private string? ValidateTemplate(string path)
{
// The warning below the field explains this case, since the user cannot change the template:
if (string.IsNullOrWhiteSpace(path) || this.IsLockedTemplateUnavailable)
return null;
if (!File.Exists(path))
return T("The template file does not exist.");
if (this.template is null)
return T("The template cannot be read. Please choose a PowerPoint presentation or template (.pptx, .potx).");
if (this.template.Families.Count == 0)
return T("The template has no layouts with room for content.");
return null;
}
/// <summary>
/// The pictures for the slides, in the order the model numbers them.
/// </summary>
private List<FileAttachment> PicturesOfRequest() => this.slidePictures.Where(n => n is { Exists: true, IsImage: true }).ToList();
/// <summary>
/// The images of the source material, which the model reads but never places.
/// </summary>
private List<FileAttachment> SourceImagesOfRequest() => this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: true }).ToList();
private string PromptPictures()
{
var rules = new List<string>();
if (this.PicturesOfRequest().Count > 0)
rules.Add(" - PICTURES lists the pictures the presentation must show, by their numbers. Place each of them exactly once, by its number, on a slide with a picture slot whose content it fits.");
if (this.PromptPictureLibraryRules() is { Length: > 0 } libraryRules)
rules.Add(libraryRules);
if (rules.Count == 0)
rules.Add(" - No pictures for the slides are attached. Leave out \"pictures\" on every slide.");
if (this.SourceImagesOfRequest().Count > 0)
rules.Add(" - Images attached as source material are for reading only. Never place them on a slide.");
return string.Join(Environment.NewLine, rules);
}
/// <summary>
/// The pictures for the slides by their numbers, for the user's request.
/// </summary>
/// <remarks>
/// The names are those of the user's files, so they go with the request, like the paths of the
/// attached documents, rather than into the system prompt. A model which reads images gets the
/// pictures as well; any other model has only their names to go by.
/// </remarks>
/// <param name="pictures">The pictures taken at the start of the request, in the order the model numbers them.</param>
/// <param name="modelSeesPictures">Whether the pictures go to the model as images.</param>
/// <returns>The section of the request; empty without pictures.</returns>
private static string PromptPictureList(IReadOnlyList<FileAttachment> pictures, bool modelSeesPictures)
{
if (pictures.Count == 0)
return string.Empty;
var list = string.Join(Environment.NewLine, pictures.Select((picture, index) => $"- Picture {index + 1}: {picture.FileName}"));
var seeing = modelSeesPictures
? "The images attached to this request start with these pictures, in this order."
: "You cannot see these pictures. Place them by what their names say.";
return $"""
# PICTURES
{seeing}
{list}
""";
}
private string PromptSlideCount()
{
var expectedSlideCount = this.ExpectedSlideCount;
if (expectedSlideCount <= 0)
return " - Choose as many slides as the content needs.";
return this.HasChosenTemplateSlides
? $" - The presentation has exactly {expectedSlideCount} slides, the title slide and the template slides included."
: $" - The presentation has exactly {expectedSlideCount} slides, the title slide included.";
}
/// <summary>
/// The rules for the slides of the template the user chose; empty when the user chose none.
/// </summary>
/// <remarks>
/// The slides themselves come with the user's request, as TEMPLATE_SLIDES, since their texts are
/// content of a file rather than instructions.
/// </remarks>
private string PromptTemplateSlides()
{
if (!this.HasChosenTemplateSlides)
return string.Empty;
return """
# Template slides
The user chose slides of the template to use in the presentation. TEMPLATE_SLIDES lists them in their order, each one either to keep unchanged or to edit.
- Write a slide to keep as { "template_slide": <its number> } and nothing else. Add "starts_chapter": true where it opens a chapter.
- Write a slide to edit with "template_slide": <its number> and its new content in the usual fields, which fill the slots its entry names. Leave out "layout": the slide keeps its own. A slot you neither fill nor remove keeps the content of the template slide, and so does every element you do not remove.
- Where an entry gives a slot its place and size "on this slide", the slide moved or resized it, e.g., to make room for a picture of its own, or carries it as a copy of another slot, e.g., a further heading with its text. Fill such a slot like any other, and stay within the lines and characters stated there rather than those of the layout.
- An entry to edit also lists the elements the slide carries besides its slots, e.g., pictures, text boxes, charts, or tables of its own, with their place and the slots they overlap. List what the slide no longer shows in "remove", by the names the entry uses, e.g., "remove": ["element 2", "label 1"].
- Remove what does not fit the new content: samples and hints of the template, e.g., placeholder pictures, sample texts such as "Lorem ipsum", or a request to name the source of a picture; old content of a slot which no longer fits; and every element which overlaps a slot you fill, unless it covers most of the slide as a background. Keep what still belongs to the slide, e.g., a logo or a decoration. Never fill and remove the same slot, and never remove the title.
- Use every listed slide exactly once, in the listed order. Add new slides before, between, and after them, wherever the content calls for it.
- Do not repeat on a new slide what a kept slide says already.
- Example: "slides": [{ "template_slide": 1 }, { "layout": "L3", "title": "..." }, { "template_slide": 3, "title": "...", "texts": [["..."]], "remove": ["element 1"] }]
""";
}
/// <summary>
/// The rule for the field listing what an edited template slide removes; empty when the user
/// edits no template slide, so that the model does not hear of the field at all.
/// </summary>
private string PromptRemoveRule() => this.template is not null && this.templateSlideUses.Any(use => use is TemplateSlideUse.EDIT)
? """
- "remove" lists the slots and elements a template slide to edit no longer shows, by the names TEMPLATE_SLIDES uses. Leave it out on every other slide.
"""
: string.Empty;
/// <summary>
/// The layouts the model plans with, for the user's request.
/// </summary>
/// <remarks>
/// The catalog names the layouts as the author of the template named them. These names come from
/// a file, which may carry instructions of somebody else, so the catalog goes with the request
/// rather than into the system prompt, and the layouts of a template pass the prompt injection
/// filter like its slides. The layouts of a plain presentation come from the code.
/// </remarks>
/// <param name="guardService">The prompt injection filter.</param>
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
/// <param name="catalog">The layouts taken at the start of the request.</param>
/// <param name="templatePath">The template the layouts were read from; empty for the layouts of a plain presentation.</param>
/// <returns>The section of the request.</returns>
private static async Task<string> PromptLayoutsAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PresentationTemplate catalog, string templatePath)
{
var layouts = catalog.ToCatalogPrompt();
if (templatePath.Length > 0)
layouts = await guardService.SanitizeAsync(layouts, PromptInjectionSource.FileContent(templatePath), promptInjectionScope);
return $"""
# LAYOUTS
{layouts}
""";
}
/// <summary>
/// The slides of the template the user chose, with what they hold, for the user's request.
/// </summary>
/// <remarks>
/// Their texts come from a file, which may carry instructions of somebody else, so they pass the
/// prompt injection filter like every attached document.
/// </remarks>
/// <param name="guardService">The prompt injection filter.</param>
/// <param name="promptInjectionScope">The action of the request, which reports what the filter removed.</param>
/// <param name="catalog">The layouts and slides taken at the start of the request.</param>
/// <param name="templatePath">The template they were read from; empty without a template.</param>
/// <param name="templateSlideUses">What the user chose for each slide of the template.</param>
/// <returns>The section of the request; empty when the user chose no slide.</returns>
private static async Task<string> PromptTemplateSlidesAsync(PromptInjectionGuardService guardService, PromptInjectionReportingScope promptInjectionScope, PresentationTemplate catalog, string templatePath, IReadOnlyList<TemplateSlideUse> templateSlideUses)
{
if (templatePath.Length == 0 || catalog.ToTemplateSlidesPrompt(templateSlideUses) is not { Length: > 0 } templateSlides)
return string.Empty;
var sanitizedTemplateSlides = await guardService.SanitizeAsync(templateSlides, PromptInjectionSource.FileContent(templatePath), promptInjectionScope);
return $"""
# TEMPLATE_SLIDES
{sanitizedTemplateSlides}
""";
}
private string PromptBulletPoints()
{
var count = this.numberOfBulletPoints > 0
? $" - A slide has at most {this.numberOfBulletPoints} main points, all its text slots together. Fewer are fine where the content needs fewer."
: " - Choose between 1 and 7 main points per text slot, as its space allows.";
return $"""
{count}
- Every point is short: at most 12 words, clear, and directly related to the slide title.
""";
}
/// <summary>
/// Reads the slide deck out of an answer and checks it against the layouts it was planned for.
/// </summary>
/// <returns>The deck, or none when the answer cannot be read, with the issues, written for the model.</returns>
private CheckedAnswer CheckAnswer(string answer, PresentationTemplate catalog, int pictureCount, LibraryPictures libraryPictures, IReadOnlyList<TemplateSlideUse> chosenTemplateSlideUses, ChosenLayouts chosenLayouts)
{
if (!SlideDeckSpec.TryParse(answer, out var deck, out var issue))
return new CheckedAnswer(null, [issue]);
return new CheckedAnswer(deck, SlideDeckSpecValidator.Validate(deck, catalog, pictureCount, this.ExpectedSlideCount, chosenTemplateSlideUses, chosenLayouts, this.numberOfBulletPoints, libraryPictures));
}
private static string RepairPromptOf(IReadOnlyList<string> issues)
{
var listedIssues = string.Join(Environment.NewLine, issues.Take(MAX_REPORTED_ISSUES).Select(issue => $"- {issue}"));
return $"""
Your JSON does not fit the layouts yet:
{listedIssues}
Answer with the complete corrected JSON object and nothing else.
""";
}
/// <summary>
/// Shows the slide deck as an outline, which is the visible result of the assistant.
/// </summary>
/// <remarks>
/// The outline becomes the last block of the chat, so copying the result, exporting it, and
/// sending it to a chat all work with the outline rather than with the JSON behind it.
/// </remarks>
private void ShowOutline(SlideDeckSpec deck, PresentationTemplate catalog, IReadOnlyDictionary<int, string> pictureNames)
{
var outline = new ContentBlock
{
Time = DateTimeOffset.Now,
ContentType = ContentType.TEXT,
Role = ChatRole.AI,
HideFromUser = false,
Content = new ContentText { Text = deck.ToMarkdownOutline(catalog, pictureNames) },
};
this.ChatThread?.Blocks.Add(outline);
this.ResultingContentBlock = outline;
}
private void RestoreSlideDeck() => this.slideDeck = this.slideDeckAnswer.Length > 0 && SlideDeckSpec.TryParse(this.slideDeckAnswer, out var deck, out _) ? deck : null;
private async Task CreatePresentationFileAsync()
{
if (!this.CanCreatePresentation || this.slideDeck is not { } deck)
return;
var fileName = FileExportFormatExtensions.ToFileNameFragment(deck.Title);
var response = await this.RustService.SaveFile(T("Save the presentation"), [FileTypes.POWER_POINT_PRESENTATION], $"{(fileName.Length == 0 ? "presentation" : fileName)}.pptx");
if (response.UserCancelled)
return;
var targetPath = response.SaveFilePath;
this.isWritingPresentation = true;
this.StateHasChanged();
try
{
var options = new PresentationWriteOptions(this.variantLabel, this.footerText.Trim(), this.keepEmptyPicturePlaceholders);
var pictures = new RustPictureSource(this.RustService, this.slideDeckPicturePaths, this.slideDeckIconNumbers);
var result = await PresentationFile.CreateAsync(this.slideDeckTemplatePath, targetPath, deck, this.slideDeckBasis, options, pictures, this.Logger, CancellationToken.None);
if (!result.IsWritten)
{
this.Logger.LogWarning("The PowerPoint template '{TemplatePath}' changed since the slides were planned, and they no longer fit it.", this.slideDeckTemplatePath);
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The template has changed since the slides were planned. Please create the slides again.")));
return;
}
var placements = deck.Slides.SelectMany(slide => slide.Pictures).ToList();
this.Logger.LogInformation("The slide assistant saved {SlideCount} slides to '{FilePath}', with {PlacedPictureCount} of {PictureCount} pictures placed and empty picture placeholders {EmptyPicturePlaceholders}.", deck.Slides.Count, targetPath, placements.Count(number => !result.SkippedPictures.Contains(number)), placements.Count, this.keepEmptyPicturePlaceholders ? "kept" : "left out");
if (result.SkippedPictures.Count > 0)
{
var names = result.SkippedPictures.Select(number => this.slideDeckPicturePaths.TryGetValue(number, out var picturePath) ? Path.GetFileName(picturePath) : number.ToString(CultureInfo.InvariantCulture));
await this.MessageBus.SendWarning(new(Icons.Material.Filled.HideImage, string.Format(T("These pictures could not be placed, because PowerPoint cannot show their format or they cannot be read: {0}"), string.Join(", ", names))));
}
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Slideshow, T("The PowerPoint file was created.")));
}
catch (Exception e)
{
this.Logger.LogError(e, "Creating the PowerPoint file '{FilePath}' failed.", targetPath);
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The PowerPoint file could not be created. Please check that the template can be read and that the target file is not open in another program.")));
}
finally
{
this.isWritingPresentation = false;
this.StateHasChanged();
}
}
/// <summary>
/// A slide deck read out of an answer of the model, with what is wrong with it.
/// </summary>
/// <param name="Deck">The deck; null when the answer cannot be read.</param>
/// <param name="Issues">The issues, written for the model; empty when the deck fits.</param>
private sealed record CheckedAnswer(SlideDeckSpec? Deck, IReadOnlyList<string> Issues);
}
@@ -0,0 +1,60 @@
using AIStudio.Chat;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The part of the Slide Assistant which tells, below the progress bar, what the LLM is doing while
/// it plans.
/// </summary>
/// <remarks>
/// The answer is JSON, which the assistant does not show. Without these texts, the user would watch
/// a bar for minutes without knowing whether anything happens: the LLM first thinks about the
/// structure, which streams nothing at all, then writes the slides one by one, and may fit them to
/// the layouts in a second round.
/// </remarks>
public partial class SlideAssistant
{
private PlanningStep planningStep;
private static readonly AssistantSessionStateKey<PlanningStep> PLANNING_STEP_STATE_KEY = new(nameof(planningStep));
private ContentText? countedAnswer;
private StreamedSlideCounter slideCounter = new();
protected override string ProgressText => this.planningStep switch
{
PlanningStep.PLANNING when this.SlidesSoFar() is var slides and > 0 => string.Format(T("The LLM is writing slide {0}..."), slides),
PlanningStep.PLANNING => T("The LLM is planning the structure of the presentation..."),
PlanningStep.REPAIRING => T("The LLM is fitting the slides to the layouts..."),
_ => string.Empty,
};
/// <summary>
/// Counts the slides which the answer streaming in has started so far.
/// </summary>
private int SlidesSoFar()
{
if (this.ResultingContentBlock?.Content is not ContentText answer)
return 0;
if (!ReferenceEquals(answer, this.countedAnswer))
{
this.countedAnswer = answer;
this.slideCounter = new();
}
return this.slideCounter.Count(answer.Text);
}
/// <summary>
/// What the LLM is asked to do right now.
/// </summary>
private enum PlanningStep
{
NONE,
PLANNING,
REPAIRING,
}
}
@@ -1,7 +1,9 @@
using System.Text;
using AIStudio.Chat;
using AIStudio.Dialogs.Settings;
using AIStudio.Settings;
using AIStudio.Tools.AssistantSessions;
using AIStudio.Tools.PowerPoint;
using AIStudio.Tools.Security;
namespace AIStudio.Assistants.SlideBuilder;
@@ -10,52 +12,77 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
{
protected override Tools.Components Component => Tools.Components.SLIDE_BUILDER_ASSISTANT;
protected override string Title => T("Slide Planner Assistant");
protected override string Title => T("Slide Assistant");
protected override string Description => T("This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also specify the number of bullet points. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience.");
protected override string Description => T("This assistant helps you create clear, structured slides from long texts or documents. Enter a presentation title and provide the content either as text or with one or more documents. Important aspects allow you to add instructions to the LLM regarding output or formatting. Set the number of slides either directly or based on your desired presentation duration. You can also limit the number of bullet points per slide. If the default value of 0 is not changed, the LLM will independently determine how many slides or bullet points to generate. The output can be flexibly generated in various languages and tailored to a specific audience. When you choose a PowerPoint template, the slides use its layouts, and you can create a PowerPoint file in its design from the result.");
protected override string SystemPrompt =>
$$$"""
You are a professional presentation editor and writer.
Create a clear, single-slide outline from the user's inputs.
Plan a presentation from the user's inputs and describe it slide by slide as JSON, using the slide layouts LAYOUTS lists.
# Presentation title:
- IGNORE the language of the PRESENTATION_TITLE.
- Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
# Content
- You get the following inputs: PRESENTATION_TITLE, PRESENTATION_CONTENT, and any attached documents that may provide additional context or source material (DOCUMENTS).
{{{this.GetDocumentTaskDescription()}}}
{{{this.PromptImportantAspects()}}}
# Subheadings
- Rule for creating the individual subheadings:
- If {{{this.numberOfSheets}}} is NOT 0
- Generate exactly {{{this.numberOfSheets}}} precise subheadings, each heading represents one slide in a presentation.
- If {{{this.timeSpecification}}} is NOT 0
- Generate exactly {{{this.calculatedNumberOfSlides}}} precise subheadings, each heading represents one slide in a presentation.
- If either parameter is 0, ignore that rules.
- Each subheadings must have:
- A clear, concise, and thematically meaningful heading.
- Place *** on its own line immediately before each heading.
# Bullet points (per subheading)
- You MUST generate exactly this {{{this.numberOfBulletPoints}}} many bullet points per subheading:
- If {{{this.numberOfBulletPoints}}} == 0 → choose a number between 1 and 7 (your choice, but max 7).
- Each bullet point must have:
- Each bullet point must be max 12 words.
- Clear and directly related to the subheading and summarizing the slide’s content.
# Output requirements:
- Output only Markdown.
- Start with a single H1 title that contains the user's PRESENTATION_TITLE.
- Then add headings with own bullet lists based on the provided source material: PRESENTATION_CONTENT, DOCUMENTS, and attached images.
# Content
- You get the following inputs: PRESENTATION_TITLE, PRESENTATION_CONTENT, the layouts the slides use (LAYOUTS), any attached documents (DOCUMENTS) or images that provide source material, and the pictures for the slides (PICTURES).
- Base the slides on the provided source material: PRESENTATION_CONTENT, DOCUMENTS, and attached images.
- If both PRESENTATION_CONTENT and attached source material are provided, use all of them, while prioritizing direct user instructions from PRESENTATION_CONTENT when resolving ambiguity.
- If PRESENTATION_CONTENT is empty but attached source material is available, create the slides from the attached source material.
- If neither PRESENTATION_CONTENT nor any attached source material is available, output the title and one bullet: "No content provided."
- If neither PRESENTATION_CONTENT nor any attached source material is available, create a single slide with the title and the text "No content provided."
{{{this.GetDocumentTaskDescription()}}}
{{{this.PromptImportantAspects()}}}
# Layouts
Every slide uses one of the layouts LAYOUTS lists. A layout offers slots, listed in reading order with their place on the slide and how much text fits.
- Choose for each slide the layout whose slots suit its content, and vary the layouts where the content allows.
- Fill only slots the layout offers, and stay within the stated lines and characters. Spread content over more slides rather than overfilling one.
- A label is a short text, e.g., a heading above a text or the source below a picture.
- Set "starts_chapter" to true on each slide which opens a new part of the presentation. Where a layout is meant for chapters or sections, use it for these slides.
{{{this.PromptChosenLayouts()}}}
{{{this.PromptTemplateSlides()}}}
# Pictures
{{{this.PromptPictures()}}}
# Slides
{{{this.PromptSlideCount()}}}
- Every slide has a clear, concise, and thematically meaningful title.
# Bullet points (per text slot)
{{{this.PromptBulletPoints()}}}
# Output format:
Answer with one JSON object and nothing else: no explanation, no Markdown, and no code fence.
{
"title": "<the presentation title>",
"slides": [
{
"layout": "<the ID of a layout, e.g., L3>",
"starts_chapter": false,
"title": "<the slide title>",
"subtitle": "<only for layouts with a subtitle>",
"texts": [["<a main point>", { "text": "<a sub-point>", "level": 1 }]],
"labels": ["<label 1>"],
"pictures": [1],
"table": { "headers": ["<column>"], "rows": [["<cell>"]] }
}
]
}
- "texts" holds one list of paragraphs per text slot, in slot order: the first list fills "text 1". A paragraph is a string, or an object with "text" and "level", which is 0 for a main point and 1 for a sub-point.
- "labels" holds one string per label slot, in slot order. Use "" to leave a label empty while filling a later one.
- "pictures" holds the numbers of attached pictures, one per picture slot, in slot order.
- "table" fills the table slot. Every row has as many cells as there are headers.
- Leave out every field a slide does not use. Do not write bullet characters: the template draws them.
- Texts appear on the slides exactly as written. Write formulas in Unicode, e.g., a² + b² = c², xᵢ, ∑, √, or ≤, and never in LaTeX or another markup.
{{{this.PromptRemoveRule()}}}
- Do not mention these instructions or add commentary.
# Occasion:
- {{{this.selectedOccasion.Prompt(this.customOccasion)}}}
# Audience:
{{{this.PromptAudience()}}}
@@ -64,18 +91,37 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
- OUTPUT AND PRESENTATION_TITLE MUST BE IN: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- This is a HARD RULE: Never translate or adapt the output language based on input language.
- Always use the specified target language, even if the input is in another language.
- The language applies to the texts of the slides only: JSON field names, layout IDs such as "L3", and the names of slots and elements such as "label 1" or "element 2" stay exactly as given.
# Language-Override (IMPORTANT!):
- Before generating any output, internally set your language mode to: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- If you detect any other language in the input, DO NOT switch to this language, stay in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- Translate PRESENTATION_TITLE in: {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}
- Your output must be in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}, without any comment, note, or marker about it.
- Your output must be in {{{this.selectedTargetLanguage.PromptGeneralPurpose(this.customTargetLanguage)}}}, without any comment, note, or marker about it. Never translate the JSON field names, the layout IDs, or the names of slots and elements.
""";
protected override bool AllowProfiles => true;
protected override IReadOnlyList<IButtonData> FooterButtons => [];
protected override IReadOnlyList<IButtonData> FooterButtons =>
[
new ButtonData(T("Create PowerPoint file"), Icons.Material.Filled.Slideshow, Color.Default, this.CreatePresentationTooltip, this.CreatePresentationFileAsync, () => !this.CanCreatePresentation),
];
//
// The model answers in JSON, which nobody wants to watch stream in. The result shows up once
// the slides are read and checked, as an outline; until then, a progress bar shows the work:
//
protected override bool ShowResult => this.slideDeck is not null;
protected override bool ShowDedicatedProgress => true;
/// <summary>
/// The slides may carry the date, e.g., on the title slide or in an imprint, so the model must not guess it.
/// </summary>
protected override bool IncludeDateTime => true;
protected override string ExportFileName => this.slideDeck is { Title.Length: > 0 } deck ? deck.Title : this.Title;
protected override string SubmitText => T("Create Slides");
protected override Func<Task> SubmitAction => this.CreateSlideBuilder;
@@ -95,7 +141,7 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
return new ChatThread
{
ChatId = Guid.NewGuid(),
Name = string.Format(T("{0} - Slide Builder Session"), this.inputTitle),
Name = string.Format(T("{0} - Slide Assistant Session"), this.inputTitle),
SystemPrompt = SystemPrompts.DEFAULT,
Blocks =
[
@@ -108,8 +154,8 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
ContentType = ContentType.TEXT,
Content = new ContentText
{
Text = this.T("The result of your previous slide builder session."),
FileAttachments = this.loadedDocumentPaths.ToList(),
Text = this.T("The result of your previous Slide Assistant session."),
FileAttachments = [..this.loadedDocumentPaths, ..this.slidePictures],
}
},
@@ -157,27 +203,59 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
this.inputTitle = string.Empty;
this.inputContent = string.Empty;
this.loadedDocumentPaths.Clear();
this.slidePictures.Clear();
this.selectedAudienceProfile = AudienceProfile.UNSPECIFIED;
this.selectedAudienceAgeGroup = AudienceAgeGroup.UNSPECIFIED;
this.selectedAudienceOrganizationalLevel = AudienceOrganizationalLevel.UNSPECIFIED;
this.selectedAudienceExpertise = AudienceExpertise.UNSPECIFIED;
this.selectedOccasion = PresentationOccasion.UNSPECIFIED;
this.customOccasion = string.Empty;
this.slideDeck = null;
this.slideDeckAnswer = string.Empty;
this.slideDeckTemplatePath = string.Empty;
this.slideDeckPicturePaths.Clear();
this.slideDeckIconNumbers.Clear();
this.slideDeckBasis.Clear();
this.templateSlideUses = [];
this.layoutNames.Clear();
if (!this.MightPreselectValues())
{
this.selectedTargetLanguage = CommonLanguages.AS_IS;
this.customTargetLanguage = string.Empty;
this.footerText = string.Empty;
this.keepEmptyPicturePlaceholders = false;
this.numberOfBulletPoints = 0;
this.pictureLibraryPath = string.Empty;
this.StartCountingPictureLibrary();
this.templatePath = string.Empty;
this.LoadTemplate();
}
}
protected override bool MightPreselectValues()
{
if (this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)
{
this.templatePath = this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath;
this.LoadTemplate();
this.ApplyPreselectedTemplateSlideUses();
this.footerText = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedFooterText;
this.keepEmptyPicturePlaceholders = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedKeepEmptyPicturePlaceholders;
this.numberOfBulletPoints = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedMaxBulletPointsPerSlide;
this.pictureLibraryPath = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedPictureLibraryPath;
this.StartCountingPictureLibrary();
this.layoutNames.Clear();
foreach (var (purpose, layoutName) in this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedLayouts)
this.layoutNames[purpose] = layoutName;
this.selectedTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage;
this.customTargetLanguage = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherLanguage;
this.selectedAudienceProfile = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceProfile;
this.selectedAudienceAgeGroup = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceAgeGroup;
this.selectedAudienceOrganizationalLevel = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceOrganizationalLevel;
this.selectedAudienceExpertise = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceExpertise;
this.selectedOccasion = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOccasion;
this.customOccasion = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherOccasion;
this.importantAspects = this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedImportantAspects;
return true;
}
@@ -193,12 +271,26 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
private AudienceOrganizationalLevel selectedAudienceOrganizationalLevel;
private AudienceExpertise selectedAudienceExpertise;
private CommonLanguages selectedTargetLanguage;
private PresentationOccasion selectedOccasion;
private string customOccasion = string.Empty;
private int numberOfSheets;
private int numberOfBulletPoints;
/// <summary>
/// Whether the organization dictates the maximum number of bullet points per slide, e.g., to fit its template.
/// </summary>
/// <remarks>
/// Like the template, the maximum is a preselection, so a lock binds only while preselection is enabled.
/// </remarks>
private bool IsBulletPointMaximumLocked => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions && ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, out var meta) && meta.IsLocked;
private int timeSpecification;
private int calculatedNumberOfSlides;
private string importantAspects = string.Empty;
private HashSet<FileAttachment> loadedDocumentPaths = [];
/// <summary>
/// The pictures the presentation must show, each on exactly one slide; source material lives in loadedDocumentPaths.
/// </summary>
private HashSet<FileAttachment> slidePictures = [];
private static readonly AssistantSessionStateKey<string> INPUT_TITLE_STATE_KEY = new(nameof(inputTitle));
private static readonly AssistantSessionStateKey<string> INPUT_CONTENT_STATE_KEY = new(nameof(inputContent));
private static readonly AssistantSessionStateKey<string> CUSTOM_TARGET_LANGUAGE_STATE_KEY = new(nameof(customTargetLanguage));
@@ -207,12 +299,14 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
private static readonly AssistantSessionStateKey<AudienceOrganizationalLevel> SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY = new(nameof(selectedAudienceOrganizationalLevel));
private static readonly AssistantSessionStateKey<AudienceExpertise> SELECTED_AUDIENCE_EXPERTISE_STATE_KEY = new(nameof(selectedAudienceExpertise));
private static readonly AssistantSessionStateKey<CommonLanguages> SELECTED_TARGET_LANGUAGE_STATE_KEY = new(nameof(selectedTargetLanguage));
private static readonly AssistantSessionStateKey<PresentationOccasion> SELECTED_OCCASION_STATE_KEY = new(nameof(selectedOccasion));
private static readonly AssistantSessionStateKey<string> CUSTOM_OCCASION_STATE_KEY = new(nameof(customOccasion));
private static readonly AssistantSessionStateKey<int> NUMBER_OF_SHEETS_STATE_KEY = new(nameof(numberOfSheets));
private static readonly AssistantSessionStateKey<int> NUMBER_OF_BULLET_POINTS_STATE_KEY = new(nameof(numberOfBulletPoints));
private static readonly AssistantSessionStateKey<int> TIME_SPECIFICATION_STATE_KEY = new(nameof(timeSpecification));
private static readonly AssistantSessionStateKey<int> CALCULATED_NUMBER_OF_SLIDES_STATE_KEY = new(nameof(calculatedNumberOfSlides));
private static readonly AssistantSessionStateKey<string> IMPORTANT_ASPECTS_STATE_KEY = new(nameof(importantAspects));
private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths));
private static readonly AssistantSessionStateKey<HashSet<FileAttachment>> SLIDE_PICTURES_STATE_KEY = new(nameof(slidePictures));
/// <inheritdoc />
protected override void CaptureCustomAssistantSessionState(AssistantSessionStateWriter state)
@@ -225,12 +319,28 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
state.Set(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, this.selectedAudienceOrganizationalLevel);
state.Set(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, this.selectedAudienceExpertise);
state.Set(SELECTED_TARGET_LANGUAGE_STATE_KEY, this.selectedTargetLanguage);
state.Set(SELECTED_OCCASION_STATE_KEY, this.selectedOccasion);
state.Set(CUSTOM_OCCASION_STATE_KEY, this.customOccasion);
state.Set(NUMBER_OF_SHEETS_STATE_KEY, this.numberOfSheets);
state.Set(NUMBER_OF_BULLET_POINTS_STATE_KEY, this.numberOfBulletPoints);
state.Set(TIME_SPECIFICATION_STATE_KEY, this.timeSpecification);
state.Set(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, this.calculatedNumberOfSlides);
state.Set(IMPORTANT_ASPECTS_STATE_KEY, this.importantAspects);
state.SetHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
state.SetHashSet(SLIDE_PICTURES_STATE_KEY, this.slidePictures);
state.Set(TEMPLATE_PATH_STATE_KEY, this.templatePath);
state.Set(FOOTER_TEXT_STATE_KEY, this.footerText);
state.Set(KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY, this.keepEmptyPicturePlaceholders);
state.Set(VARIANT_LABEL_STATE_KEY, this.variantLabel ?? string.Empty);
state.Set(ADVANCED_OPTIONS_EXPANDED_STATE_KEY, this.advancedOptionsExpanded);
state.SetList(TEMPLATE_SLIDE_USES_STATE_KEY, this.templateSlideUses);
state.SetDictionary(LAYOUT_NAMES_STATE_KEY, this.layoutNames);
state.Set(SLIDE_DECK_ANSWER_STATE_KEY, this.slideDeckAnswer);
state.Set(SLIDE_DECK_TEMPLATE_PATH_STATE_KEY, this.slideDeckTemplatePath);
state.SetDictionary(SLIDE_DECK_PICTURE_PATHS_STATE_KEY, this.slideDeckPicturePaths);
state.SetHashSet(SLIDE_DECK_ICON_NUMBERS_STATE_KEY, this.slideDeckIconNumbers);
state.Set(PICTURE_LIBRARY_PATH_STATE_KEY, this.pictureLibraryPath);
state.SetDictionary(SLIDE_DECK_BASIS_STATE_KEY, this.slideDeckBasis);
state.Set(PLANNING_STEP_STATE_KEY, this.planningStep);
}
/// <inheritdoc />
@@ -244,12 +354,33 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
state.Restore(SELECTED_AUDIENCE_ORGANIZATIONAL_LEVEL_STATE_KEY, value => this.selectedAudienceOrganizationalLevel = value);
state.Restore(SELECTED_AUDIENCE_EXPERTISE_STATE_KEY, value => this.selectedAudienceExpertise = value);
state.Restore(SELECTED_TARGET_LANGUAGE_STATE_KEY, value => this.selectedTargetLanguage = value);
state.Restore(SELECTED_OCCASION_STATE_KEY, value => this.selectedOccasion = value);
state.Restore(CUSTOM_OCCASION_STATE_KEY, value => this.customOccasion = value);
state.Restore(NUMBER_OF_SHEETS_STATE_KEY, value => this.numberOfSheets = value);
state.Restore(NUMBER_OF_BULLET_POINTS_STATE_KEY, value => this.numberOfBulletPoints = value);
state.Restore(TIME_SPECIFICATION_STATE_KEY, value => this.timeSpecification = value);
state.Restore(CALCULATED_NUMBER_OF_SLIDES_STATE_KEY, value => this.calculatedNumberOfSlides = value);
state.Restore(IMPORTANT_ASPECTS_STATE_KEY, value => this.importantAspects = value);
state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths);
state.RestoreHashSet(SLIDE_PICTURES_STATE_KEY, this.slidePictures);
state.Restore(TEMPLATE_PATH_STATE_KEY, value => this.templatePath = value);
state.Restore(FOOTER_TEXT_STATE_KEY, value => this.footerText = value);
state.Restore(KEEP_EMPTY_PICTURE_PLACEHOLDERS_STATE_KEY, value => this.keepEmptyPicturePlaceholders = value);
state.Restore(VARIANT_LABEL_STATE_KEY, value => this.variantLabel = value.Length > 0 ? value : null);
state.Restore(ADVANCED_OPTIONS_EXPANDED_STATE_KEY, value => this.advancedOptionsExpanded = value);
state.Restore(TEMPLATE_SLIDE_USES_STATE_KEY, value => this.templateSlideUses = value.ToList());
state.RestoreDictionary(LAYOUT_NAMES_STATE_KEY, this.layoutNames);
state.Restore(SLIDE_DECK_ANSWER_STATE_KEY, value => this.slideDeckAnswer = value);
state.Restore(SLIDE_DECK_TEMPLATE_PATH_STATE_KEY, value => this.slideDeckTemplatePath = value);
state.RestoreDictionary(SLIDE_DECK_PICTURE_PATHS_STATE_KEY, this.slideDeckPicturePaths);
state.RestoreHashSet(SLIDE_DECK_ICON_NUMBERS_STATE_KEY, this.slideDeckIconNumbers);
state.Restore(PICTURE_LIBRARY_PATH_STATE_KEY, value => this.pictureLibraryPath = value);
state.RestoreDictionary(SLIDE_DECK_BASIS_STATE_KEY, this.slideDeckBasis);
state.Restore(PLANNING_STEP_STATE_KEY, value => this.planningStep = value);
// Neither the template nor the slides are kept themselves, only what they were read from:
this.LoadTemplate();
this.RestoreSlideDeck();
this.StartCountingPictureLibrary();
}
#region Overrides of ComponentBase
@@ -280,13 +411,41 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
return null;
}
private bool HasValidInputDocuments() => this.loadedDocumentPaths.Any(n => n is { Exists: true });
private bool HasValidInputDocuments() => this.loadedDocumentPaths.Any(n => n is { Exists: true }) || this.slidePictures.Any(n => n is { Exists: true });
private async Task OnDocumentsChanged(HashSet<FileAttachment> _)
{
await this.KeepSlidePicturesOutOfDocumentsAsync();
if(this.Form is not null)
await this.Form.Validate();
}
private async Task OnSlidePicturesChanged(HashSet<FileAttachment> _)
{
await this.KeepSlidePicturesOutOfDocumentsAsync();
if(this.Form is not null)
await this.Form.Validate();
}
/// <summary>
/// Keeps the pictures for the slides out of the source material.
/// </summary>
/// <remarks>
/// A picture is either source material, which the LLM only reads, or a picture for the slides,
/// which the presentation must show; it cannot be both. The pictures for the slides win, since
/// the documents take every file, while the pictures take only what a slide can show. Without
/// the warning, the file would vanish from the documents without a word.
/// </remarks>
private async Task KeepSlidePicturesOutOfDocumentsAsync()
{
var picturePaths = this.slidePictures.Select(picture => picture.FilePath).ToHashSet(PathTools.COMPARER);
var displaced = this.loadedDocumentPaths.Where(document => picturePaths.Contains(document.FilePath)).ToArray();
if (displaced.Length == 0)
return;
this.loadedDocumentPaths.ExceptWith(displaced);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, string.Format(T("These files are already attached as pictures for the slides and were removed from the documents: {0}"), string.Join(", ", displaced.Select(document => document.FileName)))));
}
private string? ValidateCustomLanguage(string language)
{
@@ -296,11 +455,14 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
return null;
}
private int CalculateNumberOfSlides()
private string? ValidateCustomOccasion(string occasion)
{
return this.calculatedNumberOfSlides = (int)Math.Round(this.timeSpecification / 1.5);
if(this.selectedOccasion == PresentationOccasion.OTHER && string.IsNullOrWhiteSpace(occasion))
return T("Please describe the occasion.");
return null;
}
private string PromptImportantAspects()
{
if (string.IsNullOrWhiteSpace(this.importantAspects))
@@ -358,7 +520,7 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
};
}
private async Task<string> PromptLoadDocumentsContent()
private async Task<string> PromptLoadDocumentsContent(PromptInjectionReportingScope promptInjectionScope)
{
if (this.loadedDocumentPaths.Count == 0)
return string.Empty;
@@ -374,13 +536,6 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
""");
}
//
// One report for the whole batch: reading twenty documents must produce one dialog
// listing all of them, not twenty dialogs in a row.
//
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
await using var promptInjectionScope = guardService.BeginAction();
var numDocuments = 1;
foreach (var document in documents)
{
@@ -434,9 +589,9 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
{
sb.AppendLine($"""
There are {numImages} image file(s) attached as documents.
There are {numImages} image file(s) attached as source material.
Please consider them as documents as well and use them to
answer accordingly.
answer accordingly, but never place them on a slide.
""");
}
@@ -444,9 +599,9 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
{
sb.AppendLine($"""
Additionally, there are {numImages} image file(s) attached.
Additionally, there are {numImages} image file(s) attached as source material.
Please consider them as documents as well and use them to
answer accordingly.
answer accordingly, but never place them on a slide.
""");
}
@@ -460,14 +615,56 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
await this.Form!.Validate();
if (!this.InputIsValid)
return;
this.calculatedNumberOfSlides = this.timeSpecification > 0 ? this.CalculateNumberOfSlides() : 0;
this.CreateChatThread();
var documentContent = await this.PromptLoadDocumentsContent();
var imageAttachments = this.loadedDocumentPaths.Where(n => n is { Exists: true, IsImage: true }).ToList();
var time = this.AddUserRequest(
//
// The slides of an earlier run stay out of sight until this run has a result of its own.
// Catalog, template, and pictures are taken once, so that the prompt, the check, and the
// file later on all see the same layouts and the same numbering, even when the user
// chooses another template while the LLM is still planning:
//
this.slideDeck = null;
this.slideDeckAnswer = string.Empty;
this.slideDeckBasis.Clear();
this.slideDeckPicturePaths.Clear();
this.slideDeckIconNumbers.Clear();
this.planningStep = PlanningStep.NONE;
var catalog = this.Catalog;
var plannedTemplatePath = this.template is null ? string.Empty : this.templatePath;
var chosenTemplateSlideUses = this.ChosenTemplateSlideUses;
var chosenLayouts = this.ChosenLayoutsOfTemplate;
var imageAttachments = this.PicturesOfRequest();
//
// The pictures of the library follow the pictures for the slides in one numbering, so the
// JSON names every picture the same way. The system prompt needs the library only while
// it is written, so the planning keeps it no longer than that:
//
var library = await this.ReadPictureLibraryForPlanningAsync();
var firstLibraryNumber = imageAttachments.Count + 1;
var libraryPictures = new LibraryPictures(library.Pictures.Count, library.Pictures.Select((picture, index) => (Picture: picture, Number: firstLibraryNumber + index)).Where(entry => entry.Picture.IsIcon).Select(entry => entry.Number).ToHashSet());
this.planningLibrary = library;
this.CreateChatThread();
this.planningLibrary = PictureLibraryContent.NONE;
//
// One report for the whole request: reading twenty documents must produce one dialog
// listing all of them, not twenty dialogs in a row. The layouts and the slides of the
// template come from a file as well, so they belong to the same report:
//
string documentContent;
string layouts;
string templateSlides;
string pictureLibrary;
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
await using (var promptInjectionScope = guardService.BeginAction())
{
documentContent = await this.PromptLoadDocumentsContent(promptInjectionScope);
layouts = await PromptLayoutsAsync(guardService, promptInjectionScope, catalog, plannedTemplatePath);
templateSlides = await PromptTemplateSlidesAsync(guardService, promptInjectionScope, catalog, plannedTemplatePath, chosenTemplateSlideUses);
pictureLibrary = await PromptPictureLibraryAsync(guardService, promptInjectionScope, library, firstLibraryNumber, this.pictureLibraryPath);
}
var request =
$"""
# PRESENTATION_TITLE
```
@@ -481,10 +678,93 @@ public partial class SlideAssistant : AssistantBaseCore<SettingsDialogSlideBuild
```
{documentContent}
""",
hideContentFromUser: true,
imageAttachments);
{layouts}
{PromptPictureList(imageAttachments, this.ProviderSettings.SupportsImageInput())}
{pictureLibrary}
{templateSlides}
""";
await this.AddAIResponseAsync(time);
//
// The pictures for the slides come first, in the order of PICTURES, so that the LLM can tell
// them from the images of the source material which follow:
//
var time = this.AddUserRequest(request, hideContentFromUser: true, [..imageAttachments, ..this.SourceImagesOfRequest()]);
this.planningStep = PlanningStep.PLANNING;
var answer = await this.AddAIResponseAsync(time, hideContentFromUser: true);
// The provider failed and the base class has said so already:
if (string.IsNullOrWhiteSpace(answer))
return;
var (deck, issues) = this.CheckAnswer(answer, catalog, imageAttachments.Count, libraryPictures, chosenTemplateSlideUses, chosenLayouts);
var neededRepairRound = deck is null || issues.Count > 0;
if (neededRepairRound)
{
//
// One repair round in the same chat, so the model sees its own answer next to what is
// wrong with it. A repaired answer which cannot be read at all is worse than the first
// one, though, so it only replaces an answer it improves on:
//
var repairTime = this.AddUserRequest(RepairPromptOf(issues), hideContentFromUser: true);
this.planningStep = PlanningStep.REPAIRING;
var repairedAnswer = await this.AddAIResponseAsync(repairTime, hideContentFromUser: true);
var (repairedDeck, repairedIssues) = string.IsNullOrWhiteSpace(repairedAnswer) ? new CheckedAnswer(null, issues) : this.CheckAnswer(repairedAnswer, catalog, imageAttachments.Count, libraryPictures, chosenTemplateSlideUses, chosenLayouts);
if (repairedDeck is not null)
{
answer = repairedAnswer;
deck = repairedDeck;
issues = repairedIssues;
}
}
bool IsLibraryNumber(int number) => number >= firstLibraryNumber && number < firstLibraryNumber + library.Pictures.Count;
var libraryPlacements = deck?.Slides.SelectMany(slide => slide.Pictures).Where(IsLibraryNumber).ToList() ?? [];
this.Logger.LogInformation("The slide assistant asked with {RequestCharacters} characters, {PictureCount} pictures for the slides, and {LibraryPictureCount} pictures of the library, {LibraryIconCount} of them icons, and got {SlideCount} slides, {RepairRound}. The slides place {LibraryPlacementCount} pictures of the library, {LibraryIconPlacementCount} of them icons.", request.Length, imageAttachments.Count, library.Pictures.Count, library.Pictures.Count(picture => picture.IsIcon), deck?.Slides.Count ?? 0, neededRepairRound ? "after a repair round" : "without a repair round", libraryPlacements.Count, libraryPlacements.Count(number => library.Pictures[number - firstLibraryNumber].IsIcon));
//
// Slides on layouts the template does not have, or on template slides the user did not
// choose, cannot be shown, let alone written. Any other issue, such as a text which runs
// long, leaves slides worth looking at:
//
if (deck is null || deck.Slides.Count == 0 || deck.Slides.Any(slide => !SlideDeckSpecValidator.CanWrite(slide, catalog, chosenTemplateSlideUses)))
{
this.Logger.LogWarning("The slides the model planned cannot be used: {Issues}", string.Join(" | ", issues));
await this.MessageBus.SendError(new(Icons.Material.Filled.Slideshow, T("The LLM did not describe the slides in a usable form, even after a second request. Please try again, or choose another model.")));
return;
}
if (issues.Count > 0)
{
this.Logger.LogInformation("The slides the model planned still have issues after the repair round: {Issues}", string.Join(" | ", issues));
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Slideshow, T("Not every slide fits its layout as planned; for example, a text may run long. Please check the slides before presenting.")));
}
this.slideDeck = deck;
this.slideDeckAnswer = answer;
this.slideDeckTemplatePath = plannedTemplatePath;
foreach (var (id, description) in catalog.DescribeBasisOf(deck))
this.slideDeckBasis[id] = description;
//
// Of the library, only the pictures the deck uses are kept, not the thousands it was offered:
//
var pictureNames = new Dictionary<int, string>();
for (var index = 0; index < imageAttachments.Count; index++)
{
this.slideDeckPicturePaths[index + 1] = imageAttachments[index].FilePath;
pictureNames[index + 1] = imageAttachments[index].FileName;
}
foreach (var number in libraryPlacements.Distinct())
{
var picture = library.Pictures[number - firstLibraryNumber];
this.slideDeckPicturePaths[number] = picture.FilePath;
pictureNames[number] = picture.Name;
if (picture.IsIcon)
this.slideDeckIconNumbers.Add(number);
}
this.ShowOutline(deck, catalog, pictureNames);
}
}
@@ -0,0 +1,41 @@
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
/// <summary>
/// The explanations which the Slide Assistant and its settings dialog share, so that both say the same.
/// </summary>
public static class SlideAssistantTexts
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(SlideAssistantTexts).Namespace, nameof(SlideAssistantTexts));
/// <summary>
/// What the LLM knows about the layouts of a template, and why their names matter.
/// </summary>
public static string WhatTheLLMKnowsOfLayouts => TB("The LLM does not see what your layouts look like. It knows only their names from the slide master of your template, where their placeholders sit, and how much text fits into them. Clear names such as “Chapter” or “Content with picture” therefore help it choose well; in PowerPoint, you rename a layout in the slide master view. Logo, background, and footer come with the layout. Layouts which differ only in their color count as one, and the color applies to the whole presentation.");
/// <summary>
/// What the choice of a layout for a kind of slide does, and what it does not.
/// </summary>
public static string WhatALayoutChoiceDoes => TB("Choose which layout of your template the LLM uses for certain kinds of slides. The lists offer the layouts with room for content. Where you have no preference, the LLM decides by the names and placeholders of the layouts. A choice says which layout such a slide uses, not whether the presentation has one: whether there is, e.g., an agenda, follows from your content and your important aspects. Only for the framing slides, e.g., the table of contents or the imprint, does a choice also add such a slide; their help says so. Each chapter becomes a section in PowerPoint, which the slide sorter shows as an outline.");
/// <summary>
/// What keeping the empty picture placeholders does.
/// </summary>
public static string WhatKeepingEmptyPicturePlaceholdersDoes => TB("When a new slide has no picture for a picture placeholder of its layout, it leaves the placeholder out, as every other placeholder without content. Keep these placeholders to insert pictures of your own later: PowerPoint shows them while you edit and hides them in the slide show.");
/// <summary>
/// What a picture library is, which of its files count, and how the LLM uses them.
/// </summary>
public static string WhatAPictureLibraryOffers => string.Format(TB("Choose a folder with pictures and icons, e.g., the picture library of your organization. The LLM reads only their names, never the pictures themselves, and uses a picture where its name fits a slide; it does not have to use any. So clear names such as “rocket-launch.jpg” help. Only PNG and JPEG pictures count: many icons come as SVG, which the assistant leaves out. A picture whose name, or the name of one of its folders, the chosen folder included, starts with “icon” is an icon. An icon may appear on several slides and goes into picture placeholders of any size, except those which cover half the slide or more, as a background picture does. From a folder with more than {0} pictures, the LLM gets a random {0} of them for each planning."), MaxLibraryPictures);
/// <summary>
/// The most pictures of a library the LLM reads about, as the user reads the number.
/// </summary>
public static string MaxLibraryPictures => PictureLibrary.MAX_LIBRARY_PICTURES.ToString("N0", Tools.PluginSystem.I18N.I.Culture);
/// <summary>
/// What the presentation does with the slides a template carries besides its layouts.
/// </summary>
public static string WhatHappensToTemplateSlides => TB("Besides its layouts, your file contains slides, e.g., samples of the template or the slides of an earlier presentation. By default, the presentation leaves them out. Choose for each slide whether the presentation keeps it unchanged or lets the LLM edit its content. The LLM uses each of these slides exactly once, keeps them in their order, and may add new slides before, between, and after them. It never copies a slide as a pattern: new slides always come from the layouts of your template.");
}
@@ -0,0 +1,30 @@
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
internal static class TemplateSlideExtensions
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(TemplateSlideExtensions).Namespace, nameof(TemplateSlideExtensions));
/// <summary>
/// How many characters of its title a slide of the template shows in its label.
/// </summary>
private const int MAX_SLIDE_TITLE_CHARACTERS = 60;
/// <summary>
/// How the forms name a slide of the template: by its number and its title, and what is special about it.
/// </summary>
/// <remarks>
/// The title tells the slides apart at a glance, since a template rarely gives its slides more
/// than a title. A long one is cut, so that the label stays on one line.
/// </remarks>
public static string Label(this TemplateSlide slide)
{
var title = slide.Title.Length > MAX_SLIDE_TITLE_CHARACTERS ? $"{slide.Title[..MAX_SLIDE_TITLE_CHARACTERS].TrimEnd()}…" : slide.Title;
var label = title.Length > 0 ? string.Format(TB("Slide {0}: {1}"), slide.Number, title) : string.Format(TB("Slide {0}"), slide.Number);
if (slide.IsHidden)
label = $"{label} ({TB("hidden")})";
return label;
}
}
@@ -0,0 +1,17 @@
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Assistants.SlideBuilder;
internal static class TemplateSlideUseExtensions
{
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(TemplateSlideUseExtensions).Namespace, nameof(TemplateSlideUseExtensions));
public static string Name(this TemplateSlideUse use) => use switch
{
TemplateSlideUse.OMIT => TB("Leave out"),
TemplateSlideUse.KEEP => TB("Keep unchanged"),
TemplateSlideUse.EDIT => TB("Let the LLM edit it"),
_ => TB("Leave out"),
};
}
+29 -2
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json.Serialization;
@@ -202,6 +203,23 @@ public sealed class ContentText : IContent
// What the request carries, for telling later whether a report still describes this chat:
var blocksSent = chatThread.Blocks.Count;
//
// How long the answer takes, in three parts: the wait for its first chunk, which is the
// model reading the request and thinking; the streaming afterward; and how much of that
// the UI took to show it. Only these parts tell a slow model from a slow UI:
//
var requestStartedAt = Stopwatch.GetTimestamp();
TimeSpan? timeToFirstChunk = null;
var chunkCount = 0;
var uiDuration = TimeSpan.Zero;
async Task NotifyStreamingAsync()
{
var notifyingStartedAt = Stopwatch.GetTimestamp();
await this.StreamingEvent();
uiDuration += Stopwatch.GetElapsedTime(notifyingStartedAt);
}
// Start another thread by using a task to uncouple
// the UI thread from the AI processing:
try
@@ -220,6 +238,9 @@ public sealed class ContentText : IContent
if (token.IsCancellationRequested)
break;
chunkCount++;
timeToFirstChunk ??= Stopwatch.GetElapsedTime(requestStartedAt);
// Stop the waiting animation:
this.InitialRemoteWait = false;
this.IsStreaming = true;
@@ -241,7 +262,7 @@ public sealed class ContentText : IContent
// Energy saving mode is off. We notify the UI
// as fast as possible -- no matter the odds:
case false:
await this.StreamingEvent();
await NotifyStreamingAsync();
break;
// Energy saving mode is on. We notify the UI
@@ -249,7 +270,7 @@ public sealed class ContentText : IContent
// greater than the minimum time:
case true when now - last > MIN_TIME:
last = now;
await this.StreamingEvent();
await NotifyStreamingAsync();
break;
}
}
@@ -265,6 +286,12 @@ public sealed class ContentText : IContent
}
finally
{
var requestDuration = Stopwatch.GetElapsedTime(requestStartedAt);
if (timeToFirstChunk is { } firstChunkAfter)
LOGGER.LogInformation("The answer of model '{ModelId}' from '{ProviderInstanceName}' took {RequestSeconds:F1} s: {FirstChunkSeconds:F1} s until its first chunk, then {ChunkCount} chunks with {CharacterCount} characters, which the UI took {UISeconds:F1} s to show.", chatModel.Id, provider.InstanceName, requestDuration.TotalSeconds, firstChunkAfter.TotalSeconds, chunkCount, this.Text.Length, uiDuration.TotalSeconds);
else
LOGGER.LogInformation("The model '{ModelId}' from '{ProviderInstanceName}' sent no answer within {RequestSeconds:F1} s.", chatModel.Id, provider.InstanceName, requestDuration.TotalSeconds);
this.Text = this.Text.RemoveThinkTags().Trim();
this.EndToolRun();
@@ -0,0 +1,15 @@
<MudExpansionPanels Dense="@true" Elevation="0" Class="@this.Class">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD" Expanded="@this.Expanded" ExpandedChanged="@this.ExpandedChanged">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@this.HeaderIcon" Color="@this.IconColor" Class="mr-3"/>
<MudText Typo="Typo.button">
@this.HeaderText
</MudText>
</div>
</TitleContent>
<ChildContent>
@this.ChildContent
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A single, dense panel which hides content most users do not need, such as advanced options
/// or the source code of a generated plugin.
/// </summary>
/// <remarks>
/// Unlike the ExpansionPanel component, it brings its own panel list, so it stands anywhere in a
/// form. The header text comes from the caller, which keeps the translation with the caller as
/// well.
/// </remarks>
public partial class CompactExpansionPanel : ComponentBase
{
[Parameter]
public string HeaderText { get; set; } = string.Empty;
[Parameter]
public string HeaderIcon { get; set; } = Icons.Material.Filled.Tune;
[Parameter]
public Color IconColor { get; set; } = Color.Inherit;
/// <summary>
/// Class names for the surrounding panel list, separated by space, e.g., for its margins.
/// </summary>
[Parameter]
public string Class { get; set; } = string.Empty;
[Parameter]
public bool Expanded { get; set; }
[Parameter]
public EventCallback<bool> ExpandedChanged { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,27 +1,30 @@
@inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<PathDropZone Frameless="@true" Disabled="@(() => this.IsDisabled)" OnPathsDropped="@this.PathsDropped" Context="isDropTarget">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="@(isDropTarget ? PathDropZone.FIELD_HIGHLIGHT_STYLE : string.Empty)">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Placeholder="@(this.IsDisabled ? string.Empty : T("You can also drag & drop the folder here."))"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@(isDropTarget ? Color.Primary : this.IconColor)"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isDirectoryDialogOpen)"
Class="mb-1"
OnClick="@this.OpenDirectoryDialog">
@T("Choose Directory")
</MudButton>
</MudStack>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isDirectoryDialogOpen)"
Class="mb-1"
OnClick="@this.OpenDirectoryDialog">
@T("Choose Directory")
</MudButton>
</MudStack>
</PathDropZone>
@@ -41,6 +41,9 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<ConfigurationDirectory> Logger { get; init; } = null!;
private string internalText = string.Empty;
private bool isDirectoryDialogOpen;
@@ -105,6 +108,26 @@ public partial class ConfigurationDirectory : ConfigurationBaseCore
}
}
/// <summary>
/// Takes the first dropped path which leads to a folder.
/// </summary>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
var path = DroppedFolder.FirstUsable(paths);
if (path is null)
{
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFolder.WarningFor(paths)));
return;
}
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
this.timer.Stop();
this.internalText = path;
await this.OptionChanged(path);
}
private async Task OptionChanged(string updatedText)
{
this.TextUpdate(updatedText);
@@ -1,27 +1,30 @@
@inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<PathDropZone Frameless="@true" Disabled="@(() => this.IsDisabled)" OnPathsDropped="@this.PathsDropped" Context="isDropTarget">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Style="@(isDropTarget ? PathDropZone.FIELD_HIGHLIGHT_STYLE : string.Empty)">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Placeholder="@(this.IsDisabled ? string.Empty : T("You can also drag & drop the file here."))"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@(isDropTarget ? Color.Primary : this.IconColor)"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isFileDialogOpen)"
Class="mb-1"
OnClick="@this.OpenFileDialog">
@T("Choose File")
</MudButton>
</MudStack>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isFileDialogOpen)"
Class="mb-1"
OnClick="@this.OpenFileDialog">
@T("Choose File")
</MudButton>
</MudStack>
</PathDropZone>
@@ -48,6 +48,9 @@ public partial class ConfigurationFile : ConfigurationBaseCore
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private ILogger<ConfigurationFile> Logger { get; init; } = null!;
private string internalText = string.Empty;
private bool isFileDialogOpen;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
@@ -111,6 +114,26 @@ public partial class ConfigurationFile : ConfigurationBaseCore
}
}
/// <summary>
/// Takes the first dropped path which leads to a usable file.
/// </summary>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
var path = DroppedFile.FirstUsable(paths, this.Filter);
if (path is null)
{
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFile.WarningFor(paths)));
return;
}
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
this.timer.Stop();
this.internalText = path;
await this.OptionChanged(path);
}
private async Task OptionChanged(string updatedText)
{
this.TextUpdate(updatedText);
@@ -0,0 +1,13 @@
@using AIStudio.Tools.PowerPoint
@foreach (var area in Enum.GetValues<LayoutPurposeArea>())
{
<CompactExpansionPanel HeaderText="@this.HeaderTextOf(area)" HeaderIcon="@IconOf(area)" Class="mb-3 rounded" Expanded="@this.expandedAreas.Contains(area)" ExpandedChanged="@(expanded => this.SetExpanded(area, expanded))">
@if (this.expandedAreas.Contains(area))
{
@foreach (var purpose in Enum.GetValues<LayoutPurpose>().Where(purpose => purpose.Area() == area))
{
@this.ChoiceContent(purpose)
}
}
</CompactExpansionPanel>
}
@@ -0,0 +1,62 @@
using AIStudio.Tools.PowerPoint;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Groups the choices of a layout per kind of slide into one collapsed panel per area, e.g., teaching
/// or science, since more than a hundred choices in one list help nobody.
/// </summary>
/// <remarks>
/// The choice itself comes from the caller: the Slide Assistant uses a plain select, and its
/// settings dialog a configuration select. The header of an area tells how many of its kinds have a
/// layout, so that no choice hides in a collapsed panel.
///
/// The choices of an area render only while its panel is open. A collapsed panel still keeps its
/// content in the page, and all areas together hold more than a hundred selects, which the Slide
/// Assistant would otherwise render again with every update while the LLM plans.
/// </remarks>
public partial class LayoutChoicePanels : ComponentBase
{
/// <summary>
/// Renders the choice of a layout for one kind of slide.
/// </summary>
[Parameter]
public RenderFragment<LayoutPurpose> ChoiceContent { get; set; } = _ => _ => { };
/// <summary>
/// Whether a layout is chosen for a kind of slide.
/// </summary>
[Parameter]
public Func<LayoutPurpose, bool> IsChosen { get; set; } = _ => false;
private readonly HashSet<LayoutPurposeArea> expandedAreas = [];
private string HeaderTextOf(LayoutPurposeArea area)
{
var chosen = Enum.GetValues<LayoutPurpose>().Count(purpose => purpose.Area() == area && this.IsChosen(purpose));
return chosen == 0 ? area.Label() : $"{area.Label()} ({chosen})";
}
private void SetExpanded(LayoutPurposeArea area, bool expanded)
{
if (expanded)
this.expandedAreas.Add(area);
else
this.expandedAreas.Remove(area);
}
private static string IconOf(LayoutPurposeArea area) => area switch
{
LayoutPurposeArea.FRAMING => Icons.Material.Filled.FormatListNumbered,
LayoutPurposeArea.GENERAL => Icons.Material.Filled.ViewQuilt,
LayoutPurposeArea.TEACHING => Icons.Material.Filled.School,
LayoutPurposeArea.BUSINESS => Icons.Material.Filled.BusinessCenter,
LayoutPurposeArea.SCIENCE => Icons.Material.Filled.Science,
LayoutPurposeArea.MEDICINE => Icons.Material.Filled.MedicalServices,
LayoutPurposeArea.LAW => Icons.Material.Filled.Gavel,
_ => Icons.Material.Filled.ViewQuilt,
};
}
@@ -141,6 +141,15 @@ public partial class PathDropZone : MSGComponentBase
[Inject]
private ILogger<PathDropZone> Logger { get; init; } = null!;
/// <summary>
/// Marks a field in a frameless zone while a path hovers over it, e.g., an option in the settings.
/// </summary>
/// <remarks>
/// Such a field sits in the frame of its option already, so it gets no drop frame of its own. An
/// outline takes no room, which keeps the layout still while the cursor moves over the field.
/// </remarks>
public const string FIELD_HIGHLIGHT_STYLE = "outline: 2px dashed var(--mud-palette-primary); outline-offset: 4px; border-radius: 4px;";
private const string FRAME_CLASSES = "relative rounded-lg border-2 border-dashed mud-width-full";
private const string DEFAULT_SPACING_CLASSES = "pa-3 mb-3";
@@ -2,9 +2,6 @@ using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
// This component has a parameter called Directory, which would shadow the file system's Directory class:
using IODirectory = System.IO.Directory;
namespace AIStudio.Components;
public partial class SelectDirectory : MSGComponentBase
@@ -90,35 +87,18 @@ public partial class SelectDirectory : MSGComponentBase
/// <summary>
/// Takes the first dropped path which leads to a folder.
/// </summary>
/// <remarks>
/// A dropped file is rejected instead of being taken as its parent folder. Everything a folder
/// contains is processed, so guessing the parent of a mistakenly dropped file could pull in far
/// more data than the user meant to hand over.
/// </remarks>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
foreach (var path in paths)
var path = DroppedFolder.FirstUsable(paths);
if (path is null)
{
if (!IODirectory.Exists(path))
continue;
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
this.InternalDirectoryChanged(path);
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFolder.WarningFor(paths)));
return;
}
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a directory.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, this.GetDropWarning(paths)));
}
private string GetDropWarning(List<string> paths)
{
// Naming the actual mistake beats a generic "that did not work". Dropping a file onto a
// folder picker is the likeliest of them:
if (paths.Any(File.Exists))
return T("Please drop a folder, not a file.");
return T("The dropped folder could not be accessed. Please choose it with the folder chooser instead.");
this.Logger.LogInformation("The user dropped the directory '{DroppedDirectory}'.", path);
this.InternalDirectoryChanged(path);
}
}
@@ -3,9 +3,6 @@ using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
// This component has a parameter called File, which would shadow the file system's File class:
using IOFile = System.IO.File;
namespace AIStudio.Components;
public partial class SelectFile : MSGComponentBase
@@ -94,42 +91,18 @@ public partial class SelectFile : MSGComponentBase
/// <summary>
/// Takes the first dropped path which leads to a usable file.
/// </summary>
/// <remarks>
/// This component carries exactly one file, so a multi-selection cannot be honored as a whole.
/// A dropped folder is rejected instead of being read as "the first file inside it": the user
/// was asked for a file, and picking one for them would be a surprise.
/// </remarks>
/// <param name="paths">The dropped paths.</param>
private async Task PathsDropped(List<string> paths)
{
foreach (var path in paths)
var path = DroppedFile.FirstUsable(paths, this.Filter);
if (path is null)
{
if (!IOFile.Exists(path))
continue;
if (this.Filter is { Length: > 0 } && !FileTypes.IsAllowedPath(path, this.Filter))
continue;
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
this.InternalFileChanged(path);
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, DroppedFile.WarningFor(paths)));
return;
}
this.Logger.LogWarning("None of the {Count} dropped path(s) could be used as a file.", paths.Count);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, this.GetDropWarning(paths)));
}
private string GetDropWarning(List<string> paths)
{
// Naming the actual mistake beats a generic "that did not work". Dropping a folder onto a
// file picker is the likeliest of them:
if (paths.Any(Directory.Exists))
return T("Please drop a file, not a folder.");
// The file exists, so the file type filter is what turned it down:
if (paths.Any(IOFile.Exists))
return T("Please drop a file with a supported file type.");
return T("The dropped file could not be accessed. Please choose it with the file chooser instead.");
this.Logger.LogInformation("The user dropped the file '{DroppedFilePath}'.", path);
this.InternalFileChanged(path);
}
}
@@ -63,21 +63,9 @@
@if (!string.IsNullOrWhiteSpace(this.revisedLua))
{
<MudExpansionPanels Dense="true" Elevation="0">
<MudExpansionPanel Dense="true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Code" Class="mr-3" Color="Color.Primary" />
<MudText Typo="Typo.button">
@T("Revised Lua plugin")
</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@this.revisedLua" ReadOnly="true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace" />
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
<CompactExpansionPanel HeaderText="@T("Revised Lua plugin")" HeaderIcon="@Icons.Material.Filled.Code" IconColor="Color.Primary">
<MudTextField T="string" Text="@this.revisedLua" ReadOnly="true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace" />
</CompactExpansionPanel>
}
@if (this.isApplying || this.isAuditing)
@@ -38,21 +38,9 @@
@* The panel content is only built while it is open, so the plugin is written just
for users who want to look at it. *@
<MudExpansionPanels Dense="@true" Elevation="0">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Code" Class="mr-3" Color="Color.Primary"/>
<MudText Typo="Typo.button">
@T("Resulting Lua plugin")
</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@this.BuildLua()" ReadOnly="@true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/>
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
<CompactExpansionPanel HeaderText="@T("Resulting Lua plugin")" HeaderIcon="@Icons.Material.Filled.Code" IconColor="Color.Primary">
<MudTextField T="string" Text="@this.BuildLua()" ReadOnly="@true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/>
</CompactExpansionPanel>
@if (this.IsBusy)
{
@@ -1,16 +1,24 @@
@using AIStudio.Settings
@using AIStudio.Assistants.SlideBuilder
@using AIStudio.Settings
@using AIStudio.Tools.PowerPoint
@using AIStudio.Tools.Rust
@inherits SettingsDialogBase
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h6" Class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Spellcheck" Class="mr-2" />
@T("Assistant: Slide Planner Assistant Options")
@T("Assistant: Slide Assistant Options")
</MudText>
</TitleContent>
<DialogContent>
<MudPaper Class="pa-3 mb-8 border-dashed border rounded-lg">
<ConfigurationOption OptionDescription="@T("Preselect Slide Planner Assistant options?")" LabelOn="@T("Slide Planner Assistant options are preselected")" LabelOff="@T("No Slide Planner Assistant options are preselected")" State="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions = updatedState)" OptionHelp="@T("When enabled, you can preselect slide builder options. This is might be useful when you prefer a specific language or LLM model.")"/>
<ConfigurationOption OptionDescription="@T("Preselect Slide Assistant options?")" LabelOn="@T("Slide Assistant options are preselected")" LabelOff="@T("No Slide Assistant options are preselected")" State="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions = updatedState)" OptionHelp="@T("When enabled, you can preselect options of the Slide Assistant. This might be useful when you prefer a specific language or LLM model.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectOptions, out var meta) && meta.IsLocked"/>
<MudText Typo="Typo.h6" Class="mb-3">@T("Content and audience")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Preselect what the assistant starts with for each presentation: the aspects to emphasize, the language, the audience, and the occasion. You can still change them in the assistant.")
</MudJustifiedText>
<ConfigurationText OptionDescription="@T("Preselect important aspects")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" Text="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedImportantAspects)" TextUpdate="@(updatedText => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedImportantAspects = updatedText)" NumLines="2" OptionHelp="@T("Preselect aspects for the LLM to focus on when generating slides, such as bullet points or specific topics to emphasize.")" Icon="@Icons.Material.Filled.List"/>
<ConfigurationSelect OptionDescription="@T("Preselect the language")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage)" Data="@ConfigurationSelectDataFactory.GetCommonLanguagesOptionalData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage = selectedValue)" OptionHelp="@T("Which language should be preselected?")"/>
@if (this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTargetLanguage is CommonLanguages.OTHER)
@@ -21,6 +29,74 @@
<ConfigurationSelect OptionDescription="@T("Preselect the audience age group")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceAgeGroup)" Data="@ConfigurationSelectDataFactory.GetSlideBuilderAudienceAgeGroupData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceAgeGroup = selectedValue)" OptionHelp="@T("Which audience age group should be preselected?")"/>
<ConfigurationSelect OptionDescription="@T("Preselect the audience organizational level")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceOrganizationalLevel)" Data="@ConfigurationSelectDataFactory.GetSlideBuilderAudienceOrganizationalLevelData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceOrganizationalLevel = selectedValue)" OptionHelp="@T("Which audience organizational level should be preselected?")"/>
<ConfigurationSelect OptionDescription="@T("Preselect the audience expertise")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceExpertise)" Data="@ConfigurationSelectDataFactory.GetSlideBuilderAudienceExpertiseData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedAudienceExpertise = selectedValue)" OptionHelp="@T("Which audience expertise should be preselected?")"/>
<ConfigurationSelect OptionDescription="@T("Preselect the occasion")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOccasion)" Data="@ConfigurationSelectDataFactory.GetSlideBuilderOccasionData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOccasion = selectedValue)" OptionHelp="@T("Which occasion of the presentation should be preselected?")"/>
@if (this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOccasion is PresentationOccasion.OTHER)
{
<ConfigurationText OptionDescription="@T("Preselect another occasion")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" Icon="@Icons.Material.Filled.Event" Text="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherOccasion)" TextUpdate="@(updatedText => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedOtherOccasion = updatedText)"/>
}
<MudText Typo="Typo.h6" Class="mb-3 mt-6">@T("PowerPoint template")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@SlideAssistantTexts.WhatTheLLMKnowsOfLayouts
</MudJustifiedText>
<ConfigurationFile OptionDescription="@T("Preselect a PowerPoint template")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" Icon="@Icons.Material.Filled.Slideshow" Text="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath)" TextUpdate="@this.SetTemplatePath" FileDialogTitle="@T("Select a PowerPoint template")" Filter="@([FileTypes.POWER_POINT_TEMPLATE])" OptionHelp="@T("The slides use the layouts of this PowerPoint presentation or template (.pptx, .potx), and you can create a PowerPoint file (.pptx) from the result. OpenDocument presentations (.odp, .otp) cannot serve as a template yet. Leave it empty to plan an outline only.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.TemplatePath, out var meta) && meta.IsLocked"/>
<ConfigurationText OptionDescription="@T("Preselect the footer text")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" Icon="@Icons.Material.Filled.ShortText" Text="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedFooterText)" TextUpdate="@(updatedText => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedFooterText = updatedText)" OptionHelp="@T("Shown on every slide whose layout has a footer, e.g., your name, organization, and date. Leave it empty for no footer.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedFooterText, out var meta) && meta.IsLocked"/>
<ConfigurationSelect OptionDescription="@T("Preselect the maximum number of bullet points per slide")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedMaxBulletPointsPerSlide)" Data="@this.BulletPointMaximumOptions" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedMaxBulletPointsPerSlide = selectedValue)" OptionHelp="@T("A slide holds at most this many bullet points, in all of its text slots together. Match it to the room for text in the layouts of your template.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, out var meta) && meta.IsLocked"/>
<ConfigurationOption OptionDescription="@T("Preselect keeping empty picture placeholders?")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" LabelOn="@T("Empty picture placeholders are kept")" LabelOff="@T("Empty picture placeholders are left out")" State="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedKeepEmptyPicturePlaceholders)" StateUpdate="@(value => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedKeepEmptyPicturePlaceholders = value)" OptionHelp="@SlideAssistantTexts.WhatKeepingEmptyPicturePlaceholdersDoes" IsLocked="() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedKeepEmptyPicturePlaceholders, out var meta) && meta.IsLocked"/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">@T("Layouts for kinds of slides")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@if (this.layoutOptions.Count > 0)
{
@SlideAssistantTexts.WhatALayoutChoiceDoes
}
else
{
@T("Preselect a readable template above to choose its layouts for kinds of slides.")
}
</MudJustifiedText>
@if (this.layoutOptions.Count > 0)
{
<LayoutChoicePanels IsChosen="@(purpose => this.PreselectedLayoutOf(purpose).Length > 0)">
<ChoiceContent Context="purpose">
<ConfigurationSelect OptionDescription="@purpose.Label()" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.PreselectedLayoutOf(purpose))" Data="@this.layoutOptions" SelectionUpdate="@(selectedValue => this.SetPreselectedLayout(purpose, selectedValue))" OptionHelp="@purpose.Help()" IsLocked="() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedLayouts, out var meta) && meta.IsLocked"/>
</ChoiceContent>
</LayoutChoicePanels>
}
<MudText Typo="Typo.h6" Class="mb-3 mt-6">@T("Slides of the template")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@if (this.template is { Slides.Count: > 0 })
{
@SlideAssistantTexts.WhatHappensToTemplateSlides
}
else if (this.template is not null)
{
@T("The preselected template contains no slides besides its layouts.")
}
else
{
@T("Preselect a readable template above to choose what the presentation does with its slides.")
}
</MudJustifiedText>
@if (this.template is { } loadedTemplate)
{
@foreach (var templateSlide in loadedTemplate.Slides)
{
<ConfigurationSelect OptionDescription="@templateSlide.Label()" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.PreselectedUseOf(templateSlide))" Data="@UseOptionsOf(templateSlide)" SelectionUpdate="@(selectedValue => this.SetPreselectedUse(templateSlide.Number, selectedValue))" IsLocked="@AreTemplateSlideUsesLocked"/>
}
}
<MudText Typo="Typo.h6" Class="mb-3 mt-6">@T("Visual elements")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@SlideAssistantTexts.WhatAPictureLibraryOffers
</MudJustifiedText>
<ConfigurationDirectory OptionDescription="@T("Preselect a picture library")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" Icon="@Icons.Material.Filled.PhotoLibrary" Text="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedPictureLibraryPath)" TextUpdate="@(updatedText => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedPictureLibraryPath = updatedText)" DirectoryDialogTitle="@T("Select a picture library")" OptionHelp="@T("The LLM may choose pictures and icons for the slides from this folder. Leave it empty for no picture library.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedPictureLibraryPath, out var meta) && meta.IsLocked"/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">@T("AI selection")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Preselect which LLM plans the slides and which profile it gets. A profile can, e.g., name your organization, which the LLM may use for the imprint.")
</MudJustifiedText>
<ConfigurationMinConfidenceSelection Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" RestrictToGlobalMinimumConfidence="@true" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.MinimumProviderConfidence)" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.MinimumProviderConfidence = selectedValue)"/>
<ConfigurationProviderSelection Component="Components.SLIDE_BUILDER_ASSISTANT" Data="@this.AvailableLLMProviders" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedProvider)" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedProvider = selectedValue)"/>
<ConfigurationSelect OptionDescription="@T("Preselect a profile")" Disabled="@(() => !this.SettingsManager.ConfigurationData.SlideBuilder.PreselectOptions)" SelectedValue="@(() => ProfilePreselection.FromStoredValue(this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedProfile))" Data="@ConfigurationSelectDataFactory.GetComponentProfilesData(this.SettingsManager.ConfigurationData.Profiles)" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedProfile = selectedValue)" OptionHelp="@T("Choose whether the assistant should use the app default profile, no profile, or a specific profile.")"/>
@@ -1,3 +1,166 @@
namespace AIStudio.Dialogs.Settings;
using System.Globalization;
public partial class SettingsDialogSlideBuilder : SettingsDialogBase;
using AIStudio.Assistants.SlideBuilder;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PowerPoint;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs.Settings;
public partial class SettingsDialogSlideBuilder : SettingsDialogBase
{
[Inject]
private ILogger<SettingsDialogSlideBuilder> Logger { get; init; } = null!;
/// <summary>
/// What the preselections of layouts offer: no preference, then the layouts of the preselected template.
/// </summary>
/// <remarks>
/// Empty while no template is preselected or the template cannot be read; the dialog then says so
/// instead of offering layouts.
/// </remarks>
private List<ConfigurationSelectData<string>> layoutOptions = [];
/// <summary>
/// The preselected template, whose slides the dialog offers; null while none is preselected or it cannot be read.
/// </summary>
private PresentationTemplate? template;
/// <summary>
/// The path the template and the layout options were read from; null before the first reading.
/// </summary>
private string? templatePathRead;
/// <summary>
/// What the preselection of the maximum number of bullet points offers: no maximum, then 1 up to the most a slide may hold.
/// </summary>
private IEnumerable<ConfigurationSelectData<int>> BulletPointMaximumOptions => [new(T("No maximum"), 0), ..Enumerable.Range(1, DataSlideBuilder.MAX_BULLET_POINTS_PER_SLIDE).Select(count => new ConfigurationSelectData<int>(count.ToString(CultureInfo.InvariantCulture), count))];
#region Overrides of SettingsDialogBase
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
this.ReadTemplate();
await base.OnInitializedAsync();
}
/// <inheritdoc />
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
// The template may change in this dialog, or through a configuration plugin:
if (triggeredEvent is Event.CONFIGURATION_CHANGED or Event.PLUGINS_RELOADED)
this.ReadTemplate();
await base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
}
#endregion
/// <summary>
/// Reads the layouts and the slides of the preselected template, unless they were read from it already.
/// </summary>
private void ReadTemplate()
{
var templatePath = this.SettingsManager.ConfigurationData.SlideBuilder.TemplatePath;
if (string.Equals(templatePath, this.templatePathRead, StringComparison.Ordinal))
return;
this.templatePathRead = templatePath;
this.template = null;
this.layoutOptions = [];
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
return;
try
{
this.template = PresentationTemplate.ReadFile(templatePath);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "The PowerPoint template '{TemplatePath}' cannot be read.", templatePath);
return;
}
this.layoutOptions = [new(LayoutPurposeExtensions.NoPreference, string.Empty), ..this.template.FamilyNames.Select(name => new ConfigurationSelectData<string>(name, name))];
}
/// <summary>
/// Preselects a template, and forgets what was preselected for the slides of the previous one.
/// </summary>
/// <remarks>
/// The choices number the slides of a template, so they mean nothing for another one. A lock of
/// the organization keeps them, though: its template and its choices belong together.
/// </remarks>
private void SetTemplatePath(string path)
{
var settings = this.SettingsManager.ConfigurationData.SlideBuilder;
if (string.Equals(settings.TemplatePath, path, StringComparison.Ordinal))
return;
settings.TemplatePath = path;
if (!AreTemplateSlideUsesLocked())
settings.PreselectedTemplateSlideUses = new();
}
private static bool AreTemplateSlideUsesLocked() => ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, out var meta) && meta.IsLocked;
/// <summary>
/// What the dialog offers for a slide of the template: editing only where the slide has placeholders for content.
/// </summary>
private static List<ConfigurationSelectData<TemplateSlideUse>> UseOptionsOf(TemplateSlide slide) => Enum.GetValues<TemplateSlideUse>()
.Where(use => use is not TemplateSlideUse.EDIT || slide.IsEditable)
.Select(use => new ConfigurationSelectData<TemplateSlideUse>(use.Name(), use))
.ToList();
/// <summary>
/// What is preselected for a slide of the template, as the assistant applies it.
/// </summary>
/// <returns>The use; a slide which is missing is left out, and one which cannot be edited is kept unchanged instead.</returns>
private TemplateSlideUse PreselectedUseOf(TemplateSlide slide) => this.template is null ? TemplateSlideUse.OMIT : this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTemplateSlideUses.ToTemplateSlideUses(this.template).UseOf(slide.Number);
/// <summary>
/// Preselects what the presentation does with a slide of the template.
/// </summary>
/// <remarks>
/// Leaving a slide out is the default, so it is stored as no entry. The dictionary is replaced
/// instead of changed, so that no other holder of it, such as the default of a configuration
/// plugin, changes along.
/// </remarks>
private void SetPreselectedUse(int number, TemplateSlideUse use)
{
var uses = new Dictionary<int, TemplateSlideUse>(this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTemplateSlideUses);
if (use is TemplateSlideUse.OMIT)
uses.Remove(number);
else
uses[number] = use;
this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedTemplateSlideUses = uses;
}
/// <summary>
/// The name of the layout preselected for a kind of slide, as the dialog shows it.
/// </summary>
/// <returns>The name, or empty when the LLM decides, which it also does when the template has no layout of this name.</returns>
private string PreselectedLayoutOf(LayoutPurpose purpose) => this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedLayouts.TryGetValue(purpose, out var name) && this.layoutOptions.Any(option => string.Equals(option.Value, name, StringComparison.Ordinal)) ? name : string.Empty;
/// <summary>
/// Preselects a layout for a kind of slide, or no preference for an empty name.
/// </summary>
/// <remarks>
/// The dictionary is replaced instead of changed, so that no other holder of it, such as the
/// default of a configuration plugin, changes along.
/// </remarks>
private void SetPreselectedLayout(LayoutPurpose purpose, string name)
{
var layouts = new Dictionary<LayoutPurpose, string>(this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedLayouts);
if (name.Length == 0)
layouts.Remove(purpose);
else
layouts[purpose] = name;
this.SettingsManager.ConfigurationData.SlideBuilder.PreselectedLayouts = layouts;
}
}
@@ -71,6 +71,7 @@
<ItemGroup>
<PackageReference Include="CodeBeam.MudBlazor.Extensions" Version="8.3.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="MailKit" Version="4.18.1" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="9.0.21" />
@@ -63,7 +63,7 @@
<AssistantBlock TSettings="SettingsDialogJobPostings" Component="Components.JOB_POSTING_ASSISTANT" Name="@T("Job Posting")" Description="@T("Generate a job posting for a given job description.")" Icon="@Icons.Material.Filled.Work" Link="@Routes.ASSISTANT_JOB_POSTING"/>
<AssistantBlock TSettings="SettingsDialogLegalCheck" Component="Components.LEGAL_CHECK_ASSISTANT" Name="@T("Legal Check")" Description="@T("Ask a question about a legal document.")" Icon="@Icons.Material.Filled.Gavel" Link="@Routes.ASSISTANT_LEGAL_CHECK"/>
<AssistantBlock TSettings="SettingsDialogIconFinder" Component="Components.ICON_FINDER_ASSISTANT" Name="@T("Icon Finder")" Description="@T("Use an LLM to find an icon for a given context.")" Icon="@Icons.Material.Filled.FindInPage" Link="@Routes.ASSISTANT_ICON_FINDER"/>
<AssistantBlock TSettings="SettingsDialogSlideBuilder" Component="Components.SLIDE_BUILDER_ASSISTANT" Name="@T("Slide Planner Assistant")" Description="@T("Develop slide content based on a given topic and content.")" Icon="@Icons.Material.Filled.Slideshow" Link="@Routes.ASSISTANT_SLIDE_BUILDER"/>
<AssistantBlock TSettings="SettingsDialogSlideBuilder" Component="Components.SLIDE_BUILDER_ASSISTANT" Name="@T("Slide Assistant")" Description="@T("Turn texts and documents into slides, also as a PowerPoint file in the design of your template.")" Icon="@Icons.Material.Filled.Slideshow" Link="@Routes.ASSISTANT_SLIDE_BUILDER"/>
<AssistantBlock TSettings="SettingsDialogVisualBriefing" Component="Components.VISUAL_BRIEFING_ASSISTANT" RequiredPreviewFeature="Components.VISUAL_BRIEFING_ASSISTANT.RequiredPreviewFeature()" Name="@T("Visual Briefing Assistant")" Description="@T("Turn documents, data, images, audio, and video into an audience-ready interactive briefing.")" Icon="@Icons.Material.Filled.DashboardCustomize" Link="@Routes.ASSISTANT_VISUAL_BRIEFING" />
</AssistantCategoryBlock>
@@ -90,7 +90,7 @@
<Supporter Name="SolsticeSpectrum" Type="SupporterType.INDIVIDUAL" URL="https://github.com/SolsticeSpectrum" Acknowledgment="@T("Thanks for your build script contribution.")"/>
<Supporter Name="peerschuett" Type="SupporterType.INDIVIDUAL" URL="https://github.com/peerschuett" Acknowledgment="@T("Thank you, Peer, for familiarizing yourself with C# and for contributions such as new providers, chat templates, tool calling, and the new logo.")"/>
<Supporter Name="nilskruthoff" Type="SupporterType.INDIVIDUAL" URL="https://github.com/nilskruthoff" Acknowledgment="@T("Thanks, Nils, for taking the time to learn Rust, for building the foundation for local retrieval, and for assistant plugins and the Assistant Builder.")"/>
<Supporter Name="Sabrina-devops" Type="SupporterType.INDIVIDUAL" URL="https://github.com/Sabrina-devops" Acknowledgment="@T("Thank you, Sabrina, for bringing file attachments to the chat, the Slide Planner Assistant, and the protection against prompt injection.")"/>
<Supporter Name="Sabrina-devops" Type="SupporterType.INDIVIDUAL" URL="https://github.com/Sabrina-devops" Acknowledgment="@T("Thank you, Sabrina, for bringing file attachments to the chat, the Slide Assistant, and the protection against prompt injection.")"/>
<Supporter Name="PaulKoudelka" Type="SupporterType.INDIVIDUAL" URL="https://github.com/PaulKoudelka" Acknowledgment="@T("Thank you, Paul, for building local RAG step by step, from the vector database to the first beta, and for the upgrade to Tauri 2.")"/>
<Supporter Name="OliverKunc" Type="SupporterType.INDIVIDUAL" URL="https://github.com/OliverKunc" Acknowledgment="@T("Thanks, Oliver, for making sure no spaces get lost in answers from OpenAI.")"/>
<Supporter Name="donework" Type="SupporterType.INDIVIDUAL" URL="https://github.com/donework" Acknowledgment="@T("Thank you, Dominic, for supporting us not only financially but also with code: a smoother chat, the audio quality setting for transcriptions, personal API keys for providers of an organization, sharing feature usage with self-hosted servers, and a better Confluence search.")"/>
@@ -676,6 +676,117 @@ CONFIG["SETTINGS"] = {}
-- CONFIG["SETTINGS"]["DataChat.PreselectedDataSourceIds.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataChat.SendToChatDataSourceBehavior.AllowUserOverride"] = true
-- Configure defaults for the Slide Assistant.
-- Preselection must be enabled for the template, the picture library, and the other defaults
-- below to take effect. It is enabled by default; lock it as well to make sure users cannot do without your
-- template.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectOptions"] = true
--
-- Configure the PowerPoint template of the Slide Assistant.
-- The slides then use the layouts of this presentation or template (.pptx, .potx), and users
-- can create a PowerPoint file from the result. Without a template, the assistant plans an
-- outline only.
--
-- The simplest way is to ship the template with this plugin: put the file next to this
-- plugin.lua, or into a folder below it, and give its path relative to this plugin.lua. The
-- path must stay inside the plugin directory, i.e., no '..' segments and no links pointing
-- out of it. When the file is missing or the path is rejected, AI Studio logs a warning, and
-- this plugin does not manage the template: users keep the one they chose, or have none.
-- CONFIG["SETTINGS"]["DataSlideBuilder.TemplatePath"] = "Corporate.potx"
--
-- An absolute path works as well. It must be readable on every machine, e.g., on a network
-- share or in a folder your software distribution fills; for Flatpak installations, it must
-- be readable inside the sandbox.
-- CONFIG["SETTINGS"]["DataSlideBuilder.TemplatePath"] = "C:\\Templates\\Corporate.potx"
--
-- Configure the footer text for every slide whose layout has a footer, e.g., the name of
-- the organization. Leave it empty for no footer.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedFooterText"] = ""
--
-- Configure the maximum number of bullet points per slide, all text slots of a slide together,
-- from 1 to 7. Match it to the room for text in the layouts of your template. 0 sets no maximum,
-- and the LLM decides by the room of each layout. AI Studio logs a warning for any other value
-- and ignores it.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedMaxBulletPointsPerSlide"] = 0
--
-- Configure whether new slides keep the picture placeholders of their layout which they have no
-- picture for. PowerPoint shows them while editing, so users can insert pictures of their own,
-- and hides them in the slide show. By default, the slides leave them out.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedKeepEmptyPicturePlaceholders"] = false
--
-- Configure the picture library of the Slide Assistant: a folder with pictures and icons, e.g.,
-- the picture library of your organization. The LLM reads only the names of its PNG and JPEG
-- pictures and may place a picture where its name fits a slide. A picture whose name, or the
-- name of one of its folders, starts with "icon" counts as an icon. This includes the folder
-- you name here, so a folder "Icons" holds icons only. Leave it empty for no picture library.
--
-- Ship the folder with this plugin and give its path relative to this plugin.lua, under the
-- same rules as the template above, or give an absolute path, e.g., on a network share. When a
-- locked folder cannot be reached, the assistant plans the slides without it.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedPictureLibraryPath"] = "Pictures"
--
-- Configure the layout of your template for kinds of slides, e.g., which layout opens each
-- chapter. Give the name of the layout as the slide master of your template shows it. A kind
-- of slide you leave out has no preference, and the LLM decides; a name the template lacks
-- counts as no preference as well.
-- Allowed kinds of slides are, by area:
-- 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
-- A kind in the singular, e.g., RISK, shows one item prominently; its plural, e.g., RISKS, shows
-- several items together.
--
-- Replaces, does not merge: the table applies as a whole, also with AllowUserOverride.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedLayouts"] = {
-- ["TITLE"] = "Title Slide",
-- ["CHAPTER"] = "Section Header",
-- ["CONTENT"] = "Title and Content",
-- }
--
-- Preselect what the presentation does with the slides your template carries, by their number in the
-- file, starting with 1 and counting hidden slides. OMIT leaves a slide out, KEEP keeps it as it is,
-- and EDIT lets the LLM write new content into it; a slide without room for content stays unchanged
-- instead. A slide you do not name is left out. This applies only to the template above.
-- Replaces, does not merge: the table applies as a whole, also with AllowUserOverride.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedTemplateSlideUses"] = {
-- [1] = "KEEP",
-- [4] = "EDIT",
-- }
--
-- Allow users to choose another template or change the defaults locally.
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectOptions.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataSlideBuilder.TemplatePath.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedFooterText.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedMaxBulletPointsPerSlide.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedKeepEmptyPicturePlaceholders.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedPictureLibraryPath.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedLayouts.AllowUserOverride"] = true
-- CONFIG["SETTINGS"]["DataSlideBuilder.PreselectedTemplateSlideUses.AllowUserOverride"] = true
-- Configure defaults for the Batch Processing Assistant.
-- Preselection must be enabled for the remaining batch settings to take effect.
-- CONFIG["SETTINGS"]["DataBatchProcessing.PreselectOptions"] = true
@@ -235,7 +235,13 @@ public static class ConfigurationSelectDataFactory
foreach (var expertise in Enum.GetValues<AudienceExpertise>())
yield return new(expertise.Name(), expertise);
}
public static IEnumerable<ConfigurationSelectData<PresentationOccasion>> GetSlideBuilderOccasionData()
{
foreach (var occasion in Enum.GetValues<PresentationOccasion>())
yield return new(occasion.Name(), occasion);
}
public static IEnumerable<ConfigurationSelectData<string>> GetProfilesData(IEnumerable<Profile> profiles)
{
foreach (var profile in profiles.GetAllProfiles())
@@ -204,7 +204,7 @@ public sealed class Data
public DataEMail EMail { get; init; } = new();
public DataSlideBuilder SlideBuilder { get; init; } = new();
public DataSlideBuilder SlideBuilder { get; init; } = new(x => x.SlideBuilder);
/// <summary>
/// Gets the managed Assistant Builder defaults.
@@ -1,14 +1,101 @@
using AIStudio.Assistants.SlideBuilder;
using System.Linq.Expressions;
using AIStudio.Assistants.SlideBuilder;
using AIStudio.Provider;
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Settings.DataModel;
public class DataSlideBuilder
/// <summary>
/// Stores the settings of the Slide Assistant.
/// </summary>
/// <param name="configSelection">The managed-configuration selector.</param>
public class DataSlideBuilder(Expression<Func<Data, DataSlideBuilder>>? configSelection = null)
{
/// <summary>
/// Initializes an unmanaged Slide Assistant settings instance.
/// </summary>
public DataSlideBuilder() : this(null)
{
}
/// <summary>
/// The PowerPoint presentation or template (.pptx, .potx) whose layouts the slides use.
/// </summary>
/// <remarks>
/// Empty for no template, which makes the assistant plan an outline only. Like the other
/// preselections, the template applies only while preselection is enabled: an organization
/// which wants its corporate template used for sure locks PreselectOptions as well.
/// </remarks>
public string TemplatePath { get; set; } = ManagedConfiguration.Register(configSelection, value => value.TemplatePath, string.Empty);
/// <summary>
/// Preselect the footer text of the slides?
/// </summary>
public string PreselectedFooterText { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedFooterText, string.Empty);
/// <summary>
/// The most bullet points per slide the user can ask for.
/// </summary>
public const int MAX_BULLET_POINTS_PER_SLIDE = 7;
/// <summary>
/// Preselect the maximum number of bullet points per slide, all text slots of a slide together?
/// </summary>
/// <remarks>
/// 0 sets no maximum, as in the assistant, where the LLM decides by the room of each layout. The
/// number should fit the room for text in the layouts of the template, which is why organizations
/// may dictate it.
/// </remarks>
public int PreselectedMaxBulletPointsPerSlide { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedMaxBulletPointsPerSlide, 0);
/// <summary>
/// Whether a number of bullet points per slide can be preselected: 0 for no maximum, or up to MAX_BULLET_POINTS_PER_SLIDE.
/// </summary>
public static bool IsValidMaxBulletPointsPerSlide(int value) => value is >= 0 and <= MAX_BULLET_POINTS_PER_SLIDE;
/// <summary>
/// Preselect keeping the picture placeholders which a new slide has no picture for?
/// </summary>
/// <remarks>
/// Off by default: the slide leaves them out, as every other placeholder without content. When
/// on, PowerPoint shows them while editing, for pictures of the user's own.
/// </remarks>
public bool PreselectedKeepEmptyPicturePlaceholders { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedKeepEmptyPicturePlaceholders, false);
/// <summary>
/// Preselect the folder of pictures and icons which the LLM may choose from by their names?
/// </summary>
/// <remarks>
/// Empty for no picture library. Like the template, the folder applies only while preselection
/// is enabled, and a configuration plugin may ship it and name it relative to its directory.
/// </remarks>
public string PreselectedPictureLibraryPath { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedPictureLibraryPath, string.Empty);
/// <summary>
/// Preselect the layouts for kinds of slides, i.e., the name of a layout of the template by kind of slide?
/// </summary>
/// <remarks>
/// A kind of slide which is missing has no preference, and the LLM decides. Names instead of IDs
/// keep the choices valid across sessions, computers, and versions of the template; a name which
/// the current template lacks counts as no preference.
/// </remarks>
public Dictionary<LayoutPurpose, string> PreselectedLayouts { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedLayouts, new Dictionary<LayoutPurpose, string>());
/// <summary>
/// Preselect what the presentation does with the slides of the preselected template, by the number of each slide?
/// </summary>
/// <remarks>
/// The slides count from 1, in the order of the file, hidden ones included. A slide which is
/// missing is left out, as by default. The choices belong to the preselected template: for any
/// other template, the assistant ignores them, since its slides are other ones.
/// </remarks>
public Dictionary<int, TemplateSlideUse> PreselectedTemplateSlideUses { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedTemplateSlideUses, new Dictionary<int, TemplateSlideUse>());
/// <summary>
/// Preselect any Slide Builder options?
/// </summary>
public bool PreselectOptions { get; set; } = true;
public bool PreselectOptions { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectOptions, true);
/// <summary>
/// Preselect a profile?
@@ -49,7 +136,17 @@ public class DataSlideBuilder
/// Preselect the audience expertise?
/// </summary>
public AudienceExpertise PreselectedAudienceExpertise { get; set; }
/// <summary>
/// Preselect the occasion of the presentation?
/// </summary>
public PresentationOccasion PreselectedOccasion { get; set; }
/// <summary>
/// Preselect any other occasion?
/// </summary>
public string PreselectedOtherOccasion { get; set; } = string.Empty;
/// <summary>
/// Preselect any important aspects that the Slide Builder should take into account?
/// </summary>
@@ -153,6 +153,7 @@ public static partial class ManagedConfiguration
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
/// <param name="convert">Turns the configured text into the value to store, e.g., a path relative to the plugin into a full one; null rejects the text. Without it, the text is stored as it is.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
public static bool TryProcessConfiguration<TClass>(
@@ -160,9 +161,10 @@ public static partial class ManagedConfiguration
Expression<Func<TClass, string>> propertyExpression,
Guid configPluginId,
LuaTable settings,
bool dryRun)
bool dryRun,
Func<string, string?>? convert = null)
{
return TryProcessConfiguration(configSelection, propertyExpression, string.Empty, configPluginId, settings, dryRun);
return TryProcessConfiguration(configSelection, propertyExpression, string.Empty, configPluginId, settings, dryRun, convert);
}
/// <summary>
@@ -179,6 +181,7 @@ public static partial class ManagedConfiguration
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
/// <param name="convert">For plain strings: turns the configured text into the value to store; null rejects the text. Without it, the text is stored as it is.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TDataType">The data type of the configured value.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
@@ -188,7 +191,8 @@ public static partial class ManagedConfiguration
TDataType configuredType,
Guid configPluginId,
LuaTable settings,
bool dryRun)
bool dryRun,
Func<string, string?>? convert = null)
{
//
// Handle configured string values
@@ -217,8 +221,9 @@ public static partial class ManagedConfiguration
// Case: the read string is just a string:
case string:
configuredValue = configuredText;
successful = true;
var convertedText = convert is null ? configuredText : convert(configuredText);
successful = convertedText is not null;
configuredValue = convertedText ?? configuredText;
break;
}
}
@@ -901,6 +906,156 @@ public static partial class ManagedConfiguration
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
/// Attempts to process the configuration settings from a Lua table for dictionaries which map enum keys to texts.
/// </summary>
/// <remarks>
/// When the configuration is successfully processed, it updates the configuration metadata with the configured value.
/// Furthermore, it applies the configured managed state to the provided configuration plugin ID.
/// The setting's value is set to the configured value when locked or when the editable default should apply.
/// A key which names no member of the enum is passed over, as with the enum dictionaries.
/// </remarks>
/// <param name="configPluginId">The ID of the related configuration plugin.</param>
/// <param name="settings">The Lua table containing the settings to process.</param>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes but only check if the configuration can be read.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
public static bool TryProcessConfiguration<TClass, TKey>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression,
Guid configPluginId,
LuaTable settings,
bool dryRun)
where TKey : struct, Enum
{
//
// Handle configured dictionaries of enum keys and texts (Lua keys and values are strings)
//
// Check if that configuration was registered:
if(!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
var successful = false;
var configuredValue = new Dictionary<TKey, string>(configMeta.Default);
// Step 1 -- try to read the Lua value (we expect a table) out of the Lua table:
if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) &&
configuredLuaList.Type is LuaValueType.Table &&
configuredLuaList.TryRead<LuaTable>(out var valueTable))
{
configuredValue.Clear();
// In order to iterate over all key-value pairs in the Lua table, we have to use TryGetNext.
// Thus, we initialize the previous key variable to Nil and keep calling TryGetNext until
// there are no more pairs:
var previousKey = LuaValue.Nil;
while(valueTable.TryGetNext(previousKey, out var pair))
{
// Update the previous key for the next iteration:
previousKey = pair.Key;
// Try to read both the key and the value as strings:
var hadKey = pair.Key.TryRead<string>(out var keyText);
var hadValue = pair.Value.TryRead<string>(out var valueText);
// If both were read successfully and the key names a member of the enum, add them to the dictionary:
if (hadKey && hadValue && EnumNames.TryParse<TKey>(keyText, out var key))
configuredValue[key] = valueText;
}
successful = true;
}
if(dryRun)
return successful;
var settingName = SettingName(propertyExpression);
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
/// Attempts to process the configuration settings from a Lua table for dictionaries which map numbers to enum values.
/// </summary>
/// <remarks>
/// When the configuration is successfully processed, it updates the configuration metadata with the configured value.
/// Furthermore, it applies the configured managed state to the provided configuration plugin ID.
/// The setting's value is set to the configured value when locked or when the editable default should apply.
/// The numbers count from 1, like the slides of a presentation. An entry whose key is no such number or
/// whose value names no member of the enum is passed over with a warning, since the plugin's author
/// would otherwise never learn why the entry has no effect.
/// </remarks>
/// <param name="configPluginId">The ID of the related configuration plugin.</param>
/// <param name="settings">The Lua table containing the settings to process.</param>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes but only check if the configuration can be read.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
public static bool TryProcessConfiguration<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression,
Guid configPluginId,
LuaTable settings,
bool dryRun)
where TValue : struct, Enum
{
//
// Handle configured dictionaries of numbers and enum values (Lua keys are numbers, values are strings)
//
// Check if that configuration was registered:
if(!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
var successful = false;
var configuredValue = new Dictionary<int, TValue>(configMeta.Default);
// Step 1 -- try to read the Lua value (we expect a table) out of the Lua table:
if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) &&
configuredLuaList.Type is LuaValueType.Table &&
configuredLuaList.TryRead<LuaTable>(out var valueTable))
{
configuredValue.Clear();
// In order to iterate over all key-value pairs in the Lua table, we have to use TryGetNext.
// Thus, we initialize the previous key variable to Nil and keep calling TryGetNext until
// there are no more pairs:
var previousKey = LuaValue.Nil;
while(valueTable.TryGetNext(previousKey, out var pair))
{
// Update the previous key for the next iteration:
previousKey = pair.Key;
// Try to read the key as a number and the value as a string:
var hadKey = pair.Key.TryRead<int>(out var key) && pair.Key.Type is LuaValueType.Number && key > 0;
var hadValue = pair.Value.TryRead<string>(out var valueText);
// If both were read successfully and the value names a member of the enum, add them to the dictionary:
if (hadKey && hadValue && EnumNames.TryParse<TValue>(valueText, out var value))
configuredValue[key] = value;
// A dry run comes before the actual one, so only the actual one reports the entry:
else if (!dryRun)
Log.LogWarning($"The configuration plugin '{configPluginId}' has the entry '{pair.Key}' = '{pair.Value}' in the setting '{configMeta.SettingName}', which is no valid entry. The keys are numbers from 1, and the values are names of options. Ignoring it.");
}
successful = true;
}
if(dryRun)
return successful;
var settingName = SettingName(propertyExpression);
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
/// Handles the parsed configuration value based on whether the parsing was successful and whether it's a dry run.
/// </summary>
@@ -288,4 +288,76 @@ public static partial class ManagedConfiguration
return defaultValues;
}
/// <summary>
/// Registers a configuration setting with a default dictionary which maps enum keys to texts.
/// </summary>
/// <remarks>
/// When the method is invoked with a null configSelection, the configuration path
/// is ignored, and the specified default values are returned without registration.
/// </remarks>
/// <param name="configSelection">The expression that selects the configuration class from the root Data model.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="defaultValues">The default dictionary of values to use when the setting is not configured.</param>
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <returns>A dictionary containing the default values.</returns>
public static Dictionary<TKey, string> Register<TClass, TKey>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression, Dictionary<TKey, string> defaultValues)
where TKey : struct, Enum
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
if (configSelection is null)
return new();
var configPath = Path(configSelection, propertyExpression);
// If the metadata already exists for this configuration path, we return the default value:
if (METADATA.ContainsKey(configPath))
return defaultValues;
// Not registered yet, so we register it now:
METADATA[configPath] = new ConfigMeta<TClass, Dictionary<TKey, string>>(configSelection, propertyExpression)
{
Default = defaultValues,
};
return defaultValues;
}
/// <summary>
/// Registers a configuration setting with a default dictionary which maps numbers to enum values, e.g., slide numbers to a use.
/// </summary>
/// <remarks>
/// When the method is invoked with a null configSelection, the configuration path
/// is ignored, and the specified default values are returned without registration.
/// </remarks>
/// <param name="configSelection">The expression that selects the configuration class from the root Data model.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="defaultValues">The default dictionary of values to use when the setting is not configured.</param>
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>A dictionary containing the default values.</returns>
public static Dictionary<int, TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression, Dictionary<int, TValue> defaultValues)
where TValue : struct, Enum
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
if (configSelection is null)
return new();
var configPath = Path(configSelection, propertyExpression);
// If the metadata already exists for this configuration path, we return the default value:
if (METADATA.ContainsKey(configPath))
return defaultValues;
// Not registered yet, so we register it now:
METADATA[configPath] = new ConfigMeta<TClass, Dictionary<int, TValue>>(configSelection, propertyExpression)
{
Default = defaultValues,
};
return defaultValues;
}
}
@@ -266,6 +266,74 @@ public static partial class ManagedConfiguration
return false;
}
/// <summary>
/// Attempts to retrieve the configuration metadata for a setting which maps enum keys to texts.
/// </summary>
/// <remarks>
/// When no configuration metadata is found, it returns a NoConfig instance with the default
/// value set to an empty dictionary. This allows the caller to handle the absence of configuration
/// gracefully. In such cases, the return value of the method will be false.
/// </remarks>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the
/// configuration class.</param>
/// <param name="configMeta">The output parameter that will hold the configuration metadata
/// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TKey>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression, out ConfigMeta<TClass, Dictionary<TKey, string>> configMeta)
where TKey : struct, Enum
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<TKey, string>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
configMeta = new NoConfig<TClass, Dictionary<TKey, string>>(configSelection, propertyExpression)
{
Default = new Dictionary<TKey, string>(),
};
return false;
}
/// <summary>
/// Attempts to retrieve the configuration metadata for a setting which maps numbers to enum values.
/// </summary>
/// <remarks>
/// When no configuration metadata is found, it returns a NoConfig instance with the default
/// value set to an empty dictionary. This allows the caller to handle the absence of configuration
/// gracefully. In such cases, the return value of the method will be false.
/// </remarks>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the
/// configuration class.</param>
/// <param name="configMeta">The output parameter that will hold the configuration metadata
/// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression, out ConfigMeta<TClass, Dictionary<int, TValue>> configMeta)
where TValue : struct, Enum
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<int, TValue>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
configMeta = new NoConfig<TClass, Dictionary<int, TValue>>(configSelection, propertyExpression)
{
Default = new Dictionary<int, TValue>(),
};
return false;
}
/// <summary>
/// Checks whether a configuration plugin may manage a setting, or whether that setting belongs
/// to the IT department of an organization.
@@ -0,0 +1,172 @@
namespace AIStudio.Settings;
/// <summary>
/// Resolves a file or folder a configuration plugin names in a setting, such as the PowerPoint
/// template or the picture library of the Slide Assistant, or the icon of a provider.
/// </summary>
/// <remarks>
/// A plugin may ship the file or folder itself, next to its plugin.lua, and name it by a path
/// relative to its directory. Such a path has to stay inside that directory, so a plugin cannot reach
/// an arbitrary file elsewhere on the system through it. An absolute path, e.g., on a network share,
/// stays as the organization wrote it, where the setting allows one.
/// </remarks>
internal static class PluginFilePath
{
/// <summary>
/// Resolves a path against the directory of the plugin which states it.
/// </summary>
/// <param name="path">The path as the plugin states it: empty, absolute, or relative to the plugin directory.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="resolvedPath">The full path; empty for an empty path, which means no file.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path can be used.</returns>
public static bool TryResolve(string path, string pluginPath, out string resolvedPath, out string issue)
{
resolvedPath = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(path))
return true;
if (Path.IsPathFullyQualified(path))
{
resolvedPath = path;
return true;
}
return TryResolveInside(path, pluginPath, out resolvedPath, out issue);
}
/// <summary>
/// Resolves the path of a folder against the directory of the plugin which states it.
/// </summary>
/// <param name="path">The path as the plugin states it: empty, absolute, or relative to the plugin directory.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="resolvedPath">The full path; empty for an empty path, which means no folder.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path can be used.</returns>
public static bool TryResolveDirectory(string path, string pluginPath, out string resolvedPath, out string issue)
{
resolvedPath = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(path))
return true;
if (Path.IsPathFullyQualified(path))
{
resolvedPath = path;
return true;
}
return TryResolveInside(path, pluginPath, true, out resolvedPath, out issue);
}
/// <summary>
/// Resolves a path relative to the directory of a plugin, which has to name an existing file
/// inside that directory.
/// </summary>
/// <param name="relativePath">The path relative to the plugin directory; both kinds of slashes work.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="fullPath">The full path of the file; empty when the path cannot be used.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path names a file inside the plugin directory.</returns>
public static bool TryResolveInside(string relativePath, string pluginPath, out string fullPath, out string issue) => TryResolveInside(relativePath, pluginPath, false, out fullPath, out issue);
/// <summary>
/// Resolves a path relative to the directory of a plugin, which has to name an existing file or
/// folder inside that directory.
/// </summary>
/// <remarks>
/// Every file and folder a plugin ships itself is checked here, so that all of them follow the
/// same rules: no '..' segments, and no link along the path which leads out of the plugin directory.
/// </remarks>
/// <param name="relativePath">The path relative to the plugin directory; both kinds of slashes work.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="isDirectory">Whether the path has to name a folder rather than a file.</param>
/// <param name="fullPath">The full path; empty when the path cannot be used.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path names a file or folder inside the plugin directory.</returns>
private static bool TryResolveInside(string relativePath, string pluginPath, bool isDirectory, out string fullPath, out string issue)
{
fullPath = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(pluginPath))
{
issue = "The relative path cannot be resolved because the plugin directory is unknown.";
return false;
}
var normalizedPath = relativePath
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
if (normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
{
issue = "The relative path must not contain '..' path segments.";
return false;
}
string pluginRoot;
string candidatePath;
try
{
pluginRoot = Path.GetFullPath(pluginPath);
candidatePath = Path.GetFullPath(Path.Combine(pluginRoot, normalizedPath));
}
catch (Exception e)
{
issue = $"The relative path is invalid: {e.Message}";
return false;
}
if (!PathTools.IsInsideDirectory(pluginRoot, candidatePath))
{
issue = "The relative path points outside of the plugin directory.";
return false;
}
if (isDirectory ? !Directory.Exists(candidatePath) : !File.Exists(candidatePath))
{
issue = isDirectory ? "The folder does not exist in the plugin directory." : "The file does not exist in the plugin directory.";
return false;
}
try
{
if (!LinksStayInsideDirectory(pluginRoot, candidatePath))
{
issue = "The relative path contains a link that points outside of the plugin directory.";
return false;
}
}
catch (Exception e)
{
issue = $"The links of the relative path cannot be checked: {e.Message}";
return false;
}
fullPath = candidatePath;
return true;
}
/// <summary>
/// Whether no link along the path leads out of the root directory.
/// </summary>
private static bool LinksStayInsideDirectory(string rootDirectory, string path)
{
var relativePath = Path.GetRelativePath(rootDirectory, path);
var currentPath = Path.GetFullPath(rootDirectory);
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
{
currentPath = Path.Combine(currentPath, segment);
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
var finalTarget = pathInfo.ResolveLinkTarget(true);
if (finalTarget is not null && !PathTools.IsInsideDirectory(rootDirectory, finalTarget.FullName))
return false;
}
return true;
}
}
@@ -27,52 +27,15 @@ internal static class PluginIconFile
return false;
}
if (string.IsNullOrWhiteSpace(pluginPath))
{
issue = "The icon path cannot be resolved because the configuration plugin directory is unknown.";
return false;
}
var relativePath = iconPath
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
if (relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
{
issue = "The icon path must not contain '..' path segments.";
return false;
}
string pluginRoot;
string resolvedPath;
try
{
pluginRoot = Path.GetFullPath(pluginPath);
resolvedPath = Path.GetFullPath(Path.Combine(pluginRoot, relativePath));
}
catch (Exception e)
{
issue = $"The icon path is invalid: {e.Message}";
return false;
}
if (!PathTools.IsInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path points outside of the configuration plugin directory.";
return false;
}
if (!string.Equals(Path.GetExtension(resolvedPath), ".svg", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(Path.GetExtension(iconPath), ".svg", StringComparison.OrdinalIgnoreCase))
{
issue = "The icon file must use the .svg extension.";
return false;
}
if (!File.Exists(resolvedPath))
{
issue = "The icon file does not exist.";
// The same rules as for every other file a plugin ships:
if (!PluginFilePath.TryResolveInside(iconPath, pluginPath, out var resolvedPath, out issue))
return false;
}
try
{
@@ -84,12 +47,6 @@ internal static class PluginIconFile
return false;
}
if (!LinksStayInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path contains a link that points outside of the configuration plugin directory.";
return false;
}
return SvgIcon.TryCreateDataUrl(File.ReadAllBytes(resolvedPath), out dataUrl, out issue);
}
catch (Exception e)
@@ -98,20 +55,4 @@ internal static class PluginIconFile
return false;
}
}
private static bool LinksStayInsideDirectory(string rootDirectory, string filePath)
{
var relativePath = Path.GetRelativePath(rootDirectory, filePath);
var currentPath = Path.GetFullPath(rootDirectory);
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
{
currentPath = Path.Combine(currentPath, segment);
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
var finalTarget = pathInfo.ResolveLinkTarget(true);
if (finalTarget is not null && !PathTools.IsInsideDirectory(rootDirectory, finalTarget.FullName))
return false;
}
return true;
}
}
@@ -100,7 +100,7 @@ public static class ComponentsExtensions
Components.I18N_ASSISTANT => TB("Localization Assistant"),
Components.DOCUMENT_ANALYSIS_ASSISTANT => TB("Document Analysis Assistant"),
Components.BATCH_PROCESSING_ASSISTANT => TB("Batch Processing Assistant"),
Components.SLIDE_BUILDER_ASSISTANT => TB("Slide Planner Assistant"),
Components.SLIDE_BUILDER_ASSISTANT => TB("Slide Assistant"),
Components.VISUAL_BRIEFING_ASSISTANT => TB("Visual Briefing Assistant"),
Components.META_ASSISTANT => TB("Assistant Builder"),
Components.LOG_VIEWER_ASSISTANT => TB("Log Viewer Assistant"),
@@ -0,0 +1,42 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
/// <summary>
/// Picks the file which a picker for exactly one file takes from the paths dropped onto it.
/// </summary>
public static class DroppedFile
{
private static string TB(string fallbackEN) => PluginSystem.I18N.I.T(fallbackEN, typeof(DroppedFile).Namespace, nameof(DroppedFile));
/// <summary>
/// Finds the first dropped path which leads to a file of an allowed type.
/// </summary>
/// <remarks>
/// A picker carries exactly one file, so a multi-selection cannot be honored as a whole. A dropped
/// folder is rejected instead of being read as "the first file inside it": the user was asked for a
/// file, and picking one for them would be a surprise.
/// </remarks>
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
/// <param name="filter">The allowed file types, or none to allow every file.</param>
/// <returns>The path of the file to take, or null when no dropped path leads to a usable file.</returns>
public static string? FirstUsable(IReadOnlyList<string> paths, FileTypeFilter[]? filter) => paths.FirstOrDefault(path => File.Exists(path) && (filter is not { Length: > 0 } || FileTypes.IsAllowedPath(path, filter)));
/// <summary>
/// Explains why none of the dropped paths could be used.
/// </summary>
/// <param name="paths">The dropped paths.</param>
public static string WarningFor(IReadOnlyList<string> paths)
{
// Naming the actual mistake beats a generic "that did not work". Dropping a folder onto a
// file picker is the likeliest of them:
if (paths.Any(Directory.Exists))
return TB("Please drop a file, not a folder.");
// The file exists, so the file type filter is what turned it down:
if (paths.Any(File.Exists))
return TB("Please drop a file with a supported file type.");
return TB("The dropped file could not be accessed. Please choose it with the file chooser instead.");
}
}
@@ -0,0 +1,36 @@
namespace AIStudio.Tools;
/// <summary>
/// Picks the folder which a picker for exactly one folder takes from the paths dropped onto it.
/// </summary>
public static class DroppedFolder
{
private static string TB(string fallbackEN) => PluginSystem.I18N.I.T(fallbackEN, typeof(DroppedFolder).Namespace, nameof(DroppedFolder));
/// <summary>
/// Finds the first dropped path which leads to a folder.
/// </summary>
/// <remarks>
/// A picker carries exactly one folder, so a multi-selection cannot be honored as a whole. A
/// dropped file is rejected instead of being taken as its parent folder. Everything a folder
/// contains is processed, so guessing the parent of a mistakenly dropped file could pull in far
/// more data than the user meant to hand over.
/// </remarks>
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
/// <returns>The path of the folder to take, or null when no dropped path leads to a folder.</returns>
public static string? FirstUsable(IReadOnlyList<string> paths) => paths.FirstOrDefault(Directory.Exists);
/// <summary>
/// Explains why none of the dropped paths could be used.
/// </summary>
/// <param name="paths">The dropped paths.</param>
public static string WarningFor(IReadOnlyList<string> paths)
{
// Naming the actual mistake beats a generic "that did not work". Dropping a file onto a
// folder picker is the likeliest of them:
if (paths.Any(File.Exists))
return TB("Please drop a folder, not a file.");
return TB("The dropped folder could not be accessed. Please choose it with the folder chooser instead.");
}
}
@@ -181,7 +181,7 @@ public static class FileExportFormatExtensions
/// set below is what no common file system accepts, plus the length limit which keeps the name
/// readable in a dialog.
/// </remarks>
private static string ToFileNameFragment(string? name)
internal static string ToFileNameFragment(string? name)
{
const int MAX_LENGTH = 60;
const string FORBIDDEN_CHARACTERS = @"\/:*?""<>|";
@@ -436,6 +436,20 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.PreselectedDataSourcesRetrievalMode, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.Chat, x => x.SendToChatDataSourceBehavior, this.Id, settingsTable, dryRun);
// Config: Slide Assistant template and defaults?
ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectOptions, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.TemplatePath, this.Id, settingsTable, dryRun, convert: this.ResolvePluginFile);
ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedFooterText, this.Id, settingsTable, dryRun);
var maxBulletPointsIsValid = ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, this.Id, settingsTable, dryRun, validator: DataSlideBuilder.IsValidMaxBulletPointsPerSlide);
if (!maxBulletPointsIsValid && settingsTable.TryGetValue("DataSlideBuilder.PreselectedMaxBulletPointsPerSlide", out _))
LOG.LogWarning("The maximum number of bullet points per slide configured by plugin {ConfigPluginId} must be between 0 and {MaxBulletPoints}.", this.Id, DataSlideBuilder.MAX_BULLET_POINTS_PER_SLIDE);
ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedKeepEmptyPicturePlaceholders, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedPictureLibraryPath, this.Id, settingsTable, dryRun, convert: this.ResolvePluginDirectory);
ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedLayouts, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, this.Id, settingsTable, dryRun);
// Config: Batch Processing Assistant defaults?
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectOptions, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.InputDirectory, this.Id, settingsTable, dryRun);
@@ -497,6 +511,34 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return true;
}
/// <summary>
/// Resolves a file this plugin names in a setting: a relative path against the plugin directory,
/// so the plugin can ship the file itself.
/// </summary>
/// <returns>The full path, or null when the file cannot be used, which leaves the setting untouched.</returns>
private string? ResolvePluginFile(string path)
{
if (PluginFilePath.TryResolve(path, this.PluginPath, out var resolvedPath, out var issue))
return resolvedPath;
LOG.LogWarning($"The configuration plugin '{this.Name}' names the file '{path}', which cannot be used: {issue}");
return null;
}
/// <summary>
/// Resolves a folder this plugin names in a setting: a relative path against the plugin directory,
/// so the plugin can ship the folder itself.
/// </summary>
/// <returns>The full path, or null when the folder cannot be used, which leaves the setting untouched.</returns>
private string? ResolvePluginDirectory(string path)
{
if (PluginFilePath.TryResolveDirectory(path, this.PluginPath, out var resolvedPath, out var issue))
return resolvedPath;
LOG.LogWarning($"The configuration plugin '{this.Name}' names the folder '{path}', which cannot be used: {issue}");
return null;
}
private static bool TryValidateMinimumProviderConfidenceConfiguration(LuaTable settingsTable, out string message)
{
const string SETTING_NAME = "DataTools.MinimumProviderConfidenceByToolId";
@@ -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);
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more