feat(pptx): add placeholder via CLI and preserve chExt on group resize

Two CLI gaps carried from earlier rounds.

AddPlaceholder: `add /slide[N] --type placeholder --prop phType=...`
now creates a <p:sp> with <p:ph type="..."/> for any of the known
placeholder types (body, date, footer, slidenum, header, subtitle,
title, picture, chart, table, diagram, media, obj, clipart). Emits
an empty <p:spPr> so geometry and fonts inherit from the layout,
plus a minimal <p:txBody> that optionally prepopulates via a text=
property. Dispatch wired in Add.cs.

Group resize preserves scaling: when `set /slide[N]/group[M] width=`
or `height=` / `x=` / `y=` is applied and the group's
`TransformGroup.ChildExtents` / `ChildOffset` is null, snapshot the
current Extents / Offset into new ChildExtents / ChildOffset BEFORE
updating Extents / Offset. This establishes the ext≠chExt baseline
that PowerPoint needs to visibly compress the group's children
instead of resizing them 1:1 with the container.
This commit is contained in:
zmworm 2026-04-19 07:17:06 +08:00
parent 7284a748f7
commit 46cfd95a4f
3 changed files with 92 additions and 3 deletions

View file

@ -275,6 +275,78 @@ public partial class PowerPointHandler
}
// CONSISTENCY(add-dispatch-shape): mirrors AddGroup/AddShape resolution flow.
// Emits a <p:sp> with <p:ph type="..."/> that binds to the layout's matching
// placeholder. Leaves <p:spPr> empty so PowerPoint inherits geometry/font
// from the layout placeholder. Optional --prop text=... prepopulates text.
private string AddPlaceholder(string parentPath, int? index, Dictionary<string, string> properties)
{
var phSlideMatch = Regex.Match(parentPath, @"^/slide\[(\d+)\]$");
if (!phSlideMatch.Success)
throw new ArgumentException("Placeholders must be added to a slide: /slide[N]");
var phSlideIdx = int.Parse(phSlideMatch.Groups[1].Value);
var phSlideParts = GetSlideParts().ToList();
if (phSlideIdx < 1 || phSlideIdx > phSlideParts.Count)
throw new ArgumentException($"Slide {phSlideIdx} not found (total: {phSlideParts.Count})");
var phSlidePart = phSlideParts[phSlideIdx - 1];
var phShapeTree = GetSlide(phSlidePart).CommonSlideData?.ShapeTree
?? throw new InvalidOperationException("Slide has no shape tree");
if (!properties.TryGetValue("phType", out var phTypeStr)
&& !properties.TryGetValue("phtype", out phTypeStr)
&& !properties.TryGetValue("type", out phTypeStr))
throw new ArgumentException("'phType' property required for placeholder type (e.g. phType=body|date|footer|slidenum|header|subtitle|title)");
var phTypeVal = ParsePlaceholderType(phTypeStr)
?? throw new ArgumentException(
$"Invalid placeholder type: '{phTypeStr}'. Valid: title, body, subtitle, date, footer, slidenum, header, picture, chart, table, diagram, media, obj, clipart.");
var phId = GenerateUniqueShapeId(phShapeTree);
var phName = properties.GetValueOrDefault("name", $"{phTypeStr} Placeholder {phId}");
var shape = new Shape();
var appNvPr = new ApplicationNonVisualDrawingProperties();
appNvPr.AppendChild(new PlaceholderShape { Type = phTypeVal });
shape.NonVisualShapeProperties = new NonVisualShapeProperties(
new NonVisualDrawingProperties { Id = phId, Name = phName },
new NonVisualShapeDrawingProperties(),
appNvPr
);
// Leave ShapeProperties empty — PowerPoint pulls geometry from layout.
shape.ShapeProperties = new ShapeProperties();
// Optional text prepopulation. Build a minimal TextBody so PowerPoint
// still renders layout placeholder typography.
var textBody = new TextBody(
new Drawing.BodyProperties(),
new Drawing.ListStyle()
);
var para = new Drawing.Paragraph();
if (properties.TryGetValue("text", out var phText) && phText.Length > 0)
{
para.AppendChild(new Drawing.Run(
new Drawing.RunProperties { Language = "en-US" },
new Drawing.Text(phText)
));
}
else
{
// Empty paragraph is valid — PowerPoint shows the layout prompt text.
para.AppendChild(new Drawing.EndParagraphRunProperties { Language = "en-US" });
}
textBody.AppendChild(para);
shape.TextBody = textBody;
InsertAtPosition(phShapeTree, shape, index);
GetSlide(phSlidePart).Save();
var shapeCount = phShapeTree.Elements<Shape>().Count();
return $"/slide[{phSlideIdx}]/shape[{shapeCount}]";
}
private string AddAnimation(string parentPath, int? index, Dictionary<string, string> properties)
{
// Add animation to a shape: parentPath must be /slide[N]/shape[M]

View file

@ -54,6 +54,7 @@ public partial class PowerPointHandler
"video" or "audio" or "media" => AddMedia(parentPath, index, properties, type),
"connector" or "connection" => AddConnector(parentPath, index, properties),
"group" => AddGroup(parentPath, index, properties),
"placeholder" or "ph" => AddPlaceholder(parentPath, index, properties),
"row" or "tr" => AddRow(parentPath, index, properties),
"col" or "column" => AddColumn(parentPath, index, properties),
"cell" or "tc" => AddCell(parentPath, index, properties),

View file

@ -1956,9 +1956,25 @@ public partial class PowerPointHandler
{
var grpSpPr = grp.GroupShapeProperties ?? (grp.GroupShapeProperties = new GroupShapeProperties());
var xfrm = grpSpPr.TransformGroup ?? (grpSpPr.TransformGroup = new Drawing.TransformGroup());
TryApplyPositionSize(key.ToLowerInvariant(), value,
xfrm.Offset ?? (xfrm.Offset = new Drawing.Offset()),
xfrm.Extents ?? (xfrm.Extents = new Drawing.Extents()));
var off = xfrm.Offset ?? (xfrm.Offset = new Drawing.Offset());
var ext = xfrm.Extents ?? (xfrm.Extents = new Drawing.Extents());
var keyLower = key.ToLowerInvariant();
// CONSISTENCY(group-scale-baseline): group scaling needs <a:chOff>/<a:chExt>
// as a child-coordinate baseline. Before we mutate ext/off, snapshot the
// current ext/off into chExt/chOff if they aren't already present — that
// way the first Set of width/height captures the "before" as the logical
// child coordinate space, so shrinking ext shrinks the rendered children.
if (keyLower is "x" or "y")
{
if (xfrm.ChildOffset == null)
xfrm.ChildOffset = new Drawing.ChildOffset { X = off.X ?? 0, Y = off.Y ?? 0 };
}
else // width or height
{
if (xfrm.ChildExtents == null)
xfrm.ChildExtents = new Drawing.ChildExtents { Cx = ext.Cx ?? 0, Cy = ext.Cy ?? 0 };
}
TryApplyPositionSize(keyLower, value, off, ext);
break;
}
case "rotation" or "rotate":