mirror of
https://github.com/iOfficeAI/OfficeCLI
synced 2026-04-21 13:37:23 +00:00
Three gaps remaining from R14. Hyperlinks now accept tooltip= alongside link= on both run and shape Add / Set. ApplyRunHyperlink and ApplyShapeHyperlink set the Tooltip attribute on HyperlinkOnClick. link= gains internal-jump support: - slide[N] creates a slide-to-slide relationship and emits action="ppaction://hlinksldjump" - firstslide / lastslide / nextslide / previousslide emit the corresponding ppaction://hlinkshowjump?jump=... (PowerPoint-native) ReadRunHyperlinkUrl resolves both forms back to the original string. HTML preview wraps shape-level hyperlinks in an <a> tag. Shape hlinkClick now lives on nvSpPr/cNvPr (canonical shape location) in addition to runs so the HTML renderer can detect it from the shape tree; runs still carry the inline anchor from R14 so text stays clickable inline. External URLs get a wrapping anchor with rel="noopener" target="_blank"; cursor:pointer affordance added. Internal slide-jump links are intentionally not wrapped (no navigable href in a static HTML preview).
1395 lines
66 KiB
C#
1395 lines
66 KiB
C#
// Copyright 2025 OfficeCli (officecli.ai)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
using System.Text;
|
|
using DocumentFormat.OpenXml;
|
|
using DocumentFormat.OpenXml.Packaging;
|
|
using DocumentFormat.OpenXml.Presentation;
|
|
using OfficeCli.Core;
|
|
using Drawing = DocumentFormat.OpenXml.Drawing;
|
|
|
|
namespace OfficeCli.Handlers;
|
|
|
|
public partial class PowerPointHandler
|
|
{
|
|
// ==================== Shape Rendering ====================
|
|
|
|
/// <summary>
|
|
/// Render a shape element to HTML. When called from a group, pass overridePos
|
|
/// with the adjusted coordinates — the original element is NEVER modified.
|
|
/// </summary>
|
|
private static void RenderShape(StringBuilder sb, Shape shape, OpenXmlPart part,
|
|
Dictionary<string, string> themeColors, (long x, long y, long cx, long cy)? overridePos = null,
|
|
string? dataPath = null)
|
|
{
|
|
var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\"";
|
|
var xfrm = shape.ShapeProperties?.Transform2D;
|
|
|
|
// Shape-level hyperlink → wrap rendered shape <div> in <a> for clickability in HTML preview.
|
|
// Only external URLs are wrapped; internal slide-jump links (ppaction://hlinksldjump) are
|
|
// skipped because there is no corresponding external href in this static HTML context.
|
|
string? shapeHrefUrl = null;
|
|
string? shapeHrefTooltip = null;
|
|
{
|
|
var nvHlink = shape.NonVisualShapeProperties?.NonVisualDrawingProperties
|
|
?.GetFirstChild<Drawing.HyperlinkOnClick>();
|
|
if (nvHlink != null)
|
|
{
|
|
shapeHrefTooltip = nvHlink.Tooltip?.Value;
|
|
var action = nvHlink.Action?.Value;
|
|
var hlId = nvHlink.Id?.Value;
|
|
// Skip if this is a slide-jump action (no external URL target)
|
|
if (string.IsNullOrEmpty(action) || !action.Contains("hlink"))
|
|
{
|
|
// Plain external: no action + r:id → look up external relationship
|
|
if (!string.IsNullOrEmpty(hlId))
|
|
{
|
|
try
|
|
{
|
|
var rel = part.HyperlinkRelationships.FirstOrDefault(r => r.Id == hlId);
|
|
if (rel?.Uri != null) shapeHrefUrl = rel.Uri.ToString();
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
else if (action.Contains("hlinksldjump"))
|
|
{
|
|
// Internal slide-jump — deliberately not wrapped (no navigable href in static HTML)
|
|
shapeHrefUrl = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
long x, y, cx, cy;
|
|
if (overridePos != null)
|
|
{
|
|
(x, y, cx, cy) = overridePos.Value;
|
|
}
|
|
else if (xfrm?.Offset != null && xfrm?.Extents != null)
|
|
{
|
|
x = xfrm.Offset.X?.Value ?? 0;
|
|
y = xfrm.Offset.Y?.Value ?? 0;
|
|
cx = xfrm.Extents.Cx?.Value ?? 0;
|
|
cy = xfrm.Extents.Cy?.Value ?? 0;
|
|
}
|
|
else
|
|
{
|
|
// No xfrm — try to inherit position from matching layout/master placeholder
|
|
var resolved = ResolveInheritedPosition(shape, part);
|
|
if (resolved == null)
|
|
{
|
|
// No text content → skip silently
|
|
if (string.IsNullOrWhiteSpace(GetShapeText(shape))) return;
|
|
// Has text but no position can be resolved → use default placeholder position
|
|
resolved = GetDefaultPlaceholderPosition(shape, part);
|
|
if (resolved == null) return;
|
|
}
|
|
(x, y, cx, cy) = resolved.Value;
|
|
}
|
|
|
|
var styles = new List<string>
|
|
{
|
|
$"left:{Units.EmuToPt(x)}pt",
|
|
$"top:{Units.EmuToPt(y)}pt",
|
|
$"width:{Units.EmuToPt(cx)}pt",
|
|
$"height:{Units.EmuToPt(cy)}pt"
|
|
};
|
|
|
|
// Fill
|
|
var fillCss = GetShapeFillCss(shape.ShapeProperties, part, themeColors);
|
|
if (!string.IsNullOrEmpty(fillCss))
|
|
styles.Add(fillCss);
|
|
|
|
// Border/outline — parse for later; solid goes to CSS, non-solid to SVG
|
|
var outline = shape.ShapeProperties?.GetFirstChild<Drawing.Outline>();
|
|
var parsedOutline = outline != null ? ParseOutline(outline, themeColors) : null;
|
|
if (parsedOutline != null && parsedOutline.Value.dashType == "solid")
|
|
{
|
|
styles.Add($"border:{parsedOutline.Value.widthPt:0.##}pt solid {parsedOutline.Value.color}");
|
|
}
|
|
// Non-solid outlines rendered as SVG after the shape div
|
|
|
|
// Build transform chain (must be combined into one transform property)
|
|
var transforms = new List<string>();
|
|
|
|
// 2D rotation
|
|
if (xfrm?.Rotation != null && xfrm.Rotation.Value != 0)
|
|
{
|
|
var deg = xfrm.Rotation.Value / 60000.0;
|
|
transforms.Add($"rotate({deg:0.##}deg)");
|
|
}
|
|
|
|
// Flip
|
|
if (xfrm?.HorizontalFlip?.Value == true && xfrm.VerticalFlip?.Value == true)
|
|
transforms.Add("scale(-1,-1)");
|
|
else if (xfrm?.HorizontalFlip?.Value == true)
|
|
transforms.Add("scaleX(-1)");
|
|
else if (xfrm?.VerticalFlip?.Value == true)
|
|
transforms.Add("scaleY(-1)");
|
|
|
|
// 3D rotation (scene3d camera rotation) → CSS perspective transform
|
|
var scene3d = shape.ShapeProperties?.GetFirstChild<Drawing.Scene3DType>();
|
|
var cam = scene3d?.Camera;
|
|
var rot3d = cam?.Rotation;
|
|
if (rot3d != null)
|
|
{
|
|
var rx = (rot3d.Latitude?.Value ?? 0) / 60000.0;
|
|
var ry = (rot3d.Longitude?.Value ?? 0) / 60000.0;
|
|
var rz = (rot3d.Revolution?.Value ?? 0) / 60000.0;
|
|
if (rx != 0 || ry != 0 || rz != 0)
|
|
{
|
|
styles.Add("perspective:800px");
|
|
if (rx != 0) transforms.Add($"rotateX({rx:0.##}deg)");
|
|
if (ry != 0) transforms.Add($"rotateY({ry:0.##}deg)");
|
|
if (rz != 0) transforms.Add($"rotateZ({rz:0.##}deg)");
|
|
}
|
|
}
|
|
|
|
if (transforms.Count > 0)
|
|
styles.Add($"transform:{string.Join(" ", transforms)}");
|
|
|
|
// Geometry: preset or custom — track clip-path separately to avoid clipping text
|
|
string clipPathCss = "";
|
|
string borderRadiusCss = "";
|
|
var presetGeom = shape.ShapeProperties?.GetFirstChild<Drawing.PresetGeometry>();
|
|
if (presetGeom?.Preset?.HasValue == true)
|
|
{
|
|
var geomCss = PresetGeometryToCss(presetGeom.Preset!.InnerText!, cx, cy, presetGeom);
|
|
if (!string.IsNullOrEmpty(geomCss))
|
|
{
|
|
if (geomCss.StartsWith("clip-path:"))
|
|
clipPathCss = geomCss;
|
|
else
|
|
{
|
|
styles.Add(geomCss);
|
|
borderRadiusCss = geomCss;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Custom geometry (custGeom) → SVG clip-path
|
|
var custGeom = shape.ShapeProperties?.GetFirstChild<Drawing.CustomGeometry>();
|
|
if (custGeom != null)
|
|
{
|
|
var clipPath = CustomGeometryToClipPath(custGeom);
|
|
if (!string.IsNullOrEmpty(clipPath))
|
|
clipPathCss = clipPath;
|
|
}
|
|
}
|
|
|
|
// Shadow + Glow → combine into single filter property
|
|
var effectList = shape.ShapeProperties?.GetFirstChild<Drawing.EffectList>();
|
|
var shadowCss = EffectListToShadowCss(effectList, themeColors);
|
|
var glowCss = EffectListToGlowCss(effectList, themeColors);
|
|
// Merge multiple filter:drop-shadow into one filter property
|
|
var filterParts = new List<string>();
|
|
if (!string.IsNullOrEmpty(shadowCss))
|
|
filterParts.Add(shadowCss.Replace("filter:", ""));
|
|
if (!string.IsNullOrEmpty(glowCss))
|
|
filterParts.Add(glowCss.Replace("filter:", ""));
|
|
if (filterParts.Count > 0)
|
|
styles.Add($"filter:{string.Join(" ", filterParts)}");
|
|
|
|
// Reflection → CSS -webkit-box-reflect
|
|
var reflectionCss = EffectListToReflectionCss(effectList);
|
|
if (!string.IsNullOrEmpty(reflectionCss))
|
|
styles.Add(reflectionCss);
|
|
|
|
// Soft edge → fade out at edges using CSS mask-image
|
|
// Unlike filter:blur() which blurs the entire element,
|
|
// mask-image with edge gradients only affects the border region.
|
|
var softEdge = effectList?.GetFirstChild<Drawing.SoftEdge>()
|
|
?? shape.ShapeProperties?.GetFirstChild<Drawing.EffectList>()?.GetFirstChild<Drawing.SoftEdge>();
|
|
if (softEdge == null)
|
|
{
|
|
softEdge = shape.TextBody?.Descendants<Drawing.RunProperties>()
|
|
.Select(rp => rp.GetFirstChild<Drawing.EffectList>()?.GetFirstChild<Drawing.SoftEdge>())
|
|
.FirstOrDefault(se => se != null);
|
|
}
|
|
if (softEdge?.Radius?.HasValue == true)
|
|
{
|
|
var edgePx = Math.Max(2, softEdge.Radius.Value / 12700.0 * 0.8);
|
|
// Use linear-gradient masks on all 4 edges to create edge fade-out
|
|
styles.Add($"-webkit-mask-image:linear-gradient(to right,transparent 0,black {edgePx:0.#}px,black calc(100% - {edgePx:0.#}px),transparent 100%)," +
|
|
$"linear-gradient(to bottom,transparent 0,black {edgePx:0.#}px,black calc(100% - {edgePx:0.#}px),transparent 100%)");
|
|
styles.Add("-webkit-mask-composite:source-in;mask-composite:intersect");
|
|
}
|
|
|
|
// Bevel → approximate with inset box-shadow for a subtle 3D appearance
|
|
var sp3d = shape.ShapeProperties?.GetFirstChild<Drawing.Shape3DType>();
|
|
if (sp3d?.BevelTop != null)
|
|
{
|
|
var bevelW = sp3d.BevelTop.Width?.HasValue == true ? sp3d.BevelTop.Width.Value / 12700.0 : 6; // OOXML default 76200 EMU = 6pt
|
|
var bW = Math.Max(1, bevelW * 0.5);
|
|
styles.Add($"box-shadow:inset {bW:0.#}px {bW:0.#}px {bW * 1.5:0.#}px rgba(255,255,255,0.25),inset -{bW:0.#}px -{bW:0.#}px {bW * 1.5:0.#}px rgba(0,0,0,0.15)");
|
|
}
|
|
|
|
// Note: fill opacity (alpha) is already baked into rgba() by ResolveFillColor.
|
|
// Do NOT add a separate CSS opacity here — it would double-apply.
|
|
|
|
// Text margins
|
|
var bodyPr = shape.TextBody?.Elements<Drawing.BodyProperties>().FirstOrDefault();
|
|
long lIns = bodyPr?.LeftInset?.Value ?? 91440;
|
|
long tIns = bodyPr?.TopInset?.Value ?? 45720;
|
|
long rIns = bodyPr?.RightInset?.Value ?? 91440;
|
|
long bIns = bodyPr?.BottomInset?.Value ?? 45720;
|
|
|
|
// For non-rectangular shapes (clip-path or border-radius), add extra inner padding
|
|
// so text doesn't appear outside the visible shape area.
|
|
if ((!string.IsNullOrEmpty(clipPathCss) || !string.IsNullOrEmpty(borderRadiusCss)) && presetGeom?.Preset?.HasValue == true)
|
|
{
|
|
var (pctL, pctT, pctR, pctB) = GetShapeTextInsetPercent(presetGeom.Preset!.InnerText!);
|
|
if (pctL > 0 || pctT > 0 || pctR > 0 || pctB > 0)
|
|
{
|
|
var extraL = (long)(cx * pctL);
|
|
var extraT = (long)(cy * pctT);
|
|
var extraR = (long)(cx * pctR);
|
|
var extraB = (long)(cy * pctB);
|
|
lIns = Math.Max(lIns, extraL);
|
|
tIns = Math.Max(tIns, extraT);
|
|
rIns = Math.Max(rIns, extraR);
|
|
bIns = Math.Max(bIns, extraB);
|
|
}
|
|
}
|
|
|
|
styles.Add($"padding:{Units.EmuToPt(tIns)}pt {Units.EmuToPt(rIns)}pt {Units.EmuToPt(bIns)}pt {Units.EmuToPt(lIns)}pt");
|
|
|
|
// Vertical alignment class
|
|
var valign = "top";
|
|
if (bodyPr?.Anchor?.HasValue == true)
|
|
{
|
|
valign = bodyPr.Anchor.InnerText switch
|
|
{
|
|
"ctr" => "center",
|
|
"b" => "bottom",
|
|
_ => "top"
|
|
};
|
|
}
|
|
|
|
// Add has-fill class to clip overflow when shape has a visible background
|
|
var hasFillBg = shape.ShapeProperties?.GetFirstChild<Drawing.SolidFill>() != null
|
|
|| shape.ShapeProperties?.GetFirstChild<Drawing.GradientFill>() != null
|
|
|| shape.ShapeProperties?.GetFirstChild<Drawing.BlipFill>() != null;
|
|
var shapeClass = hasFillBg ? "shape has-fill" : "shape";
|
|
|
|
// Open <a> wrapper for shape-level hyperlink (before the shape <div>)
|
|
if (!string.IsNullOrEmpty(shapeHrefUrl))
|
|
{
|
|
var tooltipAttr = !string.IsNullOrEmpty(shapeHrefTooltip)
|
|
? $" title=\"{HtmlEncode(shapeHrefTooltip!)}\"" : "";
|
|
sb.Append($" <a class=\"shape-link\" href=\"{HtmlEncode(shapeHrefUrl!)}\" rel=\"noopener\" target=\"_blank\"{tooltipAttr} style=\"display:contents;cursor:pointer\">");
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(clipPathCss))
|
|
{
|
|
// For clip-path shapes: move fill to a clipped background layer, keep text unclipped
|
|
// Extract fill-related styles for the clipped background layer
|
|
var fillStyles = new List<string>();
|
|
var borderStyles = new List<string>();
|
|
var outerStyles = new List<string>();
|
|
foreach (var s in styles)
|
|
{
|
|
if (s.StartsWith("background:") || s.StartsWith("background-image:"))
|
|
fillStyles.Add(s);
|
|
else if (s.StartsWith("border"))
|
|
borderStyles.Add(s);
|
|
else
|
|
outerStyles.Add(s);
|
|
}
|
|
// When wrapped in a link, add cursor:pointer to the shape <div> itself
|
|
if (!string.IsNullOrEmpty(shapeHrefUrl)) outerStyles.Add("cursor:pointer");
|
|
sb.Append($" <div class=\"{shapeClass}\"{dataPathAttr} style=\"{string.Join(";", outerStyles)}\">");
|
|
// Fill layer (clipped)
|
|
if (fillStyles.Count > 0)
|
|
sb.Append($"<div style=\"position:absolute;inset:0;{clipPathCss};{string.Join(";", fillStyles)}\"></div>");
|
|
// Border layer for clip-path shapes: always use SVG polygon stroke
|
|
if (parsedOutline != null && clipPathCss.StartsWith("clip-path:polygon("))
|
|
{
|
|
var (bw, dt, bc) = parsedOutline.Value;
|
|
var polyStr = clipPathCss["clip-path:polygon(".Length..^1];
|
|
var svgPoints = polyStr.Replace("%", "");
|
|
var dashArr = DashTypeToSvgDasharray(dt, bw);
|
|
var dashAttr = !string.IsNullOrEmpty(dashArr) ? $" stroke-dasharray=\"{dashArr}\"" : "";
|
|
var safeColor = CssSanitizeColor(bc);
|
|
sb.Append($"<svg style=\"position:absolute;inset:0;width:100%;height:100%;overflow:visible\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\">");
|
|
sb.Append($"<polygon points=\"{svgPoints}\" fill=\"none\" stroke=\"{safeColor}\" stroke-width=\"{bw:0.##}pt\" vector-effect=\"non-scaling-stroke\" stroke-linecap=\"butt\"{dashAttr}/>");
|
|
sb.Append("</svg>");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!string.IsNullOrEmpty(shapeHrefUrl)) styles.Add("cursor:pointer");
|
|
sb.Append($" <div class=\"{shapeClass}\"{dataPathAttr} style=\"{string.Join(";", styles)}\">");
|
|
}
|
|
|
|
// Text content
|
|
if (shape.TextBody != null)
|
|
{
|
|
// Counter-flip text so it remains readable when shape is flipped
|
|
var flipStyle = "";
|
|
var isFlipH = xfrm?.HorizontalFlip?.Value == true;
|
|
var isFlipV = xfrm?.VerticalFlip?.Value == true;
|
|
if (isFlipH && isFlipV)
|
|
flipStyle = "transform:scale(-1,-1);";
|
|
else if (isFlipH)
|
|
flipStyle = "transform:scaleX(-1);";
|
|
else if (isFlipV)
|
|
flipStyle = "transform:scaleY(-1);";
|
|
|
|
var textStyle = !string.IsNullOrEmpty(flipStyle) || !string.IsNullOrEmpty(clipPathCss)
|
|
? $" style=\"{flipStyle}{(string.IsNullOrEmpty(clipPathCss) ? "" : "position:relative;")}\""
|
|
: "";
|
|
sb.Append($"<div class=\"shape-text valign-{valign}\"{textStyle}>");
|
|
|
|
RenderTextBody(sb, shape.TextBody, themeColors, shape, part);
|
|
sb.Append("</div>");
|
|
}
|
|
|
|
// SVG border overlay for non-solid outlines (dashed, dotted, dashDot etc.)
|
|
if (parsedOutline != null && parsedOutline.Value.dashType != "solid")
|
|
{
|
|
var (bw, dt, bc) = parsedOutline.Value;
|
|
var dashArr = DashTypeToSvgDasharray(dt, bw);
|
|
var dashAttr = !string.IsNullOrEmpty(dashArr) ? $" stroke-dasharray=\"{dashArr}\"" : "";
|
|
var safeColor = CssSanitizeColor(bc);
|
|
|
|
if (!string.IsNullOrEmpty(clipPathCss) && clipPathCss.StartsWith("clip-path:polygon("))
|
|
{
|
|
// Polygon shapes — reuse existing polygon SVG approach
|
|
var polyStr = clipPathCss["clip-path:polygon(".Length..^1];
|
|
var svgPoints = polyStr.Replace("%", "");
|
|
sb.Append($"<svg style=\"position:absolute;inset:0;width:100%;height:100%;overflow:visible\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\">");
|
|
sb.Append($"<polygon points=\"{svgPoints}\" fill=\"none\" stroke=\"{safeColor}\" stroke-width=\"{bw:0.##}pt\" vector-effect=\"non-scaling-stroke\" stroke-linecap=\"butt\"{dashAttr}/>");
|
|
sb.Append("</svg>");
|
|
}
|
|
else if (!string.IsNullOrEmpty(borderRadiusCss))
|
|
{
|
|
// Rounded rect — use SVG rect with rx/ry
|
|
var rxMatch = System.Text.RegularExpressions.Regex.Match(borderRadiusCss, @"border-radius:([\d.]+)");
|
|
var rx = rxMatch.Success ? rxMatch.Groups[1].Value : "0";
|
|
sb.Append($"<svg style=\"position:absolute;inset:0;width:100%;height:100%;overflow:visible\">");
|
|
sb.Append($"<rect x=\"{bw / 2:0.##}pt\" y=\"{bw / 2:0.##}pt\" width=\"calc(100% - {bw:0.##}pt)\" height=\"calc(100% - {bw:0.##}pt)\" rx=\"{rx}\" ry=\"{rx}\" fill=\"none\" stroke=\"{safeColor}\" stroke-width=\"{bw:0.##}pt\" stroke-linecap=\"butt\"{dashAttr}/>");
|
|
sb.Append("</svg>");
|
|
}
|
|
else if (presetGeom?.Preset?.InnerText == "ellipse")
|
|
{
|
|
// Ellipse — size in pt so stroke-width matches CSS border path.
|
|
// CONSISTENCY(shape-stroke-unit): keep stroke-width in pt across solid/non-solid paths.
|
|
sb.Append($"<svg style=\"position:absolute;inset:0;width:100%;height:100%;overflow:visible\">");
|
|
sb.Append($"<ellipse cx=\"50%\" cy=\"50%\" rx=\"calc(50% - {bw / 2:0.##}pt)\" ry=\"calc(50% - {bw / 2:0.##}pt)\" fill=\"none\" stroke=\"{safeColor}\" stroke-width=\"{bw:0.##}pt\" stroke-linecap=\"butt\"{dashAttr}/>");
|
|
sb.Append("</svg>");
|
|
}
|
|
else
|
|
{
|
|
// Plain rect — use SVG rect sized in pt so stroke-width matches the CSS
|
|
// `border:Npt solid` path (same visual weight). Inset by bw/2 so the stroke
|
|
// sits entirely inside the content box (box-sizing:border-box equivalent).
|
|
// CONSISTENCY(shape-stroke-unit): keep stroke-width in pt across solid/non-solid paths.
|
|
sb.Append($"<svg style=\"position:absolute;inset:0;width:100%;height:100%;overflow:visible\">");
|
|
sb.Append($"<rect x=\"{bw / 2:0.##}pt\" y=\"{bw / 2:0.##}pt\" width=\"calc(100% - {bw:0.##}pt)\" height=\"calc(100% - {bw:0.##}pt)\" fill=\"none\" stroke=\"{safeColor}\" stroke-width=\"{bw:0.##}pt\" stroke-linecap=\"butt\"{dashAttr}/>");
|
|
sb.Append("</svg>");
|
|
}
|
|
}
|
|
|
|
sb.Append("</div>");
|
|
if (!string.IsNullOrEmpty(shapeHrefUrl))
|
|
sb.Append("</a>");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
// ==================== Placeholder Position Inheritance ====================
|
|
|
|
/// <summary>
|
|
/// When a shape has no Transform2D, try to find position from matching placeholder
|
|
/// on the slide layout or slide master (OOXML placeholder inheritance chain).
|
|
/// </summary>
|
|
private static (long x, long y, long cx, long cy)? ResolveInheritedPosition(Shape shape, OpenXmlPart part)
|
|
{
|
|
var ph = shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties
|
|
?.GetFirstChild<PlaceholderShape>();
|
|
|
|
// Only placeholder shapes can inherit position from layout/master
|
|
if (ph == null) return null;
|
|
|
|
var slidePart = part as SlidePart;
|
|
if (slidePart == null) return null;
|
|
|
|
// Search layout then master for a matching placeholder
|
|
var layoutShapeTree = slidePart.SlideLayoutPart?.SlideLayout?.CommonSlideData?.ShapeTree;
|
|
var masterShapeTree = slidePart.SlideLayoutPart?.SlideMasterPart?.SlideMaster?.CommonSlideData?.ShapeTree;
|
|
|
|
foreach (var tree in new[] { layoutShapeTree, masterShapeTree })
|
|
{
|
|
if (tree == null) continue;
|
|
foreach (var candidate in tree.Elements<Shape>())
|
|
{
|
|
var candidatePh = candidate.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties
|
|
?.GetFirstChild<PlaceholderShape>();
|
|
if (candidatePh == null) continue;
|
|
|
|
if (!PlaceholderMatches(ph, candidatePh)) continue;
|
|
|
|
var cxfrm = candidate.ShapeProperties?.Transform2D;
|
|
if (cxfrm?.Offset != null && cxfrm?.Extents != null)
|
|
{
|
|
return (
|
|
cxfrm.Offset.X?.Value ?? 0,
|
|
cxfrm.Offset.Y?.Value ?? 0,
|
|
cxfrm.Extents.Cx?.Value ?? 0,
|
|
cxfrm.Extents.Cy?.Value ?? 0
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if two placeholder shapes match by type and/or index.
|
|
/// </summary>
|
|
private static bool PlaceholderMatches(PlaceholderShape slidePh, PlaceholderShape layoutPh)
|
|
{
|
|
// Match by index first (most specific)
|
|
if (slidePh.Index?.HasValue == true && layoutPh.Index?.HasValue == true)
|
|
return slidePh.Index.Value == layoutPh.Index.Value;
|
|
|
|
// Match by type
|
|
if (slidePh.Type?.HasValue == true && layoutPh.Type?.HasValue == true)
|
|
return slidePh.Type.Value == layoutPh.Type.Value;
|
|
|
|
// If slide ph has no type/idx, match by name or consider it a body placeholder
|
|
// Default placeholder type (when type is omitted) is "body" per OOXML spec
|
|
if (slidePh.Type?.HasValue != true && slidePh.Index?.HasValue != true)
|
|
{
|
|
// A typeless/indexless placeholder matches title if the layout has title,
|
|
// or body/subtitle by convention
|
|
if (layoutPh.Type?.HasValue == true)
|
|
{
|
|
var lt = layoutPh.Type.Value;
|
|
return lt == PlaceholderValues.Title || lt == PlaceholderValues.CenteredTitle
|
|
|| lt == PlaceholderValues.SubTitle || lt == PlaceholderValues.Body;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Last-resort fallback: provide default positions for placeholder shapes
|
|
/// with text content when no layout/master placeholder can be matched.
|
|
/// Uses standard PowerPoint default placeholder positions.
|
|
/// </summary>
|
|
private static (long x, long y, long cx, long cy)? GetDefaultPlaceholderPosition(Shape shape, OpenXmlPart part)
|
|
{
|
|
var ph = shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties
|
|
?.GetFirstChild<PlaceholderShape>();
|
|
|
|
// Get slide dimensions for proportional positioning
|
|
long slideW = 12192000; // default 33.87cm
|
|
long slideH = 6858000; // default 19.05cm
|
|
if (part is SlidePart sp)
|
|
{
|
|
var presDoc = sp.GetParentParts().OfType<PresentationPart>().FirstOrDefault();
|
|
var slideSize = presDoc?.Presentation?.SlideSize;
|
|
if (slideSize?.Cx?.HasValue == true) slideW = slideSize.Cx.Value;
|
|
if (slideSize?.Cy?.HasValue == true) slideH = slideSize.Cy.Value;
|
|
}
|
|
|
|
// Standard PowerPoint default positions (in EMU)
|
|
long margin = slideW / 16; // ~6.25% margin on each side
|
|
long contentW = slideW - margin * 2;
|
|
|
|
if (ph?.Type?.HasValue == true)
|
|
{
|
|
var t = ph.Type.Value;
|
|
if (t == PlaceholderValues.Title || t == PlaceholderValues.CenteredTitle)
|
|
return (margin, slideH / 8, contentW, slideH / 4);
|
|
if (t == PlaceholderValues.SubTitle)
|
|
return (margin, slideH * 3 / 8, contentW, slideH / 4);
|
|
if (t == PlaceholderValues.Body || t == PlaceholderValues.Object)
|
|
return (margin, slideH * 3 / 8, contentW, slideH / 2);
|
|
return null;
|
|
}
|
|
|
|
// Placeholder with no type attribute — use a generous centered area
|
|
if (ph != null)
|
|
{
|
|
// Determine position based on shape name as a hint
|
|
// Check Subtitle before Title since "Subtitle" contains "Title"
|
|
var name = shape.NonVisualShapeProperties?.NonVisualDrawingProperties?.Name?.Value ?? "";
|
|
if (name.Contains("Subtitle", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Contains("副标题", StringComparison.Ordinal))
|
|
return (margin, slideH * 3 / 8, contentW, slideH / 4);
|
|
if (name.Contains("Title", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Contains("标题", StringComparison.Ordinal))
|
|
return (margin, slideH / 8, contentW, slideH / 4);
|
|
|
|
// Generic placeholder — use body area
|
|
return (margin, slideH / 4, contentW, slideH / 2);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ==================== Shape Text Inset for Clip-Path Shapes ====================
|
|
|
|
/// <summary>
|
|
/// Returns per-side inset percentages (left, top, right, bottom) for text inside a clip-path shape.
|
|
/// Each value is 0-1, applied to the shape's width (left/right) or height (top/bottom).
|
|
/// This keeps text within the visible shape interior.
|
|
/// </summary>
|
|
private static (double L, double T, double R, double B) GetShapeTextInsetPercent(string preset) => preset switch
|
|
{
|
|
"diamond" => (0.25, 0.25, 0.25, 0.25),
|
|
"triangle" or "isosTriangle" => (0.20, 0.20, 0.20, 0),
|
|
"rtTriangle" => (0, 0.15, 0.15, 0),
|
|
"star4" => (0.28, 0.28, 0.28, 0.28),
|
|
"star5" => (0.28, 0.28, 0.28, 0.28),
|
|
"star6" => (0.25, 0.25, 0.25, 0.25),
|
|
"star8" or "star10" or "star12" => (0.20, 0.20, 0.20, 0.20),
|
|
"hexagon" => (0.25, 0.10, 0.25, 0.10),
|
|
"pentagon" => (0.12, 0.12, 0.12, 0),
|
|
"heptagon" or "octagon" or "decagon" or "dodecagon" => (0.08, 0.08, 0.08, 0.08),
|
|
"parallelogram" => (0.15, 0, 0.15, 0),
|
|
"trapezoid" => (0.12, 0, 0.12, 0),
|
|
"rightArrow" or "notchedRightArrow" => (0, 0.20, 0.25, 0.20),
|
|
"leftArrow" => (0.25, 0.20, 0, 0.20),
|
|
"upArrow" => (0.20, 0.25, 0.20, 0),
|
|
"downArrow" => (0.20, 0, 0.20, 0.25),
|
|
"chevron" or "homePlate" => (0, 0, 0.15, 0),
|
|
"heart" => (0.15, 0.15, 0.15, 0.15),
|
|
"plus" or "cross" => (0.10, 0.10, 0.10, 0.10),
|
|
"cloud" or "cloudCallout" => (0.12, 0.12, 0.12, 0.12),
|
|
"sun" => (0.20, 0.20, 0.20, 0.20),
|
|
"moon" => (0.15, 0, 0, 0),
|
|
"cube" => (0, 0.08, 0.08, 0),
|
|
"donut" => (0.25, 0.25, 0.25, 0.25),
|
|
"roundRect" => (0.07, 0.07, 0.07, 0.07),
|
|
"wedgeRectCallout" or "wedgeRoundRectCallout" or "wedgeEllipseCallout" => (0.08, 0.08, 0.08, 0.08),
|
|
"curvedRightArrow" or "curvedLeftArrow" or "curvedUpArrow" or "curvedDownArrow" => (0.12, 0.12, 0.12, 0.12),
|
|
_ => (0, 0, 0, 0)
|
|
};
|
|
|
|
// ==================== Placeholder Font Size Inheritance ====================
|
|
|
|
/// <summary>
|
|
/// Resolve the default font size for a placeholder shape by walking the inheritance chain:
|
|
/// shape listStyle → slide layout placeholder → slide master placeholder → master text styles → OOXML defaults.
|
|
/// Returns font size in hundredths of a point (e.g. 4400 = 44pt), or null if no override.
|
|
/// </summary>
|
|
private static int? ResolvePlaceholderFontSize(Shape shape, OpenXmlPart part, int level = 0)
|
|
{
|
|
var ph = shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties
|
|
?.GetFirstChild<PlaceholderShape>();
|
|
if (ph == null) return null; // Not a placeholder
|
|
|
|
// 1. Check shape's own list style for the paragraph's level
|
|
var lstStyle = shape.TextBody?.GetFirstChild<Drawing.ListStyle>();
|
|
var defRp = GetLevelDefRp(lstStyle, level);
|
|
if (defRp?.FontSize?.HasValue == true)
|
|
return defRp.FontSize.Value;
|
|
|
|
// Determine placeholder category
|
|
var phType = ph.Type?.HasValue == true ? ph.Type.Value : PlaceholderValues.Body;
|
|
bool isTitle = phType == PlaceholderValues.Title || phType == PlaceholderValues.CenteredTitle;
|
|
bool isSubTitle = phType == PlaceholderValues.SubTitle;
|
|
|
|
// 2. Check layout and master placeholder matching shapes for inherited font size
|
|
if (part is SlidePart slidePart)
|
|
{
|
|
var layoutTree = slidePart.SlideLayoutPart?.SlideLayout?.CommonSlideData?.ShapeTree;
|
|
var masterTree = slidePart.SlideLayoutPart?.SlideMasterPart?.SlideMaster?.CommonSlideData?.ShapeTree;
|
|
|
|
foreach (var tree in new[] { layoutTree, masterTree })
|
|
{
|
|
if (tree == null) continue;
|
|
foreach (var candidate in tree.Elements<Shape>())
|
|
{
|
|
var cPh = candidate.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties
|
|
?.GetFirstChild<PlaceholderShape>();
|
|
if (cPh == null) continue;
|
|
if (!PlaceholderMatches(ph, cPh)) continue;
|
|
|
|
// Check candidate's list style at the correct level
|
|
var cLstStyle = candidate.TextBody?.GetFirstChild<Drawing.ListStyle>();
|
|
var cDefRp = GetLevelDefRp(cLstStyle, level);
|
|
if (cDefRp?.FontSize?.HasValue == true)
|
|
return cDefRp.FontSize.Value;
|
|
}
|
|
}
|
|
|
|
// 3. Check master text styles (titleStyle for titles, bodyStyle for body, otherStyle for others)
|
|
var masterTxStyles = slidePart.SlideLayoutPart?.SlideMasterPart?.SlideMaster?.TextStyles;
|
|
if (masterTxStyles != null)
|
|
{
|
|
OpenXmlCompositeElement? styleList = null;
|
|
if (isTitle)
|
|
styleList = masterTxStyles.TitleStyle;
|
|
else if (isSubTitle || phType == PlaceholderValues.Body || phType == PlaceholderValues.Object)
|
|
styleList = masterTxStyles.BodyStyle;
|
|
else
|
|
styleList = masterTxStyles.OtherStyle;
|
|
|
|
if (styleList != null)
|
|
{
|
|
var sDefRp = GetLevelDefRp(styleList, level);
|
|
if (sDefRp?.FontSize?.HasValue == true)
|
|
return sDefRp.FontSize.Value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. OOXML spec defaults: Title=44pt, SubTitle=32pt, Body=24pt
|
|
if (isTitle) return 4400;
|
|
if (isSubTitle) return 3200;
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the DefaultRunProperties for a given paragraph level (0-8) from a list style or text style element.
|
|
/// Maps level 0 → Level1ParagraphProperties, level 1 → Level2ParagraphProperties, etc.
|
|
/// </summary>
|
|
private static Drawing.DefaultRunProperties? GetLevelDefRp(OpenXmlCompositeElement? styleList, int level)
|
|
{
|
|
if (styleList == null) return null;
|
|
OpenXmlElement? lvlPpr = level switch
|
|
{
|
|
0 => styleList.GetFirstChild<Drawing.Level1ParagraphProperties>(),
|
|
1 => styleList.GetFirstChild<Drawing.Level2ParagraphProperties>(),
|
|
2 => styleList.GetFirstChild<Drawing.Level3ParagraphProperties>(),
|
|
3 => styleList.GetFirstChild<Drawing.Level4ParagraphProperties>(),
|
|
4 => styleList.GetFirstChild<Drawing.Level5ParagraphProperties>(),
|
|
5 => styleList.GetFirstChild<Drawing.Level6ParagraphProperties>(),
|
|
6 => styleList.GetFirstChild<Drawing.Level7ParagraphProperties>(),
|
|
7 => styleList.GetFirstChild<Drawing.Level8ParagraphProperties>(),
|
|
8 => styleList.GetFirstChild<Drawing.Level9ParagraphProperties>(),
|
|
_ => styleList.GetFirstChild<Drawing.Level1ParagraphProperties>(),
|
|
};
|
|
return lvlPpr?.GetFirstChild<Drawing.DefaultRunProperties>();
|
|
}
|
|
|
|
// ==================== Picture Rendering ====================
|
|
|
|
/// <summary>
|
|
/// Render a picture element to HTML. When called from a group, pass overridePos
|
|
/// with the adjusted coordinates — the original element is NEVER modified.
|
|
/// </summary>
|
|
private static void RenderPicture(StringBuilder sb, Picture pic, OpenXmlPart slidePart,
|
|
Dictionary<string, string> themeColors, (long x, long y, long cx, long cy)? overridePos = null,
|
|
string? dataPath = null)
|
|
{
|
|
var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\"";
|
|
var xfrm = pic.ShapeProperties?.Transform2D;
|
|
if (xfrm?.Offset == null || xfrm?.Extents == null) return;
|
|
|
|
var x = overridePos?.x ?? xfrm.Offset.X?.Value ?? 0;
|
|
var y = overridePos?.y ?? xfrm.Offset.Y?.Value ?? 0;
|
|
var cx = overridePos?.cx ?? xfrm.Extents.Cx?.Value ?? 0;
|
|
var cy = overridePos?.cy ?? xfrm.Extents.Cy?.Value ?? 0;
|
|
|
|
var styles = new List<string>
|
|
{
|
|
$"left:{Units.EmuToPt(x)}pt",
|
|
$"top:{Units.EmuToPt(y)}pt",
|
|
$"width:{Units.EmuToPt(cx)}pt",
|
|
$"height:{Units.EmuToPt(cy)}pt"
|
|
};
|
|
|
|
// Rotation
|
|
if (xfrm.Rotation != null && xfrm.Rotation.Value != 0)
|
|
styles.Add($"transform:rotate({xfrm.Rotation.Value / 60000.0:0.##}deg)");
|
|
|
|
// Border
|
|
var outline = pic.ShapeProperties?.GetFirstChild<Drawing.Outline>();
|
|
if (outline != null)
|
|
{
|
|
var borderCss = OutlineToCss(outline, themeColors);
|
|
if (!string.IsNullOrEmpty(borderCss))
|
|
styles.Add(borderCss);
|
|
}
|
|
|
|
// Shadow
|
|
var effectList = pic.ShapeProperties?.GetFirstChild<Drawing.EffectList>();
|
|
var shadowCss = EffectListToShadowCss(effectList, themeColors);
|
|
if (!string.IsNullOrEmpty(shadowCss))
|
|
styles.Add(shadowCss);
|
|
|
|
// Reflection → CSS -webkit-box-reflect
|
|
var reflectionCss = EffectListToReflectionCss(effectList);
|
|
if (!string.IsNullOrEmpty(reflectionCss))
|
|
styles.Add(reflectionCss);
|
|
|
|
// Geometry (rounded corners)
|
|
var presetGeom = pic.ShapeProperties?.GetFirstChild<Drawing.PresetGeometry>();
|
|
if (presetGeom?.Preset?.HasValue == true)
|
|
{
|
|
var geomCss = PresetGeometryToCss(presetGeom.Preset!.InnerText!, cx, cy, presetGeom);
|
|
if (!string.IsNullOrEmpty(geomCss))
|
|
styles.Add(geomCss);
|
|
}
|
|
|
|
sb.Append($" <div class=\"picture\"{dataPathAttr} style=\"{string.Join(";", styles)}\">");
|
|
|
|
// Extract image data
|
|
var blipFill = pic.BlipFill;
|
|
var blip = blipFill?.GetFirstChild<Drawing.Blip>();
|
|
if (blip?.Embed?.HasValue == true)
|
|
{
|
|
try
|
|
{
|
|
var imgPart = slidePart.GetPartById(blip.Embed.Value!);
|
|
using var stream = imgPart.GetStream();
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
var base64 = Convert.ToBase64String(ms.ToArray());
|
|
var contentType = SanitizeContentType(imgPart.ContentType ?? "image/png");
|
|
|
|
// Crop — PowerPoint srcRect semantics: select a rectangular region of the
|
|
// source image, then scale that region to fill the container.
|
|
// CSS equivalent: render as a <div> with background-image, setting
|
|
// background-size = container / visibleFraction and background-position
|
|
// so the srcRect region aligns to the container edge.
|
|
var srcRect = blipFill?.GetFirstChild<Drawing.SourceRectangle>();
|
|
double srcL = 0, srcT = 0, srcR = 0, srcB = 0;
|
|
if (srcRect != null)
|
|
{
|
|
srcL = (srcRect.Left?.Value ?? 0) / 100000.0;
|
|
srcT = (srcRect.Top?.Value ?? 0) / 100000.0;
|
|
srcR = (srcRect.Right?.Value ?? 0) / 100000.0;
|
|
srcB = (srcRect.Bottom?.Value ?? 0) / 100000.0;
|
|
}
|
|
var hasCrop = srcL != 0 || srcT != 0 || srcR != 0 || srcB != 0;
|
|
if (hasCrop)
|
|
{
|
|
var visibleW = Math.Max(1 - srcL - srcR, 0.0001);
|
|
var visibleH = Math.Max(1 - srcT - srcB, 0.0001);
|
|
var bgSizeW = 100.0 / visibleW;
|
|
var bgSizeH = 100.0 / visibleH;
|
|
// background-position percentage semantics: pos% aligns pos%-of-image with pos%-of-container.
|
|
// To align srcRect (image region starting at fraction L) with container's left edge:
|
|
// pos_x% = L / (srcL + srcR) * 100 (denominator = 1 - visibleW)
|
|
// Fallback to 0 when there's no crop on that axis (denominator == 0).
|
|
var denomX = srcL + srcR;
|
|
var denomY = srcT + srcB;
|
|
var bgPosX = denomX > 0 ? (srcL / denomX) * 100.0 : 0.0;
|
|
var bgPosY = denomY > 0 ? (srcT / denomY) * 100.0 : 0.0;
|
|
var bgStyle = $"width:100%;height:100%;background-image:url(data:{contentType};base64,{base64});background-repeat:no-repeat;background-size:{bgSizeW:0.##}% {bgSizeH:0.##}%;background-position:{bgPosX:0.##}% {bgPosY:0.##}%";
|
|
sb.Append($"<div style=\"{bgStyle}\"></div>");
|
|
}
|
|
else
|
|
{
|
|
sb.Append($"<img src=\"data:{contentType};base64,{base64}\" loading=\"lazy\">");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Image extraction failed - show placeholder
|
|
sb.Append("<div style=\"width:100%;height:100%;background:rgba(128,128,128,0.15);display:flex;align-items:center;justify-content:center;color:rgba(128,128,128,0.5);font-size:12px\">Image</div>");
|
|
}
|
|
}
|
|
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
// ==================== Connector Rendering ====================
|
|
|
|
private static void RenderConnector(StringBuilder sb, ConnectionShape cxn, Dictionary<string, string> themeColors, string? dataPath = null)
|
|
{
|
|
var xfrm = cxn.ShapeProperties?.Transform2D;
|
|
if (xfrm?.Offset == null || xfrm?.Extents == null) return;
|
|
|
|
var x = xfrm.Offset.X?.Value ?? 0;
|
|
var y = xfrm.Offset.Y?.Value ?? 0;
|
|
var cx = xfrm.Extents.Cx?.Value ?? 0;
|
|
var cy = xfrm.Extents.Cy?.Value ?? 0;
|
|
|
|
var flipH = xfrm.HorizontalFlip?.Value == true;
|
|
var flipV = xfrm.VerticalFlip?.Value == true;
|
|
|
|
// SVG line
|
|
var outline = cxn.ShapeProperties?.GetFirstChild<Drawing.Outline>();
|
|
var defaultLineColor = themeColors.TryGetValue("tx1", out var txc) ? $"#{txc}"
|
|
: themeColors.TryGetValue("dk1", out var dkc) ? $"#{dkc}" : "#000000";
|
|
var lineColor = defaultLineColor;
|
|
var lineWidth = 1.0;
|
|
if (outline != null)
|
|
{
|
|
var c = ResolveFillColor(outline.GetFirstChild<Drawing.SolidFill>(), themeColors);
|
|
if (c != null) lineColor = c;
|
|
if (outline.Width?.HasValue == true) lineWidth = outline.Width.Value / 12700.0;
|
|
}
|
|
|
|
// Ensure minimum dimensions so the line is visible
|
|
// For horizontal lines (cy=0), the container needs height for stroke width
|
|
// For vertical lines (cx=0), the container needs width for stroke width
|
|
var minDimEmu = (long)(lineWidth * 12700 + 12700); // lineWidth + 1pt padding
|
|
var renderCx = Math.Max(cx, cx == 0 ? minDimEmu : 1);
|
|
var renderCy = Math.Max(cy, cy == 0 ? minDimEmu : 1);
|
|
var widthPt = Units.EmuToPt(renderCx);
|
|
var heightPt = Units.EmuToPt(renderCy);
|
|
|
|
// Adjust y position upward by half the added height for zero-height lines
|
|
var renderY = cy == 0 ? y - minDimEmu / 2 : y;
|
|
var renderX = cx == 0 ? x - minDimEmu / 2 : x;
|
|
|
|
var x1 = flipH ? "100%" : "0";
|
|
var y1 = flipV ? "100%" : "0";
|
|
var x2 = flipH ? "0" : "100%";
|
|
var y2 = flipV ? "0" : "100%";
|
|
|
|
// For straight lines (one dimension is 0), draw from center
|
|
string svgY1, svgY2, svgX1, svgX2;
|
|
if (cy == 0)
|
|
{
|
|
// Horizontal line: draw at vertical center
|
|
svgX1 = flipH ? "100%" : "0";
|
|
svgX2 = flipH ? "0" : "100%";
|
|
svgY1 = svgY2 = "50%";
|
|
}
|
|
else if (cx == 0)
|
|
{
|
|
// Vertical line: draw at horizontal center
|
|
svgX1 = svgX2 = "50%";
|
|
svgY1 = flipV ? "100%" : "0";
|
|
svgY2 = flipV ? "0" : "100%";
|
|
}
|
|
else
|
|
{
|
|
svgX1 = x1; svgY1 = y1; svgX2 = x2; svgY2 = y2;
|
|
}
|
|
|
|
// Dash pattern
|
|
var dashAttr = "";
|
|
var prstDash = outline?.GetFirstChild<Drawing.PresetDash>();
|
|
if (prstDash?.Val?.HasValue == true)
|
|
{
|
|
var dashVal = prstDash.Val.InnerText;
|
|
var dashArray = dashVal switch
|
|
{
|
|
"dash" or "lgDash" => $"{lineWidth * 4:0.##},{lineWidth * 3:0.##}",
|
|
"sysDash" => $"{lineWidth * 3:0.##},{lineWidth * 1:0.##}",
|
|
"dot" or "sysDot" => $"{lineWidth * 1:0.##},{lineWidth * 2:0.##}",
|
|
"dashDot" => $"{lineWidth * 4:0.##},{lineWidth * 2:0.##},{lineWidth * 1:0.##},{lineWidth * 2:0.##}",
|
|
"lgDashDot" => $"{lineWidth * 6:0.##},{lineWidth * 2:0.##},{lineWidth * 1:0.##},{lineWidth * 2:0.##}",
|
|
"lgDashDotDot" => $"{lineWidth * 6:0.##},{lineWidth * 2:0.##},{lineWidth * 1:0.##},{lineWidth * 2:0.##},{lineWidth * 1:0.##},{lineWidth * 2:0.##}",
|
|
_ => ""
|
|
};
|
|
if (!string.IsNullOrEmpty(dashArray))
|
|
dashAttr = $" stroke-dasharray=\"{dashArray}\"";
|
|
}
|
|
|
|
// Arrow markers
|
|
var headEnd = outline?.GetFirstChild<Drawing.HeadEnd>();
|
|
var tailEnd = outline?.GetFirstChild<Drawing.TailEnd>();
|
|
var hasHead = headEnd?.Type?.HasValue == true && headEnd.Type.InnerText != "none";
|
|
var hasTail = tailEnd?.Type?.HasValue == true && tailEnd.Type.InnerText != "none";
|
|
var markerDefs = "";
|
|
var markerStartAttr = "";
|
|
var markerEndAttr = "";
|
|
var safeColor = CssSanitizeColor(lineColor);
|
|
|
|
if (hasHead || hasTail)
|
|
{
|
|
var arrowSize = Math.Max(3, lineWidth * 3);
|
|
var defs = new StringBuilder();
|
|
defs.Append("<defs>");
|
|
// Both markers use a right-pointing triangle with tip at (arrowSize, arrowSize/2).
|
|
// For marker-start we use orient="auto-start-reverse" so SVG flips the right-pointing
|
|
// triangle to point outward (leftward) at the line's start. Authoring both markers
|
|
// with the same geometry avoids a past bug where the head marker was authored
|
|
// leftward-pointing and the reverse flipped it inward on straight connectors.
|
|
if (hasHead)
|
|
{
|
|
defs.Append($"<marker id=\"ah\" markerWidth=\"{arrowSize:0.#}\" markerHeight=\"{arrowSize:0.#}\" refX=\"{arrowSize:0.#}\" refY=\"{arrowSize / 2:0.#}\" orient=\"auto-start-reverse\"><polygon points=\"0 0,{arrowSize:0.#} {arrowSize / 2:0.#},0 {arrowSize:0.#}\" fill=\"{safeColor}\"/></marker>");
|
|
markerStartAttr = " marker-start=\"url(#ah)\"";
|
|
}
|
|
if (hasTail)
|
|
{
|
|
defs.Append($"<marker id=\"at\" markerWidth=\"{arrowSize:0.#}\" markerHeight=\"{arrowSize:0.#}\" refX=\"{arrowSize:0.#}\" refY=\"{arrowSize / 2:0.#}\" orient=\"auto\"><polygon points=\"0 0,{arrowSize:0.#} {arrowSize / 2:0.#},0 {arrowSize:0.#}\" fill=\"{safeColor}\"/></marker>");
|
|
markerEndAttr = " marker-end=\"url(#at)\"";
|
|
}
|
|
defs.Append("</defs>");
|
|
markerDefs = defs.ToString();
|
|
}
|
|
|
|
// Branch on preset geometry: straightConnectorN -> line; bentConnectorN -> polyline;
|
|
// curvedConnectorN -> cubic bezier path. Falls back to straight line for unknown presets.
|
|
var prstGeom = cxn.ShapeProperties?.GetFirstChild<Drawing.PresetGeometry>();
|
|
var preset = prstGeom?.Preset?.HasValue == true ? prstGeom.Preset.InnerText : "straightConnector1";
|
|
|
|
// CONSISTENCY(shape-stroke-unit): stroke-width in pt matches CSS border path (see R3 fix).
|
|
var strokeAttrs = $"stroke=\"{safeColor}\" stroke-width=\"{lineWidth:0.##}pt\" fill=\"none\"{dashAttr}{markerStartAttr}{markerEndAttr}";
|
|
|
|
var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\"";
|
|
sb.AppendLine($" <div class=\"connector\"{dataPathAttr} style=\"left:{Units.EmuToPt(renderX)}pt;top:{Units.EmuToPt(renderY)}pt;width:{widthPt}pt;height:{heightPt}pt\">");
|
|
|
|
if (preset.StartsWith("bentConnector", StringComparison.Ordinal))
|
|
{
|
|
// Bent connectors: right-angle polyline. Use viewBox=0..100 so stretched
|
|
// preserveAspectRatio=none fills the container.
|
|
// bentConnector2: single 90-degree bend (2 segments, 3 points).
|
|
// bentConnector3 (default): 3 segments with mid bend — (0,0) -> (50,0) -> (50,100) -> (100,100).
|
|
// bentConnector4/5: approximate with 25/75 splits when no adjustments set.
|
|
string points = preset switch
|
|
{
|
|
"bentConnector2" => "0,0 100,0 100,100",
|
|
"bentConnector4" or "bentConnector5" => "0,0 25,0 25,50 75,50 75,100 100,100",
|
|
_ => "0,0 50,0 50,100 100,100", // bentConnector3
|
|
};
|
|
sb.AppendLine(" <svg width=\"100%\" height=\"100%\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\" style=\"overflow:visible\">");
|
|
if (!string.IsNullOrEmpty(markerDefs))
|
|
sb.AppendLine($" {markerDefs}");
|
|
sb.AppendLine($" <polyline points=\"{points}\" {strokeAttrs}/>");
|
|
sb.AppendLine(" </svg>");
|
|
}
|
|
else if (preset.StartsWith("curvedConnector", StringComparison.Ordinal))
|
|
{
|
|
// Curved connectors: cubic bezier S-curve. Author in 0..100 viewBox.
|
|
// curvedConnector3 default: M 0,0 C 50,0 50,100 100,100 (horizontal-entry S).
|
|
string d = preset switch
|
|
{
|
|
"curvedConnector2" => "M 0,0 Q 100,0 100,100",
|
|
"curvedConnector4" or "curvedConnector5" => "M 0,0 C 25,0 25,50 50,50 C 75,50 75,100 100,100",
|
|
_ => "M 0,0 C 50,0 50,100 100,100", // curvedConnector3
|
|
};
|
|
sb.AppendLine(" <svg width=\"100%\" height=\"100%\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\" style=\"overflow:visible\">");
|
|
if (!string.IsNullOrEmpty(markerDefs))
|
|
sb.AppendLine($" {markerDefs}");
|
|
sb.AppendLine($" <path d=\"{d}\" {strokeAttrs}/>");
|
|
sb.AppendLine(" </svg>");
|
|
}
|
|
else
|
|
{
|
|
sb.AppendLine(" <svg width=\"100%\" height=\"100%\" preserveAspectRatio=\"none\" style=\"overflow:visible\">");
|
|
if (!string.IsNullOrEmpty(markerDefs))
|
|
sb.AppendLine($" {markerDefs}");
|
|
sb.AppendLine($" <line x1=\"{svgX1}\" y1=\"{svgY1}\" x2=\"{svgX2}\" y2=\"{svgY2}\" {strokeAttrs}/>");
|
|
sb.AppendLine(" </svg>");
|
|
}
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
|
|
// ==================== Group Rendering ====================
|
|
|
|
private void RenderGroup(StringBuilder sb, GroupShape grp, SlidePart slidePart, Dictionary<string, string> themeColors, string? dataPath = null)
|
|
{
|
|
var grpXfrm = grp.GroupShapeProperties?.TransformGroup;
|
|
if (grpXfrm?.Offset == null || grpXfrm?.Extents == null) return;
|
|
|
|
var x = grpXfrm.Offset.X?.Value ?? 0;
|
|
var y = grpXfrm.Offset.Y?.Value ?? 0;
|
|
var cx = grpXfrm.Extents.Cx?.Value ?? 0;
|
|
var cy = grpXfrm.Extents.Cy?.Value ?? 0;
|
|
|
|
// Child offset/extents for coordinate transformation
|
|
var childOff = grpXfrm.ChildOffset;
|
|
var childExt = grpXfrm.ChildExtents;
|
|
var scaleX = (childExt?.Cx?.Value ?? cx) != 0 ? (double)cx / (childExt?.Cx?.Value ?? cx) : 1.0;
|
|
var scaleY = (childExt?.Cy?.Value ?? cy) != 0 ? (double)cy / (childExt?.Cy?.Value ?? cy) : 1.0;
|
|
var offX = childOff?.X?.Value ?? 0;
|
|
var offY = childOff?.Y?.Value ?? 0;
|
|
|
|
// Group is selected as a whole. Children inside the group don't get their own
|
|
// data-path because nested @id= addressing isn't currently supported by
|
|
// ResolveIdPath — clicks inside walk up via closest('[data-path]') and select
|
|
// the group container.
|
|
var dataPathAttr = string.IsNullOrEmpty(dataPath) ? "" : $" data-path=\"{HtmlEncode(dataPath)}\"";
|
|
// CONSISTENCY(group-rotation): match single-shape rotation idiom from RenderShape
|
|
// (transform:rotate(Ndeg)). OOXML group rotation rotates children as a composite
|
|
// around the group's bounding-box center; CSS default transform-origin (50% 50%)
|
|
// matches this.
|
|
var grpTransform = "";
|
|
if (grpXfrm?.Rotation != null && grpXfrm.Rotation.Value != 0)
|
|
grpTransform = $";transform:rotate({grpXfrm.Rotation.Value / 60000.0:0.##}deg)";
|
|
sb.AppendLine($" <div class=\"group\"{dataPathAttr} style=\"left:{Units.EmuToPt(x)}pt;top:{Units.EmuToPt(y)}pt;width:{Units.EmuToPt(cx)}pt;height:{Units.EmuToPt(cy)}pt{grpTransform}\">");
|
|
|
|
foreach (var child in grp.ChildElements)
|
|
{
|
|
switch (child)
|
|
{
|
|
case Shape shape:
|
|
{
|
|
var pos = CalcGroupChildPos(shape.ShapeProperties?.Transform2D, offX, offY, scaleX, scaleY);
|
|
if (pos.HasValue)
|
|
RenderShape(sb, shape, slidePart, themeColors, pos);
|
|
break;
|
|
}
|
|
case Picture pic:
|
|
{
|
|
var pos = CalcGroupChildPos(pic.ShapeProperties?.Transform2D, offX, offY, scaleX, scaleY);
|
|
if (pos.HasValue)
|
|
RenderPicture(sb, pic, slidePart, themeColors, pos);
|
|
break;
|
|
}
|
|
case GroupShape nestedGrp:
|
|
{
|
|
// Nested group: calculate the group's own position within parent group
|
|
var nestedXfrm = nestedGrp.GroupShapeProperties?.TransformGroup;
|
|
if (nestedXfrm?.Offset != null && nestedXfrm?.Extents != null)
|
|
{
|
|
var nx = (long)((( nestedXfrm.Offset.X?.Value ?? 0) - offX) * scaleX);
|
|
var ny = (long)(((nestedXfrm.Offset.Y?.Value ?? 0) - offY) * scaleY);
|
|
var ncx = (long)((nestedXfrm.Extents.Cx?.Value ?? 0) * scaleX);
|
|
var ncy = (long)((nestedXfrm.Extents.Cy?.Value ?? 0) * scaleY);
|
|
RenderNestedGroup(sb, nestedGrp, slidePart, themeColors, nx, ny, ncx, ncy);
|
|
}
|
|
break;
|
|
}
|
|
case ConnectionShape cxn:
|
|
{
|
|
RenderConnector(sb, cxn, themeColors);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure calculation: compute adjusted coordinates for a group child element.
|
|
/// Returns null if the element has no transform. NEVER modifies the original element.
|
|
/// </summary>
|
|
private static (long x, long y, long cx, long cy)? CalcGroupChildPos(
|
|
Drawing.Transform2D? xfrm, long offX, long offY, double scaleX, double scaleY)
|
|
{
|
|
if (xfrm?.Offset == null || xfrm?.Extents == null) return null;
|
|
|
|
var origX = xfrm.Offset.X?.Value ?? 0;
|
|
var origY = xfrm.Offset.Y?.Value ?? 0;
|
|
var origCx = xfrm.Extents.Cx?.Value ?? 0;
|
|
var origCy = xfrm.Extents.Cy?.Value ?? 0;
|
|
|
|
return (
|
|
(long)((origX - offX) * scaleX),
|
|
(long)((origY - offY) * scaleY),
|
|
(long)(origCx * scaleX),
|
|
(long)(origCy * scaleY)
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Render a nested group with pre-calculated position (from parent group transform).
|
|
/// Recursively handles arbitrary nesting depth.
|
|
/// </summary>
|
|
private void RenderNestedGroup(StringBuilder sb, GroupShape grp, SlidePart slidePart,
|
|
Dictionary<string, string> themeColors, long x, long y, long cx, long cy)
|
|
{
|
|
var grpXfrm = grp.GroupShapeProperties?.TransformGroup;
|
|
|
|
// Child coordinate system of this nested group
|
|
var childOff = grpXfrm?.ChildOffset;
|
|
var childExt = grpXfrm?.ChildExtents;
|
|
var scaleX = (childExt?.Cx?.Value ?? cx) != 0 ? (double)cx / (childExt?.Cx?.Value ?? cx) : 1.0;
|
|
var scaleY = (childExt?.Cy?.Value ?? cy) != 0 ? (double)cy / (childExt?.Cy?.Value ?? cy) : 1.0;
|
|
var offX = childOff?.X?.Value ?? 0;
|
|
var offY = childOff?.Y?.Value ?? 0;
|
|
|
|
// CONSISTENCY(group-rotation): same idiom as RenderGroup
|
|
var grpTransform = "";
|
|
if (grpXfrm?.Rotation != null && grpXfrm.Rotation.Value != 0)
|
|
grpTransform = $";transform:rotate({grpXfrm.Rotation.Value / 60000.0:0.##}deg)";
|
|
sb.AppendLine($" <div class=\"group\" style=\"left:{Units.EmuToPt(x)}pt;top:{Units.EmuToPt(y)}pt;width:{Units.EmuToPt(cx)}pt;height:{Units.EmuToPt(cy)}pt{grpTransform}\">");
|
|
|
|
foreach (var child in grp.ChildElements)
|
|
{
|
|
switch (child)
|
|
{
|
|
case Shape shape:
|
|
{
|
|
var pos = CalcGroupChildPos(shape.ShapeProperties?.Transform2D, offX, offY, scaleX, scaleY);
|
|
if (pos.HasValue)
|
|
RenderShape(sb, shape, slidePart, themeColors, pos);
|
|
break;
|
|
}
|
|
case Picture pic:
|
|
{
|
|
var pos = CalcGroupChildPos(pic.ShapeProperties?.Transform2D, offX, offY, scaleX, scaleY);
|
|
if (pos.HasValue)
|
|
RenderPicture(sb, pic, slidePart, themeColors, pos);
|
|
break;
|
|
}
|
|
case GroupShape nestedGrp:
|
|
{
|
|
var nestedXfrm = nestedGrp.GroupShapeProperties?.TransformGroup;
|
|
if (nestedXfrm?.Offset != null && nestedXfrm?.Extents != null)
|
|
{
|
|
var nx = (long)(((nestedXfrm.Offset.X?.Value ?? 0) - offX) * scaleX);
|
|
var ny = (long)(((nestedXfrm.Offset.Y?.Value ?? 0) - offY) * scaleY);
|
|
var ncx = (long)((nestedXfrm.Extents.Cx?.Value ?? 0) * scaleX);
|
|
var ncy = (long)((nestedXfrm.Extents.Cy?.Value ?? 0) * scaleY);
|
|
RenderNestedGroup(sb, nestedGrp, slidePart, themeColors, nx, ny, ncx, ncy);
|
|
}
|
|
break;
|
|
}
|
|
case ConnectionShape cxn:
|
|
RenderConnector(sb, cxn, themeColors);
|
|
break;
|
|
}
|
|
}
|
|
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
|
|
// ==================== AlternateContent (3D Model, Zoom) Rendering ====================
|
|
|
|
/// <summary>
|
|
/// Render mc:AlternateContent elements. For 3D models, embeds the GLB as base64
|
|
/// and uses Three.js to render it interactively in the browser.
|
|
/// </summary>
|
|
private static void RenderAlternateContent(StringBuilder sb, OpenXmlElement acElement,
|
|
SlidePart slidePart, Dictionary<string, string> themeColors)
|
|
{
|
|
var isModel3D = acElement.Descendants().Any(d => d.LocalName == "model3d");
|
|
var isZoom = acElement.Descendants().Any(d => d.LocalName == "sldZm");
|
|
if (!isModel3D && !isZoom) return;
|
|
|
|
// Extract position from mc:Choice > graphicFrame/sp > xfrm
|
|
var choice = acElement.ChildElements.FirstOrDefault(e => e.LocalName == "Choice");
|
|
var frame = choice?.ChildElements.FirstOrDefault(e =>
|
|
e.LocalName == "graphicFrame" || e.LocalName == "sp");
|
|
var xfrm = frame?.ChildElements.FirstOrDefault(e => e.LocalName == "xfrm");
|
|
xfrm ??= frame?.Descendants().FirstOrDefault(e =>
|
|
e.LocalName == "xfrm" && e.Parent?.LocalName == (frame?.LocalName == "sp" ? "spPr" : frame?.LocalName));
|
|
if (xfrm == null) return;
|
|
|
|
var off = xfrm.ChildElements.FirstOrDefault(e => e.LocalName == "off");
|
|
var ext = xfrm.ChildElements.FirstOrDefault(e => e.LocalName == "ext");
|
|
if (off == null || ext == null) return;
|
|
|
|
long.TryParse(off.GetAttribute("x", "").Value, out var x);
|
|
long.TryParse(off.GetAttribute("y", "").Value, out var y);
|
|
long.TryParse(ext.GetAttribute("cx", "").Value, out var cx);
|
|
long.TryParse(ext.GetAttribute("cy", "").Value, out var cy);
|
|
if (cx == 0 || cy == 0) return;
|
|
|
|
var leftPt = Units.EmuToPt(x);
|
|
var topPt = Units.EmuToPt(y);
|
|
var widthPt2 = Units.EmuToPt(cx);
|
|
var heightPt2 = Units.EmuToPt(cy);
|
|
|
|
if (isModel3D)
|
|
{
|
|
RenderModel3D(sb, acElement, slidePart, leftPt, topPt, widthPt2, heightPt2);
|
|
}
|
|
else
|
|
{
|
|
// Zoom: render fallback image
|
|
RenderZoomFallback(sb, acElement, slidePart, leftPt, topPt, widthPt2, heightPt2);
|
|
}
|
|
}
|
|
|
|
private static int _model3dCounter;
|
|
// Cache: part URI → JS variable name, to avoid embedding the same GLB multiple times
|
|
private static readonly Dictionary<string, string> _glbDataCache = new();
|
|
|
|
/// <summary>
|
|
/// Render a 3D model using Three.js with the embedded GLB data.
|
|
/// Same GLB files across slides are deduplicated — embedded once, referenced by variable.
|
|
/// </summary>
|
|
private static void RenderModel3D(StringBuilder sb, OpenXmlElement acElement,
|
|
SlidePart slidePart, double leftPt, double topPt, double widthPt, double heightPt)
|
|
{
|
|
// Find the model3d element and get the GLB relationship
|
|
var model3d = acElement.Descendants().FirstOrDefault(d => d.LocalName == "model3d");
|
|
if (model3d == null) return;
|
|
|
|
var rNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
var embedId = model3d.GetAttribute("embed", rNs).Value;
|
|
if (string.IsNullOrEmpty(embedId)) return;
|
|
|
|
// Deduplicate: use content hash so identical GLBs across slides share one copy
|
|
string glbVarName;
|
|
try
|
|
{
|
|
var part = slidePart.GetPartById(embedId);
|
|
using var stream = part.GetStream();
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
var bytes = ms.ToArray();
|
|
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes))[..16];
|
|
if (!_glbDataCache.TryGetValue(hash, out glbVarName!))
|
|
{
|
|
glbVarName = $"_glb{_glbDataCache.Count}";
|
|
sb.AppendLine($"<script>window.{glbVarName}='{Convert.ToBase64String(bytes)}';</script>");
|
|
_glbDataCache[hash] = glbVarName;
|
|
}
|
|
}
|
|
catch { return; }
|
|
|
|
var canvasId = $"model3d_{_model3dCounter++}";
|
|
|
|
// Extract rotation from am3d:rot
|
|
var rot = model3d.Descendants().FirstOrDefault(d => d.LocalName == "rot");
|
|
double rotX = 0, rotY = 0, rotZ = 0;
|
|
if (rot != null)
|
|
{
|
|
var ax = rot.GetAttribute("ax", "").Value;
|
|
var ay = rot.GetAttribute("ay", "").Value;
|
|
var az = rot.GetAttribute("az", "").Value;
|
|
if (!string.IsNullOrEmpty(ax) && int.TryParse(ax, out var axv)) rotX = axv / 60000.0 * Math.PI / 180.0;
|
|
if (!string.IsNullOrEmpty(ay) && int.TryParse(ay, out var ayv)) rotY = ayv / 60000.0 * Math.PI / 180.0;
|
|
if (!string.IsNullOrEmpty(az) && int.TryParse(az, out var azv)) rotZ = azv / 60000.0 * Math.PI / 180.0;
|
|
}
|
|
|
|
// Extract fallback image from mc:Fallback for WebGL-unavailable environments
|
|
string? fallbackImgSrc = null;
|
|
var fallback = acElement.ChildElements.FirstOrDefault(e => e.LocalName == "Fallback");
|
|
var fbBlip = fallback?.Descendants().FirstOrDefault(d => d.LocalName == "blip");
|
|
if (fbBlip != null)
|
|
{
|
|
var fbRNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
var fbEmbedId = fbBlip.GetAttribute("embed", fbRNs).Value;
|
|
if (!string.IsNullOrEmpty(fbEmbedId))
|
|
{
|
|
try
|
|
{
|
|
var fbPart = slidePart.GetPartById(fbEmbedId);
|
|
using var fbStream = fbPart.GetStream();
|
|
using var fbMs = new MemoryStream();
|
|
fbStream.CopyTo(fbMs);
|
|
var fbBytes = fbMs.ToArray();
|
|
if (fbBytes.Length > 200)
|
|
fallbackImgSrc = $"data:{fbPart.ContentType ?? "image/png"};base64,{Convert.ToBase64String(fbBytes)}";
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
|
|
var containerId = $"m3d_wrap_{canvasId}";
|
|
sb.AppendLine($" <div id=\"{containerId}\" style=\"position:absolute;" +
|
|
$"left:{leftPt:0.##}pt;top:{topPt:0.##}pt;" +
|
|
$"width:{widthPt:0.##}pt;height:{heightPt:0.##}pt;" +
|
|
$"overflow:hidden;\">");
|
|
sb.AppendLine($" <canvas id=\"{canvasId}\" style=\"width:100%;height:100%;\"></canvas>");
|
|
if (fallbackImgSrc != null)
|
|
sb.AppendLine($" <img class=\"m3d-fallback\" src=\"{fallbackImgSrc}\" style=\"width:100%;height:100%;object-fit:contain;display:none;\" />");
|
|
sb.AppendLine(" </div>");
|
|
|
|
sb.AppendLine($@" <script type=""module"">
|
|
let THREE, GLTFLoader;
|
|
try {{
|
|
THREE = await import('three');
|
|
({{ GLTFLoader }} = await import('three/addons/loaders/GLTFLoader.js'));
|
|
}} catch(e) {{
|
|
// Three.js unavailable (offline) — show fallback image
|
|
const c = document.getElementById('{canvasId}');
|
|
if (c) {{ c.style.display='none'; const fb=c.parentElement?.querySelector('.m3d-fallback'); if(fb) fb.style.display='block'; }}
|
|
throw e; // stop execution of this module
|
|
}}
|
|
(function() {{
|
|
const canvas = document.getElementById('{canvasId}');
|
|
if (!canvas) return;
|
|
const container = canvas.parentElement;
|
|
try {{
|
|
const designW = {widthPt:0.##} * 96 / 72;
|
|
const designH = {heightPt:0.##} * 96 / 72;
|
|
canvas.width = designW * 2; canvas.height = designH * 2;
|
|
canvas.style.width = '100%'; canvas.style.height = '100%';
|
|
|
|
const w = designW, h = designH;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const renderer = new THREE.WebGLRenderer({{ canvas, alpha: true, antialias: true }});
|
|
renderer.setSize(canvas.width / dpr, canvas.height / dpr);
|
|
renderer.setPixelRatio(dpr);
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 1000);
|
|
|
|
// Lighting (matches PowerPoint 3-point setup)
|
|
scene.add(new THREE.AmbientLight(0x808080, 0.8));
|
|
const key = new THREE.DirectionalLight(0xfff0e0, 1.2);
|
|
key.position.set(2, 3, 4);
|
|
scene.add(key);
|
|
const fill = new THREE.DirectionalLight(0x6090e0, 0.6);
|
|
fill.position.set(-3, 2, -1);
|
|
scene.add(fill);
|
|
const rim = new THREE.DirectionalLight(0xd0b0ff, 0.4);
|
|
rim.position.set(-1, 1, -3);
|
|
scene.add(rim);
|
|
|
|
// Load GLB from base64
|
|
const b64 = window.{glbVarName};
|
|
const bin = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
const loader = new GLTFLoader();
|
|
loader.parse(bin.buffer, '', (gltf) => {{
|
|
const model = gltf.scene;
|
|
// Center and fit model
|
|
const box = new THREE.Box3().setFromObject(model);
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const size = box.getSize(new THREE.Vector3());
|
|
model.position.sub(center);
|
|
const maxDim = Math.max(size.x, size.y, size.z);
|
|
const scale = 2.0 / maxDim;
|
|
model.scale.setScalar(scale);
|
|
// Apply rotation from PowerPoint
|
|
model.rotation.x = {rotX:F6};
|
|
model.rotation.y = {rotY:F6};
|
|
model.rotation.z = {rotZ:F6};
|
|
scene.add(model);
|
|
// Position camera
|
|
camera.position.set(0, 0, 3.2);
|
|
camera.lookAt(0, 0, 0);
|
|
// Auto-rotate animation
|
|
let baseRotY = {rotY:F6};
|
|
function animate() {{
|
|
requestAnimationFrame(animate);
|
|
baseRotY += 0.003;
|
|
model.rotation.y = baseRotY;
|
|
renderer.render(scene, camera);
|
|
}}
|
|
animate();
|
|
}});
|
|
}} catch(e) {{
|
|
// WebGL unavailable — show fallback image
|
|
canvas.style.display = 'none';
|
|
const fb = container?.querySelector('.m3d-fallback');
|
|
if (fb) fb.style.display = 'block';
|
|
}}
|
|
}})();
|
|
</script>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Render a zoom element using its fallback image.
|
|
/// </summary>
|
|
private static void RenderZoomFallback(StringBuilder sb, OpenXmlElement acElement,
|
|
SlidePart slidePart, double leftPt, double topPt, double widthPt, double heightPt)
|
|
{
|
|
var fallback = acElement.ChildElements.FirstOrDefault(e => e.LocalName == "Fallback");
|
|
var fbBlip = fallback?.Descendants().FirstOrDefault(d => d.LocalName == "blip");
|
|
string? imgSrc = null;
|
|
if (fbBlip != null)
|
|
{
|
|
var rNs = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
var embedId = fbBlip.GetAttribute("embed", rNs).Value;
|
|
if (!string.IsNullOrEmpty(embedId))
|
|
{
|
|
try
|
|
{
|
|
var part = slidePart.GetPartById(embedId);
|
|
using var stream = part.GetStream();
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
var bytes = ms.ToArray();
|
|
if (bytes.Length > 200)
|
|
imgSrc = $"data:{part.ContentType ?? "image/png"};base64,{Convert.ToBase64String(bytes)}";
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
|
|
sb.AppendLine($" <div style=\"position:absolute;" +
|
|
$"left:{leftPt:0.##}pt;top:{topPt:0.##}pt;" +
|
|
$"width:{widthPt:0.##}pt;height:{heightPt:0.##}pt;" +
|
|
$"border:2px dashed rgba(255,193,7,0.6);border-radius:8px;" +
|
|
$"overflow:hidden;\">");
|
|
if (imgSrc != null)
|
|
sb.AppendLine($" <img src=\"{imgSrc}\" style=\"width:100%;height:100%;object-fit:contain;\" />");
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
}
|