C#使用OpenXML生成一个PPT

PPT格式的生成一直是个难题
相较于Word和PDF,PPT有着严格的格式规定
NPOI官方文档中有操作PPT的具体命名空间
但是由于一些不可抗力因素,现在NPOI已经不能使用了
所以现在用更底层的OpenXML分享一个PPT生成
并且写了个例子方法 SaveFromImages ,传入ppt储存的地址,再传入一个带有图片的文件夹
就会用该文件夹下面的图片生成一个PPT
考虑到代码可能有点生涩难懂,特意让AI尽量在每行都加上了注释
供给大家参考学习

using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Presentation;
using D = DocumentFormat.OpenXml.Drawing;
using System;
using System.Collections.Generic;
using System.IO;

namespace OfficeModule
{
    /// <summary>
    /// 基于 Open XML SDK(DocumentFormat.OpenXml 3.x) 的 pptx 保存工具,不依赖 WPS/Office COM。
    /// 内置一份合法的最小 pptx 模板(含母版/版式/主题),生成的文件可被 WPS 与 PowerPoint 正常打开。
    /// </summary>
    // 标注本类仅支持 Windows:因为 GetImageSize 用到了 System.Drawing.Common(Windows 专属)
    [System.Runtime.Versioning.SupportedOSPlatform("windows")]
    public static class PptxSaver
    {
        // 默认幻灯片宽度(EMU):9144000 EMU = 10 英寸,对应 4:3 比例
        public const int DefaultSlideWidthEmu = 9144000;
        // 默认幻灯片高度(EMU):6858000 EMU = 7.5 英寸,对应 4:3 比例
        public const int DefaultSlideHeightEmu = 6858000;

        /// <summary>
        /// 内置最小 pptx 模板(Base64),含完整的母版/版式/主题,保证生成的 pptx 结构合法可打开。
        /// </summary>
        private const string EmbeddedTemplateBase64 =
@"            UEsDBBQAAAAIAECD/FyfTLSnIgEAAM4DAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbLWTyW7CMBCG
            X8XytSKGHqqqInDocup2oA8wciZg1Zs8A4K375BQiVa0ILVcEo3nXz5F8Xi6Dl6tsJBLsdajaqgV
            RpsaF+e1fps9DK61IobYgE8Ra71B0tPJeLbJSEq8kWq9YM43xpBdYACqUsYomzaVACxjmZsM9h3m
            aC6HwytjU2SMPOBthp6M77CFpWd1v5bjnqOgJ61ue+G2q9aQs3cWWPZmFZtvLYNdQyXOTkMLl+lC
            BNocbNhufi7Y+V7kwxTXoHqFws8QRGVyZpMLkvg6bfV70gHU1LbOYpPsMoil2g8L/stYBXDx4ggM
            eTmk/jX6b5ou9SSCR9ikJdP+cB6aPvskpicgll97fzgPU599jInlfmD//DtGF/PZaLr7OPkAUEsD
            BBQAAAAIAECD/FwbyrjurgAAACwBAAALAAAAX3JlbHMvLnJlbHONz80KwjAMB/BXKbm7Tg8ism4X
            EXaV+QClzbri+kFTxb29xZMTDx6T/PMLabqnm9kDE9ngBWyrGhh6FbT1RsB1OG8OwChLr+UcPApY
            kKBrmwvOMpcVmmwkVgxPAqac45FzUhM6SVWI6MtkDMnJXMpkeJTqJg3yXV3vefo0YG2yXgtIvd4C
            G5aI/9hhHK3CU1B3hz7/OPGVKLJMBrOAGDOPCak03+mqyMDbhq++bF9QSwMEFAAAAAgAQIP8XDn+
            ef0mAQAAQwIAABQAAABwcHQvcHJlc2VudGF0aW9uLnhtbI2RwW7CMAxAf6XKfaQtpesqApcJadJ2
            gn1AlrpQKU2i2LB2X7+0wFYmTeIWO37PjrNcd62OTuCxsUawZBazCIyyVWP2gr3vNg8Fi5CkqaS2
            BgTrAdl6tXSl84BgSFIAoyAxWErBDkSu5BzVAVqJM+vAhLva+lZSCP2eV15+BnmreRrHOW9lY9iF
            9/fwtq4bBc9WHdvQ/izxoMc58NA4vNrcPbbpK25HQnmC7fEDgTbWEIbdsOHZqKs3iQT+pXpF+pOJ
            mkqwNMkes2KeZ2Fzvhwy4SZhfLXk/+C357NkkU/o9Jee1m6/ItUJ9pRkWRyHj1O9YHmxKMaAehe+
            C5UHMFk3HwyuNJYAL9hP5YBdHWNVBbU8atpBR1vqNYzJEITO5zmmW1t9A1BLAwQUAAAACABAg/xc
            IxQfSr0AAAC3AQAAHwAAAHBwdC9fcmVscy9wcmVzZW50YXRpb24ueG1sLnJlbHOtkMEKwjAMhl+l
            5O66eRARqxcRPHiR+QChzbbi1pamint7exBx4sGDx/xJvnxkvb0PvbhRZOudgqooQZDT3ljXKjjX
            +9kSBCd0BnvvSMFIDNvN+kQ9przCnQ0sMsOxgi6lsJKSdUcDcuEDudxpfBww5TK2MqC+YEtyXpYL
            Gd8ZMGWKg1EQD6YCUY+BfmH7prGadl5fB3LpywnJvTV0RE4UMxZjS0nBWziZqIrMB/lda/53rQ+h
            Z/qSkJOHbx5QSwMEFAAAAAgAQIP8XHAhA+wWAQAAOwIAABUAAABwcHQvc2xpZGVzL3NsaWRlMS54
            bWyNUbtuwzAM/BVBe0O3Q1EYcTL0NbUJkPQDBJm2BegFSnWdv68sO3BaZMgiUeTdkUett4PRrEcK
            ytmK368KztBKVyvbVvzr+Hb3xFmIwtZCO4sVP2Hg283al0HXLHFtKEXFuxh9CRBkh0aElfNoU61x
            ZERMT2qhJvGTNI2Gh6J4BCOU5TOfbuG7plESX5z8NmjjJEKoRUxzh075cFbzt6h5wpBkMvvPSKMz
            edB1duiPhDhGtn8nf/B7yuXPfk9M1WlbnFlh0lI4zIUZBhMpB/CP3p5DUQ4NmfFO3thQ8bT603jC
            mMMhMjkl5ZKV3e4KVnavV9BwbgAXTWGxBYtTqelD+F2fp0ori0jPOeXTl00eLiCQP3/zC1BLAwQU
            AAAACABAg/xc3RFNSrIAAAA1AQAAIAAAAHBwdC9zbGlkZXMvX3JlbHMvc2xpZGUxLnhtbC5yZWxz
            jc+9CsIwEAfwVwm3m7QOItLURYSCk+gDHMm1DbZJyEWxb29GCw6O9/X7c83xPU/iRYld8BpqWYEg
            b4J1ftBwv503exCc0VucgicNCzEc2+ZKE+ZywqOLLIrhWcOYczwoxWakGVmGSL5M+pBmzKVMg4po
            HjiQ2lbVTqVvA9am6KyG1NkaxG2J9I8d+t4ZOgXznMnnHxGKJ2fpgkt45sJiGihrkPK7v1qqZYkA
            1TZq9W77AVBLAwQUAAAACABAg/xcMBDCwS8BAABuAgAAIQAAAHBwdC9zbGlkZUxheW91dHMvc2xp
            ZGVMYXlvdXQxLnhtbI1Sy27CMBD8Fcv3stBDVUUEpD4vbUGCfoDrbB6qX1qbNPn7Ok4QtOLAxdmM
            Z8Y7ay/XnVasRfKNNTlfzOacoZG2aEyV88/9y809Zz4IUwhlDea8R8/Xq6XLvCreRG8PgUUH4zOR
            8zoElwF4WaMWfmYdmrhXWtIixF+qoCDxE521gtv5/A60aAyf9HSN3pZlI/HJyoNGE0YTQiVC7N7X
            jfNHN3eNmyP00Sap/7YUehezfilhvjlLNGojsOBDcrlTBTNCR+AhMdI43J4Qh8q0r+R2bkuJ+9Fu
            iTXFoJ00HKaNiQajKBXwT14dS5F1JenhG0fAupzHe+qHFQYMu8DkCMoTKuvNBa6sny+w4XgAnB0K
            p1gwxk6dK3oXbtOmruJkA9Jjgly82THDGQXOXsrqF1BLAwQUAAAACABAg/xca5z8D7EAAAA1AQAA
            LAAAAHBwdC9zbGlkZUxheW91dHMvX3JlbHMvc2xpZGVMYXlvdXQxLnhtbC5yZWxzjc+9CsIwEAfw
            Vwm3m7QOItLURQQHF9EHOJJrG2yTkIti396MLTg43tfvzzXHzzSKNyV2wWuoZQWCvAnW+V7D437e
            7EFwRm9xDJ40zMRwbJsbjZjLCQ8usiiGZw1DzvGgFJuBJmQZIvky6UKaMJcy9SqieWJPaltVO5WW
            BqxNcbEa0sXWIO5zpH/s0HXO0CmY10Q+/4hQPDpLV+RMqbCYesoapFz2V0u1LBGg2kat3m2/UEsD
            BBQAAAAIAECD/FwIqV8+oQEAAHkDAAAhAAAAcHB0L3NsaWRlTWFzdGVycy9zbGlkZU1hc3RlcjEu
            eG1sjZPZcuMgEEV/heJ9giwrnozKch4yW6qyVZx8AIbWUkFAAfHIfz+NJOJsD3kRV4fuC1yk9fnQ
            K7IH5zujK7o4ySgBLYzsdFPRx4ff384o8YFryZXRUNEDeHq+WdvSK3nNfQBH0EH7kle0DcGWjHnR
            Qs/9ibGgca42rucBX13DpOP/0LlXLM+yFet5p+nc777Sb+q6E/DTiOcedJhMHCgecPe+7axPbvYr
            btaBR5ux+82W4vnEVsk47prpeQ816eSAGWXZAit4OTrDhXJkz1VFd82Css2azcWzGqOyDw4gKr3/
            4+zW3rlxhZv9nUNPtKRE8x7TjQbjxFzGpqZRsHftTZK8HGrXxxHjIbhDvMNDfLLIYAhETFAcqWhv
            P6kV7a9PqllagL1alB2PxY5hCeWuuSUYRUVVwGOFAZV8QrVr8sjyyPLIUHEhMH+smEUieSIvNctE
            lokUiRSJnCZymsgqkRUlrer0E34VcaCkNurvBJKaosev+oofzHO4lFc+vCPjXeWL4ntxtlwVPyhx
            ZSTuUqab/9gehm04KPAv89NPs/kPUEsDBBQAAAAIAECD/FyqX9wsyAAAALwBAAAsAAAAcHB0L3Ns
            aWRlTWFzdGVycy9fcmVscy9zbGlkZU1hc3RlcjEueG1sLnJlbHOtkE1qwzAQha8iZh/JzqKUECWb
            Ugh0VdIDDNLYFrEloZmU+vYVCYQYssgim4H5ed97zHb/N43qlwqHFC20ugFF0SUfYm/h5/i5egfF
            gtHjmCJZmIlhv9t+04hSJTyEzKoyIlsYRPLGGHYDTcg6ZYp106UyodS29CajO2FPZt00b6bcM2DJ
            VAdvoRx8C+o4Z3qGnbouOPpI7jxRlAcWhsfg6QvndJaKxdKTWND6fr44anW1APM42fqVyaRqaZHp
            MrnWWwyz+PruH1BLAwQUAAAACABAg/xcd/+kazACAABECAAAFAAAAHBwdC90aGVtZS90aGVtZTEu
            eG1s1ZXfbtowFMZfxfL96hCS0CJCRROiXXTaRdsHMI6TuDhOZHsF3n7GgfwhoGnTtGpBAvv4930+
            x7HN4nFfcvBBpWKVCOHkzoGAClKlTOQhfHtNvtxDoDQWKeaVoCE8UAUflws81wUtKTBqoeY4hIXW
            9RwhRUwYq7uqpsKMZZUssTZdmaNU4p1xLTlyHSdAJWYCAoFLY/qNCVZiDlvfNTdfQqtjgHD5Quxk
            Dfw9yxihlk23k+OPOqiIS/CBeQjNFGm1e6V7DQHHSpuBEDr2gWi5QK2I6xvani6xz0l3EqRb1+pk
            vmmFnud7war1dxv/MbeerYN10PpZABNiKp1c8Zy5kXdie1DTvOIdz+LpZMD3/KcjfuUfPwN+2vHe
            iE+SqFvDHtQ0/RHvPz08xUN/v+ODET9zVrE3G/AWKjgT2xHt+ME0OlfbIlnFv17FH3wvmbknvKNQ
            b3c1eqFv7bUSv1cyMYB9uVgzAfShphkmhoswZxvJwDPLC32cBs8p7o03IaIuQujCtmTil3P8nntn
            iPrl2WLLm7VmjPMXfeD0WdlUVMVZmpig7VhRu7R1YZqn6YbcPxKhcb5cDHtgd9wCrg8BwXUIM7O0
            plnWaQiVyCHAPDeXH9ES/nHitVQ6xqpoMDt0PueiS8L1PXOGPjuL6f3kE7NAl++HZhkl+kak65qx
            xuTq6N+H0bXMNnnyn52Ny4xR7+Tb3ugPt9q8m6pjc5P84Fo1181eSxydr8p2vax0+RNQSwECFAAU
            AAAACABAg/xcn0y0pyIBAADOAwAAEwAAAAAAAAAAAAAAgAEAAAAAW0NvbnRlbnRfVHlwZXNdLnht
            bFBLAQIUABQAAAAIAECD/FwbyrjurgAAACwBAAALAAAAAAAAAAAAAACAAVMBAABfcmVscy8ucmVs
            c1BLAQIUABQAAAAIAECD/Fw5/nn9JgEAAEMCAAAUAAAAAAAAAAAAAACAASoCAABwcHQvcHJlc2Vu
            dGF0aW9uLnhtbFBLAQIUABQAAAAIAECD/FwjFB9KvQAAALcBAAAfAAAAAAAAAAAAAACAAYIDAABw
            cHQvX3JlbHMvcHJlc2VudGF0aW9uLnhtbC5yZWxzUEsBAhQAFAAAAAgAQIP8XHAhA+wWAQAAOwIA
            ABUAAAAAAAAAAAAAAIABfAQAAHBwdC9zbGlkZXMvc2xpZGUxLnhtbFBLAQIUABQAAAAIAECD/Fzd
            EU1KsgAAADUBAAAgAAAAAAAAAAAAAACAAcUFAABwcHQvc2xpZGVzL19yZWxzL3NsaWRlMS54bWwu
            cmVsc1BLAQIUABQAAAAIAECD/FwwEMLBLwEAAG4CAAAhAAAAAAAAAAAAAACAAbUGAABwcHQvc2xp
            ZGVMYXlvdXRzL3NsaWRlTGF5b3V0MS54bWxQSwECFAAUAAAACABAg/xca5z8D7EAAAA1AQAALAAA
            AAAAAAAAAAAAgAEjCAAAcHB0L3NsaWRlTGF5b3V0cy9fcmVscy9zbGlkZUxheW91dDEueG1sLnJl
            bHNQSwECFAAUAAAACABAg/xcCKlfPqEBAAB5AwAAIQAAAAAAAAAAAAAAgAEeCQAAcHB0L3NsaWRl
            TWFzdGVycy9zbGlkZU1hc3RlcjEueG1sUEsBAhQAFAAAAAgAQIP8XKpf3CzIAAAAvAEAACwAAAAA
            AAAAAAAAAIAB/goAAHBwdC9zbGlkZU1hc3RlcnMvX3JlbHMvc2xpZGVNYXN0ZXIxLnhtbC5yZWxz
            UEsBAhQAFAAAAAgAQIP8XHf/pGswAgAARAgAABQAAAAAAAAAAAAAAIABEAwAAHBwdC90aGVtZS90
            aGVtZTEueG1sUEsFBgAAAAALAAsALgMAAHIOAAAAAA==";

        /// <summary>
        /// 把指定文件夹下的所有图片(每张占一页)生成为 pptx。
        /// 图片按文件名自然顺序排序,每页一张,等比缩放并居中铺满幻灯片。
        /// 支持的图片格式:jpg/jpeg/png/bmp/gif/tif/tiff/wmf/emf。
        /// </summary>
        /// <param name="destPath">目标 pptx 完整路径(若已存在会被覆盖)</param>
        /// <param name="imageFolder">图片所在文件夹完整路径</param>
        /// <param name="slideWidthEmu">幻灯片宽度(EMU),默认 9144000(10英寸,4:3)</param>
        /// <param name="slideHeightEmu">幻灯片高度(EMU),默认 6858000(7.5英寸)</param>
        /// <returns>成功返回 true;无图片或失败返回 false</returns>
        public static bool SaveFromImages(string destPath, string imageFolder,
            int slideWidthEmu = DefaultSlideWidthEmu, int slideHeightEmu = DefaultSlideHeightEmu)
        {
            // —— 参数校验 ——
            if (string.IsNullOrWhiteSpace(destPath))
                throw new ArgumentException("目标路径不能为空", nameof(destPath));
            if (string.IsNullOrWhiteSpace(imageFolder))
                throw new ArgumentException("图片文件夹路径不能为空", nameof(imageFolder));
            if (!Directory.Exists(imageFolder))
                throw new DirectoryNotFoundException("图片文件夹不存在: " + imageFolder);

            // —— 收集图片文件 ——
            // 用不区分大小写的 HashSet 存放允许的图片扩展名
            var exts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
                { ".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tif", ".tiff", ".wmf", ".emf" };
            var files = new List<string>();
            // 遍历文件夹下所有文件,挑出图片
            foreach (var f in Directory.EnumerateFiles(imageFolder))
            {
                if (exts.Contains(Path.GetExtension(f)))
                    files.Add(f);
            }
            // 一张图片都没有则直接返回失败
            if (files.Count == 0) return false;
            // 按文件名自然排序(1.jpg < 2.jpg < 10.jpg),保证页码顺序符合直觉
            files.Sort(NaturalCompare);

            // 确保目标文件所在目录存在,否则创建
            string dir = Path.GetDirectoryName(destPath) ?? string.Empty;
            if (dir.Length > 0 && !Directory.Exists(dir))
                Directory.CreateDirectory(dir);

            // —— 步骤1:从内置模板克隆出目标文件 ——
            // 模板含完整母版/版式/主题,保证结构合法可被 Office 打开(这是关键,缺这些 Office 会拒绝打开)
            byte[] tplBytes = Convert.FromBase64String(EmbeddedTemplateBase64);
            // 把模板字节装入只读内存流
            using (var tplStream = new MemoryStream(tplBytes, 0, tplBytes.Length, writable: false))
            // 以只读方式打开模板文档
            using (var tplDoc = PresentationDocument.Open(tplStream, false))
            // 准备一个内存流接收克隆结果
            using (var outStream = new MemoryStream())
            {
                // 把模板整体克隆到内存流(深拷贝所有部件)
                tplDoc.Clone(outStream);
                // 写完后把流指针拨回起点,准备写出
                outStream.Position = 0;
                // 用独占方式创建目标文件并写入克隆结果(避免句柄延迟释放导致后续打开失败)
                using (var outFs = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None))
                    outStream.WriteTo(outFs);
            }

            // —— 步骤2:打开目标文件,删除模板原有幻灯片,追加图片幻灯片 ——
            // 以可写方式重新打开刚写出的目标文件
            using (var doc = PresentationDocument.Open(destPath, true))
            {
                // 取演示文稿部件(PresentationPart 是 pptx 的核心部件,承载 Presentation)
                var presPart = doc.PresentationPart ?? throw new InvalidOperationException("模板缺少 PresentationPart");
                // 取幻灯片ID列表(p:sldIdLst),它记录了所有幻灯片的顺序与关系ID
                var slideIdList = presPart.Presentation?.SlideIdList
                    ?? throw new InvalidOperationException("模板缺少 SlideIdList");

                // —— 删除模板原有的空白幻灯片(模板自带1张空白页,我们不需要它) ——
                // 先把现有 SlideId 快照到列表(避免遍历时修改集合)
                var oldSlideIds = new List<SlideId>(slideIdList.ChildElements.OfType<SlideId>());
                foreach (var sid in oldSlideIds)
                {
                    // 通过关系ID拿到对应的 SlidePart
                    var rel = presPart.GetPartById(sid.RelationshipId!);
                    // 从包中删除该幻灯片部件
                    presPart.DeletePart(rel);
                    // 从ID列表中移除该条目
                    sid.Remove();
                }

                // —— 取母版下的第一个版式,作为新幻灯片的版式引用 ——
                // 每张幻灯片必须引用一个版式,否则 Office 打不开
                var masterPart = presPart.SlideMasterParts.First();
                var layoutPart = masterPart.SlideLayoutParts.First();

                // —— 如模板幻灯片尺寸与目标不同,更新之 ——
                var slideSize = presPart.Presentation?.GetFirstChild<SlideSize>();
                // 读取模板当前尺寸(用 ?.Value 防止 Cx/Cy 为 null)
                int curCx = slideSize?.Cx?.Value ?? 0;
                int curCy = slideSize?.Cy?.Value ?? 0;
                if (slideSize != null && (curCx != slideWidthEmu || curCy != slideHeightEmu))
                {
                    slideSize.Cx = slideWidthEmu;   // 设置幻灯片宽度
                    slideSize.Cy = slideHeightEmu;  // 设置幻灯片高度
                    // 备注页尺寸跟随幻灯片(宽高互换)
                    var notesSize = presPart.Presentation?.GetFirstChild<NotesSize>();
                    if (notesSize != null) { notesSize.Cx = slideHeightEmu; notesSize.Cy = slideWidthEmu; }
                }

                // —— 逐张图片创建幻灯片 ——
                uint idx = 0;  // 幻灯片ID从256起递增(Office约定SlideId通常≥256)
                foreach (var imgPath in files)
                {
                    // 在演示文稿中新建一个幻灯片部件
                    var slidePart = presPart.AddNewPart<SlidePart>();
                    // 用图片构造幻灯片内容(见 CreateSlideWithImage)
                    slidePart.Slide = CreateSlideWithImage(slidePart, imgPath, slideWidthEmu, slideHeightEmu);
                    // 把该幻灯片关联到版式(建立 slide→layout 关系,关系链完整性的关键)
                    slidePart.AddPart(layoutPart, "rIdLayout");
                    // 取该幻灯片在 Presentation 中的关系ID
                    var rId = presPart.GetIdOfPart(slidePart);
                    // 在幻灯片ID列表中登记这张幻灯片
                    slideIdList.Append(new SlideId { Id = 256U + idx, RelationshipId = rId });
                    idx++;
                }

                // 保存 Presentation 的修改(把内存中的改动写回包)
                presPart.Presentation?.Save();
            }
            return true;
        }

        /// <summary>
        /// 构造一张含图片的幻灯片:图片等比缩放、居中放置在幻灯片上。
        /// </summary>
        private static Slide CreateSlideWithImage(SlidePart slidePart, string imgPath, int slideW, int slideH)
        {
            // —— 1. 读取图片原始像素尺寸,用于等比缩放计算 ——
            int imgW, imgH;
            GetImageSize(imgPath, out imgW, out imgH);

            // —— 2. 计算图片等比缩放后的目标尺寸(EMU),铺满幻灯片但不超出 ——
            long targetW, targetH;
            if (imgW <= 0 || imgH <= 0)
            {
                // 读不到尺寸时兜底:直接铺满整页
                targetW = slideW; targetH = slideH;
            }
            else
            {
                // 取宽高两个方向缩放比的较小值,保证图片完整显示且不变形
                double ratio = Math.Min((double)slideW / imgW, (double)slideH / imgH);
                targetW = (long)(imgW * ratio);   // 缩放后宽度
                targetH = (long)(imgH * ratio);   // 缩放后高度
            }
            // 居中偏移:让图片在幻灯片上水平/垂直居中
            long offX = (slideW - targetW) / 2;
            long offY = (slideH - targetH) / 2;

            // —— 3. 把图片文件作为 ImagePart 添加到当前幻灯片 ——
            // 注意:ImagePart 必须加到 SlidePart,不能加到 PresentationPart
            var imgPart = slidePart.AddImagePart(ImagePartTypeFor(imgPath));
            // 以共享读方式打开图片源文件,复制到 ImagePart 的流中
            using (var fs = new FileStream(imgPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
                fs.CopyTo(imgPart.GetStream(FileMode.Create));
            // 取图片部件在本幻灯片中的关系ID,供下面 Blip 引用
            string rId = slidePart.GetIdOfPart(imgPart);

            // —— 4. 构造 Picture 元素(p:pic),由三部分组成:nvPicPr + blipFill + spPr ——
            var pic = new Picture(
                // p:nvPicPr 非可视属性:cNvPr(id/名称) + cNvPicPr(锁定纵横比) + nvPr
                new NonVisualPictureProperties(
                    new NonVisualDrawingProperties { Id = 2U, Name = Path.GetFileName(imgPath) },
                    new NonVisualPictureDrawingProperties(new D.PictureLocks { NoChangeAspect = true }),  // 锁定比例,防止变形
                    new ApplicationNonVisualDrawingProperties()),
                // p:blipFill 图片填充:blip 通过 rId 引用上面的图片部件,stretch 表示拉伸填充
                new BlipFill(new D.Blip { Embed = rId }, new D.Stretch(new D.FillRectangle())),
                // p:spPr 形状属性:位置/尺寸(transform) + 预设几何(矩形)
                new ShapeProperties(
                    new D.Transform2D(new D.Offset { X = offX, Y = offY }, new D.Extents { Cx = targetW, Cy = targetH }),
                    new D.PresetGeometry(new D.AdjustValueList()) { Preset = D.ShapeTypeValues.Rectangle }));

            // —— 5. 构造形状树(p:spTree),把图片放进去 ——
            // spTree 必须有 nvGrpSpPr 和 grpSpPr 两个必需子元素
            var spTree = new ShapeTree(
                // p:nvGrpSpPr 组的非可视属性
                new NonVisualGroupShapeProperties(new NonVisualDrawingProperties { Id = 1U, Name = "" }, new NonVisualGroupShapeProperties(), new ApplicationNonVisualDrawingProperties()),
                // p:grpSpPr 组的形状属性(变换)
                new GroupShapeProperties(new D.Transform2D(new D.Offset { X = 0, Y = 0 }, new D.Extents { Cx = 0, Cy = 0 })));
            // 把图片追加到形状树
            spTree.Append(pic);

            // —— 6. 组装幻灯片 ——
            // 幻灯片由 CommonSlideData(含 spTree) 构成
            var slide = new Slide(new CommonSlideData(spTree));
            // 追加颜色映射覆盖(使用母版默认映射)
            slide.Append(new ColorMapOverride(new D.MasterColorMapping()));
            return slide;
        }

        // 标注仅 Windows:System.Drawing.Common 在非 Windows 平台不受支持
        [System.Runtime.Versioning.SupportedOSPlatform("windows")]
        private static void GetImageSize(string path, out int width, out int height)
        {
            width = 0; height = 0;  // 默认值,读不到尺寸时由调用方兜底
            try
            {
                // 用 GDI+ 读取图片文件,拿到像素宽高
                using var img = System.Drawing.Image.FromFile(path);
                width = img.Width;
                height = img.Height;
            }
            catch { }
            // 异常时保持(0,0),调用方会用"铺满整页"兜底,不会崩溃
        }

        /// <summary>
        /// 根据图片扩展名返回 Open XML 的 PartTypeInfo(3.x 用 PartTypeInfo 取代旧版 ImagePartType 枚举)。
        /// </summary>
        private static PartTypeInfo ImagePartTypeFor(string imgPath)
        {
            // 取扩展名并转小写,按 switch 映射到对应的图片类型枚举
            return Path.GetExtension(imgPath).ToLowerInvariant() switch
            {
                ".jpg" or ".jpeg" => ImagePartType.Jpeg,
                ".png" => ImagePartType.Png,
                ".bmp" => ImagePartType.Bmp,
                ".gif" => ImagePartType.Gif,
                ".tif" or ".tiff" => ImagePartType.Tiff,
                ".wmf" => ImagePartType.Wmf,
                ".emf" => ImagePartType.Emf,
                _ => ImagePartType.Jpeg,  // 未知扩展名兜底为 JPEG
            };
        }

        /// <summary>自然排序比较器:直接转调 Windows API,实现 1<2<10 的数字感知排序。</summary>
        private static int NaturalCompare(string a, string b) => StrCmpLogicalW(a, b);

        // 调用 Windows shell 的 StrCmpLogicalW:按"自然顺序"比较字符串(数字按数值而非字符序比较)
        [System.Runtime.InteropServices.DllImport("shlwapi.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
        private static extern int StrCmpLogicalW(string a, string b);

        /// <summary>
        /// 读取 pptx 的幻灯片数量(便于调用后校验生成结果)。
        /// </summary>
        public static int GetSlideCount(string path)
        {
            // 文件不存在直接返回0
            if (!File.Exists(path)) return 0;
            // 以只读方式打开 pptx
            using var doc = PresentationDocument.Open(path, false);
            var pres = doc.PresentationPart?.Presentation;
            // 返回幻灯片ID列表的子元素数量(即幻灯片张数)
            return pres?.SlideIdList?.ChildElements.Count ?? 0;
        }
    }
}

posted @ 2026-08-10 15:13  Poetindusk  阅读(2)  评论(0)    收藏  举报