mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 19:47:22 +00:00
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
455 lines
23 KiB
C#
455 lines
23 KiB
C#
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();
|
|
}
|
|
} |