// Copyright 2026 OfficeCLI (https://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 OfficeCli.Core.TableStyles;
using Drawing = DocumentFormat.OpenXml.Drawing;

namespace OfficeCli.Handlers;

public partial class PowerPointHandler
{
    // EMU to pixel conversion: 1 inch = 914400 EMU = 192 px (2x 96 DPI for retina)
    // So 1 px = 914400 / 192 = 4762.5 EMU
    // But to match officeshot's 1920×1080 from standard 10"×7.5" slides:
    //   10 inches * 914400 = 9144000 EMU → 1920 px → 1 px = 4762.5 EMU
    // Standard 13.333" × 7.5" (widescreen): 12192000 × 6858000 EMU → 1920 × 1080
    //   1 px = 12192000 / 1920 = 6350 EMU
    private const double EmuPerPx = 6350.0;

    private static double EmuToPx(long emu) => Math.Round(emu / EmuPerPx, 2);
    private static double EmuToPx(double emu) => Math.Round(emu / EmuPerPx, 2);

    /// <summary>
    /// Generate a self-contained native SVG for a single slide.
    /// ViewBox uses pixel coordinates (matching officeshot 1920×1080 output).
    /// </summary>
    public string ViewAsSvg(int slideNum)
    {
        using var _cul = InvariantCultureScope.Enter();
        var slideParts = GetSlideParts().ToList();
        if (slideNum < 1 || slideNum > slideParts.Count)
            throw new CliException($"Slide {slideNum} does not exist. This presentation has {slideParts.Count} slide(s).")
            {
                Code = "out_of_range",
                Suggestion = $"Use a slide number between 1 and {slideParts.Count}."
            };

        var slidePart = slideParts[slideNum - 1];
        var (slideWidthEmu, slideHeightEmu) = GetSlideSize();
        var themeColors = ResolveThemeColorMap();

        double svgW = EmuToPx(slideWidthEmu);
        double svgH = EmuToPx(slideHeightEmu);

        var sb = new StringBuilder();
        var defsBuilder = new StringBuilder();
        int defIdCounter = 0;

        sb.AppendLine($"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"");
        sb.AppendLine($"     width=\"{svgW:0.##}\" height=\"{svgH:0.##}\"");
        sb.AppendLine($"     viewBox=\"0 0 {svgW:0.##} {svgH:0.##}\">");

        const string defsPlaceholder = "<!--DEFS_PLACEHOLDER-->";
        sb.AppendLine(defsPlaceholder);

        // Slide background
        var bgColor = GetSlideBackgroundSvgColor(slidePart, themeColors);
        sb.AppendLine($"<rect width=\"{svgW:0.##}\" height=\"{svgH:0.##}\" fill=\"{bgColor}\"/>");

        // Render layout/master placeholders
        RenderLayoutPlaceholdersSvg(sb, defsBuilder, ref defIdCounter, slidePart, themeColors);

        // Render slide elements
        RenderSlideElementsSvg(sb, defsBuilder, ref defIdCounter, slidePart, slideNum, slideWidthEmu, slideHeightEmu, themeColors);

        sb.AppendLine("</svg>");

        // Insert accumulated defs
        var result = sb.ToString();
        var defsContent = defsBuilder.ToString();
        if (!string.IsNullOrEmpty(defsContent))
            result = result.Replace(defsPlaceholder, $"<defs>\n{defsContent}</defs>");
        else
            result = result.Replace(defsPlaceholder, "");

        return result;
    }

    private string GetSlideBackgroundSvgColor(SlidePart slidePart, Dictionary<string, string> themeColors)
    {
        var bg = GetSlide(slidePart).CommonSlideData?.Background;
        if (bg != null)
        {
            var bgPr = bg.BackgroundProperties;
            if (bgPr != null)
            {
                var solidFill = bgPr.GetFirstChild<Drawing.SolidFill>();
                var color = ResolveFillColor(solidFill, themeColors);
                if (color != null) return color;
            }
        }
        return "white";
    }

    private void RenderSlideElementsSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        SlidePart slidePart, int slideNum, long slideWidthEmu, long slideHeightEmu,
        Dictionary<string, string> themeColors)
    {
        var shapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree;
        if (shapeTree == null) return;

        foreach (var element in shapeTree.ChildElements)
        {
            switch (element)
            {
                case Shape shape:
                    RenderShapeSvg(sb, defs, ref defId, shape, slidePart, themeColors);
                    break;
                case ConnectionShape cxn:
                    RenderConnectorSvg(sb, defs, ref defId, cxn, themeColors);
                    break;
                case Picture pic:
                    RenderPictureSvg(sb, defs, ref defId, pic, slidePart, themeColors);
                    break;
                case GraphicFrame gf:
                    if (gf.Descendants<Drawing.Table>().Any())
                        RenderTableSvg(sb, defs, ref defId, gf, themeColors);
                    else if (gf.Descendants().Any(e => e.LocalName == "chart" && e.NamespaceUri.Contains("chart")))
                        RenderChartSvg(sb, gf, slidePart, themeColors);
                    break;
                case GroupShape grp:
                    RenderGroupSvg(sb, defs, ref defId, grp, slidePart, themeColors);
                    break;
                default:
                    // mc:AlternateContent (e.g. an a14 math text box) — render the
                    // shapes inside mc:Choice/mc:Fallback instead of dropping the
                    // whole element (#228, same drop as the HTML preview had).
                    if (element.LocalName == "AlternateContent")
                        RenderAltContentSvg(sb, defs, ref defId, element, slidePart, themeColors);
                    break;
                // TODO: Chart
            }
        }
    }

    // Shapes wrapped in mc:AlternateContent. Prefer mc:Choice (full-fidelity
    // content) over mc:Fallback; route each inner element through the normal
    // SVG dispatch. overrideOffScale carries the enclosing group's child
    // coordinate projection (CONSISTENCY(group-child-pos)); null at slide level.
    private void RenderAltContentSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        OpenXmlElement acElement, SlidePart slidePart, Dictionary<string, string> themeColors,
        (long offX, long offY, double scaleX, double scaleY)? overrideOffScale = null)
    {
        var altChild = acElement.ChildElements.FirstOrDefault(e => e.LocalName == "Choice")
                    ?? acElement.ChildElements.FirstOrDefault(e => e.LocalName == "Fallback");
        if (altChild == null) return;
        foreach (var inner in altChild.ChildElements)
        {
            // CONSISTENCY(mc-alt-coerce): see CoerceAltContentChild — untyped
            // inside groups, typed at spTree level.
            switch (CoerceAltContentChild(inner))
            {
                case Shape shape:
                {
                    (long x, long y, long cx, long cy)? pos = null;
                    if (overrideOffScale is (var ox, var oy, var sx, var sy))
                    {
                        pos = CalcGroupChildPos(shape.ShapeProperties?.Transform2D, ox, oy, sx, sy);
                        if (!pos.HasValue) break;
                    }
                    RenderShapeSvg(sb, defs, ref defId, shape, slidePart, themeColors, pos);
                    break;
                }
                case Picture pic:
                {
                    (long x, long y, long cx, long cy)? pos = null;
                    if (overrideOffScale is (var ox, var oy, var sx, var sy))
                    {
                        pos = CalcGroupChildPos(pic.ShapeProperties?.Transform2D, ox, oy, sx, sy);
                        if (!pos.HasValue) break;
                    }
                    RenderPictureSvg(sb, defs, ref defId, pic, slidePart, themeColors, pos);
                    break;
                }
                case ConnectionShape cxn:
                    RenderConnectorSvg(sb, defs, ref defId, cxn, themeColors);
                    break;
            }
        }
    }

    private void RenderLayoutPlaceholdersSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        SlidePart slidePart, Dictionary<string, string> themeColors)
    {
        var slidePlaceholders = new HashSet<string>();
        var slideShapeTree = GetSlide(slidePart).CommonSlideData?.ShapeTree;
        if (slideShapeTree != null)
        {
            foreach (var shape in slideShapeTree.Elements<Shape>())
            {
                var ph = shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties
                    ?.GetFirstChild<PlaceholderShape>();
                if (ph?.Index?.HasValue == true) slidePlaceholders.Add($"idx:{ph.Index.Value}");
                if (ph?.Type?.HasValue == true) slidePlaceholders.Add($"type:{ph.Type.InnerText}");
            }
        }

        var layoutPart = slidePart.SlideLayoutPart;
        if (layoutPart != null)
            RenderInheritedShapesSvg(sb, defs, ref defId, layoutPart.SlideLayout?.CommonSlideData?.ShapeTree,
                layoutPart, slidePlaceholders, themeColors);

        var masterPart = layoutPart?.SlideMasterPart;
        if (masterPart != null)
            RenderInheritedShapesSvg(sb, defs, ref defId, masterPart.SlideMaster?.CommonSlideData?.ShapeTree,
                masterPart, slidePlaceholders, themeColors);
    }

    private void RenderInheritedShapesSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        ShapeTree? shapeTree, OpenXmlPart part, HashSet<string> skipIndices,
        Dictionary<string, string> themeColors)
    {
        if (shapeTree == null) return;

        foreach (var element in shapeTree.ChildElements)
        {
            if (element is not Shape shape) continue;

            var ph = shape.NonVisualShapeProperties?.ApplicationNonVisualDrawingProperties
                ?.GetFirstChild<PlaceholderShape>();
            if (ph != null)
            {
                if (ph.Index?.HasValue == true && skipIndices.Contains($"idx:{ph.Index.Value}")) continue;
                if (ph.Type?.HasValue == true && skipIndices.Contains($"type:{ph.Type.InnerText}")) continue;
                if (string.IsNullOrWhiteSpace(GetShapeText(shape))) continue;
            }
            else
            {
                if (shape.ShapeProperties?.Transform2D == null) continue;
            }

            RenderShapeSvg(sb, defs, ref defId, shape, part, themeColors);
        }
    }

    // ==================== Shape Rendering (SVG) ====================

    private void RenderShapeSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        Shape shape, OpenXmlPart part, Dictionary<string, string> themeColors,
        (long x, long y, long cx, long cy)? overridePos = null)
    {
        var xfrm = shape.ShapeProperties?.Transform2D;

        long xEmu, yEmu, cxEmu, cyEmu;
        if (overridePos != null)
        {
            (xEmu, yEmu, cxEmu, cyEmu) = overridePos.Value;
        }
        else if (xfrm?.Offset != null && xfrm?.Extents != null)
        {
            xEmu = xfrm.Offset.X?.Value ?? 0;
            yEmu = xfrm.Offset.Y?.Value ?? 0;
            cxEmu = xfrm.Extents.Cx?.Value ?? 0;
            cyEmu = xfrm.Extents.Cy?.Value ?? 0;
        }
        else
        {
            var resolved = ResolveInheritedPosition(shape, part);
            if (resolved == null)
            {
                if (string.IsNullOrWhiteSpace(GetShapeText(shape))) return;
                resolved = GetDefaultPlaceholderPosition(shape, part);
                if (resolved == null) return;
            }
            (xEmu, yEmu, cxEmu, cyEmu) = resolved.Value;
        }

        if (cxEmu <= 0 || cyEmu <= 0) return;

        // Convert to px
        double x = EmuToPx(xEmu), y = EmuToPx(yEmu);
        double w = EmuToPx(cxEmu), h = EmuToPx(cyEmu);

        // Resolve fill
        var spPr = shape.ShapeProperties;
        string fillColor = "none";
        double fillOpacity = 1.0;
        string? gradientRef = null;
        ResolveSvgFillWithOpacity(spPr, part, themeColors, out fillColor, out fillOpacity, defs, ref defId, out gradientRef);

        // Resolve outline
        var outline = spPr?.GetFirstChild<Drawing.Outline>();
        string strokeColor = "none";
        double strokeWidth = 0;
        double strokeOpacity = 1.0;
        string strokeDasharray = "";
        if (outline != null && outline.GetFirstChild<Drawing.NoFill>() == null)
        {
            var outlineColor = ResolveFillColor(outline.GetFirstChild<Drawing.SolidFill>(), themeColors) ?? "#000000";
            ParseSvgColor(outlineColor, out strokeColor, out strokeOpacity);
            strokeWidth = EmuToPx(outline.Width?.HasValue == true ? outline.Width.Value : 12700);
            var dash = outline.GetFirstChild<Drawing.PresetDash>();
            if (dash?.Val?.HasValue == true)
            {
                var sw = strokeWidth;
                strokeDasharray = dash.Val.InnerText switch
                {
                    "dash" or "lgDash" or "sysDash" => $"{sw * 3:0.##} {sw * 2:0.##}",
                    "dot" or "sysDot" => $"{sw:0.##} {sw:0.##}",
                    "dashDot" or "lgDashDot" or "sysDashDot" => $"{sw * 3:0.##} {sw:0.##} {sw:0.##} {sw:0.##}",
                    _ => ""
                };
            }
        }

        // Build transform
        var transforms = new List<string>();
        transforms.Add($"translate({x:0.##},{y:0.##})");

        if (xfrm?.Rotation != null && xfrm.Rotation.Value != 0)
        {
            var deg = xfrm.Rotation.Value / 60000.0;
            transforms.Add($"rotate({deg:0.##},{w / 2:0.##},{h / 2:0.##})");
        }

        if (xfrm?.HorizontalFlip?.Value == true && xfrm.VerticalFlip?.Value == true)
            transforms.Add($"translate({w:0.##},{h:0.##}) scale(-1,-1)");
        else if (xfrm?.HorizontalFlip?.Value == true)
            transforms.Add($"translate({w:0.##},0) scale(-1,1)");
        else if (xfrm?.VerticalFlip?.Value == true)
            transforms.Add($"translate(0,{h:0.##}) scale(1,-1)");

        // Effects → SVG filters (shadow, glow, soft edge)
        var effectList = spPr?.GetFirstChild<Drawing.EffectList>();
        string? filterRef = null;
        if (effectList != null)
        {
            filterRef = BuildSvgShadowFilter(effectList, themeColors, ref defId, defs);
            if (filterRef == null)
                filterRef = BuildSvgGlowFilter(effectList, themeColors, ref defId, defs);
            if (filterRef == null)
                filterRef = BuildSvgSoftEdgeFilter(effectList, ref defId, defs);
        }

        // Bevel → approximate with inset highlight/shadow
        var sp3d = spPr?.GetFirstChild<Drawing.Shape3DType>();
        bool hasBevel = sp3d?.BevelTop != null;

        var gAttrs = $"transform=\"{string.Join(" ", transforms)}\"";
        if (filterRef != null)
            gAttrs += $" filter=\"url(#{filterRef})\"";
        sb.Append($"<g {gAttrs}>");

        // Resolve preset geometry for corner radius
        var presetGeom = spPr?.GetFirstChild<Drawing.PresetGeometry>();
        string presetName = presetGeom?.Preset?.InnerText ?? "rect";
        double rx = 0, ry = 0;
        if (presetName == "flowChartTerminator" || presetName == "flowChartAlternateProcess")
        {
            // Stadium/capsule shape — max border radius
            rx = ry = Math.Min(w, h) / 2;
        }
        else if (presetName is "roundRect" or "round1Rect" or "round2SameRect" or "round2DiagRect")
        {
            var minSide = Math.Min(cxEmu, cyEmu);
            long avVal = 16667; // default 16.667%
            var avList = presetGeom?.GetFirstChild<Drawing.AdjustValueList>();
            var gd = avList?.GetFirstChild<Drawing.ShapeGuide>();
            if (gd?.Formula?.Value != null && gd.Formula.Value.StartsWith("val "))
            {
                if (long.TryParse(gd.Formula.Value.AsSpan(4), out var parsed))
                    avVal = parsed;
            }
            var radiusEmu = minSide * avVal / 100000;
            rx = ry = EmuToPx(radiusEmu);
        }

        // Common fill/stroke attributes
        var fillValue = gradientRef != null ? $"url(#{gradientRef})" : fillColor;
        var fillStrokeAttrs = new List<string> { $"fill=\"{fillValue}\"" };
        if (fillOpacity < 1.0)
            fillStrokeAttrs.Add($"fill-opacity=\"{fillOpacity:0.##}\"");
        if (strokeColor != "none")
        {
            fillStrokeAttrs.Add($"stroke=\"{strokeColor}\"");
            fillStrokeAttrs.Add($"stroke-width=\"{strokeWidth:0.##}\"");
            if (strokeOpacity < 1.0)
                fillStrokeAttrs.Add($"stroke-opacity=\"{strokeOpacity:0.##}\"");
            if (!string.IsNullOrEmpty(strokeDasharray))
                fillStrokeAttrs.Add($"stroke-dasharray=\"{strokeDasharray}\"");
        }
        var fsStr = string.Join(" ", fillStrokeAttrs);

        // Draw shape based on geometry type
        var polygonPoints = GetPresetPolygonPoints(presetName, w, h, presetGeom);

        // CustomGeometry fallback — convert path to SVG polygon
        if (polygonPoints == null && presetName == "rect")
        {
            var custGeom = spPr?.GetFirstChild<Drawing.CustomGeometry>();
            if (custGeom != null)
            {
                var svgPath = CustomGeometryToSvgPath(custGeom, w, h);
                if (svgPath != null)
                {
                    sb.Append($"<path d=\"{svgPath}\" {fsStr}/>");
                    polygonPoints = "CUSTOM"; // flag to skip default rect
                }
            }
        }
        if (polygonPoints == "CUSTOM")
        {
            // Already rendered via CustomGeometry path above
        }
        else if (presetName is "flowChartConnector" or "flowChartOffpageConnector" or "smileyFace" or "smiley")
        {
            sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{h / 2:0.##}\" rx=\"{w / 2:0.##}\" ry=\"{h / 2:0.##}\" {fsStr}/>");
        }
        else if (presetName is "donut" or "noSmoking")
        {
            // Donut: hole size from adj value (default 50000 = 50% of outer radius)
            var donutAdj = ReadAdjValue(presetGeom, 0, 50000) / 100000.0;
            var outerRx = w / 2; var outerRy = h / 2;
            var innerRx = outerRx * donutAdj; var innerRy = outerRy * donutAdj;
            sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{h / 2:0.##}\" rx=\"{outerRx:0.##}\" ry=\"{outerRy:0.##}\" {fsStr}/>");
            sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{h / 2:0.##}\" rx=\"{innerRx:0.##}\" ry=\"{innerRy:0.##}\" fill=\"white\"/>");
        }
        else if (presetName is "can" or "cylinder")
        {
            // Cylinder: cap height from adj value (default 25000 = 25% of height)
            var canAdj = ReadAdjValue(presetGeom, 0, 25000) / 100000.0;
            var capH = h * canAdj;
            sb.Append($"<rect y=\"{capH:0.##}\" width=\"{w:0.##}\" height=\"{h - capH * 2:0.##}\" {fsStr}/>");
            sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{capH:0.##}\" rx=\"{w / 2:0.##}\" ry=\"{capH:0.##}\" {fsStr}/>");
            sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{h - capH:0.##}\" rx=\"{w / 2:0.##}\" ry=\"{capH:0.##}\" {fsStr}/>");
        }
        else if (presetName == "ellipse")
        {
            sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{h / 2:0.##}\" rx=\"{w / 2:0.##}\" ry=\"{h / 2:0.##}\" {fsStr}/>");
        }
        else if (polygonPoints != null)
        {
            sb.Append($"<polygon points=\"{polygonPoints}\" {fsStr}/>");
        }
        else
        {
            // rect / roundRect / other rect variants
            var rectExtra = "";
            if (rx > 0)
                rectExtra = $" rx=\"{rx:0.##}\" ry=\"{ry:0.##}\"";
            sb.Append($"<rect width=\"{w:0.##}\" height=\"{h:0.##}\"{rectExtra} {fsStr}/>");
        }

        // Bevel effect — inset highlight/shadow
        if (hasBevel)
        {
            var bevelW = sp3d!.BevelTop!.Width?.HasValue == true ? EmuToPx(sp3d.BevelTop.Width.Value) : 3;
            var bW = Math.Max(1, bevelW * 0.5);
            if (presetName == "ellipse")
            {
                sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{h / 2:0.##}\" rx=\"{w / 2 - bW:0.##}\" ry=\"{h / 2 - bW:0.##}\" fill=\"none\" stroke=\"rgba(255,255,255,0.25)\" stroke-width=\"{bW:0.##}\"/>");
            }
            else
            {
                sb.Append($"<rect x=\"{bW:0.##}\" y=\"{bW:0.##}\" width=\"{w - bW * 2:0.##}\" height=\"{h - bW * 2:0.##}\" fill=\"none\" stroke=\"rgba(255,255,255,0.2)\" stroke-width=\"{bW:0.##}\"{(rx > 0 ? $" rx=\"{rx - bW:0.##}\"" : "")}/>");
            }
        }

        // Reflection effect — clone shape flipped below
        var reflection = effectList?.GetFirstChild<Drawing.Reflection>();
        if (reflection != null)
        {
            var reflDist = EmuToPx(reflection.Distance?.HasValue == true ? reflection.Distance.Value : 0);
            var startOpacity = reflection.StartOpacity?.HasValue == true ? reflection.StartOpacity.Value / 100000.0 : 0.5;
            var reflId = $"refl{defId++}";
            defs.AppendLine($"<linearGradient id=\"{reflId}\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">");
            defs.AppendLine($"  <stop offset=\"0%\" stop-color=\"white\" stop-opacity=\"{startOpacity:0.##}\"/>");
            defs.AppendLine("  <stop offset=\"100%\" stop-color=\"white\" stop-opacity=\"0\"/>");
            defs.AppendLine("</linearGradient>");
            var maskId = $"rmask{defId++}";
            defs.AppendLine($"<mask id=\"{maskId}\"><rect width=\"{w:0.##}\" height=\"{h:0.##}\" fill=\"url(#{reflId})\"/></mask>");

            sb.Append($"<g transform=\"translate(0,{2 * h + reflDist:0.##}) scale(1,-1)\" mask=\"url(#{maskId})\" opacity=\"0.4\">");
            // Re-draw the shape geometry for reflection
            if (presetName == "ellipse")
                sb.Append($"<ellipse cx=\"{w / 2:0.##}\" cy=\"{h / 2:0.##}\" rx=\"{w / 2:0.##}\" ry=\"{h / 2:0.##}\" {fsStr}/>");
            else if (polygonPoints != null)
                sb.Append($"<polygon points=\"{polygonPoints}\" {fsStr}/>");
            else
                sb.Append($"<rect width=\"{w:0.##}\" height=\"{h:0.##}\"{(rx > 0 ? $" rx=\"{rx:0.##}\"" : "")} {fsStr}/>");
            sb.Append("</g>");
        }

        // Text content
        if (shape.TextBody != null)
        {
            var bodyPr = shape.TextBody.Elements<Drawing.BodyProperties>().FirstOrDefault();
            double lIns = EmuToPx(bodyPr?.LeftInset?.Value ?? 91440);
            double tIns = EmuToPx(bodyPr?.TopInset?.Value ?? 45720);
            double rIns = EmuToPx(bodyPr?.RightInset?.Value ?? 91440);
            double bIns = EmuToPx(bodyPr?.BottomInset?.Value ?? 45720);

            var valign = "top";
            if (bodyPr?.Anchor?.HasValue == true)
            {
                valign = bodyPr.Anchor.InnerText switch
                {
                    "ctr" => "center",
                    "b" => "bottom",
                    _ => "top"
                };
            }

            // PowerPoint mirrors text along with the shape on flipH/flipV. The
            // shape-level flip transform (lines ~267-271) already applies to the
            // inner text, so do not counter-flip here. An earlier implementation
            // counter-flipped to keep text upright; that diverged from real
            // PowerPoint output (e.g. flipH renders "AI" as "IA").
            RenderTextBodyFO(sb, shape.TextBody, themeColors, w, h,
                lIns, tIns, rIns, bIns, valign, shape, part);
        }

        sb.AppendLine("</g>");
    }

    // ==================== Fill Resolution (SVG) ====================

    /// <summary>
    /// Resolve fill color for SVG, separating color and opacity.
    /// Also handles gradient fills by creating SVG gradient definitions.
    /// </summary>
    private static void ResolveSvgFillWithOpacity(ShapeProperties? spPr, OpenXmlPart part,
        Dictionary<string, string> themeColors, out string color, out double opacity,
        StringBuilder defs, ref int defId, out string? gradientRef)
    {
        color = "none";
        opacity = 1.0;
        gradientRef = null;
        if (spPr == null) return;

        if (spPr.GetFirstChild<Drawing.NoFill>() != null)
            return;

        var solidFill = spPr.GetFirstChild<Drawing.SolidFill>();
        if (solidFill != null)
        {
            var resolved = ResolveFillColor(solidFill, themeColors);
            if (resolved != null)
            {
                ParseSvgColor(resolved, out color, out opacity);
                return;
            }
        }

        // Gradient fill
        var gradFill = spPr.GetFirstChild<Drawing.GradientFill>();
        if (gradFill != null)
        {
            gradientRef = BuildSvgGradient(gradFill, themeColors, ref defId, defs);
            if (gradientRef != null)
                return;
        }

        // Image fill (blip)
        var blipFill = spPr.GetFirstChild<Drawing.BlipFill>();
        if (blipFill != null)
        {
            var dataUri = BlipToDataUri(blipFill, part);
            if (dataUri != null && defs != null)
            {
                var patId = $"pat{defId++}";
                defs.AppendLine($"<pattern id=\"{patId}\" patternUnits=\"objectBoundingBox\" width=\"1\" height=\"1\">");
                defs.AppendLine($"  <image href=\"{dataUri}\" width=\"100%\" height=\"100%\" preserveAspectRatio=\"xMidYMid slice\"/>");
                defs.AppendLine("</pattern>");
                gradientRef = patId;
                return;
            }
        }
    }

    /// <summary>
    /// Parse a CSS color (hex or rgba) into SVG-compatible color + opacity.
    /// </summary>
    private static void ParseSvgColor(string cssColor, out string svgColor, out double opacity)
    {
        opacity = 1.0;
        if (cssColor.StartsWith("rgba(", StringComparison.OrdinalIgnoreCase))
        {
            // rgba(r,g,b,a)
            var inner = cssColor[5..^1];
            var parts = inner.Split(',');
            if (parts.Length >= 4)
            {
                var r = int.Parse(parts[0].Trim());
                var g = int.Parse(parts[1].Trim());
                var b = int.Parse(parts[2].Trim());
                opacity = double.Parse(parts[3].Trim(), System.Globalization.CultureInfo.InvariantCulture);
                svgColor = $"#{r:X2}{g:X2}{b:X2}";
                return;
            }
        }
        svgColor = cssColor;
    }

    // ==================== Text Rendering (SVG) ====================

    private void RenderTextBodySvg(StringBuilder sb, OpenXmlElement textBody,
        Dictionary<string, string> themeColors,
        double shapeW, double shapeH,
        double lIns, double tIns, double rIns, double bIns,
        string valign, Shape? placeholderShape = null, OpenXmlPart? placeholderPart = null,
        string? textColorOverride = null)
    {
        var paragraphs = textBody.Elements<Drawing.Paragraph>().ToList();
        if (paragraphs.Count == 0) return;

        double textW = shapeW - lIns - rIns;
        if (textW <= 0) return;

        const double ptToPx = 96.0 / 72.0;

        // Gather paragraph info
        var paraInfos = new List<(Drawing.Paragraph para, double fontSizePt, string align, double lineHeight, double spaceBefore, double spaceAfter, string? bullet)>();
        foreach (var para in paragraphs)
        {
            var firstRun = para.Elements<Drawing.Run>().FirstOrDefault();
            var rp = firstRun?.RunProperties;

            int? paraDefaultFontSize = null;
            if (placeholderShape != null && placeholderPart != null)
            {
                int level = para.ParagraphProperties?.Level?.Value ?? 0;
                paraDefaultFontSize = ResolvePlaceholderFontSize(placeholderShape, placeholderPart, level);
            }
            double fontSizePt = paraDefaultFontSize.HasValue ? paraDefaultFontSize.Value / 100.0 : 18;
            if (rp?.FontSize?.HasValue == true)
                fontSizePt = rp.FontSize.Value / 100.0;

            var align = "start";
            var pProps = para.ParagraphProperties;
            if (pProps?.Alignment?.HasValue == true)
            {
                align = pProps.Alignment.InnerText switch
                {
                    "ctr" => "middle",
                    "r" => "end",
                    "just" or "dist" => "start", // SVG can't justify, fall back to start
                    _ => "start"
                };
            }

            // Line spacing. Issue #236 (same defect as the HTML preview):
            // PowerPoint single/percent spacing is relative to the font's line
            // pitch (~1.2× Latin, ~1.32× CJK), not 1.0× the font size.
            double svgPitch = SingleSpacingPitch(rp);
            double lineHeight = svgPitch; // PowerPoint default is single spacing
            var lsPct = pProps?.GetFirstChild<Drawing.LineSpacing>()?.GetFirstChild<Drawing.SpacingPercent>().PercentVal();
            if (lsPct.HasValue) lineHeight = lsPct.Value / 100000.0 * svgPitch;
            var lsPts = pProps?.GetFirstChild<Drawing.LineSpacing>()?.GetFirstChild<Drawing.SpacingPoints>()?.Val?.Value;
            if (lsPts.HasValue) lineHeight = lsPts.Value / 100.0 / fontSizePt; // convert pt spacing to ratio

            // Paragraph spacing
            double spaceBefore = 0;
            var sbPts = pProps?.GetFirstChild<Drawing.SpaceBefore>()?.GetFirstChild<Drawing.SpacingPoints>()?.Val?.Value;
            if (sbPts.HasValue) spaceBefore = sbPts.Value / 100.0 * ptToPx;

            double spaceAfter = 0;
            var saPts = pProps?.GetFirstChild<Drawing.SpaceAfter>()?.GetFirstChild<Drawing.SpacingPoints>()?.Val?.Value;
            if (saPts.HasValue) spaceAfter = saPts.Value / 100.0 * ptToPx;

            // Bullet
            string? bullet = null;
            var bulletChar = pProps?.GetFirstChild<Drawing.CharacterBullet>()?.Char?.Value;
            if (bulletChar != null) bullet = bulletChar;
            else if (pProps?.GetFirstChild<Drawing.AutoNumberedBullet>() != null) bullet = "\u2022";

            paraInfos.Add((para, fontSizePt, align, lineHeight, spaceBefore, spaceAfter, bullet));
        }

        // Calculate total text height
        double totalHeightPx = 0;
        foreach (var (_, fontSizePt, _, lineHeight, spaceBefore, spaceAfter, _) in paraInfos)
        {
            totalHeightPx += spaceBefore + fontSizePt * ptToPx * lineHeight + spaceAfter;
        }

        // Vertical alignment
        double usableH = shapeH - tIns - bIns;
        double startY = valign switch
        {
            "center" => tIns + (usableH - totalHeightPx) / 2,
            "bottom" => tIns + usableH - totalHeightPx,
            _ => tIns
        };

        // Render each paragraph
        double currentY = startY;
        foreach (var (para, fontSizePt, align, lineHeight, spaceBefore, spaceAfter, bullet) in paraInfos)
        {
            currentY += spaceBefore;
            double fontSizePx = fontSizePt * ptToPx;
            double lineHeightPx = fontSizePx * lineHeight;
            double baselineY = currentY + fontSizePx * 0.85;

            // Paragraph indent
            double indent = 0;
            var pProps = para.ParagraphProperties;
            if (pProps?.LeftMargin?.HasValue == true)
                indent = EmuToPx(pProps.LeftMargin.Value);
            double textIndent = 0;
            if (pProps?.Indent?.HasValue == true)
                textIndent = EmuToPx(pProps.Indent.Value);

            double textAnchorX = align switch
            {
                "middle" => lIns + textW / 2.0,
                "end" => lIns + textW,
                _ => lIns + indent + textIndent
            };

            var runs = para.Elements<Drawing.Run>().ToList();
            if (runs.Count == 0)
            {
                currentY += lineHeightPx;
                continue;
            }

            sb.Append($"<text x=\"{textAnchorX:0.##}\" y=\"{baselineY:0.##}\" text-anchor=\"{align}\"");
            sb.Append($" font-size=\"{fontSizePx:0.##}\"");
            sb.Append($" font-family=\"{OfficeDefaultFonts.MinorLatin}, {SvgEncode(ResolveDocCjkFallback())}, sans-serif\"");
            sb.Append(">");

            // Bullet character
            if (bullet != null)
                sb.Append($"<tspan fill=\"currentColor\">{SvgEncode(bullet)} </tspan>");

            foreach (var run in runs)
            {
                var text = run.Text?.Text ?? "";
                if (string.IsNullOrEmpty(text)) continue;

                var rp = run.RunProperties;
                var tspanAttrs = new List<string>();

                // Color
                var runFill = rp?.GetFirstChild<Drawing.SolidFill>();
                var runColorCss = ResolveFillColor(runFill, themeColors) ?? textColorOverride ?? "#000000";
                ParseSvgColor(runColorCss, out var runColor, out var runOpacity);
                tspanAttrs.Add($"fill=\"{SvgEncode(runColor)}\"");
                if (runOpacity < 1.0)
                    tspanAttrs.Add($"fill-opacity=\"{runOpacity:0.##}\"");

                // Per-run font size
                if (rp?.FontSize?.HasValue == true)
                {
                    var runFontPx = rp.FontSize.Value / 100.0 * ptToPx;
                    tspanAttrs.Add($"font-size=\"{runFontPx:0.##}\"");
                }

                if (rp?.Bold?.Value == true)
                    tspanAttrs.Add("font-weight=\"bold\"");
                if (rp?.Italic?.Value == true)
                    tspanAttrs.Add("font-style=\"italic\"");

                // Underline + Strikethrough
                var decos = new List<string>();
                if (rp?.Underline?.HasValue == true && rp.Underline.Value != Drawing.TextUnderlineValues.None)
                    decos.Add("underline");
                if (rp?.Strike?.HasValue == true && rp.Strike.Value != Drawing.TextStrikeValues.NoStrike)
                    decos.Add("line-through");
                if (decos.Count > 0)
                    tspanAttrs.Add($"text-decoration=\"{string.Join(" ", decos)}\"");

                // Character spacing
                if (rp?.Spacing?.HasValue == true && rp.Spacing.Value != 0)
                    tspanAttrs.Add($"letter-spacing=\"{rp.Spacing.Value / 100.0 * ptToPx:0.##}\"");

                // Superscript/subscript
                if (rp?.Baseline?.HasValue == true && rp.Baseline.Value != 0)
                {
                    var dy = -rp.Baseline.Value / 100000.0 * fontSizePx;
                    tspanAttrs.Add($"dy=\"{dy:0.##}\"");
                    tspanAttrs.Add($"font-size=\"{fontSizePx * 0.65:0.##}\"");
                }

                var font = rp?.GetFirstChild<Drawing.LatinFont>()?.Typeface?.Value
                    ?? rp?.GetFirstChild<Drawing.EastAsianFont>()?.Typeface?.Value;
                if (font != null && !font.StartsWith("+", StringComparison.Ordinal))
                    tspanAttrs.Add($"font-family=\"{SvgEncode(font)}\"");

                sb.Append($"<tspan {string.Join(" ", tspanAttrs)}>{SvgEncode(text)}</tspan>");
            }

            sb.Append("</text>");
            currentY += lineHeightPx + spaceAfter;
        }
    }

    // ==================== Chart Rendering (SVG) ====================

    private void RenderChartSvg(StringBuilder sb, GraphicFrame gf, SlidePart slidePart, Dictionary<string, string> themeColors)
    {
        // Use the existing RenderChart which outputs HTML with embedded SVG.
        // We'll capture its output, extract the SVG portion, and embed it.
        var pxfrm = gf.GetFirstChild<DocumentFormat.OpenXml.Presentation.Transform>();
        var off = pxfrm?.GetFirstChild<Drawing.Offset>();
        var ext = pxfrm?.GetFirstChild<Drawing.Extents>();
        if (off == null || ext == null) return;

        double cx = EmuToPx(off.X?.Value ?? 0);
        double cy = EmuToPx(off.Y?.Value ?? 0);
        double cw = EmuToPx(ext.Cx?.Value ?? 0);
        double ch = EmuToPx(ext.Cy?.Value ?? 0);

        // Render the chart using the existing HTML+SVG renderer into a temporary buffer
        var chartSb = new StringBuilder();
        RenderChart(chartSb, gf, slidePart, themeColors);
        var chartHtml = chartSb.ToString();

        // Extract SVG content from the HTML output
        // The HTML contains: <div ...><div>title</div><svg viewBox="...">...chart...</svg><div>legend</div></div>
        var svgStart = chartHtml.IndexOf("<svg ", StringComparison.Ordinal);
        var svgEnd = chartHtml.IndexOf("</svg>", StringComparison.Ordinal);
        if (svgStart < 0 || svgEnd < 0) return;

        var svgContent = chartHtml[svgStart..(svgEnd + 6)];

        // Extract viewBox from the inner SVG
        var vbMatch = System.Text.RegularExpressions.Regex.Match(svgContent, @"viewBox=""([^""]+)""");
        var viewBox = vbMatch.Success ? vbMatch.Groups[1].Value : "0 0 360 252";

        // Extract just the inner content (between <svg ...> and </svg>)
        var innerStart = svgContent.IndexOf('>') + 1;
        var innerEnd = svgContent.LastIndexOf("</svg>", StringComparison.Ordinal);
        var innerSvg = svgContent[innerStart..innerEnd];

        // Extract chart title and font-size from HTML
        var titleMatch = System.Text.RegularExpressions.Regex.Match(chartHtml, @"font-weight:bold[^>]*>([^<]+)<");
        var title = titleMatch.Success ? titleMatch.Groups[1].Value : "";
        var titleFsMatch = System.Text.RegularExpressions.Regex.Match(chartHtml, @"font-size:(\d+\.?\d*)pt");
        var titleFontPx = titleFsMatch.Success && double.TryParse(titleFsMatch.Groups[1].Value, out var tfp) ? (int)(tfp * 1.33) : 11;

        // Embed as nested SVG at the chart position
        sb.Append($"<g transform=\"translate({cx:0.##},{cy:0.##})\">");

        // Chart background
        sb.Append($"<rect width=\"{cw:0.##}\" height=\"{ch:0.##}\" fill=\"white\" fill-opacity=\"0\"/>");

        // Title
        double titleH = 0;
        if (!string.IsNullOrEmpty(title))
        {
            titleH = 16;
            sb.Append($"<text x=\"{cw / 2:0.##}\" y=\"12\" text-anchor=\"middle\" font-size=\"{titleFontPx}\" font-weight=\"bold\" fill=\"{_chartValueColor}\">{SvgEncode(title)}</text>");
        }

        // Nested SVG for chart content
        sb.Append($"<svg x=\"0\" y=\"{titleH:0.##}\" width=\"{cw:0.##}\" height=\"{ch - titleH:0.##}\" viewBox=\"{viewBox}\" preserveAspectRatio=\"xMidYMid meet\">");
        sb.Append(innerSvg);
        sb.Append("</svg>");

        // Legend extraction and rendering
        var legendMatch = System.Text.RegularExpressions.Regex.Match(chartHtml,
            @"chart-legend.*?>(.*?)</div>\s*$", System.Text.RegularExpressions.RegexOptions.Singleline);
        if (legendMatch.Success)
        {
            // Extract legend items — parse <span> with background color and text
            var legendItems = System.Text.RegularExpressions.Regex.Matches(legendMatch.Groups[1].Value,
                @"background:(#[0-9A-Fa-f]+).*?</span>([^<]+)");
            if (legendItems.Count > 0)
            {
                double legendY = ch - 14;
                double legendX = cw / 2 - legendItems.Count * 40;
                foreach (System.Text.RegularExpressions.Match item in legendItems)
                {
                    var color = item.Groups[1].Value;
                    var label = item.Groups[2].Value.Trim();
                    sb.Append($"<rect x=\"{legendX:0.##}\" y=\"{legendY:0.##}\" width=\"8\" height=\"8\" fill=\"{color}\"/>");
                    sb.Append($"<text x=\"{legendX + 10:0.##}\" y=\"{legendY + 7:0.##}\" font-size=\"8\" fill=\"{_chartValueColor}\">{SvgEncode(label)}</text>");
                    legendX += 80;
                }
            }
        }

        sb.AppendLine("</g>");
    }

    // ==================== Picture Rendering (SVG) ====================

    private static void RenderPictureSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        Picture pic, SlidePart slidePart, Dictionary<string, string> themeColors,
        (long x, long y, long cx, long cy)? overridePos = null)
    {
        var xfrm = pic.ShapeProperties?.Transform2D;
        if (xfrm?.Offset == null || xfrm?.Extents == null) return;

        double px = EmuToPx(overridePos?.x ?? xfrm.Offset.X?.Value ?? 0);
        double py = EmuToPx(overridePos?.y ?? xfrm.Offset.Y?.Value ?? 0);
        double pw = EmuToPx(overridePos?.cx ?? xfrm.Extents.Cx?.Value ?? 0);
        double ph = EmuToPx(overridePos?.cy ?? xfrm.Extents.Cy?.Value ?? 0);
        if (pw <= 0 || ph <= 0) return;

        // Extract image
        var blipFill = pic.BlipFill;
        var blip = blipFill?.GetFirstChild<Drawing.Blip>();
        if (blip?.Embed?.HasValue != true) return;

        string? dataUri = null;
        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");
            dataUri = $"data:{contentType};base64,{base64}";
        }
        catch { return; }

        if (dataUri == null) return;

        // Transform
        var transforms = new List<string> { $"translate({px:0.##},{py:0.##})" };
        if (xfrm.Rotation != null && xfrm.Rotation.Value != 0)
            transforms.Add($"rotate({xfrm.Rotation.Value / 60000.0:0.##},{pw / 2:0.##},{ph / 2:0.##})");

        // Clip for crop
        string? clipId = null;
        var srcRect = blipFill?.GetFirstChild<Drawing.SourceRectangle>();
        if (srcRect != null)
        {
            var cl = (srcRect.Left?.Value ?? 0) / 100000.0;
            var ct = (srcRect.Top?.Value ?? 0) / 100000.0;
            var cr = (srcRect.Right?.Value ?? 0) / 100000.0;
            var cb = (srcRect.Bottom?.Value ?? 0) / 100000.0;
            if (cl != 0 || ct != 0 || cr != 0 || cb != 0)
            {
                clipId = $"clip{defId++}";
                defs.AppendLine($"<clipPath id=\"{clipId}\">");
                defs.AppendLine($"  <rect x=\"{pw * cl:0.##}\" y=\"{ph * ct:0.##}\" width=\"{pw * (1 - cl - cr):0.##}\" height=\"{ph * (1 - ct - cb):0.##}\"/>");
                defs.AppendLine("</clipPath>");
            }
        }

        sb.Append($"<g transform=\"{string.Join(" ", transforms)}\"");
        if (clipId != null) sb.Append($" clip-path=\"url(#{clipId})\"");
        sb.Append(">");
        sb.Append($"<image href=\"{dataUri}\" width=\"{pw:0.##}\" height=\"{ph:0.##}\" preserveAspectRatio=\"none\"/>");
        sb.AppendLine("</g>");
    }

    // ==================== Group Rendering (SVG) ====================

    private void RenderGroupSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        GroupShape grp, SlidePart slidePart, Dictionary<string, string> themeColors)
    {
        var grpXfrm = grp.GroupShapeProperties?.TransformGroup;
        if (grpXfrm?.Offset == null || grpXfrm?.Extents == null) return;

        double gx = EmuToPx(grpXfrm.Offset.X?.Value ?? 0);
        double gy = EmuToPx(grpXfrm.Offset.Y?.Value ?? 0);
        long cx = grpXfrm.Extents.Cx?.Value ?? 0;
        long cy = grpXfrm.Extents.Cy?.Value ?? 0;

        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;

        sb.Append($"<g transform=\"translate({gx:0.##},{gy:0.##})\">");

        foreach (var child in grp.ChildElements)
        {
            switch (child)
            {
                case Shape shape:
                {
                    var pos = CalcGroupChildPos(shape.ShapeProperties?.Transform2D, offX, offY, scaleX, scaleY);
                    if (pos.HasValue)
                        RenderShapeSvg(sb, defs, ref defId, shape, slidePart, themeColors, pos);
                    break;
                }
                case Picture pic:
                {
                    var pos = CalcGroupChildPos(pic.ShapeProperties?.Transform2D, offX, offY, scaleX, scaleY);
                    if (pos.HasValue)
                        RenderPictureSvg(sb, defs, ref defId, pic, slidePart, themeColors, pos);
                    break;
                }
                case ConnectionShape cxn:
                    RenderConnectorSvg(sb, defs, ref defId, cxn, themeColors);
                    break;
                default:
                    // mc:AlternateContent inside a group — see slide-level branch (#228).
                    if (child.LocalName == "AlternateContent")
                        RenderAltContentSvg(sb, defs, ref defId, child, slidePart, themeColors,
                            (offX, offY, scaleX, scaleY));
                    break;
            }
        }

        sb.AppendLine("</g>");
    }

    // ==================== Connector Rendering (SVG) ====================

    private static void RenderConnectorSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        ConnectionShape cxn, Dictionary<string, string> themeColors)
    {
        var xfrm = cxn.ShapeProperties?.Transform2D;
        if (xfrm?.Offset == null || xfrm?.Extents == null) return;

        long xEmu = xfrm.Offset.X?.Value ?? 0;
        long yEmu = xfrm.Offset.Y?.Value ?? 0;
        long cxEmu = xfrm.Extents.Cx?.Value ?? 0;
        long cyEmu = xfrm.Extents.Cy?.Value ?? 0;
        var flipH = xfrm.HorizontalFlip?.Value == true;
        var flipV = xfrm.VerticalFlip?.Value == true;

        double px1 = EmuToPx(xEmu), py1 = EmuToPx(yEmu);
        double px2 = EmuToPx(xEmu + cxEmu), py2 = EmuToPx(yEmu + cyEmu);

        // Apply flips
        double lx1 = flipH ? px2 : px1, ly1 = flipV ? py2 : py1;
        double lx2 = flipH ? px1 : px2, ly2 = flipV ? py1 : py2;

        // Outline
        var outline = cxn.ShapeProperties?.GetFirstChild<Drawing.Outline>();
        var defaultColor = themeColors.TryGetValue("tx1", out var txc) ? $"#{txc}"
            : themeColors.TryGetValue("dk1", out var dkc) ? $"#{dkc}" : "#000000";
        string strokeColor = defaultColor;
        double strokeOpacity = 1.0;
        double strokeWidth = 1.5; // px
        if (outline != null)
        {
            var c = ResolveFillColor(outline.GetFirstChild<Drawing.SolidFill>(), themeColors);
            if (c != null) ParseSvgColor(c, out strokeColor, out strokeOpacity);
            if (outline.Width?.HasValue == true) strokeWidth = EmuToPx(outline.Width.Value);
            if (strokeWidth < 0.5) strokeWidth = 0.5;
        }

        // Dash
        string dashAttr = "";
        var prstDash = outline?.GetFirstChild<Drawing.PresetDash>();
        if (prstDash?.Val?.HasValue == true)
        {
            var sw = strokeWidth;
            var dashArray = prstDash.Val.InnerText switch
            {
                "dash" or "lgDash" => $"{sw * 4:0.##},{sw * 3:0.##}",
                "dot" or "sysDot" => $"{sw:0.##},{sw * 2:0.##}",
                "dashDot" => $"{sw * 4:0.##},{sw * 2:0.##},{sw:0.##},{sw * 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 hasTail = tailEnd?.Type?.HasValue == true && tailEnd.Type.InnerText != "none";
        var hasHead = headEnd?.Type?.HasValue == true && headEnd.Type.InnerText != "none";
        string markerStartAttr = "", markerEndAttr = "";

        if (hasTail)
        {
            var markerId = $"arrow{defId++}";
            var s = Math.Max(4, strokeWidth * 3);
            defs.AppendLine($"<marker id=\"{markerId}\" markerWidth=\"{s:0.#}\" markerHeight=\"{s:0.#}\" refX=\"0\" refY=\"{s / 2:0.#}\" orient=\"auto\">");
            defs.AppendLine($"  <polygon points=\"0,0 {s:0.#},{s / 2:0.#} 0,{s:0.#}\" fill=\"{strokeColor}\"/>");
            defs.AppendLine("</marker>");
            markerEndAttr = $" marker-end=\"url(#{markerId})\"";
        }
        if (hasHead)
        {
            var markerId = $"arrow{defId++}";
            var s = Math.Max(4, strokeWidth * 3);
            defs.AppendLine($"<marker id=\"{markerId}\" markerWidth=\"{s:0.#}\" markerHeight=\"{s:0.#}\" refX=\"{s:0.#}\" refY=\"{s / 2:0.#}\" orient=\"auto-start-reverse\">");
            defs.AppendLine($"  <polygon points=\"{s:0.#},0 0,{s / 2:0.#} {s:0.#},{s:0.#}\" fill=\"{strokeColor}\"/>");
            defs.AppendLine("</marker>");
            markerStartAttr = $" marker-start=\"url(#{markerId})\"";
        }

        var opacityAttr = strokeOpacity < 1.0 ? $" stroke-opacity=\"{strokeOpacity:0.##}\"" : "";
        sb.AppendLine($"<line x1=\"{lx1:0.##}\" y1=\"{ly1:0.##}\" x2=\"{lx2:0.##}\" y2=\"{ly2:0.##}\" stroke=\"{strokeColor}\" stroke-width=\"{strokeWidth:0.##}\"{opacityAttr}{dashAttr}{markerStartAttr}{markerEndAttr}/>");
    }

    // ==================== Table Rendering (SVG) ====================

    private void RenderTableSvg(StringBuilder sb, StringBuilder defs, ref int defId,
        GraphicFrame gf, Dictionary<string, string> themeColors)
    {
        var table = gf.Descendants<Drawing.Table>().FirstOrDefault();
        if (table == null) return;

        var offset = gf.Transform?.Offset;
        var extents = gf.Transform?.Extents;
        if (offset == null || extents == null) return;

        double tx = EmuToPx(offset.X?.Value ?? 0);
        double ty = EmuToPx(offset.Y?.Value ?? 0);
        double tw = EmuToPx(extents.Cx?.Value ?? 0);
        double th = EmuToPx(extents.Cy?.Value ?? 0);

        // Table style. Banding flags and grid dimensions feed the per-cell
        // Core/TableStyles resolver below — mirrors HtmlPreview.Tables.cs.
        var tblPr = table.GetFirstChild<Drawing.TableProperties>();
        var tableStyleId = tblPr?.GetFirstChild<Drawing.TableStyleId>()?.InnerText;
        bool hasFirstRow = tblPr?.FirstRow?.Value == true;
        bool hasBandRow = tblPr?.BandRow?.Value == true;
        bool hasLastRow = tblPr?.LastRow?.Value == true;
        bool hasFirstCol = tblPr?.FirstColumn?.Value == true;
        bool hasLastCol = tblPr?.LastColumn?.Value == true;
        bool hasBandCol = tblPr?.BandColumn?.Value == true;
        var allRowsSvg = table.Elements<Drawing.TableRow>().ToList();
        int totalRowsSvg = allRowsSvg.Count;

        // Column widths
        var gridCols = table.TableGrid?.Elements<Drawing.GridColumn>().ToList();
        long totalColWidth = gridCols?.Sum(gc => gc.Width?.Value ?? 0) ?? 0;
        var colWidths = new List<double>();
        if (gridCols != null && totalColWidth > 0)
        {
            foreach (var gc in gridCols)
                colWidths.Add(tw * (gc.Width?.Value ?? 0) / totalColWidth);
        }

        sb.Append($"<g transform=\"translate({tx:0.##},{ty:0.##})\">");

        double currentY = 0;
        int rowIndex = 0;
        foreach (var row in table.Elements<Drawing.TableRow>())
        {
            double rowH = EmuToPx(row.Height?.Value ?? 0);
            double currentX = 0;
            int colIndex = 0;
            bool isHeaderRow = hasFirstRow && rowIndex == 0;
            bool isBandedOdd = hasBandRow && (!hasFirstRow ? rowIndex % 2 == 0 : rowIndex > 0 && (rowIndex - 1) % 2 == 0);

            foreach (var cell in row.Elements<Drawing.TableCell>())
            {
                double cellW = colIndex < colWidths.Count ? colWidths[colIndex] : tw / Math.Max(1, colWidths.Count);

                // Cell fill — explicit first, then table style
                var tcPr = cell.TableCellProperties ?? cell.GetFirstChild<Drawing.TableCellProperties>();
                var cellFill = ResolveFillColor(tcPr?.GetFirstChild<Drawing.SolidFill>(), themeColors);
                string cellFillColor = "none";
                double cellFillOpacity = 1.0;
                string? textColorOverride = null;

                if (cellFill != null)
                {
                    ParseSvgColor(cellFill, out cellFillColor, out cellFillOpacity);
                }
                else
                {
                    // Core/TableStyles resolver — same call shape used in
                    // HtmlPreview.Tables.cs. Returns null for unknown style
                    // ids; an unstyled (or unrecognised) table simply gets
                    // no fill, matching previous behaviour for that case.
                    var resolved = TableStyleResolver.Resolve(
                        tableStyleId,
                        new CellPosition(
                            RowIndex: rowIndex, ColIndex: colIndex,
                            RowCount: totalRowsSvg, ColCount: colWidths.Count,
                            HasFirstRow: hasFirstRow, HasLastRow: hasLastRow,
                            HasFirstCol: hasFirstCol, HasLastCol: hasLastCol,
                            HasBandedRows: hasBandRow, HasBandedCols: hasBandCol),
                        themeColors);
                    if (resolved?.Fill != null)
                        ParseSvgColor(resolved.Fill, out cellFillColor, out cellFillOpacity);
                    if (resolved?.TextColor != null)
                        textColorOverride = resolved.TextColor;
                }

                // Cell background
                if (cellFillColor != "none")
                {
                    var opAttr = cellFillOpacity < 1.0 ? $" fill-opacity=\"{cellFillOpacity:0.##}\"" : "";
                    sb.Append($"<rect x=\"{currentX:0.##}\" y=\"{currentY:0.##}\" width=\"{cellW:0.##}\" height=\"{rowH:0.##}\" fill=\"{cellFillColor}\"{opAttr}/>");
                }

                // Cell border
                sb.Append($"<rect x=\"{currentX:0.##}\" y=\"{currentY:0.##}\" width=\"{cellW:0.##}\" height=\"{rowH:0.##}\" fill=\"none\" stroke=\"#BFBFBF\" stroke-width=\"0.5\"/>");

                // Cell text
                var textBody = cell.GetFirstChild<Drawing.TextBody>();
                if (textBody != null)
                {
                    double padL = EmuToPx(tcPr?.LeftMargin?.Value ?? 91440);
                    double padT = EmuToPx(tcPr?.TopMargin?.Value ?? 45720);
                    double padR = EmuToPx(tcPr?.RightMargin?.Value ?? 91440);
                    double padB = EmuToPx(tcPr?.BottomMargin?.Value ?? 45720);

                    var valign = "top";
                    if (tcPr?.Anchor?.HasValue == true)
                        valign = tcPr.Anchor.InnerText switch { "ctr" => "center", "b" => "bottom", _ => "top" };

                    // Render text at cell position with offset
                    sb.Append($"<g transform=\"translate({currentX:0.##},{currentY:0.##})\">");
                    RenderTextBodyFO(sb, textBody, themeColors, cellW, rowH,
                        padL, padT, padR, padB, valign, textColorOverride: textColorOverride);
                    sb.Append("</g>");
                }

                currentX += cellW;
                colIndex++;
            }
            currentY += rowH;
            rowIndex++;
        }

        sb.AppendLine("</g>");
    }

    // ==================== Text Rendering via foreignObject ====================

    /// <summary>
    /// Render text using foreignObject + HTML for automatic wrapping.
    /// Can be swapped with RenderTextBodySvg for pure SVG output.
    /// </summary>
    private void RenderTextBodyFO(StringBuilder sb, OpenXmlElement textBody,
        Dictionary<string, string> themeColors,
        double shapeW, double shapeH,
        double lIns, double tIns, double rIns, double bIns,
        string valign, Shape? placeholderShape = null, OpenXmlPart? placeholderPart = null,
        string? textColorOverride = null)
    {
        var paragraphs = textBody.Elements<Drawing.Paragraph>().ToList();
        if (paragraphs.Count == 0) return;

        double textW = shapeW - lIns - rIns;
        double textH = shapeH - tIns - bIns;
        if (textW <= 0 || textH <= 0) return;

        // Vertical alignment via flexbox
        var justifyContent = valign switch
        {
            "center" => "center",
            "bottom" => "flex-end",
            _ => "flex-start"
        };

        sb.Append($"<foreignObject x=\"{lIns:0.##}\" y=\"{tIns:0.##}\" width=\"{textW:0.##}\" height=\"{textH:0.##}\">");
        sb.Append($"<div xmlns=\"http://www.w3.org/1999/xhtml\" style=\"width:100%;height:100%;overflow:hidden;display:flex;flex-direction:column;justify-content:{justifyContent};line-height:1\">");

        foreach (var para in paragraphs)
        {
            var paraStyles = new List<string>();

            var pProps = para.ParagraphProperties;

            // Issue #236 (same defect as the HTML preview): the paragraph div
            // owns line-height, so give it a font-size matching its tallest run
            // — otherwise the CSS strut resolves against the inherited root
            // size and small-text line boxes balloon to the root font size.
            int? foDefaultFontSize = null;
            if (placeholderShape != null && placeholderPart != null)
            {
                int level = para.ParagraphProperties?.Level?.Value ?? 0;
                foDefaultFontSize = ResolvePlaceholderFontSize(placeholderShape, placeholderPart, level);
            }
            int foMaxFontHundredths = 0;
            foreach (var r0 in para.Elements<Drawing.Run>())
            {
                int sz = r0.RunProperties?.FontSize?.Value ?? foDefaultFontSize ?? 1800;
                if (sz > foMaxFontHundredths) foMaxFontHundredths = sz;
            }
            if (foMaxFontHundredths == 0) foMaxFontHundredths = foDefaultFontSize ?? 1800;
            paraStyles.Add($"font-size:{foMaxFontHundredths / 100.0:0.##}pt");
            double foPitch = SingleSpacingPitch(para.Elements<Drawing.Run>().FirstOrDefault()?.RunProperties);

            // Alignment
            if (pProps?.Alignment?.HasValue == true)
            {
                var align = pProps.Alignment.InnerText switch
                {
                    "l" => "left",
                    "ctr" => "center",
                    "r" => "right",
                    "just" or "dist" => "justify",
                    _ => "left"
                };
                paraStyles.Add($"text-align:{align}");
            }

            // Paragraph spacing
            var sbPts = pProps?.GetFirstChild<Drawing.SpaceBefore>()?.GetFirstChild<Drawing.SpacingPoints>()?.Val?.Value;
            if (sbPts.HasValue) paraStyles.Add($"margin-top:{sbPts.Value / 100.0:0.##}pt");
            var saPts = pProps?.GetFirstChild<Drawing.SpaceAfter>()?.GetFirstChild<Drawing.SpacingPoints>()?.Val?.Value;
            if (saPts.HasValue) paraStyles.Add($"margin-bottom:{saPts.Value / 100.0:0.##}pt");

            // Line spacing — unitless multipliers scale by the single-spacing
            // pitch (issue #236); fixed-pt stays absolute.
            var lsPct = pProps?.GetFirstChild<Drawing.LineSpacing>()?.GetFirstChild<Drawing.SpacingPercent>().PercentVal();
            if (lsPct.HasValue) paraStyles.Add($"line-height:{lsPct.Value / 100000.0 * foPitch:0.##}");
            var lsPts = pProps?.GetFirstChild<Drawing.LineSpacing>()?.GetFirstChild<Drawing.SpacingPoints>()?.Val?.Value;
            if (lsPts.HasValue) paraStyles.Add($"line-height:{lsPts.Value / 100.0:0.##}pt");
            else if (!lsPct.HasValue) paraStyles.Add($"line-height:{foPitch:0.##}");

            // Indent
            if (pProps?.Indent?.HasValue == true)
                paraStyles.Add($"text-indent:{EmuToPx(pProps.Indent.Value):0.##}px");
            if (pProps?.LeftMargin?.HasValue == true)
                paraStyles.Add($"margin-left:{EmuToPx(pProps.LeftMargin.Value):0.##}px");

            sb.Append($"<div style=\"white-space:pre-wrap;word-wrap:break-word;margin:0;{string.Join(";", paraStyles)}\">");

            // Bullet
            var bulletChar = pProps?.GetFirstChild<Drawing.CharacterBullet>()?.Char?.Value;
            var bulletAuto = pProps?.GetFirstChild<Drawing.AutoNumberedBullet>();
            if (bulletChar != null || bulletAuto != null)
            {
                var bullet = bulletChar ?? "\u2022";
                sb.Append($"<span>{HtmlEncode(bullet)} </span>");
            }

            // OfficeMath detection
            var paraXml = para.OuterXml;
            if (paraXml.Contains("oMath"))
            {
                var mathMatch = System.Text.RegularExpressions.Regex.Match(paraXml,
                    @"<m:oMathPara[^>]*>.*?</m:oMathPara>|<m:oMath[^>]*>.*?</m:oMath>",
                    System.Text.RegularExpressions.RegexOptions.Singleline);
                if (mathMatch.Success)
                {
                    try
                    {
                        var wrapper = new OpenXmlUnknownElement("wrapper");
                        wrapper.InnerXml = mathMatch.Value;
                        var oMath = wrapper.Descendants().FirstOrDefault(e => e.LocalName == "oMathPara" || e.LocalName == "oMath");
                        if (oMath != null)
                        {
                            var latex = FormulaParser.ToLatex(oMath);
                            // Convert OOXML Math to standard MathML for browser-native rendering
                            var mathMl = OmmlToMathMl(oMath);
                            if (mathMl != null)
                                sb.Append($"<div style=\"font-size:1.2em\">{mathMl}</div>");
                            else
                                sb.Append($"<span data-formula=\"{HtmlEncode(latex)}\" style=\"font-family:'Cambria Math','Times New Roman',serif;font-style:italic;font-size:1.1em\">{HtmlEncode(latex)}</span>");
                        }
                    }
                    catch { }
                }
            }

            var runs = para.Elements<Drawing.Run>().ToList();
            if (runs.Count == 0 && !paraXml.Contains("oMath"))
            {
                sb.Append("&#160;"); // non-breaking space for empty paragraph
            }
            else
            {
                foreach (var run in runs)
                {
                    var text = run.Text?.Text ?? "";
                    if (string.IsNullOrEmpty(text)) continue;

                    var rp = run.RunProperties;
                    var styles = new List<string>();

                    // Font
                    var font = rp?.GetFirstChild<Drawing.LatinFont>()?.Typeface?.Value
                        ?? rp?.GetFirstChild<Drawing.EastAsianFont>()?.Typeface?.Value;
                    if (font != null && !font.StartsWith("+", StringComparison.Ordinal))
                    {
                        // foreignObject renders this span as live HTML, so the
                        // font-family value sits inside an inline CSS string.
                        // HtmlEncode only protects the HTML attribute layer
                        // (turns ' into &#39; which the parser unescapes back
                        // into ' inside CSS), letting a crafted theme typeface
                        // close the CSS string and inject rules. Use the same
                        // allowlist CssSanitize as the HtmlPreview path.
                        var safe = CssSanitize(font);
                        if (!string.IsNullOrEmpty(safe))
                            styles.Add($"font-family:'{safe}'");
                    }
                    else
                    {
                        // CONSISTENCY(svg-default-font): when a run has no
                        // explicit font, emit the same Office default chain
                        // the title-text path uses (around L676) so SVG
                        // matches PowerPoint's effective Calibri default.
                        // CJK fallback is locale-driven via ResolveDocCjkFallback.
                        styles.Add($"font-family:'{OfficeDefaultFonts.MinorLatin}',{ResolveDocCjkFallback()},sans-serif");
                    }

                    // Size — resolve per-paragraph from placeholder inheritance chain
                    int? paraDefaultFontSize = null;
                    if (placeholderShape != null && placeholderPart != null)
                    {
                        int level = para.ParagraphProperties?.Level?.Value ?? 0;
                        paraDefaultFontSize = ResolvePlaceholderFontSize(placeholderShape, placeholderPart, level);
                    }
                    double fontSizePt = paraDefaultFontSize.HasValue ? paraDefaultFontSize.Value / 100.0 : 18;
                    if (rp?.FontSize?.HasValue == true)
                        fontSizePt = rp.FontSize.Value / 100.0;
                    styles.Add($"font-size:{fontSizePt:0.##}pt");

                    // Bold / Italic
                    if (rp?.Bold?.Value == true) styles.Add("font-weight:bold");
                    if (rp?.Italic?.Value == true) styles.Add("font-style:italic");

                    // Underline / Strikethrough
                    var decos = new List<string>();
                    if (rp?.Underline?.HasValue == true && rp.Underline.Value != Drawing.TextUnderlineValues.None)
                        decos.Add("underline");
                    if (rp?.Strike?.HasValue == true && rp.Strike.Value != Drawing.TextStrikeValues.NoStrike)
                        decos.Add("line-through");
                    if (decos.Count > 0)
                        styles.Add($"text-decoration:{string.Join(" ", decos)}");

                    // Color
                    var runFill = rp?.GetFirstChild<Drawing.SolidFill>();
                    var color = ResolveFillColor(runFill, themeColors) ?? textColorOverride
                        ?? (themeColors.TryGetValue("dk1", out var dk1c) ? $"#{dk1c}" : "#000000");
                    styles.Add($"color:{color}");

                    // Character spacing
                    if (rp?.Spacing?.HasValue == true && rp.Spacing.Value != 0)
                        styles.Add($"letter-spacing:{rp.Spacing.Value / 100.0:0.##}pt");

                    // Superscript / Subscript
                    if (rp?.Baseline?.HasValue == true && rp.Baseline.Value != 0)
                    {
                        styles.Add(rp.Baseline.Value > 0 ? "vertical-align:super;font-size:smaller" : "vertical-align:sub;font-size:smaller");
                    }

                    sb.Append($"<span style=\"{string.Join(";", styles)}\">{HtmlEncode(text)}</span>");
                }
            }

            // Line breaks
            foreach (var br in para.Elements<Drawing.Break>())
                sb.Append("<br/>");

            sb.Append("</div>");
        }

        sb.Append("</div></foreignObject>");
    }

    // ==================== SVG Preset Geometries ====================

    /// <summary>
    /// Returns SVG polygon points string for common preset shapes, or null if not a polygon shape.
    /// </summary>
    private static string? GetPresetPolygonPoints(string preset, double w, double h, Drawing.PresetGeometry? presetGeom = null)
    {
        return preset switch
        {
            // Triangles
            "triangle" or "isosTriangle" => $"{w / 2:0.##},0 0,{h:0.##} {w:0.##},{h:0.##}",
            "rtTriangle" => $"0,0 0,{h:0.##} {w:0.##},{h:0.##}",

            // Diamond
            "diamond" => $"{w / 2:0.##},0 {w:0.##},{h / 2:0.##} {w / 2:0.##},{h:0.##} 0,{h / 2:0.##}",

            // Parallelogram
            "parallelogram" => $"{w * 0.25:0.##},0 {w:0.##},0 {w * 0.75:0.##},{h:0.##} 0,{h:0.##}",
            "trapezoid" => $"{w * 0.2:0.##},0 {w * 0.8:0.##},0 {w:0.##},{h:0.##} 0,{h:0.##}",

            // Pentagon, Hexagon, etc.
            "pentagon" => BuildRegularPolygon(5, w, h),
            "hexagon" => BuildRegularPolygon(6, w, h),
            "heptagon" => BuildRegularPolygon(7, w, h),
            "octagon" => BuildRegularPolygon(8, w, h),
            "decagon" => BuildRegularPolygon(10, w, h),
            "dodecagon" => BuildRegularPolygon(12, w, h),

            // Stars — inner radius from adj (default varies by star type)
            "star4" => BuildStar(4, w, h, ReadAdjValue(presetGeom, 0, 50000) / 100000.0),
            // CONSISTENCY(star5-adj-scale): OOXML adj for star5 is fraction * 50000 (default 19098 → inner ratio ~0.382).
            // Matches Star5Polygon in PowerPointHandler.HtmlPreview.Css.cs.
            "star5" => BuildStar(5, w, h, ReadAdjValue(presetGeom, 0, 19098) / 50000.0),
            "star6" => BuildStar(6, w, h, ReadAdjValue(presetGeom, 0, 28868) / 100000.0),
            "star8" => BuildStar(8, w, h, ReadAdjValue(presetGeom, 0, 38268) / 100000.0),
            "star10" => BuildStar(10, w, h, ReadAdjValue(presetGeom, 0, 38268) / 100000.0),
            "star12" => BuildStar(12, w, h, ReadAdjValue(presetGeom, 0, 38268) / 100000.0),

            // Arrows
            "rightArrow" => $"0,{h * 0.25:0.##} {w * 0.7:0.##},{h * 0.25:0.##} {w * 0.7:0.##},0 {w:0.##},{h / 2:0.##} {w * 0.7:0.##},{h:0.##} {w * 0.7:0.##},{h * 0.75:0.##} 0,{h * 0.75:0.##}",
            "leftArrow" => $"{w:0.##},{h * 0.25:0.##} {w * 0.3:0.##},{h * 0.25:0.##} {w * 0.3:0.##},0 0,{h / 2:0.##} {w * 0.3:0.##},{h:0.##} {w * 0.3:0.##},{h * 0.75:0.##} {w:0.##},{h * 0.75:0.##}",
            "upArrow" => $"{w * 0.25:0.##},{h:0.##} {w * 0.25:0.##},{h * 0.3:0.##} 0,{h * 0.3:0.##} {w / 2:0.##},0 {w:0.##},{h * 0.3:0.##} {w * 0.75:0.##},{h * 0.3:0.##} {w * 0.75:0.##},{h:0.##}",
            "downArrow" => $"{w * 0.25:0.##},0 {w * 0.75:0.##},0 {w * 0.75:0.##},{h * 0.7:0.##} {w:0.##},{h * 0.7:0.##} {w / 2:0.##},{h:0.##} 0,{h * 0.7:0.##} {w * 0.25:0.##},{h * 0.7:0.##}",

            // Chevron
            "chevron" => $"0,0 {w * 0.8:0.##},0 {w:0.##},{h / 2:0.##} {w * 0.8:0.##},{h:0.##} 0,{h:0.##} {w * 0.2:0.##},{h / 2:0.##}",
            // adj = point depth fraction of width (default 25000 = 25%); body = (1-adj)*w.
            "homePlate" => $"0,0 {w * (1 - ReadAdjValue(presetGeom, 0, 25000) / 100000.0):0.##},0 {w:0.##},{h / 2:0.##} {w * (1 - ReadAdjValue(presetGeom, 0, 25000) / 100000.0):0.##},{h:0.##} 0,{h:0.##}",

            // Cross / Plus
            "plus" or "cross" => $"{w * 0.33:0.##},0 {w * 0.67:0.##},0 {w * 0.67:0.##},{h * 0.33:0.##} {w:0.##},{h * 0.33:0.##} {w:0.##},{h * 0.67:0.##} {w * 0.67:0.##},{h * 0.67:0.##} {w * 0.67:0.##},{h:0.##} {w * 0.33:0.##},{h:0.##} {w * 0.33:0.##},{h * 0.67:0.##} 0,{h * 0.67:0.##} 0,{h * 0.33:0.##} {w * 0.33:0.##},{h * 0.33:0.##}",

            // Heart (approximate with polygon)
            "heart" => BuildHeartPath(w, h),

            // Flowchart shapes
            "flowChartProcess" => null, // rect, handled by default
            "flowChartDecision" => $"{w / 2:0.##},0 {w:0.##},{h / 2:0.##} {w / 2:0.##},{h:0.##} 0,{h / 2:0.##}",
            "flowChartInputOutput" or "flowChartData" => $"{w * 0.2:0.##},0 {w:0.##},0 {w * 0.8:0.##},{h:0.##} 0,{h:0.##}",
            "flowChartManualInput" => $"0,{h * 0.15:0.##} {w:0.##},0 {w:0.##},{h:0.##} 0,{h:0.##}",
            "flowChartManualOperation" => $"0,0 {w:0.##},0 {w * 0.85:0.##},{h:0.##} {w * 0.15:0.##},{h:0.##}",
            "flowChartPreparation" => $"{w * 0.15:0.##},0 {w * 0.85:0.##},0 {w:0.##},{h / 2:0.##} {w * 0.85:0.##},{h:0.##} {w * 0.15:0.##},{h:0.##} 0,{h / 2:0.##}",
            "flowChartExtract" => $"{w / 2:0.##},0 {w:0.##},{h:0.##} 0,{h:0.##}",
            "flowChartMerge" => $"0,0 {w:0.##},0 {w / 2:0.##},{h:0.##}",
            "flowChartDocument" => BuildDocumentPath(w, h),
            "flowChartMultidocument" => BuildDocumentPath(w * 0.9, h * 0.9), // simplified
            "flowChartDelay" => BuildDelayPath(w, h),
            "flowChartSort" => $"{w / 2:0.##},0 {w:0.##},{h / 2:0.##} {w / 2:0.##},{h:0.##} 0,{h / 2:0.##}",
            "flowChartCollate" => $"{w / 2:0.##},0 {w:0.##},{h / 2:0.##} {w / 2:0.##},{h:0.##} 0,{h / 2:0.##}",
            "flowChartDisplay" => BuildDisplayPath(w, h),
            "flowChartPunchedCard" => $"{w * 0.12:0.##},0 {w:0.##},0 {w:0.##},{h:0.##} 0,{h:0.##} 0,{h * 0.15:0.##}",
            "flowChartPunchedTape" => BuildDocumentPath(w, h),
            "flowChartConnector" or "flowChartOffpageConnector" => null, // ellipse handled separately

            // Snip rectangles
            "snip1Rect" => $"0,0 {w * 0.92:0.##},0 {w:0.##},{h * 0.08:0.##} {w:0.##},{h:0.##} 0,{h:0.##}",
            "snip2SameRect" => $"{w * 0.08:0.##},0 {w * 0.92:0.##},0 {w:0.##},{h * 0.08:0.##} {w:0.##},{h:0.##} 0,{h:0.##} 0,{h * 0.08:0.##}",

            // Special shapes
            "lightningBolt" => $"{w * 0.4:0.##},0 {w * 0.65:0.##},{h * 0.35:0.##} {w * 0.52:0.##},{h * 0.35:0.##} {w:0.##},{h * 0.6:0.##} {w * 0.55:0.##},{h * 0.6:0.##} {w * 0.7:0.##},{h:0.##} {w * 0.2:0.##},{h * 0.55:0.##} {w * 0.4:0.##},{h * 0.55:0.##} 0,{h * 0.35:0.##} {w * 0.3:0.##},{h * 0.35:0.##}",
            "sun" => BuildSunPath(w, h),
            "moon" => BuildMoonPath(w, h),
            "smileyFace" or "smiley" => null, // handled as ellipse below
            "donut" or "noSmoking" => null, // handled specially
            "foldedCorner" => $"0,0 {w * 0.85:0.##},0 {w:0.##},{h * 0.15:0.##} {w:0.##},{h:0.##} 0,{h:0.##}",
            "cube" => $"0,{h * 0.2:0.##} {w * 0.8:0.##},{h * 0.2:0.##} {w:0.##},0 {w:0.##},{h * 0.8:0.##} {w * 0.8:0.##},{h:0.##} 0,{h:0.##}",
            "can" or "cylinder" => null, // handled specially

            // Left/right arrow
            "leftRightArrow" => $"0,{h / 2:0.##} {w * 0.15:0.##},0 {w * 0.15:0.##},{h * 0.25:0.##} {w * 0.85:0.##},{h * 0.25:0.##} {w * 0.85:0.##},0 {w:0.##},{h / 2:0.##} {w * 0.85:0.##},{h:0.##} {w * 0.85:0.##},{h * 0.75:0.##} {w * 0.15:0.##},{h * 0.75:0.##} {w * 0.15:0.##},{h:0.##}",
            "notchedRightArrow" => $"0,{h * 0.25:0.##} {w * 0.7:0.##},{h * 0.25:0.##} {w * 0.7:0.##},0 {w:0.##},{h / 2:0.##} {w * 0.7:0.##},{h:0.##} {w * 0.7:0.##},{h * 0.75:0.##} 0,{h * 0.75:0.##} {w * 0.1:0.##},{h / 2:0.##}",

            // Cloud / callout - approximate with polygon
            "cloud" or "cloudCallout" => BuildCloudPath(w, h),

            // Callout shapes with tail
            "wedgeRectCallout" => $"0,0 {w:0.##},0 {w:0.##},{h * 0.75:0.##} {w * 0.55:0.##},{h * 0.75:0.##} {w * 0.35:0.##},{h:0.##} {w * 0.4:0.##},{h * 0.75:0.##} 0,{h * 0.75:0.##}",
            "wedgeRoundRectCallout" => $"{w * 0.08:0.##},0 {w * 0.92:0.##},0 {w:0.##},{h * 0.08:0.##} {w:0.##},{h * 0.67:0.##} {w * 0.92:0.##},{h * 0.75:0.##} {w * 0.55:0.##},{h * 0.75:0.##} {w * 0.35:0.##},{h:0.##} {w * 0.4:0.##},{h * 0.75:0.##} {w * 0.08:0.##},{h * 0.75:0.##} 0,{h * 0.67:0.##} 0,{h * 0.08:0.##}",
            "wedgeEllipseCallout" => BuildEllipseCalloutPath(w, h),

            _ => null
        };
    }

    private static string BuildRegularPolygon(int sides, double w, double h)
    {
        var points = new List<string>();
        for (int i = 0; i < sides; i++)
        {
            var angle = -Math.PI / 2 + 2 * Math.PI * i / sides;
            var px = w / 2 + w / 2 * Math.Cos(angle);
            var py = h / 2 + h / 2 * Math.Sin(angle);
            points.Add($"{px:0.##},{py:0.##}");
        }
        return string.Join(" ", points);
    }

    private static string BuildStar(int pointCount, double w, double h, double innerRatio = 0.4)
    {
        var points = new List<string>();
        var outerR = Math.Min(w, h) / 2;
        var innerR = outerR * innerRatio;
        for (int i = 0; i < pointCount * 2; i++)
        {
            var angle = -Math.PI / 2 + Math.PI * i / pointCount;
            var r = i % 2 == 0 ? outerR : innerR;
            var px = w / 2 + r * Math.Cos(angle) * (w / Math.Min(w, h));
            var py = h / 2 + r * Math.Sin(angle) * (h / Math.Min(w, h));
            points.Add($"{px:0.##},{py:0.##}");
        }
        return string.Join(" ", points);
    }

    private static string BuildHeartPath(double w, double h)
    {
        // Heart parametric equation with better proportions
        var points = new List<string>();
        int n = 48;
        for (int i = 0; i <= n; i++)
        {
            var t = 2 * Math.PI * i / n;
            var hx = 16 * Math.Pow(Math.Sin(t), 3);
            var hy = -(13 * Math.Cos(t) - 5 * Math.Cos(2 * t) - 2 * Math.Cos(3 * t) - Math.Cos(4 * t));
            // Scale to fit bounding box: hx range is [-16,16], hy range is [-17,15]
            var px = w / 2 + hx / 16 * w * 0.48;
            var py = h * 0.4 + hy / 17 * h * 0.48;
            points.Add($"{px:0.##},{py:0.##}");
        }
        return string.Join(" ", points);
    }

    private static string BuildSunPath(double w, double h)
    {
        // Sun: circle body + triangle rays
        var points = new List<string>();
        var cx = w / 2; var cy = h / 2;
        var outerR = Math.Min(w, h) / 2;
        var innerR = outerR * 0.55;
        int rays = 12;
        for (int i = 0; i < rays * 2; i++)
        {
            var angle = -Math.PI / 2 + Math.PI * i / rays;
            var r = i % 2 == 0 ? outerR : innerR;
            points.Add($"{cx + r * Math.Cos(angle) * (w / Math.Min(w, h)):0.##},{cy + r * Math.Sin(angle) * (h / Math.Min(w, h)):0.##}");
        }
        return string.Join(" ", points);
    }

    private static string BuildMoonPath(double w, double h)
    {
        // Crescent moon
        var points = new List<string>();
        int n = 20;
        // Outer arc (full circle left half)
        for (int i = 0; i <= n; i++)
        {
            var angle = Math.PI / 2 + Math.PI * i / n;
            var px = w / 2 + w * 0.45 * Math.Cos(angle);
            var py = h / 2 + h * 0.45 * Math.Sin(angle);
            points.Add($"{px:0.##},{py:0.##}");
        }
        // Inner arc (concave right side)
        for (int i = n; i >= 0; i--)
        {
            var angle = Math.PI / 2 + Math.PI * i / n;
            var px = w * 0.35 + w * 0.3 * Math.Cos(angle);
            var py = h / 2 + h * 0.35 * Math.Sin(angle);
            points.Add($"{px:0.##},{py:0.##}");
        }
        return string.Join(" ", points);
    }

    private static string BuildDocumentPath(double w, double h)
    {
        // Rectangle with wavy bottom
        var points = new List<string> { $"0,0", $"{w:0.##},0", $"{w:0.##},{h * 0.8:0.##}" };
        int n = 12;
        for (int i = 0; i <= n; i++)
        {
            var px = w * (1 - (double)i / n);
            var py = h * 0.8 + h * 0.1 * Math.Sin(Math.PI * 2 * i / n);
            points.Add($"{px:0.##},{py:0.##}");
        }
        return string.Join(" ", points);
    }

    private static string BuildDelayPath(double w, double h)
    {
        // Rect with right semicircle
        var points = new List<string> { $"0,0", $"{w * 0.6:0.##},0" };
        int n = 12;
        for (int i = 0; i <= n; i++)
        {
            var angle = -Math.PI / 2 + Math.PI * i / n;
            var px = w * 0.6 + w * 0.4 * Math.Cos(angle);
            var py = h / 2 + h / 2 * Math.Sin(angle);
            points.Add($"{px:0.##},{py:0.##}");
        }
        points.Add($"0,{h:0.##}");
        return string.Join(" ", points);
    }

    private static string BuildDisplayPath(double w, double h)
    {
        // Hexagon-like with right rounded side
        var points = new List<string>
        {
            $"{w * 0.15:0.##},0", $"{w * 0.7:0.##},0"
        };
        int n = 8;
        for (int i = 0; i <= n; i++)
        {
            var angle = -Math.PI / 2 + Math.PI * i / n;
            var px = w * 0.7 + w * 0.3 * Math.Cos(angle);
            var py = h / 2 + h / 2 * Math.Sin(angle);
            points.Add($"{px:0.##},{py:0.##}");
        }
        points.Add($"{w * 0.15:0.##},{h:0.##}");
        points.Add($"0,{h / 2:0.##}");
        return string.Join(" ", points);
    }

    private static string BuildEllipseCalloutPath(double w, double h)
    {
        var points = new List<string>();
        // Main ellipse (75% height)
        int n = 24;
        double eh = h * 0.75;
        for (int i = 0; i <= n; i++)
        {
            var angle = 2 * Math.PI * i / n;
            // Insert tail at bottom (~6 o'clock position)
            if (i == n * 3 / 8) // ~135 degrees
            {
                points.Add($"{w * 0.55:0.##},{eh / 2 + eh / 2 * Math.Sin(angle):0.##}");
                points.Add($"{w * 0.35:0.##},{h:0.##}"); // tail tip
                points.Add($"{w * 0.4:0.##},{eh / 2 + eh / 2 * Math.Sin(angle):0.##}");
            }
            var px = w / 2 + w / 2 * Math.Cos(angle);
            var py = eh / 2 + eh / 2 * Math.Sin(angle);
            points.Add($"{px:0.##},{py:0.##}");
        }
        return string.Join(" ", points);
    }

    private static string BuildCloudPath(double w, double h)
    {
        // Cloud shape approximated with overlapping circles as polygon
        var points = new List<string>();
        // Bottom arc
        AddArcPoints(points, w * 0.5, h * 0.85, w * 0.45, h * 0.2, Math.PI * 0.0, Math.PI * 1.0, 16);
        // Left arc
        AddArcPoints(points, w * 0.15, h * 0.55, w * 0.18, h * 0.35, Math.PI * 0.7, Math.PI * 1.5, 10);
        // Top-left arc
        AddArcPoints(points, w * 0.3, h * 0.25, w * 0.2, h * 0.22, Math.PI * 1.0, Math.PI * 1.8, 10);
        // Top arc
        AddArcPoints(points, w * 0.55, h * 0.18, w * 0.22, h * 0.2, Math.PI * 1.2, Math.PI * 2.0, 10);
        // Top-right arc
        AddArcPoints(points, w * 0.75, h * 0.28, w * 0.2, h * 0.25, Math.PI * 1.5, Math.PI * 2.2, 10);
        // Right arc
        AddArcPoints(points, w * 0.85, h * 0.55, w * 0.18, h * 0.35, Math.PI * 1.5, Math.PI * 2.3, 10);
        return string.Join(" ", points);
    }

    private static void AddArcPoints(List<string> points, double cx, double cy,
        double rx, double ry, double startAngle, double endAngle, int segments)
    {
        for (int i = 0; i <= segments; i++)
        {
            var angle = startAngle + (endAngle - startAngle) * i / segments;
            var px = cx + rx * Math.Cos(angle);
            var py = cy + ry * Math.Sin(angle);
            points.Add($"{px:0.##},{py:0.##}");
        }
    }

    // ==================== SVG Gradient ====================

    private static string? BuildSvgGradient(Drawing.GradientFill gradFill,
        Dictionary<string, string> themeColors, ref int defId, StringBuilder defs)
    {
        var stops = gradFill.GradientStopList?.Elements<Drawing.GradientStop>().ToList();
        if (stops == null || stops.Count < 2) return null;

        // Build stop elements
        var stopElements = new List<string>();
        foreach (var gs in stops)
        {
            string stopColor = "#000000";
            double stopOpacity = 1.0;

            var color = ResolveFillColor(gs.GetFirstChild<Drawing.SolidFill>(), themeColors);
            if (color == null)
            {
                var rgb = gs.GetFirstChild<Drawing.RgbColorModelHex>()?.Val?.Value;
                if (rgb != null && rgb.Length >= 6)
                {
                    color = $"#{rgb[..6]}";
                    var alpha = gs.GetFirstChild<Drawing.RgbColorModelHex>()?.GetFirstChild<Drawing.Alpha>()?.Val?.Value;
                    if (alpha.HasValue) stopOpacity = alpha.Value / 100000.0;
                }
                else
                {
                    var scheme = gs.GetFirstChild<Drawing.SchemeColor>();
                    if (scheme?.Val?.InnerText != null && themeColors.TryGetValue(scheme.Val.InnerText, out var tc))
                    {
                        color = $"#{ApplyColorTransforms(tc, scheme)}".Replace("rgba(", "").Replace(")", "");
                        // Re-resolve properly
                        var resolved = ApplyColorTransforms(tc, scheme);
                        ParseSvgColor(resolved, out color, out stopOpacity);
                    }
                }
            }
            else
            {
                ParseSvgColor(color, out stopColor, out stopOpacity);
                color = stopColor;
            }

            var pos = gs.Position?.Value;
            var offset = pos.HasValue ? $"{pos.Value / 1000.0:0.##}%" : "";
            var opacityAttr = stopOpacity < 1.0 ? $" stop-opacity=\"{stopOpacity:0.##}\"" : "";
            stopElements.Add($"  <stop offset=\"{offset}\" stop-color=\"{color}\"{opacityAttr}/>");
        }

        var gradId = $"grad{defId++}";

        // Radial or linear?
        var pathGrad = gradFill.GetFirstChild<Drawing.PathGradientFill>();
        if (pathGrad != null)
        {
            defs.AppendLine($"<radialGradient id=\"{gradId}\">");
            foreach (var s in stopElements) defs.AppendLine(s);
            defs.AppendLine("</radialGradient>");
        }
        else
        {
            var linear = gradFill.GetFirstChild<Drawing.LinearGradientFill>();
            var angleDeg = linear?.Angle?.HasValue == true ? linear.Angle.Value / 60000.0 : 90.0;
            // OOXML angle: 0=right, 90=bottom. Convert to SVG gradient coordinates.
            var angleRad = (angleDeg + 90) * Math.PI / 180;
            var x1 = 50 - 50 * Math.Cos(angleRad);
            var y1 = 50 - 50 * Math.Sin(angleRad);
            var x2 = 50 + 50 * Math.Cos(angleRad);
            var y2 = 50 + 50 * Math.Sin(angleRad);

            defs.AppendLine($"<linearGradient id=\"{gradId}\" x1=\"{x1:0.##}%\" y1=\"{y1:0.##}%\" x2=\"{x2:0.##}%\" y2=\"{y2:0.##}%\">");
            foreach (var s in stopElements) defs.AppendLine(s);
            defs.AppendLine("</linearGradient>");
        }

        return gradId;
    }

    // ==================== SVG Effects ====================

    private static string? BuildSvgShadowFilter(Drawing.EffectList effectList,
        Dictionary<string, string> themeColors, ref int defId, StringBuilder defs)
    {
        var shadow = effectList.GetFirstChild<Drawing.OuterShadow>();
        if (shadow == null) return null;

        // Absent <a:alpha> => fully opaque (OOXML default), matching PowerPoint.
        var alpha = shadow.Descendants<Drawing.Alpha>().FirstOrDefault()?.Val?.Value ?? 100000;
        var opacity = alpha / 100000.0;

        var rgb = shadow.GetFirstChild<Drawing.RgbColorModelHex>()?.Val?.Value;
        int r = 0, g = 0, b = 0;
        if (rgb != null && rgb.Length >= 6)
        {
            (r, g, b) = ColorMath.HexToRgb(rgb);
        }
        else
        {
            var schemeColor = shadow.GetFirstChild<Drawing.SchemeColor>()?.Val?.InnerText;
            if (schemeColor != null && themeColors.TryGetValue(schemeColor, out var sc) && sc.Length >= 6)
            {
                (r, g, b) = ColorMath.HexToRgb(sc);
            }
        }

        var blurPx = EmuToPx(shadow.BlurRadius?.HasValue == true ? shadow.BlurRadius.Value : 50800);
        var distPx = EmuToPx(shadow.Distance?.HasValue == true ? shadow.Distance.Value : 38100);
        var angleDeg = shadow.Direction?.HasValue == true ? shadow.Direction.Value / 60000.0 : 45;
        var angleRad = angleDeg * Math.PI / 180;
        var dx = distPx * Math.Cos(angleRad);
        var dy = distPx * Math.Sin(angleRad);

        var filterId = $"shadow{defId++}";
        defs.AppendLine($"<filter id=\"{filterId}\" x=\"-20%\" y=\"-20%\" width=\"150%\" height=\"150%\">");
        defs.AppendLine($"  <feDropShadow dx=\"{dx:0.##}\" dy=\"{dy:0.##}\" stdDeviation=\"{blurPx / 2:0.##}\" flood-color=\"rgb({r},{g},{b})\" flood-opacity=\"{opacity:0.##}\"/>");
        defs.AppendLine("</filter>");

        return filterId;
    }

    private static string? BuildSvgGlowFilter(Drawing.EffectList effectList,
        Dictionary<string, string> themeColors, ref int defId, StringBuilder defs)
    {
        var glow = effectList.GetFirstChild<Drawing.Glow>();
        if (glow == null) return null;

        var radiusPx = EmuToPx(glow.Radius?.HasValue == true ? glow.Radius.Value : 63500);
        var alpha = glow.Descendants<Drawing.Alpha>().FirstOrDefault()?.Val?.Value ?? 40000;
        var opacity = alpha / 100000.0;

        int r = 0, g = 0, b = 0;
        var rgb = glow.GetFirstChild<Drawing.RgbColorModelHex>()?.Val?.Value;
        if (rgb != null && rgb.Length >= 6)
        {
            (r, g, b) = ColorMath.HexToRgb(rgb);
        }
        else
        {
            var scheme = glow.GetFirstChild<Drawing.SchemeColor>()?.Val?.InnerText;
            if (scheme != null && themeColors.TryGetValue(scheme, out var sc) && sc.Length >= 6)
            {
                (r, g, b) = ColorMath.HexToRgb(sc);
            }
        }

        var filterId = $"glow{defId++}";
        defs.AppendLine($"<filter id=\"{filterId}\" x=\"-30%\" y=\"-30%\" width=\"160%\" height=\"160%\">");
        defs.AppendLine($"  <feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{radiusPx:0.##}\" result=\"blur\"/>");
        defs.AppendLine($"  <feFlood flood-color=\"rgb({r},{g},{b})\" flood-opacity=\"{opacity:0.##}\" result=\"color\"/>");
        defs.AppendLine("  <feComposite in=\"color\" in2=\"blur\" operator=\"in\" result=\"glow\"/>");
        defs.AppendLine("  <feMerge><feMergeNode in=\"glow\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>");
        defs.AppendLine("</filter>");

        return filterId;
    }

    /// <summary>
    /// Convert OOXML CustomGeometry path data to SVG path d attribute.
    /// </summary>
    private static string? CustomGeometryToSvgPath(Drawing.CustomGeometry custGeom, double w, double h)
    {
        var pathList = custGeom.GetFirstChild<Drawing.PathList>();
        if (pathList == null) return null;

        var path = pathList.GetFirstChild<Drawing.Path>();
        if (path == null) return null;

        var pathW = path.Width?.HasValue == true ? path.Width.Value : 1;
        var pathH = path.Height?.HasValue == true ? path.Height.Value : 1;
        if (pathW == 0) pathW = 1;
        if (pathH == 0) pathH = 1;

        // Helper to parse point coordinate
        double Px(Drawing.Point p) => long.TryParse(p.X?.Value, out var v) ? v * w / pathW : 0;
        double Py(Drawing.Point p) => long.TryParse(p.Y?.Value, out var v) ? v * h / pathH : 0;

        var sb = new StringBuilder();
        foreach (var child in path.ChildElements)
        {
            switch (child.LocalName)
            {
                case "moveTo":
                    var mt = child.GetFirstChild<Drawing.Point>();
                    if (mt != null)
                        sb.Append($"M{Px(mt):0.##},{Py(mt):0.##} ");
                    break;
                case "lnTo":
                    var lt = child.GetFirstChild<Drawing.Point>();
                    if (lt != null)
                        sb.Append($"L{Px(lt):0.##},{Py(lt):0.##} ");
                    break;
                case "cubicBezTo":
                    var pts = child.Elements<Drawing.Point>().ToList();
                    if (pts.Count >= 3)
                        sb.Append($"C{Px(pts[0]):0.##},{Py(pts[0]):0.##} {Px(pts[1]):0.##},{Py(pts[1]):0.##} {Px(pts[2]):0.##},{Py(pts[2]):0.##} ");
                    break;
                case "quadBezTo":
                    var qpts = child.Elements<Drawing.Point>().ToList();
                    if (qpts.Count >= 2)
                        sb.Append($"Q{Px(qpts[0]):0.##},{Py(qpts[0]):0.##} {Px(qpts[1]):0.##},{Py(qpts[1]):0.##} ");
                    break;
                case "arcTo":
                    break; // Complex to convert — skip
                case "close":
                    sb.Append("Z ");
                    break;
            }
        }

        var result = sb.ToString().Trim();
        return string.IsNullOrEmpty(result) ? null : result;
    }

    private static string? BuildSvgSoftEdgeFilter(Drawing.EffectList effectList,
        ref int defId, StringBuilder defs)
    {
        var softEdge = effectList.GetFirstChild<Drawing.SoftEdge>();
        if (softEdge?.Radius?.HasValue != true) return null;

        var radiusPx = Math.Max(1, EmuToPx(softEdge.Radius.Value) * 0.5);
        var filterId = $"soft{defId++}";
        defs.AppendLine($"<filter id=\"{filterId}\" x=\"-5%\" y=\"-5%\" width=\"110%\" height=\"110%\">");
        defs.AppendLine($"  <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{radiusPx:0.##}\"/>");
        defs.AppendLine("</filter>");
        return filterId;
    }

    // ==================== SVG Helpers ====================

    /// <summary>
    /// Read an adjustment value from PresetGeometry's AdjustValueList.
    /// OOXML stores adj values as "val NNNNN" in ShapeGuide formulas.
    /// </summary>
    private static long ReadAdjValue(Drawing.PresetGeometry? presetGeom, int index, long defaultValue)
    {
        var avList = presetGeom?.GetFirstChild<Drawing.AdjustValueList>();
        if (avList == null) return defaultValue;

        var guides = avList.Elements<Drawing.ShapeGuide>().ToList();
        if (index >= guides.Count) return defaultValue;

        var formula = guides[index].Formula?.Value;
        if (formula != null && formula.StartsWith("val "))
        {
            if (long.TryParse(formula.AsSpan(4), out var parsed))
                return parsed;
        }
        return defaultValue;
    }

    /// <summary>
    /// Convert OOXML Math (OMML) to standard MathML for browser-native rendering.
    /// </summary>
    private static string? OmmlToMathMl(OpenXmlElement oMath)
    {
        try
        {
            var sb = new StringBuilder();
            sb.Append("<math xmlns=\"http://www.w3.org/1998/Math/MathML\" display=\"block\">");
            ConvertOmmlNode(sb, oMath);
            sb.Append("</math>");
            return sb.ToString();
        }
        catch { return null; }
    }

    private static void ConvertOmmlNode(StringBuilder sb, OpenXmlElement node)
    {
        foreach (var child in node.ChildElements)
        {
            switch (child.LocalName)
            {
                case "r": // Run (text)
                    var text = child.Descendants().FirstOrDefault(e => e.LocalName == "t")?.InnerText ?? "";
                    if (text.Length > 0 && text.All(c => char.IsDigit(c) || c == '.'))
                        sb.Append($"<mn>{SvgEncode(text)}</mn>");
                    else if (text.Length > 0 && text.All(c => "+-*/=<>≤≥≠±∓×÷^|&~!@#%".Contains(c)))
                        sb.Append($"<mo>{SvgEncode(text)}</mo>");
                    else
                        sb.Append($"<mi>{SvgEncode(text)}</mi>");
                    break;
                case "f": // Fraction
                    sb.Append("<mfrac>");
                    var num = child.ChildElements.FirstOrDefault(e => e.LocalName == "num");
                    var den = child.ChildElements.FirstOrDefault(e => e.LocalName == "den");
                    sb.Append("<mrow>"); if (num != null) ConvertOmmlNode(sb, num); sb.Append("</mrow>");
                    sb.Append("<mrow>"); if (den != null) ConvertOmmlNode(sb, den); sb.Append("</mrow>");
                    sb.Append("</mfrac>");
                    break;
                case "rad": // Radical (sqrt)
                    var deg = child.ChildElements.FirstOrDefault(e => e.LocalName == "deg");
                    var radE = child.ChildElements.FirstOrDefault(e => e.LocalName == "e");
                    if (deg != null && deg.Descendants().Any(e => e.LocalName == "t" && !string.IsNullOrEmpty(e.InnerText)))
                    {
                        sb.Append("<mroot>");
                        sb.Append("<mrow>"); if (radE != null) ConvertOmmlNode(sb, radE); sb.Append("</mrow>");
                        sb.Append("<mrow>"); ConvertOmmlNode(sb, deg); sb.Append("</mrow>");
                        sb.Append("</mroot>");
                    }
                    else
                    {
                        sb.Append("<msqrt>");
                        if (radE != null) ConvertOmmlNode(sb, radE);
                        sb.Append("</msqrt>");
                    }
                    break;
                case "sSup": // Superscript
                    var supBase = child.ChildElements.FirstOrDefault(e => e.LocalName == "e");
                    var sup = child.ChildElements.FirstOrDefault(e => e.LocalName == "sup");
                    sb.Append("<msup>");
                    sb.Append("<mrow>"); if (supBase != null) ConvertOmmlNode(sb, supBase); sb.Append("</mrow>");
                    sb.Append("<mrow>"); if (sup != null) ConvertOmmlNode(sb, sup); sb.Append("</mrow>");
                    sb.Append("</msup>");
                    break;
                case "sSub": // Subscript
                    var subBase = child.ChildElements.FirstOrDefault(e => e.LocalName == "e");
                    var sub = child.ChildElements.FirstOrDefault(e => e.LocalName == "sub");
                    sb.Append("<msub>");
                    sb.Append("<mrow>"); if (subBase != null) ConvertOmmlNode(sb, subBase); sb.Append("</mrow>");
                    sb.Append("<mrow>"); if (sub != null) ConvertOmmlNode(sb, sub); sb.Append("</mrow>");
                    sb.Append("</msub>");
                    break;
                case "sSubSup": // SubSuperscript
                    var ssBase = child.ChildElements.FirstOrDefault(e => e.LocalName == "e");
                    var ssSub = child.ChildElements.FirstOrDefault(e => e.LocalName == "sub");
                    var ssSup = child.ChildElements.FirstOrDefault(e => e.LocalName == "sup");
                    sb.Append("<msubsup>");
                    sb.Append("<mrow>"); if (ssBase != null) ConvertOmmlNode(sb, ssBase); sb.Append("</mrow>");
                    sb.Append("<mrow>"); if (ssSub != null) ConvertOmmlNode(sb, ssSub); sb.Append("</mrow>");
                    sb.Append("<mrow>"); if (ssSup != null) ConvertOmmlNode(sb, ssSup); sb.Append("</mrow>");
                    sb.Append("</msubsup>");
                    break;
                case "nary": // N-ary (sum, integral, product)
                    var naryPr = child.ChildElements.FirstOrDefault(e => e.LocalName == "naryPr");
                    var naryChar = naryPr?.Descendants().FirstOrDefault(e => e.LocalName == "chr")?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value;
                    var narySub = child.ChildElements.FirstOrDefault(e => e.LocalName == "sub");
                    var narySup = child.ChildElements.FirstOrDefault(e => e.LocalName == "sup");
                    var naryE = child.ChildElements.FirstOrDefault(e => e.LocalName == "e");
                    sb.Append("<mrow>");
                    sb.Append("<munderover>");
                    sb.Append($"<mo>{SvgEncode(naryChar ?? "\u222B")}</mo>");
                    sb.Append("<mrow>"); if (narySub != null) ConvertOmmlNode(sb, narySub); sb.Append("</mrow>");
                    sb.Append("<mrow>"); if (narySup != null) ConvertOmmlNode(sb, narySup); sb.Append("</mrow>");
                    sb.Append("</munderover>");
                    if (naryE != null) ConvertOmmlNode(sb, naryE);
                    sb.Append("</mrow>");
                    break;
                case "d": // Delimiter (parentheses, brackets, etc.)
                    var dPr = child.ChildElements.FirstOrDefault(e => e.LocalName == "dPr");
                    var begChr = dPr?.Descendants().FirstOrDefault(e => e.LocalName == "begChr")?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? "(";
                    var endChr = dPr?.Descendants().FirstOrDefault(e => e.LocalName == "endChr")?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? ")";
                    var sepChr = dPr?.Descendants().FirstOrDefault(e => e.LocalName == "sepChr")?.GetAttributes().FirstOrDefault(a => a.LocalName == "val").Value ?? ",";
                    var dElements = child.ChildElements.Where(e => e.LocalName == "e").ToList();
                    sb.Append($"<mrow><mo>{SvgEncode(begChr)}</mo>");
                    for (int di = 0; di < dElements.Count; di++)
                    {
                        if (di > 0) sb.Append($"<mo>{SvgEncode(sepChr)}</mo>");
                        ConvertOmmlNode(sb, dElements[di]);
                    }
                    sb.Append($"<mo>{SvgEncode(endChr)}</mo></mrow>");
                    break;
                case "oMath" or "oMathPara":
                    ConvertOmmlNode(sb, child);
                    break;
                default:
                    // Recurse for unknown container elements
                    if (child.HasChildren)
                        ConvertOmmlNode(sb, child);
                    break;
            }
        }
    }

    /// <summary>
    /// PowerPoint single-spacing line pitch for a resolved typeface: the bare
    /// ascent+descent(+lineGap) ratio (~1.2× Latin faces, ~1.32× CJK faces
    /// like Microsoft YaHei — issue #236). Unresolvable/unlocatable fonts fall
    /// back to the 1.2 Latin norm (GetPitchRatio returns 1.0 on failure, which
    /// is never a real line pitch).
    /// </summary>
    internal static double SingleSpacingPitch(string? typeface)
    {
        if (typeface != null && !typeface.StartsWith("+", StringComparison.Ordinal))
        {
            var r = FontMetricsReader.GetPitchRatio(typeface);
            if (r > 1.0) return r;
        }
        return 1.2;
    }

    /// <summary>Pitch from a run's explicit latin/ea typeface (theme tokens fall back to 1.2).</summary>
    internal static double SingleSpacingPitch(Drawing.RunProperties? rp)
        => SingleSpacingPitch(rp?.GetFirstChild<Drawing.LatinFont>()?.Typeface?.Value
            ?? rp?.GetFirstChild<Drawing.EastAsianFont>()?.Typeface?.Value);

    private static string SvgEncode(string text)
    {
        return text
            .Replace("&", "&amp;")
            .Replace("<", "&lt;")
            .Replace(">", "&gt;")
            .Replace("\"", "&quot;")
            .Replace("'", "&apos;");
    }
}
