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

346 lines
16 KiB
C#
Raw Normal View History

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;
}
}
}