在 Office 文档里面,可以使用自己定制的自绘制形状,自己绘制的内容将会存放为 pathLst 也就是 List of Shape Paths 内容到文档里面。本文将告诉大家如何将 PathLst 自定义形状转换为标准的 SVG 路径,以支持在 WPF 或 UWP 中的 Path 元素作为 Geometry 显示

在 ECMA 376 标准里面的 20.1.9.16 有对 PathLst 做详细的规定,本文的方法适合于符合 ECMA 376 的 Office 文档,包括 PPT 和 Word 和 Excel 等文档

开始之前请先看一下效果,下面是 PowerPoint 里面的内容

下面是一个简单的 WPF 应用,读取这份文档的内容,将里面的形状显示出来

以上的全部代码放在 githubgitee 欢迎下载测试

通过 ECMA 376 的 20.1.9.16 文档可以了解到在自定义形状上,使用 a:custGeom 表示,而具体的形状使用 a:pathLst 表示,一个例子的内容如下

<a:custGeom>
 <a:pathLst>
   <a:path w="2824222" h="590309">
     <a:moveTo>
       <a:pt x="0" y="428263"/>
     </a:moveTo>
     <a:lnTo>
       <a:pt x="1620455" y="590309"/>
     </a:lnTo>
   </a:path>
 </a:pathLst>
</a:custGeom>

在 OpenXML SDK 里面,读取页面里面所有的自定义形状,可以使用如下代码

            using (var presentationDocument =
                DocumentFormat.OpenXml.Packaging.PresentationDocument.Open("自定义形状.pptx", false))
            {
                var presentationPart = presentationDocument.PresentationPart;
                var presentation = presentationPart.Presentation;

                // 先获取页面
                var slideIdList = presentation.SlideIdList;

                foreach (var slideId in slideIdList.ChildElements.OfType<SlideId>())
                {
                    // 获取页面内容
                    SlidePart slidePart = (SlidePart) presentationPart.GetPartById(slideId.RelationshipId);

                    var slide = slidePart.Slide;

                    foreach (var customGeometry in slide.Descendants<DocumentFormat.OpenXml.Drawing.CustomGeometry>())
                    {
                    }
                }
            }

在获取到 CustomGeometry 对象之后,可以尝试去读取他的 PathList 内容,如下面代码

 var pathList = customGeometry.Descendants<PathList>().FirstOrDefault();

接下来还请自行百度 svg 规范,了解在 svg 中各个 Key 的作用,包括 M 表示 MoveTo 而 L 表示 LineTo 等等。在 PathList 里面可以选择的值如下

  • MoveTo
  • LineTo
  • ArcTo
  • QuadraticBezierCurveTo
  • CubicBezierCurveTo
  • CloseShapePath

刚刚好和 svg 的 MLAQCZ 对应上,可以使用如下方式转换

        public static (string stringPath, bool isLine) BuildPathString(PathList pathList)
        {
            var stringPath = new StringBuilder(128);
            bool isLine = true;

            foreach (var path in pathList.Elements<DocumentFormat.OpenXml.Drawing.Path>())
            {
                EmuPoint currentPixelPoint = default;
                foreach (var pathData in path.ChildElements)
                {
                    currentPixelPoint =
                        ConvertToPathString(pathData, stringPath, currentPixelPoint, out var isPathLine);
                    if (!isPathLine)
                    {
                        isLine = false;
                    }
                }
            }

            return (stringPath.ToString(), isLine);
        }

        private static EmuPoint ConvertToPathString(OpenXmlElement pathData, StringBuilder stringPath,
            EmuPoint currentPoint, out bool isLine)
        {
            isLine = true;

            switch (pathData)
            {
                case MoveTo moveTo:
                {
                    // 关于定义的 Key 的值请百度参考 svg 规范
                    var defineKey = "M";
                    var moveToPoint = moveTo.Point;
                    if (moveToPoint?.X != null && moveToPoint?.Y != null)
                    {
                        stringPath.Append(defineKey);
                        var emuPoint = moveToPoint.PointToEmuPoint();
                        var point = emuPoint.ToPixelPoint();
                        PointToString(point);
                        return emuPoint;
                    }

                    break;
                }
                case LineTo lineTo:
                {
                    var defineKey = "L";

                    var lineToPoint = lineTo.Point;
                    if (lineToPoint?.X != null && lineToPoint?.Y != null)
                    {
                        stringPath.Append(defineKey);
                        var emuPoint = lineToPoint.PointToEmuPoint();
                        var point = emuPoint.ToPixelPoint();
                        PointToString(point);
                        return emuPoint;
                    }

                    break;
                }
                case ArcTo arcTo:
                {
                    Degree swingAngDegree = new Degree(0);
                    var swingAngleString = arcTo.SwingAngle;
                    if (swingAngleString != null)
                    {
                        if (int.TryParse(swingAngleString, out var swingAngle))
                        {
                            swingAngDegree = new Degree(swingAngle);
                        }
                    }

                    Degree startAngleDegree = new Degree(0);
                    var startAngleString = arcTo.StartAngle;
                    if (startAngleString != null)
                    {
                        if (int.TryParse(startAngleString.Value, out var startAngle))
                        {
                            startAngleDegree = new Degree(startAngle);
                        }
                    }

                    var widthRadius = EmuStringToEmu(arcTo.WidthRadius);
                    var heightRadius = EmuStringToEmu(arcTo.HeightRadius);

                    return ArcToToString(stringPath, currentPoint, widthRadius, heightRadius,
                        startAngleDegree, swingAngDegree);
                }
                case QuadraticBezierCurveTo quadraticBezierCurveTo:
                {
                    var defineKey = "Q";

                    return ConvertPointList(quadraticBezierCurveTo, defineKey, stringPath);
                }
                case CubicBezierCurveTo cubicBezierCurveTo:
                {
                    var defineKey = "C";

                    return ConvertPointList(cubicBezierCurveTo, defineKey, stringPath);
                }
                case CloseShapePath closeShapePath:
                {
                    var defineKey = "Z";
                    isLine = false;
                    stringPath.Append(defineKey);
                    break;
                }
            }

            return default;

            void PointToString(PixelPoint point) => PixelPointToString(point, stringPath);
        }

以上的 ArcToToString 代码如下

        private static EmuPoint ArcToToString(StringBuilder stringPath, EmuPoint currentPoint,
            Emu widthRadius,
            Emu heightRadius,
            Degree startAngleString, Degree swingAngleString)
        {
            const string comma = Comma;

            var stAng = DegreeToRadiansAngle(startAngleString);
            var swAng = DegreeToRadiansAngle(swingAngleString);

            var wR = widthRadius.Value;
            var hR = heightRadius.Value;

            var p1 = GetEllipsePoint(wR, hR, stAng);
            var p2 = GetEllipsePoint(wR, hR, stAng + swAng);
            var pt = new EmuPoint(currentPoint.X.Value - p1.X.Value + p2.X.Value,
                currentPoint.Y.Value - p1.Y.Value + p2.Y.Value);

            var isLargeArcFlag = swAng >= Math.PI;
            currentPoint = pt;

            // 格式如下
            // A rx ry x-axis-rotation large-arc-flag sweep-flag x y
            // 这里 large-arc-flag 是 1 和 0 表示
            stringPath.Append("A")
                .Append(EmuToPixelString(wR)) //rx
                .Append(comma)
                .Append(EmuToPixelString(hR)) //ry
                .Append(comma)
                .Append("0") // x-axis-rotation
                .Append(comma)
                .Append(isLargeArcFlag ? "1" : "0") //large-arc-flag
                .Append(comma)
                .Append("1") // sweep-flag
                .Append(comma)
                .Append(EmuToPixelString(pt.X))
                .Append(comma)
                .Append(EmuToPixelString(pt.Y))
                .Append(' ');
            return currentPoint;
        }

        private static double DegreeToRadiansAngle(Degree x)
        {
            return x.DoubleValue * Math.PI / 180;
        }

        private static EmuPoint GetEllipsePoint(double a, double b, double theta)
        {
            var aSinTheta = a * Math.Sin(theta);
            var bCosTheta = b * Math.Cos(theta);
            var circleRadius = Math.Sqrt(aSinTheta * aSinTheta + bCosTheta * bCosTheta);
            return new EmuPoint(a * bCosTheta / circleRadius, b * aSinTheta / circleRadius);
        }

这里面 OpenXML 的数值单位是 EMU 单位,和像素的转换请看 Office Open XML 的测量单位 而我这里使用开源的 dotnetCampus.OpenXMLUnitConverter 库 进行单位的转换

以下是我在此项目中用到的 NuGet 库

  <ItemGroup>
    <PackageReference Include="dotnetCampus.AsyncWorkerCollection" Version="1.6.0" />
    <PackageReference Include="dotnetCampus.OpenXMLUnitConverter" Version="1.0.4" />
    <PackageReference Include="DocumentFormat.OpenXml" Version="2.12.1" />
  </ItemGroup>

而以上定义的 EmuPoint 和 PixelPoint 指的是使用的 Point 的 X 和 Y 的单位分别是 Emu 和 Pixel 单位

        readonly struct EmuPoint
        {
            public EmuPoint(Emu x, Emu y)
            {
                X = x;
                Y = y;
            }

            public EmuPoint(double x, double y)
            {
                X = new Emu(x);
                Y = new Emu(y);
            }

            public Emu X { get; }
            public Emu Y { get; }
        }

        readonly struct PixelPoint
        {
            public PixelPoint(Pixel x, Pixel y)
            {
                X = x;
                Y = y;
            }

            public Pixel X { get; }
            public Pixel Y { get; }
        }

        static PixelPoint ToPixelPoint(this EmuPoint emuPoint)
        {
            return new PixelPoint(emuPoint.X.ToPixel(), emuPoint.Y.ToPixel());
        }

        static EmuPoint PointToEmuPoint(this Point? point)
        {
            var x = EmuStringToEmu(point?.X);
            var y = EmuStringToEmu(point?.Y);
            return new EmuPoint(x, y);
        }

在获取到了 Path 字符串之后,可以使用如下代码转换为 Geometry 元素

  var geometry = Geometry.Parse(stringPath);

在 XAML 上添加一个 Path 元素就可以显示

      <Path x:Name="Path" Stroke="Black" StrokeThickness="2"></Path>

      Path.Data = geometry;

更多的代码细节还请到 githubgitee 上阅读代码

可以通过如下方式获取本文的源代码,先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin adcdd72536b1405c70c7c7cf6fe4e2876da891dd

以上使用的是 gitee 的源,如果 gitee 不能访问,请替换为 github 的源

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git

获取代码之后,进入 LalyearnabodaLayheryacucha 文件夹

本文的属性是依靠 dotnet OpenXML 解压缩文档为文件夹工具 工具协助测试的,这个工具是开源免费的工具,欢迎小伙伴使用

如果遇到里面写的是公式,而不是具体的数值,请看 dotnet OpenXML SDK 形状几何 Geometry 的计算公式含义

我将本文的逻辑放到 DocumentFormat.OpenXml.Flatten 库里面,在 GitHub 上完全开源: https://github.com/dotnet-campus/DocumentFormat.OpenXml.Extensions

更多请看 Office 使用 OpenXML SDK 解析文档博客目录


本文会经常更新,请阅读原文: https://blog.lindexi.com/post/dotnet-OpenXML-%E8%AE%A9-PathLst-%E8%87%AA%E5%AE%9A%E4%B9%89%E5%BD%A2%E7%8A%B6%E8%BD%AC-SVG-%E8%B7%AF%E5%BE%84%E6%A0%BC%E5%BC%8F%E7%9A%84-Geometry-%E5%86%85%E5%AE%B9.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

如果你想持续阅读我的最新博客,请点击 RSS 订阅,推荐使用RSS Stalker订阅博客,或者收藏我的博客导航

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名林德熙(包含链接: https://blog.lindexi.com ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系

微软最具价值专家


无盈利,不卖课,做纯粹的技术博客

以下是广告时间

推荐关注 Edi.Wang 的公众号

欢迎进入 Eleven 老师组建的 .NET 社区

以上广告全是友情推广,无盈利