Skip to main content

pptx_rs/
presentation.rs

1//! # 演示文稿(Presentation)—— 顶层 API
2//!
3//! 对标 python-pptx 的 `Presentation` 类,是用户与本库交互的**唯一入口**:
4//!
5//! - 通过 [`Presentation::new`] / [`Presentation::open`] 创建或加载 `.pptx`;
6//! - 通过 [`Presentation::slides_mut`] 增删/编辑幻灯片;
7//! - 通过 [`Presentation::save`] / [`Presentation::to_bytes`] 序列化输出。
8//!
9//! # 内部数据
10//!
11//! `Presentation` 内部维护三类"集合":
12//!
13//! - [`Slides`]:用户实际编辑的幻灯片列表([`Slide`]);
14//! - [`SlideLayouts`]:版式列表(每个 `Slide` 通过 `rId` 引用其中一个);
15//! - [`SlideMasters`]:母版列表(版式再引用母版,呈现"主-版-页"三层结构)。
16//!
17//! 这三者**不**与 python-pptx 完全等价(python-pptx 用方法 `slide_layouts[i]` 暴露),
18//! 而是直接以集合形式提供,便于将来加入更复杂的工作流。
19//!
20//! # 在三层架构中的位置
21//!
22//! `Presentation` 属于**高阶 API 层**。它直接聚合 `Slides` / `SlideLayouts` /
23//! `SlideMasters` 三个集合;当用户调用 `save` 时,由 `to_opc_package` 把这些内存模型
24//! 序列化为 [`crate::opc::OpcPackage`],再交给 zip 写出 `.pptx`。
25//!
26//! # 单元
27//!
28//! 幻灯片尺寸字段(`width` / `height`)一律使用 [`Emu`],与 OOXML XML 中
29//! `<p:sldSz cx="..." cy="..."/>` 一致。常用换算:1 in = 914 400 EMU,
30//! 1 cm = 360 000 EMU。
31//!
32//! # 示例
33//!
34//! ```no_run
35//! use pptx_rs::Presentation;
36//! use pptx_rs::Inches;
37//!
38//! let mut p = Presentation::new().unwrap();
39//! let counter = p.id_counter();
40//! let s = p.slides_mut().add_slide(counter).unwrap();
41//! s.shapes_mut().add_textbox_with_text(
42//!     Inches(1.0), Inches(1.0), Inches(4.0), Inches(1.0),
43//!     "Hello, pptx-rs!",
44//! ).unwrap();
45//! p.save("out.pptx").unwrap();
46//! ```
47//!
48//! (doctest 用 `unwrap` 仅为缩短示例;生产代码应使用 `?` 传播错误。)
49
50use std::cell::Cell;
51use std::io::Read;
52use std::path::Path;
53use std::rc::Rc;
54
55use crate::notes_masters::{NotesMasterRef, NotesMasters};
56use crate::opc::package::{ct, rels_partname_for};
57use crate::opc::part::{new_part_name, Part, PartName};
58use crate::opc::rels::{RelType, Relationship, Relationships};
59use crate::opc::OpcPackage;
60use crate::oxml::presentation::{PresentationRoot, SlideIdEntry};
61use crate::oxml::slidelayout::SldLayout as OxmlSldLayout;
62use crate::oxml::slidemaster::SldMaster as OxmlSldMaster;
63use crate::oxml::theme::default_theme_xml;
64use crate::slide::{Slide, SlideEntry, Slides};
65use crate::slide_layouts::{SlideLayoutRef, SlideLayouts};
66use crate::slide_masters::{SlideMasterRef, SlideMasters};
67use crate::units::Emu;
68
69/// 默认演示文稿宽度(EMU)。对应 10 英寸(4:3 比例)—— 1 in = 914 400 EMU。
70pub const DEFAULT_WIDTH_EMU: i64 = 9_144_000;
71
72/// 默认演示文稿高度(EMU)。对应 7.5 英寸 —— 1 in = 914 400 EMU。
73pub const DEFAULT_HEIGHT_EMU: i64 = 6_858_000;
74
75/// 演示文稿(内存模型)。
76///
77/// # 字段语义
78///
79/// - `slides` / `slide_layouts` / `slide_masters`:三类"主-版-页"集合;
80/// - `width` / `height`:画布尺寸,序列化为 `<p:sldSz cx="..." cy="..."/>`;
81/// - `id_counter`:共享 ID 计数器(每张 slide / 每个 shape 都会 `next_shape_id`);
82/// - **媒体**(图片等 blob)**按 slide 存储**——参见 `Slide::register_media`。
83///   保存时由 `to_opc_package` 遍历 `self.slides` 聚合写入 zip;同一 partname 只写一次。
84#[derive(Debug)]
85pub struct Presentation {
86    /// 幻灯片集合(用户主要操作的子结构)。
87    pub(crate) slides: Slides,
88    /// 版式集合(与 `slide_masters` 一起构成"主-版-页"分层)。
89    pub(crate) slide_layouts: SlideLayouts,
90    /// 母版集合。
91    pub(crate) slide_masters: SlideMasters,
92    /// 备注母版集合(TODO-045)。
93    ///
94    /// 一个演示文稿通常只有 0 或 1 个备注母版。
95    /// `Presentation::new` 创建的空白文档无此 part;
96    /// `from_opc` 会从 `presentation.xml.rels` 中的 `NotesMaster` 关系解析。
97    pub(crate) notes_masters: NotesMasters,
98    /// 主题(`<a:theme>`)。
99    ///
100    /// TODO-001:从已有 PPTX 解析后存储,写路径使用 `self.theme.to_xml()`
101    /// 而非 `default_theme_xml()`,实现 read→save 保真。
102    pub(crate) theme: crate::oxml::theme::Theme,
103    /// 母版 ID 列表(`<p:sldMasterIdLst>`,从 presentation.xml 解析)。
104    ///
105    /// TODO-001:用于写路径 `PresentationRoot.sld_master_ids`,支持多母版。
106    /// 空列表时写路径使用默认的单个母版。
107    pub(crate) sld_master_ids: Vec<(u32, String)>,
108    /// 画布宽度(EMU)。
109    pub(crate) width: Emu,
110    /// 画布高度(EMU)。
111    pub(crate) height: Emu,
112    /// 共享 shape id 计数器(`Rc<Cell<u32>>` 以便跨多个集合共享)。
113    pub(crate) id_counter: Rc<Cell<u32>>,
114    /// 文档核心属性(对标 pypdf `DocumentInformation` / OOXML `core-properties`)。
115    pub(crate) core_properties: CoreProperties,
116    /// 自定义文档属性(`/docProps/custom.xml`,TODO-034)。
117    ///
118    /// 用户自定义的键值对,在 `to_opc_package` 中非空时序列化为 custom.xml。
119    pub(crate) custom_properties: CustomProperties,
120    /// 评论作者列表(`/ppt/commentAuthors.xml`,TODO-036)。
121    ///
122    /// 全局共享的评论作者清单。当任意 slide 有评论时,`to_opc_package`
123    /// 会写出 `commentAuthors.xml` 并在 `_rels/.rels` 添加关系。
124    pub(crate) comment_authors: crate::oxml::comments::CommentAuthorList,
125    /// 修改密码保护参数(`<p:modifyVerifier>`)。
126    ///
127    /// 设置后,`to_opc_package` 会在 `presentation.xml` 中注入 modifyVerifier 元素,
128    /// WPS / PowerPoint 打开时提示"以只读方式打开"或"输入密码以修改"。
129    pub(crate) modify_protection: Option<crate::crypto::ModifyProtection>,
130    /// 章节分组列表(`<p14:sectionLst>`,TODO-039)。
131    ///
132    /// 用于把若干 slide 归入命名分组(PowerPoint 大纲视图中的"节")。
133    /// 空列表时不输出 sectionLst 扩展;非空时由 `to_opc_package` 在
134    /// `presentation.xml` 的 `<p:extLst>` 内写出。
135    pub(crate) sections: crate::oxml::section::SectionList,
136}
137
138/// 文档核心属性(对标 pypdf `DocumentInformation` + OOXML `core-properties`)。
139///
140/// 对应 `docProps/core.xml` 和 `docProps/app.xml` 中的元数据字段。
141/// 在 `to_opc_package` 中会序列化为这两个 XML part。
142///
143/// # 与 pypdf 的对应
144///
145/// | pypdf `DocumentInformation` | 本结构体字段 | OOXML 路径 |
146/// |---|---|---|
147/// | `title` | `title` | `dc:title` |
148/// | `author` | `creator` | `dc:creator` |
149/// | `subject` | `subject` | `dc:subject` |
150/// | `creator` | `last_modified_by` | `cp:lastModifiedBy` |
151/// | `producer` | `application` | `Application` (app.xml) |
152/// | `creation_date` | `created` | `dcterms:created` |
153/// | `modification_date` | `modified` | `dcterms:modified` |
154/// | `keywords` | `keywords` | `cp:keywords` |
155#[derive(Debug, Clone, Default)]
156pub struct CoreProperties {
157    /// 文档标题。
158    pub title: Option<String>,
159    /// 文档作者/创建者。
160    pub creator: Option<String>,
161    /// 文档主题。
162    pub subject: Option<String>,
163    /// 最后修改者。
164    pub last_modified_by: Option<String>,
165    /// 创建应用程序(如 "pptx-rs" / "Microsoft Office PowerPoint")。
166    pub application: Option<String>,
167    /// 创建时间(ISO 8601 格式,如 "2026-06-14T12:00:00Z")。
168    pub created: Option<String>,
169    /// 最后修改时间(ISO 8601 格式)。
170    pub modified: Option<String>,
171    /// 关键词(逗号分隔)。
172    pub keywords: Option<String>,
173    /// 分类。
174    pub category: Option<String>,
175    /// 备注/描述。
176    pub description: Option<String>,
177    /// 修订号。
178    pub revision: Option<String>,
179}
180
181/// 自定义属性值类型(`/docProps/custom.xml` 中的 `vt:*` 元素)。
182///
183/// 对应 OOXML VTypes 命名空间下的几种常用类型。
184/// 每个变体序列化为对应的 `<vt:xxx>` 元素。
185///
186/// # OOXML 结构
187///
188/// ```text
189/// <property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Key">
190///   <vt:lpwstr>Value</vt:lpwstr>
191/// </property>
192/// ```
193#[derive(Clone, Debug, PartialEq)]
194pub enum CustomPropertyValue {
195    /// 字符串(`<vt:lpwstr>`)。
196    Text(String),
197    /// 32 位整数(`<vt:i4>`)。
198    Int(i32),
199    /// 双精度浮点(`<vt:r8>`)。
200    Float(f64),
201    /// 布尔(`<vt:bool>`)。
202    Bool(bool),
203    /// 日期时间(`<vt:filetime>`,ISO 8601 格式字符串)。
204    DateTime(String),
205}
206
207impl CustomPropertyValue {
208    /// 返回对应的 `<vt:xxx>` 元素名。
209    fn vt_element(&self) -> &'static str {
210        match self {
211            CustomPropertyValue::Text(_) => "vt:lpwstr",
212            CustomPropertyValue::Int(_) => "vt:i4",
213            CustomPropertyValue::Float(_) => "vt:r8",
214            CustomPropertyValue::Bool(_) => "vt:bool",
215            CustomPropertyValue::DateTime(_) => "vt:filetime",
216        }
217    }
218
219    /// 返回值的字符串表示(用于序列化到 `<vt:xxx>` 元素文本)。
220    fn value_str(&self) -> String {
221        match self {
222            CustomPropertyValue::Text(s) => s.clone(),
223            CustomPropertyValue::Int(i) => i.to_string(),
224            CustomPropertyValue::Float(f) => f.to_string(),
225            CustomPropertyValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
226            CustomPropertyValue::DateTime(s) => s.clone(),
227        }
228    }
229}
230
231/// 自定义文档属性集合(`/docProps/custom.xml`)。
232///
233/// 对标 python-pptx 中 `Presentation.custom_properties`(v1.0+)。
234/// 存储用户自定义的键值对,PowerPoint 在"文件 → 信息 → 属性 → 高级属性 → 自定义"中显示。
235///
236/// # 序列化
237///
238/// `to_opc_package` 会在 `custom_properties` 非空时写出 `/docProps/custom.xml`,
239/// 并在 `_rels/.rels` 中添加 `custom-properties` 关系。
240///
241/// # 示例
242///
243/// ```no_run
244/// use pptx_rs::Presentation;
245/// use pptx_rs::presentation::CustomPropertyValue;
246///
247/// let mut p = Presentation::new().unwrap();
248/// p.custom_properties_mut().set("Project", CustomPropertyValue::Text("Demo".to_string()));
249/// p.custom_properties_mut().set("Version", CustomPropertyValue::Int(42));
250/// p.save("out.pptx").unwrap();
251/// ```
252#[derive(Debug, Clone, Default)]
253pub struct CustomProperties {
254    /// 有序键值对列表(保留插入顺序,便于稳定序列化)。
255    entries: Vec<(String, CustomPropertyValue)>,
256}
257
258impl CustomProperties {
259    /// 创建空的自定义属性集合。
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    /// 是否为空。
265    pub fn is_empty(&self) -> bool {
266        self.entries.is_empty()
267    }
268
269    /// 返回条目数。
270    pub fn len(&self) -> usize {
271        self.entries.len()
272    }
273
274    /// 设置(或覆盖)一个自定义属性。
275    ///
276    /// # 参数
277    /// - `name`:属性名(不可为空);
278    /// - `value`:属性值。
279    pub fn set(&mut self, name: impl Into<String>, value: CustomPropertyValue) {
280        let name = name.into();
281        for entry in &mut self.entries {
282            if entry.0 == name {
283                entry.1 = value;
284                return;
285            }
286        }
287        self.entries.push((name, value));
288    }
289
290    /// 取指定名称的属性值。
291    pub fn get(&self, name: &str) -> Option<&CustomPropertyValue> {
292        self.entries.iter().find(|(k, _)| k == name).map(|(_, v)| v)
293    }
294
295    /// 移除指定名称的属性。
296    ///
297    /// 返回被移除的值(若存在)。
298    pub fn remove(&mut self, name: &str) -> Option<CustomPropertyValue> {
299        let idx = self.entries.iter().position(|(k, _)| k == name)?;
300        Some(self.entries.remove(idx).1)
301    }
302
303    /// 返回所有条目的迭代器。
304    pub fn iter(&self) -> impl Iterator<Item = (&str, &CustomPropertyValue)> {
305        self.entries.iter().map(|(k, v)| (k.as_str(), v))
306    }
307
308    /// 序列化为 `/docProps/custom.xml` 的 XML 字符串。
309    ///
310    /// # XML 结构
311    ///
312    /// ```text
313    /// <Properties xmlns="...custom-properties" xmlns:vt="...docPropsVTypes">
314    ///   <property fmtid="{D5CDD505-...}" pid="2" name="Key1">
315    ///     <vt:lpwstr>Value1</vt:lpwstr>
316    ///   </property>
317    ///   ...
318    /// </Properties>
319    /// ```
320    ///
321    /// `pid` 从 2 开始(1 保留给 SummaryInformation)。
322    pub fn to_xml(&self) -> String {
323        if self.entries.is_empty() {
324            return String::new();
325        }
326        let mut out = String::with_capacity(256 * self.entries.len());
327        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
328        out.push_str("<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties\"");
329        out.push_str(
330            " xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\">\n",
331        );
332        for (i, (name, value)) in self.entries.iter().enumerate() {
333            let pid = (i + 2) as u32; // pid 从 2 开始
334            let vt_elem = value.vt_element();
335            let val = xml_escape(&value.value_str());
336            out.push_str(&format!(
337                "  <property fmtid=\"{{D5CDD505-2E9C-101B-9397-08002B2CF9AE}}\" pid=\"{}\" name=\"{}\">\n",
338                pid,
339                xml_escape(name)
340            ));
341            out.push_str(&format!("    <{}>{}</{}>\n", vt_elem, val, vt_elem));
342            out.push_str("  </property>\n");
343        }
344        out.push_str("</Properties>");
345        out
346    }
347
348    /// 从 `/docProps/custom.xml` 的 XML 字符串解析。
349    ///
350    /// # 参数
351    /// - `xml`:custom.xml 的完整内容。
352    ///
353    /// # 返回值
354    /// 解析出的 `CustomProperties`。解析失败的条目会被跳过(不中断整体解析)。
355    pub fn from_xml(xml: &str) -> Self {
356        use quick_xml::events::Event;
357        use quick_xml::Reader;
358
359        let mut props = CustomProperties::new();
360        let mut rd = Reader::from_str(xml);
361        rd.config_mut().trim_text(true);
362        let mut buf = Vec::new();
363        // 当前正在解析的 <property> 的 name 属性
364        let mut cur_name: Option<String> = None;
365        // 当前正在解析的 <vt:xxx> 元素名
366        let mut cur_vt: Option<String> = None;
367        // 当前累积的文本内容
368        let mut cur_text = String::new();
369
370        loop {
371            match rd.read_event_into(&mut buf) {
372                Ok(Event::Start(e)) => {
373                    let name = e.name();
374                    let qname = String::from_utf8_lossy(name.as_ref()).to_string();
375                    let local = local_name(name.as_ref());
376                    if local == b"property" {
377                        // 提取 name 属性
378                        for a in e.attributes().flatten() {
379                            if a.key.as_ref() == b"name" {
380                                cur_name = Some(
381                                    a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
382                                        .unwrap_or_default()
383                                        .to_string(),
384                                );
385                            }
386                        }
387                    } else if cur_name.is_some() && qname.starts_with("vt:") {
388                        // 用完整 QName(含 vt: 前缀)标识 VTypes 元素
389                        cur_vt = Some(qname);
390                        cur_text.clear();
391                    }
392                }
393                Ok(Event::Empty(e)) => {
394                    let name = e.name();
395                    let local = local_name(name.as_ref());
396                    if local == b"property" {
397                        // 自闭合 <property/>:无值,跳过
398                        cur_name = None;
399                    }
400                }
401                Ok(Event::Text(t)) => {
402                    if cur_vt.is_some() {
403                        cur_text.push_str(&t.decode().unwrap_or_default());
404                    }
405                }
406                Ok(Event::End(e)) => {
407                    let name = e.name();
408                    let qname = String::from_utf8_lossy(name.as_ref()).to_string();
409                    let local = local_name(name.as_ref());
410                    if qname.starts_with("vt:") && cur_vt.is_some() {
411                        // 结束 <vt:xxx> 元素,构造值
412                        if let Some(prop_name) = cur_name.take() {
413                            let value = parse_vt_value(&cur_vt.take().unwrap(), &cur_text);
414                            if let Some(v) = value {
415                                props.set(prop_name, v);
416                            }
417                        }
418                        cur_vt = None;
419                        cur_text.clear();
420                    } else if local == b"property" {
421                        cur_name = None;
422                    }
423                }
424                Ok(Event::Eof) => break,
425                _ => {}
426            }
427            buf.clear();
428        }
429        props
430    }
431}
432
433/// XML 特殊字符转义(`&` / `<` / `>` / `"` / `'`)。
434fn xml_escape(s: &str) -> String {
435    let mut out = String::with_capacity(s.len());
436    for c in s.chars() {
437        match c {
438            '&' => out.push_str("&amp;"),
439            '<' => out.push_str("&lt;"),
440            '>' => out.push_str("&gt;"),
441            '"' => out.push_str("&quot;"),
442            '\'' => out.push_str("&apos;"),
443            _ => out.push(c),
444        }
445    }
446    out
447}
448
449/// 取命名空间本地名(`a:off` → `off`)。
450fn local_name(name: &[u8]) -> &[u8] {
451    match name.iter().position(|&b| b == b':') {
452        Some(i) => &name[i + 1..],
453        None => name,
454    }
455}
456
457/// 根据 `<vt:xxx>` 元素名和文本内容构造 `CustomPropertyValue`。
458fn parse_vt_value(vt_elem: &str, text: &str) -> Option<CustomPropertyValue> {
459    let trimmed = text.trim();
460    match vt_elem {
461        "vt:lpwstr" | "vt:bstr" => Some(CustomPropertyValue::Text(trimmed.to_string())),
462        "vt:i4" | "vt:int" | "vt:i2" | "vt:ui1" | "vt:ui2" | "vt:ui4" => {
463            trimmed.parse::<i32>().ok().map(CustomPropertyValue::Int)
464        }
465        "vt:i8" | "vt:ui8" => {
466            // 64 位整数截断为 i32(自定义属性中罕见超大值)
467            trimmed.parse::<i64>().ok().and_then(|v| {
468                if v >= i32::MIN as i64 && v <= i32::MAX as i64 {
469                    Some(CustomPropertyValue::Int(v as i32))
470                } else {
471                    None
472                }
473            })
474        }
475        "vt:r4" | "vt:r8" | "vt:decimal" => {
476            trimmed.parse::<f64>().ok().map(CustomPropertyValue::Float)
477        }
478        "vt:bool" => {
479            let v = trimmed.eq_ignore_ascii_case("true") || trimmed == "1";
480            if v || trimmed.eq_ignore_ascii_case("false") || trimmed == "0" {
481                Some(CustomPropertyValue::Bool(v))
482            } else {
483                None
484            }
485        }
486        "vt:filetime" | "vt:date" => Some(CustomPropertyValue::DateTime(trimmed.to_string())),
487        _ => None,
488    }
489}
490
491/// 媒体条目:保存到 `/ppt/media/<file>` 目录中的二进制资源。
492///
493/// 对应 python-pptx 中 `SlideShapes.add_picture(...)` 隐式管理的 part。
494/// `rid` 是**预先生成的**关系 id(如 `"rIdImg1"`),用于在 `slideN.xml.rels` 中
495/// 显式添加 `<Relationship Id="rIdImg1" Type="...image" Target="../media/img.png"/>`。
496#[derive(Debug, Clone)]
497pub struct MediaEntry {
498    /// 媒体 part 路径,例如 `/ppt/media/image1.png`。
499    pub partname: PartName,
500    /// 媒体 MIME / Office Content-Type(如 `image/png`)。
501    pub content_type: String,
502    /// 二进制内容(图片像素、嵌入字体字节等)。
503    pub blob: Vec<u8>,
504    /// 在 slide xml 中引用的关系 id(形如 `rIdImg1`)。
505    pub rid: String,
506}
507
508/// 图表条目:保存到 `/ppt/charts/chartN.xml` 的 chart part 元数据。
509///
510/// 对应 python-pptx 中 `SlideShapes.add_chart(...)` 隐式管理的 chart part。
511/// 在 `to_opc_package` 阶段,每个 `ChartEntry` 会:
512/// 1. 写出独立的 `/ppt/charts/chartN.xml` part(内容为 `Chart::to_xml()`);
513/// 2. 在 `slideN.xml.rels` 中添加 `<Relationship Type=".../chart" Target="../charts/chartN.xml"/>`;
514/// 3. 把 `rid` 同步回 `ChartShape.frame.graphic.Chart.rid`,供 `<c:chart r:id="..."/>` 引用。
515///
516/// # 与 MediaEntry 的差异
517/// - MediaEntry 持有 `blob: Vec<u8>`(二进制);
518/// - ChartEntry 持有 `chart: OxmlChart`(强类型模型),写出时调用 `chart.to_xml()`。
519#[derive(Debug, Clone)]
520pub struct ChartEntry {
521    /// chart part 路径,例如 `/ppt/charts/chart1.xml`。
522    pub partname: PartName,
523    /// 强类型 Chart 模型(包含类型/数据/标题)。
524    pub chart: crate::oxml::chart::Chart,
525    /// 在 slide xml 中引用的关系 id(形如 `rIdChart1`)。
526    pub rid: String,
527    /// 嵌入式 Excel 工作簿二进制内容(TODO-004 Excel 嵌入)。
528    ///
529    /// - `None`:图表数据仅靠 numCache/strCache,不嵌入 Excel;
530    /// - `Some(bytes)`:写出时生成 `/ppt/embeddings/Microsoft_Excel_WorksheetN.xlsx`
531    ///   part + `chartN.xml.rels` 关系(Type=Package),并在 chart XML 中写入
532    ///   `<c:externalData r:id="rIdXlsxN"/>` 引用。PowerPoint 打开图表时会从
533    ///   该 xlsx part 读取数据源("编辑数据" 启动 Excel)。
534    ///
535    /// 内容应为有效的 `.xlsx` 文件字节流(OOXML SpreadsheetML 包格式)。
536    /// 库不校验内容有效性,PowerPoint 会按 zip + XML 解析。
537    pub xlsx_blob: Option<Vec<u8>>,
538}
539
540/// OLE 对象条目:保存到 `/ppt/embeddings/oleObjectN.bin` 的 OLE part 元数据(TODO-043)。
541///
542/// 对应 python-pptx 中 `SlideShapes.add_ole_object(...)` 隐式管理的 oleObject part。
543/// 在 `to_opc_package` 阶段,每个 `OleEntry` 会:
544/// 1. 写出独立的 `/ppt/embeddings/oleObjectN.bin` part(内容为原始 OLE 二进制 blob);
545/// 2. 在 `slideN.xml.rels` 中添加 `<Relationship Type=".../oleObject" Target="../embeddings/oleObjectN.bin"/>`;
546/// 3. 把 `rid` 同步回 `OleObjectShape.frame.graphic.OleObject.rid`,供 `<p:oleObj r:id="..."/>` 引用。
547///
548/// # 与 MediaEntry 的差异
549///
550/// - MediaEntry 持有图片二进制,用于 `<p:pic>` 的 `<a:blip r:embed="..."/>`;
551/// - OleEntry 持有 OLE 复合文档二进制,用于 `<p:oleObj r:id="..."/>`;
552/// - 两者 partname 命名空间不同(`/ppt/media/` vs `/ppt/embeddings/`)。
553///
554/// # 与 ChartEntry 的差异
555///
556/// - ChartEntry 持有强类型 Chart 模型,写出时调用 `chart.to_xml()` 生成 XML;
557/// - OleEntry 持有原始二进制 blob,写出时直接写入字节,**不**经过 XML 序列化。
558#[derive(Debug, Clone)]
559pub struct OleEntry {
560    /// oleObject part 路径,例如 `/ppt/embeddings/oleObject1.bin`。
561    pub partname: PartName,
562    /// 原始 OLE 二进制数据(CFB 复合文档)。
563    pub blob: Vec<u8>,
564    /// 在 slide xml 中引用的关系 id(形如 `rIdOle1`)。
565    pub rid: String,
566}
567
568/// 视频媒体条目:保存到 `/ppt/media/mediaN.mp4` 的视频 part 元数据(TODO-033)。
569///
570/// 对应 python-pptx 中 `SlideShapes.add_movie(...)` / `add_video(...)` 隐式管理的 media part。
571/// 在 `to_opc_package` 阶段,每个 `VideoEntry` 会:
572/// 1. 写出独立的 `/ppt/media/mediaN.mp4` part(内容为原始视频二进制 blob);
573/// 2. 在 `slideN.xml.rels` 中添加 `<Relationship Type=".../video" Target="../media/mediaN.mp4"/>`;
574/// 3. 把 `rid` 同步回 `Picture.pic.media`(`MediaKind::Video { rid }`),供
575///    `<a:videoFile r:link="..."/>` 引用。
576///
577/// # 与 MediaEntry 的差异
578///
579/// - MediaEntry 持有**海报帧图片**二进制,关系类型为 `.../image`,用 `r:embed` 引用;
580/// - VideoEntry 持有**视频文件**二进制,关系类型为 `.../video`,用 `r:link` 引用;
581/// - 两者 partname 都在 `/ppt/media/` 下,但文件名命名空间区分(`imageN.png` vs `mediaN.mp4`)。
582///
583/// # 与 OleEntry 的差异
584///
585/// - OleEntry 的 partname 在 `/ppt/embeddings/` 下,关系类型为 `.../oleObject`;
586/// - VideoEntry 的 partname 在 `/ppt/media/` 下,关系类型为 `.../video`;
587/// - OleEntry 通过 `<p:oleObj r:id="..."/>` 引用(`r:id`),VideoEntry 通过
588///   `<a:videoFile r:link="..."/>` 引用(`r:link`)。
589#[derive(Debug, Clone)]
590pub struct VideoEntry {
591    /// 视频 part 路径,例如 `/ppt/media/media1.mp4`。
592    pub partname: PartName,
593    /// 原始视频二进制数据(如 MP4 字节流)。
594    pub blob: Vec<u8>,
595    /// 在 slide xml 中引用的关系 id(形如 `rIdVideo1`)。
596    ///
597    /// 该 rid 会写入 `slideN.xml.rels` 的 `<Relationship Id="rIdVideoN" Type=".../video"/>`,
598    /// 同时同步到 `Picture.pic.media`(`MediaKind::Video { rid }`),
599    /// 供 `<a:videoFile r:link="rIdVideoN"/>` 引用。
600    pub rid: String,
601}
602
603/// 音频媒体条目:保存到 `/ppt/media/mediaN.mp3` 的音频 part 元数据(TODO-033)。
604///
605/// 与 [`VideoEntry`] 结构完全对称,仅媒体类型与 Content-Type 不同。
606/// 在 `to_opc_package` 阶段,每个 `AudioEntry` 会:
607/// 1. 写出独立的 `/ppt/media/mediaN.mp3` part(内容为原始音频二进制 blob);
608/// 2. 在 `slideN.xml.rels` 中添加 `<Relationship Type=".../audio" Target="../media/mediaN.mp3"/>`;
609/// 3. 把 `rid` 同步回 `Picture.pic.media`(`MediaKind::Audio { rid }`),供
610///    `<a:audioFile r:link="..."/>` 引用。
611#[derive(Debug, Clone)]
612pub struct AudioEntry {
613    /// 音频 part 路径,例如 `/ppt/media/media1.mp3`。
614    pub partname: PartName,
615    /// 原始音频二进制数据(如 MP3 字节流)。
616    pub blob: Vec<u8>,
617    /// 在 slide xml 中引用的关系 id(形如 `rIdAudio1`)。
618    pub rid: String,
619}
620
621/// SmartArt(diagram)条目:保存到 `/ppt/diagrams/` 下的 4 个 part 元数据(TODO-037)。
622///
623/// 对应 python-pptx 中 `SlideShapes.add_shape(...)` 隐式管理的 SmartArt parts。
624/// 在 OOXML 中,一个 SmartArt 图形由 4 个独立 part 组成:
625///
626/// | part | 路径 | 关系类型 | `dgm:relIds` 属性 |
627/// |---|---|---|---|
628/// | data | `/ppt/diagrams/dataN.xml` | `.../diagramData` | `r:dm` |
629/// | layout | `/ppt/diagrams/layoutN.xml` | `.../diagramLayout` | `r:lo` |
630/// | quickStyle | `/ppt/diagrams/quickStylesN.xml` | `.../diagramQuickStyle` | `r:qs` |
631/// | colors | `/ppt/diagrams/colorsN.xml` | `.../diagramColors` | `r:cs` |
632///
633/// 被 slide 的 `<p:graphicFrame>` 内 `<a:graphicData uri="...diagram"><dgm:relIds .../>` 引用。
634///
635/// # Round-trip 策略
636///
637/// 当前实现采用**完整 round-trip**:读路径保留 4 个 part 的原始 XML 字符串,
638/// 写路径直接写入保留的 XML(不重新序列化)。这样保证:
639/// - 任何 SmartArt 模板/布局/颜色变体都能正确保留;
640/// - 不依赖完整的 diagram schema 实现(避免数千行代码)。
641///
642/// # 与 ChartEntry / OleEntry 的差异
643///
644/// - ChartEntry 持有强类型 Chart 模型,写出时调用 `chart.to_xml()`;
645/// - OleEntry 持有二进制 blob;
646/// - DiagramEntry 持有 4 份 XML 字符串,直接写入 zip(不经过序列化)。
647#[derive(Debug, Clone)]
648pub struct DiagramEntry {
649    /// data part 路径,例如 `/ppt/diagrams/data1.xml`。
650    pub data_partname: PartName,
651    /// layout part 路径,例如 `/ppt/diagrams/layout1.xml`。
652    pub layout_partname: PartName,
653    /// quickStyle part 路径,例如 `/ppt/diagrams/quickStyles1.xml`。
654    pub quick_style_partname: PartName,
655    /// colors part 路径,例如 `/ppt/diagrams/colors1.xml`。
656    pub colors_partname: PartName,
657    /// 原始 data XML 字符串(`<dgm:dataModel>...</dgm:dataModel>`)。
658    pub data_xml: String,
659    /// 原始 layout XML 字符串(`<dgm:layoutDef>...</dgm:layoutDef>`)。
660    pub layout_xml: String,
661    /// 原始 quickStyle XML 字符串(`<dgm:styleData>...</dgm:styleData>`)。
662    pub quick_style_xml: String,
663    /// 原始 colors XML 字符串(`<dgm:colorsDef>...</dgm:colorsDef>`)。
664    pub colors_xml: String,
665    /// 在 slide xml 中引用 data part 的关系 id(形如 `rIdDgmData1`)。
666    ///
667    /// 对应 `<dgm:relIds r:dm="rIdDgmData1"/>`。
668    pub data_rid: String,
669    /// 在 slide xml 中引用 layout part 的关系 id(形如 `rIdDgmLayout1`)。
670    ///
671    /// 对应 `<dgm:relIds r:lo="rIdDgmLayout1"/>`。
672    pub layout_rid: String,
673    /// 在 slide xml 中引用 quickStyle part 的关系 id(形如 `rIdDgmQs1`)。
674    ///
675    /// 对应 `<dgm:relIds r:qs="rIdDgmQs1"/>`。
676    pub quick_style_rid: String,
677    /// 在 slide xml 中引用 colors part 的关系 id(形如 `rIdDgmColors1`)。
678    ///
679    /// 对应 `<dgm:relIds r:cs="rIdDgmColors1"/>`。
680    pub colors_rid: String,
681}
682
683impl DiagramEntry {
684    /// 按需解析 data part 为强类型 [`crate::oxml::diagram::DataModel`](TODO-037)。
685    ///
686    /// `DiagramEntry` 默认以 `String` blob 持有原始 XML,保证 byte-exact round-trip。
687    /// 当调用方需要结构化访问 SmartArt 节点(如查询节点文本、遍历父子关系)时,
688    /// 调用本方法触发按需解析。
689    ///
690    /// # 返回值
691    /// - 成功:返回 [`crate::oxml::diagram::DataModel`],含所有节点与连接。
692    /// - 失败:返回 `Error::Xml`(data_xml 畸形时)。
693    ///
694    /// # 示例
695    ///
696    /// ```no_run
697    /// # use pptx_rs::presentation::DiagramEntry;
698    /// # let entry: DiagramEntry = unimplemented!();
699    /// let data_model = entry.data_model().expect("parse data part");
700    /// for pt in &data_model.points {
701    ///     println!("node {}: {:?}", pt.model_id, pt.text);
702    /// }
703    /// ```
704    pub fn data_model(&self) -> crate::Result<crate::oxml::diagram::DataModel> {
705        crate::oxml::diagram::DataModel::parse_from_xml(&self.data_xml)
706    }
707
708    /// 按需解析 layout part 为强类型 [`crate::oxml::diagram::LayoutDef`]。
709    ///
710    /// 返回元数据(uniqueId / title / desc / catLst)+ layoutNode 子树原始 XML。
711    pub fn layout_def(&self) -> crate::Result<crate::oxml::diagram::LayoutDef> {
712        crate::oxml::diagram::LayoutDef::parse_from_xml(&self.layout_xml)
713    }
714
715    /// 按需解析 quickStyle part 为强类型 [`crate::oxml::diagram::QuickStyleDef`]。
716    ///
717    /// 返回 styleLbl 列表(仅 name + 原始 XML)。
718    pub fn quick_style_def(&self) -> crate::Result<crate::oxml::diagram::QuickStyleDef> {
719        crate::oxml::diagram::QuickStyleDef::parse_from_xml(&self.quick_style_xml)
720    }
721
722    /// 按需解析 colors part 为强类型 [`crate::oxml::diagram::ColorsDef`]。
723    ///
724    /// 返回元数据 + styleClrLbl 列表(仅 name + 原始 XML)。
725    pub fn colors_def(&self) -> crate::Result<crate::oxml::diagram::ColorsDef> {
726        crate::oxml::diagram::ColorsDef::parse_from_xml(&self.colors_xml)
727    }
728
729    /// 把修改后的 [`crate::oxml::diagram::DataModel`] 写回 `data_xml` 字段(TODO-037 文本节点编辑)。
730    ///
731    /// 典型用法:调用方先通过 [`DiagramEntry::data_model`] 解析得到 `DataModel`,
732    /// 修改节点文本(如 `set_point_text`),再调用本方法把修改写回 `data_xml`,
733    /// 后续 `Presentation::save` 会把更新后的 `data_xml` 写入 zip。
734    ///
735    /// # 参数
736    /// - `data_model`:修改后的 DataModel 实例。
737    ///
738    /// # 示例
739    ///
740    /// ```no_run
741    /// # use pptx_rs::presentation::DiagramEntry;
742    /// # let entry: DiagramEntry = unimplemented!();
743    /// let mut dm = entry.data_model().expect("parse");
744    /// dm.set_point_text(1, "新文本");
745    /// entry.set_data_model(&dm);
746    /// ```
747    pub fn set_data_model(&mut self, data_model: &crate::oxml::diagram::DataModel) {
748        self.data_xml = data_model.to_xml();
749    }
750
751    /// 便捷方法:直接修改指定 `model_id` 节点的文本并写回 `data_xml`。
752    ///
753    /// 内部流程:解析 data_xml → 修改节点文本 → 序列化回 data_xml。
754    ///
755    /// # 参数
756    /// - `model_id`:目标节点 ID;
757    /// - `new_text`:新的文本内容。
758    ///
759    /// # 返回值
760    /// - `Ok(true)`:找到节点并成功修改;
761    /// - `Ok(false)`:未找到指定 `model_id` 的节点;
762    /// - `Err`:data_xml 解析失败(畸形 XML)。
763    pub fn set_point_text(
764        &mut self,
765        model_id: u32,
766        new_text: impl Into<String>,
767    ) -> crate::Result<bool> {
768        let mut dm = self.data_model()?;
769        let ok = dm.set_point_text(model_id, new_text);
770        if ok {
771            self.data_xml = dm.to_xml();
772        }
773        Ok(ok)
774    }
775}
776
777impl Presentation {
778    /// 新建一个**完全空白**的演示文稿。
779    ///
780    /// 内部会做两件事:
781    ///
782    /// 1. 把 `id_counter` 初始化为 `2`(保留 `1` 给"隐藏占位"用途,与 Office 一致);
783    /// 2. 调用 `Presentation::ensure_default_master_and_layout` 创建 1 个
784    ///    默认母版 + 1 个空白版式,保证保存出的 `.pptx` 在 PowerPoint 中能正常打开。
785    ///
786    /// # 错误
787    /// 当前实现不会失败;保留 `Result` 是为后续扩展(例如从模板加载 master)留口。
788    pub fn new() -> crate::Result<Self> {
789        let id_counter = Rc::new(Cell::new(2));
790        let mut pres = Presentation {
791            slides: Slides::new(),
792            slide_layouts: SlideLayouts::new(),
793            slide_masters: SlideMasters::new(),
794            notes_masters: NotesMasters::new(),
795            theme: crate::oxml::theme::Theme::default(),
796            sld_master_ids: Vec::new(),
797            width: Emu(DEFAULT_WIDTH_EMU),
798            height: Emu(DEFAULT_HEIGHT_EMU),
799            id_counter,
800            core_properties: CoreProperties {
801                application: Some("pptx-rs".to_string()),
802                ..Default::default()
803            },
804            custom_properties: CustomProperties::default(),
805            comment_authors: crate::oxml::comments::CommentAuthorList::default(),
806            modify_protection: None,
807            sections: crate::oxml::section::SectionList::default(),
808        };
809        pres.ensure_default_master_and_layout()?;
810        Ok(pres)
811    }
812
813    /// 打开一个已存在的 `.pptx` 文件并解析为内存模型。
814    ///
815    /// 等价于 [`Presentation::load`],提供"短名 + 详细名"两个入口。
816    ///
817    /// # 错误
818    /// - [`crate::Error::Io`]:文件不存在或权限不足;
819    /// - [`crate::Error::Zip`]:zip 损坏;
820    /// - [`crate::Error::Xml`]:内部 XML 无法解析(**注意:当前 read 路径尚未完整实现**,
821    ///   实际只解析 `[Content_Types].xml`,其它 part 暂未还原到 oxml 模型)。
822    pub fn open(path: impl AsRef<Path>) -> crate::Result<Self> {
823        Self::load(path.as_ref())
824    }
825
826    /// 加载 `.pptx` 的详细入口(与 [`Presentation::open`] 等价)。
827    pub fn load(path: &Path) -> crate::Result<Self> {
828        let pkg = OpcPackage::load(path)?;
829        Self::from_opc(pkg)
830    }
831
832    /// 从内存中的 zip 字节流加载。
833    ///
834    /// 与 [`Presentation::load`] 的区别:本方法**不**触发磁盘 IO,可用于:
835    ///
836    /// - Web 服务接收 `multipart/form-data` 上传后的字节流;
837    /// - 单元测试中 fixture 来自 `include_bytes!` 的场景;
838    /// - 通过 `std::io::Cursor` 包装任意 `Read` 来源。
839    ///
840    /// # 错误
841    /// - [`crate::Error::Io`]:`ZipArchive::new` 失败;
842    /// - [`crate::Error::Xml`]:`[Content_Types].xml` 解析失败。
843    pub fn load_bytes(bytes: &[u8]) -> crate::Result<Self> {
844        let mut pkg = OpcPackage::new();
845        let cursor = std::io::Cursor::new(bytes);
846        let mut zip = zip::ZipArchive::new(cursor)?;
847        // 第一步:先读 [Content_Types].xml —— 后续 part 的 Content-Type 都靠它推断。
848        let mut ct_xml = String::new();
849        zip.by_name("[Content_Types].xml")?
850            .read_to_string(&mut ct_xml)?;
851        pkg.content_types = crate::opc::package::parse_content_types_public(&ct_xml)?;
852        // 第二步:把所有 part 装入 `parts` 表(关系文件统一走 RELATIONSHIPS content-type)。
853        for i in 0..zip.len() {
854            let mut e = zip.by_index(i)?;
855            let name = e.name().to_string();
856            if name == "[Content_Types].xml" || e.is_dir() {
857                continue;
858            }
859            let mut blob = Vec::with_capacity(e.size() as usize);
860            e.read_to_end(&mut blob)?;
861            let ct_str: String = if name.ends_with(".rels") {
862                crate::opc::package::ct::RELATIONSHIPS.to_string()
863            } else {
864                // 用 [Content_Types].xml 推断 partname → Content-Type:
865                //   1) 先查 Override(精确 partname 匹配)
866                //   2) 再查 Default(扩展名匹配)
867                //   3) 都没有则回退 octet-stream
868                let partname = format!("/{}", name);
869                crate::opc::package::derive_content_type(&pkg.content_types, &partname)
870            };
871            let partname = format!("/{}", name);
872            let p = Part::new(PartName::from_unchecked(partname), ct_str, blob);
873            pkg.parts.insert(p.partname.as_str().to_string(), p);
874        }
875        Self::from_opc(pkg)
876    }
877
878    /// 从已构造好的 [`OpcPackage`] 创建 `Presentation`。
879    ///
880    /// 这是 [`Presentation::load`] / [`Presentation::load_bytes`] 的真正工作函数——
881    /// 它会把 zip 中的 part 全部还原为内存模型,从而开启 **read-modify-write** 流程。
882    ///
883    /// # 还原范围
884    ///
885    /// | 部分 | 是否还原 | 说明 |
886    /// |---|---|---|
887    /// | `presentation.xml` → `sldIdLst` | ✅ | 决定 slide 顺序与 sld_id |
888    /// | `presentation.xml` → `sldSz` | ✅ | 画布尺寸(回退默认 4:3) |
889    /// | `presentation.xml.rels` → slide 关系 | ✅ | rid → slideN.xml partname |
890    /// | `slideN.xml` → `Sld`(含 shapes) | ✅ | 走 [`crate::oxml::parse_sld::parse_sld`] |
891    /// | `slideN.xml.rels` → layout 关系 | ✅ | 回填 `Slide::layout_rid` |
892    /// | `slideN.xml.rels` → notes 关系 | ✅ | 若有则读 notesSlideN.xml |
893    /// | `notesSlideN.xml` → 备注 `TextBody` | ✅ | 走 [`crate::oxml::parse_sld::parse_notes`] |
894    /// | `notesSlideN.xml` 原始 partname | ✅ | 回填 `Slide::notes_partname`,save 时复用 |
895    /// | `notesSlideN.xml.rels` → Slide target | ✅ | 回填 `Slide::notes_slide_rel_target`,save 时复用 |
896    /// | `slideMasters` / `slideLayouts` / `theme` | ❌ | 路线图中:本版本以默认 master+layout 占位 |
897    ///
898    /// # 错误
899    /// - [`crate::Error::Xml`]:任意 slide / notes / presentation XML 解析失败;
900    /// - [`crate::Error::Opc`]:`presentation.xml.rels` 缺失或关键 rid 未找到。
901    fn from_opc(pkg: OpcPackage) -> crate::Result<Self> {
902        // 局部类型别名:减少 `diagram_tasks` 元组类型的视觉噪声(clippy::type_complexity)。
903        // 字段顺序:(dm_rid, lo_rid, qs_rid, cs_rid, data_partname, layout_partname,
904        //           quick_style_partname, colors_partname)
905        type DiagramTask = (
906            String,
907            String,
908            String,
909            String,
910            String,
911            String,
912            String,
913            String,
914        );
915        // 共享 id_counter —— 后续读出的 slide 直接共用。
916        let id_counter = Rc::new(Cell::new(2));
917        let mut pres = Presentation {
918            slides: Slides::new(),
919            slide_layouts: SlideLayouts::new(),
920            slide_masters: SlideMasters::new(),
921            notes_masters: NotesMasters::new(),
922            theme: crate::oxml::theme::Theme::default(),
923            sld_master_ids: Vec::new(),
924            width: Emu(DEFAULT_WIDTH_EMU),
925            height: Emu(DEFAULT_HEIGHT_EMU),
926            id_counter: id_counter.clone(),
927            core_properties: CoreProperties::default(),
928            custom_properties: CustomProperties::default(),
929            comment_authors: crate::oxml::comments::CommentAuthorList::default(),
930            modify_protection: None,
931            sections: crate::oxml::section::SectionList::default(),
932        };
933        pres.ensure_default_master_and_layout()?;
934
935        // ---------- 1) 读 presentation.xml ----------
936        let pres_part = pkg
937            .get_part("/ppt/presentation.xml")
938            .ok_or_else(|| crate::Error::opc("presentation.xml not found in package"))?;
939        let pres_xml = String::from_utf8_lossy(&pres_part.blob).into_owned();
940        let (sld_id_list, sld_w, sld_h, sld_master_id_list, sections) =
941            crate::oxml::parse_sld::parse_pres_root(&pres_xml)?;
942        // TODO-001:存储解析出的 sldMasterIdLst,供写路径使用
943        pres.sld_master_ids = sld_master_id_list;
944        // TODO-039:存储解析出的 sectionLst,供写路径使用
945        pres.sections = sections;
946        if let (Some(w), Some(h)) = (sld_w, sld_h) {
947            pres.width = w;
948            pres.height = h;
949        }
950
951        // ---------- 2) 读 presentation.xml.rels 找 slide 关系 ----------
952        let pres_rels_part = pkg
953            .get_part("/ppt/_rels/presentation.xml.rels")
954            .ok_or_else(|| crate::Error::opc("presentation.xml.rels not found"))?;
955        let pres_rels_xml = String::from_utf8_lossy(&pres_rels_part.blob).into_owned();
956        let pres_rels = Relationships::from_xml(&pres_rels_xml)?;
957
958        // ---------- 2.5) 读取 slideMaster / slideLayout / theme ----------
959        // 从 presentation.xml.rels 中找出所有 SlideMaster 关系,逐个解析。
960        // 若找到至少一个 master,则清空默认的 master/layout,用解析到的替换。
961        let mut parsed_masters: Vec<(String, String, OxmlSldMaster)> = Vec::new(); // (rid, partname, oxml)
962        let mut parsed_layouts: Vec<(String, String, OxmlSldLayout)> = Vec::new(); // (rid, partname, oxml)
963        for r in pres_rels.iter() {
964            if !matches!(r.reltype, RelType::SlideMaster) {
965                continue;
966            }
967            let master_partname =
968                resolve_relative_partname("/ppt/presentation.xml", r.target.as_str());
969            let master_xml = match pkg.get_part(master_partname.as_str()) {
970                Some(p) => String::from_utf8_lossy(&p.blob).into_owned(),
971                None => continue,
972            };
973            let master = match crate::oxml::parse_sld::parse_sld_master(&master_xml) {
974                Ok(m) => m,
975                Err(_e) => continue,
976            };
977            // 读 slideMasterN.xml.rels 找 SlideLayout 和 Theme 关系
978            let master_rels_path = rels_partname_for(master_partname.as_str());
979            if let Some(mrp) = pkg.get_part(master_rels_path.as_str()) {
980                let mrp_xml = String::from_utf8_lossy(&mrp.blob).into_owned();
981                if let Ok(master_rels) = Relationships::from_xml(&mrp_xml) {
982                    for mr in master_rels.iter() {
983                        if matches!(mr.reltype, RelType::SlideLayout) {
984                            let layout_partname = resolve_relative_partname(
985                                master_partname.as_str(),
986                                mr.target.as_str(),
987                            );
988                            let layout_xml = match pkg.get_part(layout_partname.as_str()) {
989                                Some(p) => String::from_utf8_lossy(&p.blob).into_owned(),
990                                None => continue,
991                            };
992                            if let Ok(layout) =
993                                crate::oxml::parse_sld::parse_sld_layout(&layout_xml)
994                            {
995                                parsed_layouts.push((mr.id.clone(), layout_partname, layout));
996                            }
997                        }
998                        // Theme 关系:解析并存储到 Presentation(TODO-001:read→save 保真)
999                        if matches!(mr.reltype, RelType::Theme) {
1000                            let theme_partname = resolve_relative_partname(
1001                                master_partname.as_str(),
1002                                mr.target.as_str(),
1003                            );
1004                            if let Some(tp) = pkg.get_part(theme_partname.as_str()) {
1005                                let theme_xml = String::from_utf8_lossy(&tp.blob).into_owned();
1006                                // 解析 theme 并存储到 Presentation(写路径使用 self.theme.to_xml())
1007                                if let Ok(theme) = crate::oxml::parse_sld::parse_theme(&theme_xml) {
1008                                    pres.theme = theme;
1009                                }
1010                            }
1011                        }
1012                    }
1013                }
1014            }
1015            parsed_masters.push((r.id.clone(), master_partname, master));
1016        }
1017        // 若解析到 master/layout,则替换默认的
1018        if !parsed_masters.is_empty() {
1019            pres.slide_masters.items.clear();
1020            pres.slide_layouts.items.clear();
1021            for (rid, partname, oxml) in parsed_masters {
1022                let idx = pres.slide_masters.items.len();
1023                pres.slide_masters.items.push(SlideMasterRef {
1024                    idx,
1025                    partname,
1026                    rid,
1027                    oxml: Rc::new(std::cell::RefCell::new(oxml)),
1028                });
1029            }
1030            for (rid, partname, oxml) in parsed_layouts {
1031                let idx = pres.slide_layouts.items.len();
1032                pres.slide_layouts.items.push(SlideLayoutRef {
1033                    idx,
1034                    partname,
1035                    rid,
1036                    oxml: Rc::new(std::cell::RefCell::new(oxml)),
1037                });
1038            }
1039        }
1040
1041        // ---------- 2.6) 读取 notesMaster(TODO-045) ----------
1042        // 从 presentation.xml.rels 中找出所有 NotesMaster 关系,逐个解析。
1043        // 与 slideMaster 不同,notesMaster 是可选 part——空白文档无此 part。
1044        for r in pres_rels.iter() {
1045            if !matches!(r.reltype, RelType::NotesMaster) {
1046                continue;
1047            }
1048            let nm_partname = resolve_relative_partname("/ppt/presentation.xml", r.target.as_str());
1049            let nm_xml = match pkg.get_part(nm_partname.as_str()) {
1050                Some(p) => String::from_utf8_lossy(&p.blob).into_owned(),
1051                None => continue,
1052            };
1053            let nm = match crate::oxml::parse_sld::parse_notes_master(&nm_xml) {
1054                Ok(m) => m,
1055                Err(_e) => continue,
1056            };
1057            let idx = pres.notes_masters.items.len();
1058            pres.notes_masters.items.push(NotesMasterRef {
1059                idx,
1060                partname: nm_partname,
1061                rid: r.id.clone(),
1062                oxml: Rc::new(std::cell::RefCell::new(nm)),
1063            });
1064        }
1065
1066        // ---------- 3) 遍历 sldIdLst,还原每张 slide ----------
1067        // 维护"已读 layout rid"的最大编号,避免 id_counter 内部冲突。
1068        let mut max_id_seen: u32 = 2;
1069        for (sld_id, rid) in &sld_id_list {
1070            // 找 rid 对应的 target(如 "slides/slide1.xml")。
1071            let rel = match pres_rels.get(rid) {
1072                Some(r) => r,
1073                None => {
1074                    // 关系缺失:跳过此 slide,不让单点失败拖垮整份文档。
1075                    continue;
1076                }
1077            };
1078            // presentation.xml 是 /ppt/presentation.xml,所以 rels 中的相对路径
1079            // 以 /ppt/ 为基准解析。绝对路径(以 '/' 开头)直接使用。
1080            let slide_partname =
1081                resolve_relative_partname("/ppt/presentation.xml", rel.target.as_str());
1082            // 读 slideN.xml
1083            let slide_part = match pkg.get_part(slide_partname.as_str()) {
1084                Some(p) => p,
1085                None => continue,
1086            };
1087            let slide_xml = String::from_utf8_lossy(&slide_part.blob).into_owned();
1088            let mut sld = match crate::oxml::parse_sld::parse_sld(&slide_xml) {
1089                Ok(s) => s,
1090                Err(_e) => {
1091                    // 单张 slide 解析失败:跳过、记日志。
1092                    continue;
1093                }
1094            };
1095            // 更新 sld.id
1096            sld.id = *sld_id;
1097
1098            // ---------- 4) 读 slideN.xml.rels:layout + notes + image + comments ----------
1099            let rels_path = rels_partname_for(slide_partname.as_str());
1100            let mut layout_rid = String::from("rId1");
1101            let mut notes_rid: Option<String> = None;
1102            // notes target 的原始相对路径(如 "../notesSlides/notesSlide1.xml"),
1103            // 在遍历 slide_rels 时一并收集,避免重复解析 rels 文件。
1104            let mut notes_target_raw: Option<String> = None;
1105            // comments target 的原始相对路径(如 "../comments/comment1.xml")。
1106            let mut comments_rid: Option<String> = None;
1107            let mut comments_target_raw: Option<String> = None;
1108            // 收集 image 关系以重建 media_entries(确保 save 时 rels 完整)
1109            let mut image_rels: Vec<(String, String)> = Vec::new(); // (rid, target_partname)
1110                                                                    // 收集 SmartArt 4 类 diagram 关系(rid -> 绝对 partname),用于后续构造 DiagramEntry(TODO-037)。
1111                                                                    // 一个 slide 可能含多个 SmartArt,每个 SmartArt 持有 4 个独立 rels,因此按 rid 建立扁平映射;
1112                                                                    // 后续根据 sld 中 SmartArtRef 的 4 个 rid 配对成 DiagramEntry。
1113            let mut diagram_rel_map: std::collections::HashMap<String, String> =
1114                std::collections::HashMap::new();
1115            // 收集 chart 关系(rid -> 绝对 partname),用于后续读取 chartN.xml 解析 Chart 模型(TODO-004 读路径)。
1116            // 一个 slide 可能含多个 chart graphicFrame,每个引用一个独立的 chartN.xml part。
1117            let mut chart_rel_map: std::collections::HashMap<String, String> =
1118                std::collections::HashMap::new();
1119            if let Some(rels_part) = pkg.get_part(rels_path.as_str()) {
1120                let rels_xml = String::from_utf8_lossy(&rels_part.blob).into_owned();
1121                if let Ok(slide_rels) = Relationships::from_xml(&rels_xml) {
1122                    for r in slide_rels.iter() {
1123                        match r.reltype {
1124                            RelType::SlideLayout => {
1125                                layout_rid = r.id.clone();
1126                            }
1127                            RelType::NotesSlide => {
1128                                notes_rid = Some(r.id.clone());
1129                                // 保留原始 target(相对路径),供后续 resolve_relative_partname 使用
1130                                notes_target_raw = Some(r.target.as_str().to_string());
1131                            }
1132                            RelType::Comments => {
1133                                comments_rid = Some(r.id.clone());
1134                                comments_target_raw = Some(r.target.as_str().to_string());
1135                            }
1136                            RelType::Image => {
1137                                let rel_target = r.target.as_str();
1138                                let abs =
1139                                    resolve_relative_partname(slide_partname.as_str(), rel_target);
1140                                image_rels.push((r.id.clone(), abs));
1141                            }
1142                            RelType::DiagramData
1143                            | RelType::DiagramLayout
1144                            | RelType::DiagramQuickStyle
1145                            | RelType::DiagramColors => {
1146                                // 收集 SmartArt 的 4 类关系(TODO-037),按 rid 建立映射。
1147                                let rel_target = r.target.as_str();
1148                                let abs =
1149                                    resolve_relative_partname(slide_partname.as_str(), rel_target);
1150                                diagram_rel_map.insert(r.id.clone(), abs);
1151                            }
1152                            RelType::Chart => {
1153                                // 收集 chart 关系(TODO-004 读路径),按 rid 建立映射。
1154                                // 后续根据 graphicFrame.Graphic::Chart.rid 查找 partname,
1155                                // 读取 chartN.xml 内容调用 Chart::parse_from_xml 还原模型。
1156                                let rel_target = r.target.as_str();
1157                                let abs =
1158                                    resolve_relative_partname(slide_partname.as_str(), rel_target);
1159                                chart_rel_map.insert(r.id.clone(), abs);
1160                            }
1161                            _ => {}
1162                        }
1163                    }
1164                }
1165            }
1166            // 把 layout_rid 同步到 sld。
1167            sld.set_layout_rid(layout_rid.clone());
1168
1169            // ---------- 5) 处理 notes(如有) ----------
1170            // 收集本 slide 的 notes 元数据,用于写路径**复用**原始 OPC 关系,
1171            // 避免 read→save 后 partname / rid 漂移导致外部引用断链。
1172            //   - `parsed_notes_partname`:解析后的绝对 partname(如 `/ppt/notesSlides/notesSlide1.xml`);
1173            //   - `parsed_notes_rels_target`:`notesSlideN.xml.rels` 中 `Slide` 关系的 target。
1174            let mut parsed_notes_partname: Option<String> = None;
1175            let mut parsed_notes_rels_target: Option<String> = None;
1176            if let Some(raw_target) = &notes_target_raw {
1177                // 从 slide rels 中取 target(相对路径如 "../notesSlides/notesSlide1.xml"),
1178                // 然后解析成绝对 partname。
1179                let notes_partname =
1180                    resolve_relative_partname(slide_partname.as_str(), raw_target.as_str());
1181                parsed_notes_partname = Some(notes_partname.clone());
1182                // 读 `notesSlideN.xml.rels`,找出其中指向所属 slide 的 `Slide` 关系 target。
1183                //   关系文件: /ppt/notesSlides/_rels/notesSlideN.xml.rels
1184                //   target 形如 "../slides/slide1.xml"。
1185                let notes_rels_path = rels_partname_for(&notes_partname);
1186                if let Some(nrp) = pkg.get_part(notes_rels_path.as_str()) {
1187                    let nrp_xml = String::from_utf8_lossy(&nrp.blob).into_owned();
1188                    if let Ok(nr) = Relationships::from_xml(&nrp_xml) {
1189                        for r in nr.iter() {
1190                            if matches!(r.reltype, RelType::Slide) {
1191                                parsed_notes_rels_target = Some(r.target.as_str().to_string());
1192                                break;
1193                            }
1194                        }
1195                    }
1196                }
1197                if let Some(np) = pkg.get_part(notes_partname.as_str()) {
1198                    let notes_xml = String::from_utf8_lossy(&np.blob).into_owned();
1199                    if let Ok(tb) = crate::oxml::parse_sld::parse_notes(&notes_xml) {
1200                        sld.notes = Some(tb);
1201                    }
1202                }
1203            }
1204
1205            // ---------- 5.5) 处理 comments(如有) ----------
1206            // 收集本 slide 的评论 partname,用于写路径**复用**原始 OPC 关系。
1207            let mut parsed_comments_partname: Option<String> = None;
1208            let mut parsed_comments_lst: Option<crate::oxml::comments::CommentList> = None;
1209            if let Some(raw_target) = &comments_target_raw {
1210                // 从 slide rels 中取 target(相对路径如 "../comments/comment1.xml"),
1211                // 然后解析成绝对 partname。
1212                let comments_partname =
1213                    resolve_relative_partname(slide_partname.as_str(), raw_target.as_str());
1214                parsed_comments_partname = Some(comments_partname.clone());
1215                // 读 commentN.xml 本体,解析出 CommentList
1216                if let Some(cp) = pkg.get_part(comments_partname.as_str()) {
1217                    let comments_xml = String::from_utf8_lossy(&cp.blob).into_owned();
1218                    if let Ok(lst) = crate::oxml::parse_sld::parse_comments(&comments_xml) {
1219                        parsed_comments_lst = Some(lst);
1220                    }
1221                }
1222            }
1223
1224            // ---------- 6) 构造 SlideEntry 并推入 ----------
1225            // 跟新 id_counter 状态:保证后续 shape id 单调递增。
1226            // 扫描 sld 内最大 sp id(仅 Sp / Pic 等有显式 id 的 shape)。
1227            for shape in &sld.shapes {
1228                let sp_id = match shape {
1229                    crate::oxml::SlideShape::Sp(sp) => sp.id,
1230                    crate::oxml::SlideShape::Pic(pic) => pic.id,
1231                    crate::oxml::SlideShape::CxnSp(c) => c.id,
1232                    crate::oxml::SlideShape::Group(g) => g.id,
1233                    crate::oxml::SlideShape::GraphicFrame(gf) => gf.id,
1234                };
1235                if sp_id > max_id_seen {
1236                    max_id_seen = sp_id;
1237                }
1238            }
1239
1240            let mut slide = Slide::from_sld(sld, id_counter.clone(), layout_rid);
1241            if let Some(nrid) = notes_rid {
1242                slide.set_notes_rid(nrid);
1243            }
1244            // 把解析出的 notes 元数据回填到 Slide(写路径将**复用**这些值):
1245            //   - partname:保证 read→save 不漂移;
1246            //   - 反向 rels target:保证 `notesSlideN.xml.rels → slideN.xml` 不断链。
1247            if let Some(np) = parsed_notes_partname {
1248                slide.set_notes_partname(np);
1249            }
1250            if let Some(nrt) = parsed_notes_rels_target {
1251                slide.set_notes_slide_rel_target(nrt);
1252            }
1253            // 把解析出的 comments 元数据回填到 Slide:
1254            //   - rid:保证 read→save 不漂移;
1255            //   - partname:保证 `commentN.xml` 路径稳定。
1256            if let Some(crid) = comments_rid {
1257                slide.set_comments_rid(crid);
1258            }
1259            if let Some(cp) = parsed_comments_partname {
1260                slide.set_comments_partname(cp);
1261            }
1262            if let Some(clst) = parsed_comments_lst {
1263                slide.set_comments(Some(clst));
1264            }
1265
1266            // ---------- 5.5) 重建 media_entries(从 image_rels 还原) ----------
1267            // 目的:save 时 to_opc_package 会按 media_entries 写 Image 关系 + media part;
1268            //      读路径下必须把已读到的 Image 关系补成 MediaEntry,否则保存时会丢图。
1269            for (rid, target_partname) in &image_rels {
1270                if let Some(media_part) = pkg.get_part(target_partname.as_str()) {
1271                    let partname = crate::opc::part::new_part_name(target_partname.as_str());
1272                    slide.register_media(MediaEntry {
1273                        partname,
1274                        content_type: media_part.content_type.clone(),
1275                        blob: media_part.blob.clone(),
1276                        rid: rid.clone(),
1277                    });
1278                }
1279            }
1280
1281            // ---------- 5.6) 处理 SmartArt(TODO-037 round-trip) ----------
1282            // 遍历 slide.inner.shapes 找 GraphicFrame.Graphic::SmartArt,
1283            // 根据其 4 个 rid 查 diagram_rel_map 找 target,读取 4 个 part 内容,
1284            // 构造 DiagramEntry 注入 slide.diagram_entries,保证 read→save 完整 round-trip。
1285            //
1286            // **配对策略**:SmartArtRef 在 parse_sld 阶段已提取 4 个 rid(dm/lo/qs/cs),
1287            // 这里直接根据 rid 查 diagram_rel_map 得到绝对 partname,再读 part 内容。
1288            // 缺失任一关系则跳过该 SmartArt(保留 slide xml 中的 raw_xml 引用,但 4 个 part 不写)。
1289            //
1290            // **两阶段策略**(避免借用冲突,与 5.7 chart 处理一致):
1291            // 1. 阶段一(不可变借用):遍历 shapes 收集 SmartArt 的 4 个 rid + 4 个 partname;
1292            // 2. 阶段二(可变借用):逐个读取 part 内容 + 构造 DiagramEntry + 注册。
1293            // 元组字段顺序:(dm_rid, lo_rid, qs_rid, cs_rid, data_partname, layout_partname,
1294            //                quick_style_partname, colors_partname)
1295            let mut diagram_tasks: Vec<DiagramTask> = Vec::new();
1296            for shape in &slide.inner.shapes {
1297                if let crate::oxml::SlideShape::GraphicFrame(gf) = shape {
1298                    if let crate::oxml::shape::Graphic::SmartArt(smart_ref) = &gf.graphic {
1299                        let (Some(dm_rid), Some(lo_rid), Some(qs_rid), Some(cs_rid)) = (
1300                            &smart_ref.dm_rid,
1301                            &smart_ref.lo_rid,
1302                            &smart_ref.qs_rid,
1303                            &smart_ref.cs_rid,
1304                        ) else {
1305                            // SmartArtRef 的 4 个 rid 未完整提取,跳过(保留 raw_xml 但不构造 entry)。
1306                            continue;
1307                        };
1308                        let (
1309                            Some(data_partname),
1310                            Some(layout_partname),
1311                            Some(quick_style_partname),
1312                            Some(colors_partname),
1313                        ) = (
1314                            diagram_rel_map.get(dm_rid),
1315                            diagram_rel_map.get(lo_rid),
1316                            diagram_rel_map.get(qs_rid),
1317                            diagram_rel_map.get(cs_rid),
1318                        )
1319                        else {
1320                            // 4 个 rels 中有任一缺失(不完整的 SmartArt),跳过。
1321                            continue;
1322                        };
1323                        diagram_tasks.push((
1324                            dm_rid.clone(),
1325                            lo_rid.clone(),
1326                            qs_rid.clone(),
1327                            cs_rid.clone(),
1328                            data_partname.clone(),
1329                            layout_partname.clone(),
1330                            quick_style_partname.clone(),
1331                            colors_partname.clone(),
1332                        ));
1333                    }
1334                }
1335            }
1336            for (
1337                dm_rid,
1338                lo_rid,
1339                qs_rid,
1340                cs_rid,
1341                data_partname,
1342                layout_partname,
1343                quick_style_partname,
1344                colors_partname,
1345            ) in diagram_tasks
1346            {
1347                // 读取 4 个 part 的原始 XML 内容
1348                let data_xml = pkg
1349                    .get_part(data_partname.as_str())
1350                    .map(|p| String::from_utf8_lossy(&p.blob).into_owned())
1351                    .unwrap_or_default();
1352                let layout_xml = pkg
1353                    .get_part(layout_partname.as_str())
1354                    .map(|p| String::from_utf8_lossy(&p.blob).into_owned())
1355                    .unwrap_or_default();
1356                let quick_style_xml = pkg
1357                    .get_part(quick_style_partname.as_str())
1358                    .map(|p| String::from_utf8_lossy(&p.blob).into_owned())
1359                    .unwrap_or_default();
1360                let colors_xml = pkg
1361                    .get_part(colors_partname.as_str())
1362                    .map(|p| String::from_utf8_lossy(&p.blob).into_owned())
1363                    .unwrap_or_default();
1364                let entry = DiagramEntry {
1365                    data_partname: crate::opc::part::new_part_name(data_partname.as_str()),
1366                    layout_partname: crate::opc::part::new_part_name(layout_partname.as_str()),
1367                    quick_style_partname: crate::opc::part::new_part_name(
1368                        quick_style_partname.as_str(),
1369                    ),
1370                    colors_partname: crate::opc::part::new_part_name(colors_partname.as_str()),
1371                    data_xml,
1372                    layout_xml,
1373                    quick_style_xml,
1374                    colors_xml,
1375                    data_rid: dm_rid,
1376                    layout_rid: lo_rid,
1377                    quick_style_rid: qs_rid,
1378                    colors_rid: cs_rid,
1379                };
1380                slide.register_diagram(entry);
1381            }
1382
1383            // ---------- 5.7) 处理 chart(TODO-004 读路径) ----------
1384            // 遍历 slide.inner.shapes 找 GraphicFrame.Graphic::Chart,
1385            // 根据其 rid 查 chart_rel_map 找 target,读取 chartN.xml 内容,
1386            // 调用 Chart::parse_from_xml 解析,用解析结果替换占位 Chart 模型。
1387            //
1388            // **配对策略**:parse_sld 阶段已在 graphicFrame 内提取 `<c:chart r:id="..."/>` 的 rid,
1389            // 这里直接根据 rid 查 chart_rel_map 得到绝对 partname,再读 part 内容解析。
1390            // 缺失关系或解析失败则保留占位 Chart(chart_type=Column, data 空),不阻塞 round-trip。
1391            //
1392            // **两阶段策略**(避免借用冲突):
1393            // 1. 阶段一(不可变借用):遍历 shapes 收集 (rid, chart_partname) 对;
1394            // 2. 阶段二(可变借用):逐个读取 chartN.xml + 解析 + 替换 graphic + 注册 ChartEntry。
1395            let mut chart_tasks: Vec<(String, String)> = Vec::new(); // (rid, chart_partname)
1396            for shape in &slide.inner.shapes {
1397                if let crate::oxml::SlideShape::GraphicFrame(gf) = shape {
1398                    if let crate::oxml::shape::Graphic::Chart(chart) = &gf.graphic {
1399                        let rid = chart.rid.as_str();
1400                        if rid.is_empty() {
1401                            continue;
1402                        }
1403                        if let Some(chart_partname) = chart_rel_map.get(rid) {
1404                            chart_tasks.push((rid.to_string(), chart_partname.clone()));
1405                        }
1406                    }
1407                }
1408            }
1409            for (rid, chart_partname) in chart_tasks {
1410                let Some(chart_part) = pkg.get_part(chart_partname.as_str()) else {
1411                    continue;
1412                };
1413                let chart_xml = String::from_utf8_lossy(&chart_part.blob).into_owned();
1414                let Ok(mut parsed) = crate::oxml::chart::Chart::parse_from_xml(&chart_xml) else {
1415                    continue;
1416                };
1417                parsed.rid = rid.clone();
1418                // 把解析后的 Chart 同步回 slide 的 graphicFrame(in-place 替换匹配 rid 的 Chart)。
1419                for s in slide.inner.shapes.iter_mut() {
1420                    if let crate::oxml::SlideShape::GraphicFrame(gf2) = s {
1421                        if let crate::oxml::shape::Graphic::Chart(c2) = &mut gf2.graphic {
1422                            if c2.rid == rid {
1423                                *c2 = parsed.clone();
1424                                break;
1425                            }
1426                        }
1427                    }
1428                }
1429                // 注册 ChartEntry,供 to_opc_package 写出 chartN.xml part。
1430                // partname 用解析得到的绝对路径(保留原 partname 避免 rels 漂移)。
1431                slide.register_chart(ChartEntry {
1432                    partname: crate::opc::part::new_part_name(chart_partname.as_str()),
1433                    chart: parsed,
1434                    rid,
1435                    xlsx_blob: None,
1436                });
1437            }
1438
1439            let entry = SlideEntry::new(slide, *sld_id, rid.clone(), slide_partname);
1440            pres.slides.push_entry(entry);
1441        }
1442        // 同步 id_counter 到"已见最大 id"——后续 add_* 不会冲撞已读 shape。
1443        pres.id_counter.set(max_id_seen);
1444
1445        // ---------- 读 docProps/custom.xml(自定义属性,TODO-034) ----------
1446        if let Some(custom_part) = pkg.get_part("/docProps/custom.xml") {
1447            let custom_xml = String::from_utf8_lossy(&custom_part.blob).into_owned();
1448            pres.custom_properties = CustomProperties::from_xml(&custom_xml);
1449        }
1450
1451        // ---------- 读 ppt/commentAuthors.xml(评论作者,TODO-036) ----------
1452        if let Some(authors_part) = pkg.get_part("/ppt/commentAuthors.xml") {
1453            let authors_xml = String::from_utf8_lossy(&authors_part.blob).into_owned();
1454            if let Ok(lst) = crate::oxml::parse_sld::parse_comment_authors(&authors_xml) {
1455                pres.comment_authors = lst;
1456            }
1457        }
1458
1459        Ok(pres)
1460    }
1461
1462    /// 不可变幻灯片集合。
1463    pub fn slides(&self) -> &Slides {
1464        &self.slides
1465    }
1466    /// 可变幻灯片集合。
1467    pub fn slides_mut(&mut self) -> &mut Slides {
1468        &mut self.slides
1469    }
1470
1471    /// 取出共享的 `id_counter` 克隆。
1472    ///
1473    /// `Slides::add_slide` 接受这个计数器,从而保证跨 slide 的 shape id 不冲突。
1474    pub fn id_counter(&self) -> std::rc::Rc<std::cell::Cell<u32>> {
1475        self.id_counter.clone()
1476    }
1477
1478    /// 不可变版式集合。
1479    pub fn slide_layouts(&self) -> &SlideLayouts {
1480        &self.slide_layouts
1481    }
1482    /// 可变版式集合。
1483    pub fn slide_layouts_mut(&mut self) -> &mut SlideLayouts {
1484        &mut self.slide_layouts
1485    }
1486
1487    /// 按 slide 索引取该 slide 所引用的版式(TODO-007)。
1488    ///
1489    /// 对标 python-pptx `slide.slide_layout`。本方法通过匹配
1490    /// `slide.layout_rid()` 与 `SlideLayoutRef.rid()` 查找。
1491    ///
1492    /// # 参数
1493    /// - `slide_idx`:slide 在 `slides` 集合中的索引。
1494    ///
1495    /// # 返回
1496    /// - `Some(SlideLayoutRef)`:找到匹配的版式(克隆,因 `SlideLayoutRef` 是 `Clone`);
1497    /// - `None`:索引越界,或未找到 rid 匹配的版式。
1498    ///
1499    /// # 示例
1500    /// ```no_run
1501    /// use pptx_rs::Presentation;
1502    /// let mut p = Presentation::new().unwrap();
1503    /// let counter = p.id_counter();
1504    /// let _ = p.slides_mut().add_slide(counter).unwrap();
1505    /// // 获取第 0 张 slide 所引用的版式
1506    /// if let Some(layout) = p.layout_for_slide(0) {
1507    ///     println!("版式名: {}", layout.name());
1508    /// }
1509    /// ```
1510    pub fn layout_for_slide(&self, slide_idx: usize) -> Option<SlideLayoutRef> {
1511        let entry = self.slides.get(slide_idx)?;
1512        let layout_rid = entry.sld.layout_rid();
1513        self.slide_layouts
1514            .items
1515            .iter()
1516            .find(|l| l.rid == layout_rid)
1517            .cloned()
1518    }
1519
1520    /// 获取使用指定版式的所有 slide 索引(TODO-008)。
1521    ///
1522    /// 对标 python-pptx `SlideLayout.used_by_slides`。
1523    ///
1524    /// # 参数
1525    /// - `layout_rid`:版式的关系 id(`SlideLayoutRef.rid()`)。
1526    ///
1527    /// # 返回
1528    /// 所有 `layout_rid()` 等于 `layout_rid` 的 slide 索引列表(升序)。
1529    pub fn slides_using_layout(&self, layout_rid: &str) -> Vec<usize> {
1530        self.slides
1531            .iter()
1532            .enumerate()
1533            .filter(|(_, entry)| entry.sld.layout_rid() == layout_rid)
1534            .map(|(i, _)| i)
1535            .collect()
1536    }
1537
1538    /// 不可变母版集合。
1539    pub fn slide_masters(&self) -> &SlideMasters {
1540        &self.slide_masters
1541    }
1542    /// 可变母版集合。
1543    pub fn slide_masters_mut(&mut self) -> &mut SlideMasters {
1544        &mut self.slide_masters
1545    }
1546
1547    /// 当前画布宽度(EMU)。
1548    pub fn slide_width(&self) -> Emu {
1549        self.width
1550    }
1551    /// 当前画布高度(EMU)。
1552    pub fn slide_height(&self) -> Emu {
1553        self.height
1554    }
1555
1556    /// 显式设置画布尺寸。
1557    ///
1558    /// 注意此方法**不**触发任何 `Slide` 内部 shape 的重新布局;shape 仍按各自的
1559    /// EMU 坐标保存,调用方需自行决定是否缩放。
1560    pub fn set_slide_size(&mut self, width: Emu, height: Emu) {
1561        self.width = width;
1562        self.height = height;
1563    }
1564
1565    /// 取文档核心属性(不可变引用)。
1566    ///
1567    /// 对标 pypdf `PdfReader.metadata` / python-pptx `Presentation.core_properties`。
1568    pub fn core_properties(&self) -> &CoreProperties {
1569        &self.core_properties
1570    }
1571
1572    /// 取文档核心属性(可变引用)。
1573    ///
1574    /// 对标 pypdf `PdfWriter.add_metadata(infos)`。
1575    /// 修改后会在 `save` / `to_bytes` 时序列化到 `docProps/core.xml` 和 `docProps/app.xml`。
1576    pub fn core_properties_mut(&mut self) -> &mut CoreProperties {
1577        &mut self.core_properties
1578    }
1579
1580    /// 取自定义文档属性(不可变引用)。
1581    ///
1582    /// 对标 python-pptx `Presentation.custom_properties`(v1.0+)。
1583    pub fn custom_properties(&self) -> &CustomProperties {
1584        &self.custom_properties
1585    }
1586
1587    /// 取自定义文档属性(可变引用)。
1588    ///
1589    /// 修改后会在 `save` / `to_bytes` 时序列化到 `docProps/custom.xml`。
1590    ///
1591    /// # 示例
1592    ///
1593    /// ```no_run
1594    /// use pptx_rs::Presentation;
1595    /// use pptx_rs::presentation::CustomPropertyValue;
1596    ///
1597    /// let mut p = Presentation::new().unwrap();
1598    /// p.custom_properties_mut().set("Project", CustomPropertyValue::Text("Demo".to_string()));
1599    /// p.custom_properties_mut().set("Version", CustomPropertyValue::Int(42));
1600    /// ```
1601    pub fn custom_properties_mut(&mut self) -> &mut CustomProperties {
1602        &mut self.custom_properties
1603    }
1604
1605    /// 取评论作者列表(不可变引用)。
1606    ///
1607    /// 评论作者在 `save` / `to_bytes` 时序列化到 `/ppt/commentAuthors.xml`。
1608    pub fn comment_authors(&self) -> &crate::oxml::comments::CommentAuthorList {
1609        &self.comment_authors
1610    }
1611
1612    /// 取评论作者列表(可变引用)。
1613    ///
1614    /// 修改后会在 `save` / `to_bytes` 时序列化到 `/ppt/commentAuthors.xml`。
1615    ///
1616    /// # 示例
1617    ///
1618    /// ```no_run
1619    /// use pptx_rs::Presentation;
1620    ///
1621    /// let mut p = Presentation::new().unwrap();
1622    /// let author_id = p.comment_authors_mut().get_or_insert_id("张三", "ZS");
1623    /// ```
1624    pub fn comment_authors_mut(&mut self) -> &mut crate::oxml::comments::CommentAuthorList {
1625        &mut self.comment_authors
1626    }
1627
1628    /// 取章节分组列表(不可变引用,TODO-039)。
1629    ///
1630    /// 章节分组对应 PowerPoint 大纲视图中的"节"功能,在 `presentation.xml`
1631    /// 的 `<p:extLst>` 内以 `<p14:sectionLst>` 扩展元素持久化。
1632    ///
1633    /// # 与 python-pptx 的对应
1634    ///
1635    /// python-pptx 截至 v1.0 仍未提供 section API,本方法是 pptx-rs 的扩展。
1636    pub fn sections(&self) -> &crate::oxml::section::SectionList {
1637        &self.sections
1638    }
1639
1640    /// 取章节分组列表(可变引用,TODO-039)。
1641    ///
1642    /// 修改后会在 `save` / `to_bytes` 时序列化到 `presentation.xml` 的
1643    /// `<p:extLst><p:ext uri="{521415D9-36F7-43E2-AB2F-B90AF26B5E64}">`
1644    /// 内的 `<p14:sectionLst>`。
1645    ///
1646    /// # 示例
1647    ///
1648    /// ```no_run
1649    /// use pptx_rs::Presentation;
1650    /// use pptx_rs::oxml::section::Section;
1651    ///
1652    /// let mut p = Presentation::new().unwrap();
1653    /// // 假设已添加 2 张 slide,它们的 sld_id 分别为 256 / 257
1654    /// let mut s1 = Section::new("引言");
1655    /// s1.push(256);
1656    /// s1.push(257);
1657    /// p.sections_mut().push(s1);
1658    /// ```
1659    pub fn sections_mut(&mut self) -> &mut crate::oxml::section::SectionList {
1660        &mut self.sections
1661    }
1662
1663    /// 取备注母版集合(不可变引用,TODO-045)。
1664    ///
1665    /// 备注母版是所有备注页的"模板"——定义了备注页的默认占位符与文本样式。
1666    /// 一个演示文稿通常只有 0 或 1 个备注母版。
1667    ///
1668    /// # 与 python-pptx 的对应
1669    ///
1670    /// - `pptx.Presentation.notes_master` ←→ `presentation.notes_masters().first()`;
1671    /// - python-pptx 仅暴露单个 `notes_master` 属性,本库用集合表达以兼容多母版场景。
1672    ///
1673    /// # 示例
1674    ///
1675    /// ```no_run
1676    /// use pptx_rs::Presentation;
1677    ///
1678    /// let p = Presentation::new().unwrap();
1679    /// // 空白文档无备注母版
1680    /// assert!(p.notes_masters().is_empty());
1681    /// ```
1682    pub fn notes_masters(&self) -> &NotesMasters {
1683        &self.notes_masters
1684    }
1685
1686    /// 取备注母版集合(可变引用,TODO-045)。
1687    ///
1688    /// 主要用于在内存中修改已解析出的备注母版形状(写路径暂未实现持久化)。
1689    pub fn notes_masters_mut(&mut self) -> &mut NotesMasters {
1690        &mut self.notes_masters
1691    }
1692
1693    /// 便捷方法:取第一个备注母版(python-pptx `presentation.notes_master` 风格)。
1694    ///
1695    /// `None` 表示该演示文稿无备注母版。
1696    pub fn notes_master(&self) -> Option<&NotesMasterRef> {
1697        self.notes_masters.first()
1698    }
1699
1700    /// 一次性设置多个核心属性(便捷方法)。
1701    ///
1702    /// 对标 pypdf `PdfWriter.add_metadata(infos)` 的"批量设置"语义。
1703    /// 传入 `None` 的字段**不会**覆盖已有值。
1704    ///
1705    /// # 示例
1706    ///
1707    /// ```no_run
1708    /// use pptx_rs::Presentation;
1709    /// let mut p = Presentation::new().unwrap();
1710    /// p.set_metadata(
1711    ///     Some("My Presentation"),  // title
1712    ///     Some("Author Name"),      // creator
1713    ///     None,                     // subject (keep existing)
1714    ///     None,                     // keywords
1715    /// );
1716    /// ```
1717    pub fn set_metadata(
1718        &mut self,
1719        title: Option<&str>,
1720        creator: Option<&str>,
1721        subject: Option<&str>,
1722        keywords: Option<&str>,
1723    ) {
1724        if let Some(t) = title {
1725            self.core_properties.title = Some(t.to_string());
1726        }
1727        if let Some(c) = creator {
1728            self.core_properties.creator = Some(c.to_string());
1729        }
1730        if let Some(s) = subject {
1731            self.core_properties.subject = Some(s.to_string());
1732        }
1733        if let Some(k) = keywords {
1734            self.core_properties.keywords = Some(k.to_string());
1735        }
1736    }
1737
1738    /// 幻灯片总数。
1739    ///
1740    /// 对标 pypdf `PdfReader.num_pages` / python-pptx `len(prs.slides)`。
1741    pub fn num_slides(&self) -> usize {
1742        self.slides.len()
1743    }
1744
1745    /// 给所有 slide 添加**文本水印**。
1746    ///
1747    /// 对标 pypdf `PageObject.merge_page(watermark_page)` 的水印注入模式。
1748    ///
1749    /// # 实现原理
1750    /// 在每张 slide 上添加一个**旋转 + 半透明**的文本框(`p:sp` + `cNvSpPr txBox="1"`),
1751    /// 位于 slide 中心,字体大小可配置。文本框的 `z-order` 被推到最顶层
1752    /// (即最后添加的形状),确保水印覆盖在内容之上。
1753    ///
1754    /// # 参数
1755    /// - `text`:水印文本(如 "CONFIDENTIAL" / "DRAFT");
1756    /// - `font_size_pt`:字体大小(磅),默认 36;
1757    /// - `color`:水印颜色,默认灰色 `RGBColor(0xC0, 0xC0, 0xC0)`;
1758    /// - `rotation_deg`:旋转角度(度),默认 -30(逆时针 30°);
1759    /// - `alpha`:不透明度(0-100000),默认 30_000(30% 不透明 / 70% 透明);
1760    /// - `font_name`:字体名称,默认 "Calibri"。
1761    ///
1762    /// # 与 pypdf 的差异
1763    /// - pypdf 用"页面合并"(`merge_page`)实现水印——把水印 PDF 页叠加到目标页;
1764    /// - 本方法用"形状注入"——直接在 slide XML 中插入一个旋转文本框。
1765    ///   两种方式在视觉上等价,但 OOXML 不支持"页面合并"语义。
1766    #[allow(clippy::field_reassign_with_default)]
1767    pub fn add_watermark(
1768        &mut self,
1769        text: &str,
1770        font_size_pt: Option<f64>,
1771        color: Option<crate::units::RGBColor>,
1772        rotation_deg: Option<i32>,
1773        alpha: Option<i32>,
1774        font_name: Option<&str>,
1775    ) -> crate::Result<()> {
1776        let fs = font_size_pt.unwrap_or(36.0);
1777        let clr = color.unwrap_or(crate::units::RGBColor(0xC0, 0xC0, 0xC0));
1778        let rot = rotation_deg.unwrap_or(-30);
1779        let alpha_val = alpha.unwrap_or(30_000);
1780        let font = font_name.unwrap_or("Calibri");
1781        let rot_emu = rot * 60000; // 1° = 60000 EMU 角度单位
1782
1783        for i in 0..self.slides.len() {
1784            // 索引 i 来自 0..len,不会越界;使用 ok_or 传播错误以遵守 §5 规则
1785            let slide = self
1786                .slides
1787                .get_mut(i)
1788                .ok_or(crate::Error::IndexOutOfRange(i))?;
1789            let sld = &mut slide.sld;
1790            let id = sld.next_shape_id();
1791
1792            let mut sp = crate::oxml::shape::Sp::default();
1793            sp.id = id;
1794            sp.name = format!("Watermark {}", i + 1);
1795            sp.c_nv_sp_pr_tx_box = true;
1796
1797            // 位置:居中(大约在 slide 中心)
1798            let cx = self.width.0;
1799            let cy = self.height.0;
1800            sp.properties.xfrm.off_x = Some(Emu(cx / 4));
1801            sp.properties.xfrm.off_y = Some(Emu(cy / 4));
1802            sp.properties.xfrm.ext_cx = Some(Emu(cx / 2));
1803            sp.properties.xfrm.ext_cy = Some(Emu(cy / 2));
1804            sp.properties.xfrm.rot = Some(rot_emu);
1805            sp.properties.geometry = Some(crate::oxml::sppr::Geometry::preset(
1806                crate::oxml::simpletypes::PresetGeometry::Rectangle,
1807            ));
1808            // 水印形状无填充(透明背景),无边框
1809            sp.properties.fill = crate::oxml::sppr::Fill::None;
1810            sp.properties.line = None;
1811
1812            // 文本
1813            let mut tb = crate::oxml::txbody::TextBody::new();
1814            let mut para = crate::oxml::txbody::Paragraph::default();
1815            let mut run = crate::oxml::txbody::Run::default();
1816            run.text = text.to_string();
1817            run.properties.size = Some(crate::units::Pt(fs));
1818            run.properties.color = crate::oxml::color::Color::RGB(clr);
1819            run.properties.bold = true;
1820            run.properties.alpha = Some(alpha_val);
1821            // 设置字体
1822            run.properties.latin_font = Some(font.to_string());
1823            run.properties.eastasia_font = Some("宋体".to_string());
1824            para.runs.push(run);
1825            // 居中对齐
1826            para.properties.alignment = Some(crate::oxml::simpletypes::Alignment::Center);
1827            tb.paragraphs.push(para);
1828            sp.text = tb;
1829
1830            sld.inner.shapes.push(crate::oxml::SlideShape::Sp(sp));
1831        }
1832        Ok(())
1833    }
1834
1835    /// 给所有 slide 添加**图片水印**。
1836    ///
1837    /// 对标 pypdf `PageObject.merge_page(watermark_page)` 的水印注入模式。
1838    ///
1839    /// # 实现原理
1840    /// 在每张 slide 上添加一个**半透明**的图片形状(`p:pic` + `a:alphaModFix`),
1841    /// 位于 slide 中心,大小可配置。图片的 `z-order` 被推到最顶层
1842    /// (即最后添加的形状),确保水印覆盖在内容之上。
1843    ///
1844    /// # 参数
1845    /// - `image_bytes`:图片二进制数据(PNG / JPG / BMP 等);
1846    /// - `ext`:图片扩展名(如 `"png"` / `"jpg"`,不含前导 `.`);
1847    /// - `alpha`:不透明度(0-100000),默认 30_000(30% 不透明 / 70% 透明);
1848    /// - `left`:水印左上角 x 坐标(EMU),默认居中偏左 1/4 画布宽度;
1849    /// - `top`:水印左上角 y 坐标(EMU),默认居中偏上 1/4 画布高度;
1850    /// - `width`:水印宽度(EMU),默认画布宽度的一半;
1851    /// - `height`:水印高度(EMU),默认画布高度的一半。
1852    ///
1853    /// # 与文本水印的差异
1854    /// - 文本水印(`add_watermark`)用旋转文本框实现,适合"CONFIDENTIAL"等文字;
1855    /// - 图片水印用半透明图片实现,适合公司 logo / 自定义图案等场景。
1856    ///
1857    /// # 错误
1858    /// - [`crate::Error::Encryption`]:图片字节为空。
1859    #[allow(clippy::field_reassign_with_default)]
1860    #[allow(clippy::too_many_arguments)]
1861    pub fn add_image_watermark(
1862        &mut self,
1863        image_bytes: &[u8],
1864        ext: &str,
1865        alpha: Option<i32>,
1866        left: Option<Emu>,
1867        top: Option<Emu>,
1868        width: Option<Emu>,
1869        height: Option<Emu>,
1870    ) -> crate::Result<()> {
1871        if image_bytes.is_empty() {
1872            return Err(crate::Error::encryption("image bytes must not be empty"));
1873        }
1874        let alpha_val = alpha.unwrap_or(30_000);
1875        let cx = self.width.0;
1876        let cy = self.height.0;
1877        let wm_left = left.unwrap_or(Emu(cx / 4));
1878        let wm_top = top.unwrap_or(Emu(cy / 4));
1879        let wm_width = width.unwrap_or(Emu(cx / 2));
1880        let wm_height = height.unwrap_or(Emu(cy / 2));
1881
1882        // 所有 slide 共享同一个 media partname(同一张图片只存一份到 zip)
1883        let ext_norm = if ext.starts_with('.') {
1884            ext.to_string()
1885        } else {
1886            format!(".{}", ext)
1887        };
1888        // 用第一张 slide 的 media_index 分配全局唯一的 partname
1889        let global_media_idx = self
1890            .slides
1891            .get(0)
1892            .map(|s| s.sld.next_media_index())
1893            .unwrap_or(1);
1894        let shared_partname = crate::opc::part::new_part_name(
1895            format!("/ppt/media/image{}{}", global_media_idx, ext_norm).as_str(),
1896        );
1897        let ct = crate::shape::picture::content_type_for(&ext_norm);
1898
1899        for i in 0..self.slides.len() {
1900            // 索引 i 来自 0..len,不会越界;使用 ok_or 传播错误以遵守 §5 规则
1901            let slide = self
1902                .slides
1903                .get_mut(i)
1904                .ok_or(crate::Error::IndexOutOfRange(i))?;
1905            let id = slide.sld.next_shape_id();
1906            let rid = slide.sld.allocate_image_rid();
1907
1908            // 构造 oxml Pic
1909            let mut pic = crate::oxml::shape::Pic::default();
1910            pic.id = id;
1911            pic.name = format!("WatermarkImage {}", i + 1);
1912            pic.rid = rid.clone();
1913            pic.alpha = Some(alpha_val);
1914            pic.fill_mode = crate::oxml::sppr::BlipFillMode::Stretch;
1915            pic.properties.xfrm.off_x = Some(wm_left);
1916            pic.properties.xfrm.off_y = Some(wm_top);
1917            pic.properties.xfrm.ext_cx = Some(wm_width);
1918            pic.properties.xfrm.ext_cy = Some(wm_height);
1919            // 图片形状不需要填充和边框
1920            pic.properties.fill = crate::oxml::sppr::Fill::None;
1921            pic.properties.line = None;
1922
1923            // 注册 media 到 slide(所有 slide 共享同一个 partname,to_opc_package 会去重)
1924            slide.sld.register_media(MediaEntry {
1925                partname: shared_partname.clone(),
1926                content_type: ct.to_string(),
1927                blob: image_bytes.to_vec(),
1928                rid,
1929            });
1930
1931            slide
1932                .sld
1933                .inner
1934                .shapes
1935                .push(crate::oxml::SlideShape::Pic(pic));
1936        }
1937        Ok(())
1938    }
1939
1940    /// 设置修改密码保护(打开时可只读浏览,修改需密码)。
1941    ///
1942    /// 对标 PowerPoint "保护演示文稿 → 限制访问" 功能。
1943    ///
1944    /// # 算法
1945    /// 使用 SHA-512 + 随机 salt + 100 000 次迭代,符合 MS-OFFCRYPTO §2.4.2.4。
1946    /// 保护信息注入到 `presentation.xml` 的 `<p:modifyVerifier>` 元素中。
1947    ///
1948    /// # 参数
1949    /// - `password`:修改密码(不能为空)。
1950    ///
1951    /// # 错误
1952    /// - [`crate::Error::Encryption`]:密码为空。
1953    pub fn set_write_protection(&mut self, password: &str) -> crate::Result<()> {
1954        if password.is_empty() {
1955            return Err(crate::Error::encryption("password must not be empty"));
1956        }
1957        let salt = crate::crypto::generate_random_bytes(crate::crypto::SALT_LEN);
1958        self.modify_protection = Some(crate::crypto::ModifyProtection::from_password(
1959            password,
1960            &salt,
1961            crate::crypto::MODIFY_SPIN_COUNT,
1962        ));
1963        Ok(())
1964    }
1965
1966    /// 验证修改密码是否匹配。
1967    ///
1968    /// # 参数
1969    /// - `password`:待验证的密码。
1970    ///
1971    /// # 返回
1972    /// - `Ok(true)`:密码匹配;
1973    /// - `Ok(false)`:密码不匹配;
1974    /// - 若未设置修改保护,返回 `Ok(false)`。
1975    pub fn verify_write_protection(&self, password: &str) -> crate::Result<bool> {
1976        match &self.modify_protection {
1977            Some(mp) => Ok(mp.verify_password(password)),
1978            None => Ok(false),
1979        }
1980    }
1981
1982    /// 移除修改密码保护。
1983    pub fn remove_write_protection(&mut self) {
1984        self.modify_protection = None;
1985    }
1986
1987    /// 检查是否设置了修改密码保护。
1988    pub fn is_write_protected(&self) -> bool {
1989        self.modify_protection.is_some()
1990    }
1991
1992    /// 保存为加密的 `.pptx` 文件(打开文件需密码)。
1993    ///
1994    /// 对标 PowerPoint "保护演示文稿 → 用密码进行加密" 功能。
1995    /// 使用 ECMA-376 Agile Encryption(AES-256-CBC + SHA-512)。
1996    ///
1997    /// # 参数
1998    /// - `path`:输出文件路径;
1999    /// - `password`:加密密码。
2000    ///
2001    /// # 错误
2002    /// - [`crate::Error::Encryption`]:加密过程失败。
2003    /// - [`crate::Error::Io`]:文件写入失败。
2004    pub fn save_encrypted(&self, path: impl AsRef<Path>, password: &str) -> crate::Result<()> {
2005        let pkg = self.to_opc_package()?;
2006        let zip_bytes = pkg.to_bytes()?;
2007        let encrypted = crate::crypto::encrypt_package(&zip_bytes, password)?;
2008        std::fs::write(path.as_ref(), &encrypted)?;
2009        Ok(())
2010    }
2011
2012    /// 序列化为加密的字节流(打开需密码)。
2013    ///
2014    /// 等价于"保存到内存"的加密版本,适用于网络传输等场景。
2015    pub fn to_encrypted_bytes(&self, password: &str) -> crate::Result<Vec<u8>> {
2016        let pkg = self.to_opc_package()?;
2017        let zip_bytes = pkg.to_bytes()?;
2018        crate::crypto::encrypt_package(&zip_bytes, password)
2019    }
2020
2021    /// 打开加密的 `.pptx` 文件。
2022    ///
2023    /// # 参数
2024    /// - `path`:文件路径;
2025    /// - `password`:解密密码。
2026    ///
2027    /// # 错误
2028    /// - [`crate::Error::Encryption`]:密码错误或加密格式损坏;
2029    /// - [`crate::Error::Io`]:文件读取失败。
2030    pub fn open_encrypted(path: impl AsRef<Path>, password: &str) -> crate::Result<Self> {
2031        let bytes = std::fs::read(path.as_ref())?;
2032        Self::load_encrypted_bytes(&bytes, password)
2033    }
2034
2035    /// 从加密的字节流加载。
2036    pub fn load_encrypted_bytes(bytes: &[u8], password: &str) -> crate::Result<Self> {
2037        let decrypted = crate::crypto::decrypt_package(bytes, password)?;
2038        Self::load_bytes(&decrypted)
2039    }
2040
2041    /// 检查文件是否为加密的 OOXML 文档。
2042    ///
2043    /// 加密文档的特征:ZIP 中包含 `EncryptionInfo` 条目。
2044    pub fn is_encrypted_file(path: impl AsRef<Path>) -> crate::Result<bool> {
2045        let bytes = std::fs::read(path.as_ref())?;
2046        Ok(crate::crypto::is_encrypted_package(&bytes))
2047    }
2048
2049    /// 检查字节流是否为加密的 OOXML 文档。
2050    pub fn is_encrypted_bytes(bytes: &[u8]) -> bool {
2051        crate::crypto::is_encrypted_package(bytes)
2052    }
2053
2054    /// 保存到本地 `.pptx` 文件。
2055    ///
2056    /// # 错误
2057    /// 透传 `Presentation::to_opc_package` 与 [`OpcPackage::save`] 的一切错误。
2058    pub fn save(&self, path: impl AsRef<Path>) -> crate::Result<()> {
2059        let pkg = self.to_opc_package()?;
2060        pkg.save(path)
2061    }
2062
2063    /// 把整份演示文稿序列化为 zip 字节流。
2064    ///
2065    /// 等价于"保存到内存"——适用于网络响应、自动化测试 fixture、邮件附件等场景。
2066    pub fn to_bytes(&self) -> crate::Result<Vec<u8>> {
2067        let pkg = self.to_opc_package()?;
2068        pkg.to_bytes()
2069    }
2070
2071    /// 内部入口:把内存模型组装为完整的 [`OpcPackage`]。
2072    ///
2073    /// 该方法是 `save` / `to_bytes` 的公共实现,负责按以下顺序构建 part 树:
2074    ///
2075    /// 1. `_rels/.rels`(根关系)—— 指向 `ppt/presentation.xml` + `docProps/*`;
2076    /// 2. `docProps/core.xml` + `docProps/app.xml`(最小化的 Office 文档属性);
2077    /// 3. `ppt/theme/theme1.xml`(标准 Office 主题);
2078    /// 4. `ppt/slideMasters/slideMaster1.xml` + 关系(指向 theme + slideLayouts);
2079    /// 5. `ppt/slideLayouts/slideLayout1.xml` + 关系(指向 slideMaster1);
2080    /// 6. `ppt/_rels/presentation.xml.rels` + 全部 slide / media 关系;
2081    /// 7. `ppt/presentation.xml`(包含 `sldSz` 与 `sldIdLst`);
2082    /// 8. 每个 slide 的 `ppt/slides/slideN.xml` 与其 `_rels/slideN.xml.rels`;
2083    /// 9. 媒体(图片)`ppt/media/*`;
2084    /// 10. `ppt/presProps.xml` / `ppt/viewProps.xml` / `ppt/tableStyles.xml`(必备辅件)。
2085    fn to_opc_package(&self) -> crate::Result<OpcPackage> {
2086        let mut pkg = OpcPackage::new();
2087
2088        // ---------------- 1) 根 _rels/.rels ----------------
2089        // 关系中至少需要:指向 presentation.xml 的 OfficeDocument + 指向 docProps 的元数据。
2090        let mut root_rels = Relationships::new();
2091        root_rels.add(Relationship::internal(
2092            "rId1",
2093            RelType::OfficeDocument,
2094            new_part_name("/ppt/presentation.xml"),
2095        ))?;
2096        root_rels.add(Relationship::internal(
2097            "rId2",
2098            crate::opc::rels::RelType::Other(
2099                "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties".to_string(),
2100            ),
2101            new_part_name("/docProps/core.xml"),
2102        ))?;
2103        root_rels.add(Relationship::internal(
2104            "rId3",
2105            crate::opc::rels::RelType::Other(
2106                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties".to_string(),
2107            ),
2108            new_part_name("/docProps/app.xml"),
2109        ))?;
2110        // 自定义属性关系(仅当 custom_properties 非空时添加)
2111        if !self.custom_properties.is_empty() {
2112            root_rels.add(Relationship::internal(
2113                "rId4",
2114                crate::opc::rels::RelType::Other(
2115                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties".to_string(),
2116                ),
2117                new_part_name("/docProps/custom.xml"),
2118            ))?;
2119        }
2120        let root_rels_xml = root_rels.to_xml();
2121        let root_rels_part = Part::new(
2122            new_part_name("/_rels/.rels"),
2123            ct::RELATIONSHIPS,
2124            root_rels_xml.into_bytes(),
2125        );
2126        pkg.put_part(root_rels_part);
2127
2128        // ---------------- 2) docProps/core.xml ----------------
2129        // 极简实现:title/creator/lastModifiedBy 三个字段,PowerPoint 会接受。
2130        let core_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2131<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
2132    xmlns:dc="http://purl.org/dc/elements/1.1/"
2133    xmlns:dcterms="http://purl.org/dc/terms/"
2134    xmlns:dcmitype="http://purl.org/dc/dcmitype/"
2135    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
2136  <dc:title>pptx-rs</dc:title>
2137  <dc:creator>pptx-rs</dc:creator>
2138  <cp:lastModifiedBy>pptx-rs</cp:lastModifiedBy>
2139</cp:coreProperties>"#;
2140        pkg.put_part(Part::new(
2141            new_part_name("/docProps/core.xml"),
2142            ct::CORE_PROPS,
2143            core_xml.as_bytes().to_vec(),
2144        ));
2145
2146        // ---------------- 2.1) docProps/app.xml ----------------
2147        // 标注 Application 与 AppVersion,PowerPoint 用它显示"由 X 产生"。
2148        let app_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2149<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
2150            xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
2151  <Application>pptx-rs</Application>
2152  <AppVersion>1.0</AppVersion>
2153</Properties>"#;
2154        pkg.put_part(Part::new(
2155            new_part_name("/docProps/app.xml"),
2156            ct::APP_PROPS,
2157            app_xml.as_bytes().to_vec(),
2158        ));
2159
2160        // ---------------- 2.2) docProps/custom.xml(仅当 custom_properties 非空) -----
2161        // 自定义属性:用户自定义的键值对,PowerPoint 在"文件 → 信息 → 属性"中显示。
2162        if !self.custom_properties.is_empty() {
2163            let custom_xml = self.custom_properties.to_xml();
2164            if !custom_xml.is_empty() {
2165                pkg.put_part(Part::new(
2166                    new_part_name("/docProps/custom.xml"),
2167                    ct::CUSTOM_PROPS,
2168                    custom_xml.into_bytes(),
2169                ));
2170            }
2171        }
2172
2173        // ---------------- 3) theme1.xml ----------------
2174        // TODO-001:使用解析出的 theme(若有),否则用默认 Office 主题。
2175        // 这样 read→save 能保留原始主题的颜色/字体/格式方案。
2176        let theme_xml = if self.theme.name.is_empty()
2177            && self.theme.color_scheme.dk1.is_none()
2178            && self.theme.font_scheme.major_latin.is_empty()
2179        {
2180            // 未解析到 theme(新建的 Presentation),用默认 Office 主题
2181            default_theme_xml()
2182        } else {
2183            self.theme.to_xml()
2184        };
2185        pkg.put_part(Part::new(
2186            new_part_name("/ppt/theme/theme1.xml"),
2187            ct::THEME,
2188            theme_xml.into_bytes(),
2189        ));
2190
2191        // ---------------- 4) slideMaster1 + 关系 ----------------
2192        // TODO-001:使用解析出的 master XML(若有),否则用默认。
2193        // 母版先列 slideLayout,再列 theme —— 与 Office 顺序保持一致以避免兼容性问题。
2194        let master_xml = if let Some(master_ref) = self.slide_masters.items.first() {
2195            master_ref.oxml.borrow().to_xml()
2196        } else {
2197            OxmlSldMaster::default().to_xml()
2198        };
2199        let master_partname = new_part_name("/ppt/slideMasters/slideMaster1.xml");
2200        let mut master_rels = Relationships::new();
2201        master_rels.add(Relationship::internal_str(
2202            "rId1",
2203            RelType::SlideLayout,
2204            "../slideLayouts/slideLayout1.xml",
2205        ))?;
2206        master_rels.add(Relationship::internal_str(
2207            "rId2",
2208            RelType::Theme,
2209            "../theme/theme1.xml",
2210        ))?;
2211        let master_rels_xml = master_rels.to_xml();
2212        let master_rels_partname = rels_partname_for(master_partname.as_str());
2213        pkg.put_part(Part::new(
2214            PartName::from_unchecked(master_rels_partname),
2215            ct::RELATIONSHIPS,
2216            master_rels_xml.into_bytes(),
2217        ));
2218        pkg.put_part(Part::new(
2219            master_partname,
2220            ct::SLIDE_MASTER,
2221            master_xml.into_bytes(),
2222        ));
2223
2224        // ---------------- 5) slideLayout1(空白) + 关系 ----------------
2225        // TODO-001:使用解析出的 layout XML(若有),否则用默认。
2226        // 版式关系只指向所属母版。
2227        let layout_xml = if let Some(layout_ref) = self.slide_layouts.items.first() {
2228            layout_ref.oxml.borrow().to_xml()
2229        } else {
2230            OxmlSldLayout::default().to_xml()
2231        };
2232        let layout_partname = new_part_name("/ppt/slideLayouts/slideLayout1.xml");
2233        let mut layout_rels = Relationships::new();
2234        layout_rels.add(Relationship::internal_str(
2235            "rId1",
2236            RelType::SlideMaster,
2237            "../slideMasters/slideMaster1.xml",
2238        ))?;
2239        let layout_rels_partname = rels_partname_for(layout_partname.as_str());
2240        pkg.put_part(Part::new(
2241            PartName::from_unchecked(layout_rels_partname),
2242            ct::RELATIONSHIPS,
2243            layout_rels.to_xml().into_bytes(),
2244        ));
2245        pkg.put_part(Part::new(
2246            layout_partname,
2247            ct::SLIDE_LAYOUT,
2248            layout_xml.into_bytes(),
2249        ));
2250
2251        // ---------------- 6) presentation.xml.rels ----------------
2252        // 关系至少包括:master、layout、theme、presProps、viewProps、tableStyles。
2253        // 注意:rid 使用 "rIdP1"-"rIdP6" 前缀,避免与 slide 的 "rIdN" 冲突。
2254        // (真实 .pptx 中 slide rid 可能从 rId3 开始,与固定 rId3 冲突。)
2255        let mut pres_rels = Relationships::new();
2256        pres_rels.add(Relationship::internal_str(
2257            "rIdP1",
2258            RelType::SlideMaster,
2259            "slideMasters/slideMaster1.xml",
2260        ))?;
2261        pres_rels.add(Relationship::internal_str(
2262            "rIdP2",
2263            RelType::SlideLayout,
2264            "slideLayouts/slideLayout1.xml",
2265        ))?;
2266        pres_rels.add(Relationship::internal_str(
2267            "rIdP3",
2268            RelType::Theme,
2269            "theme/theme1.xml",
2270        ))?;
2271        pres_rels.add(Relationship::internal_str(
2272            "rIdP4",
2273            crate::opc::rels::RelType::Other(
2274                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps"
2275                    .to_string(),
2276            ),
2277            "presProps.xml",
2278        ))?;
2279        pres_rels.add(Relationship::internal_str(
2280            "rIdP5",
2281            crate::opc::rels::RelType::Other(
2282                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps"
2283                    .to_string(),
2284            ),
2285            "viewProps.xml",
2286        ))?;
2287        pres_rels.add(Relationship::internal_str(
2288            "rIdP6",
2289            crate::opc::rels::RelType::Other(
2290                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles"
2291                    .to_string(),
2292            ),
2293            "tableStyles.xml",
2294        ))?;
2295
2296        // presentation.xml 根结构:sldSz + sldIdLst + sldMasterIdLst。
2297        // TODO-001:使用解析出的 sld_master_ids(若有),支持多母版 read→save 保真。
2298        let sld_master_ids: Vec<crate::oxml::presentation::SldMasterIdEntry> =
2299            if !self.sld_master_ids.is_empty() {
2300                self.sld_master_ids
2301                    .iter()
2302                    .map(|(id, rid)| crate::oxml::presentation::SldMasterIdEntry {
2303                        id: *id,
2304                        rid: rid.clone(),
2305                    })
2306                    .collect()
2307            } else {
2308                // 默认:空列表,to_xml 会写出默认的单个母版
2309                Vec::new()
2310            };
2311        let mut pres_root = PresentationRoot {
2312            slide_width: Some(self.width),
2313            slide_height: Some(self.height),
2314            slide_ids: Vec::new(),
2315            sld_master_ids,
2316            // TODO-039:把 Presentation.sections 透传给 PresentationRoot,
2317            // 由 PresentationRoot::to_xml 在 <p:extLst> 内输出 <p14:sectionLst>。
2318            sections: self.sections.clone(),
2319            ..Default::default()
2320        };
2321
2322        // ---------------- 7) 遍历 slide,写 XML + 关系 ----------------
2323        let mut notes_index: u32 = 0;
2324        let mut comments_index: u32 = 0;
2325        // chart 全局索引:保证多 slide 之间的 chartN.xml partname 唯一。
2326        let mut chart_global_index: u32 = 0;
2327        // chart 嵌入式 Excel 全局索引:保证多 chart 之间的 Microsoft_Excel_WorksheetN.xlsx partname 唯一(TODO-004 Excel 嵌入)。
2328        let mut chart_xlsx_global_index: u32 = 0;
2329        // ole 全局索引:保证多 slide 之间的 oleObjectN.bin partname 唯一(TODO-043)。
2330        let mut ole_global_index: u32 = 0;
2331        // video/audio 全局索引:保证多 slide 之间的 mediaN.mp4 / mediaN.mp3 partname 唯一(TODO-033)。
2332        // 注意:imageN.png 走 media_entries 的去重逻辑,不在此处计数;
2333        // 视频/音频每次嵌入都生成独立 part(即使多个 slide 引用同一文件也分别写出)。
2334        let mut video_global_index: u32 = 0;
2335        let mut audio_global_index: u32 = 0;
2336        // SmartArt 全局索引:保证多 slide 之间的 dataN.xml / layoutN.xml 等 partname 唯一(TODO-037)。
2337        let mut diagram_global_index: u32 = 0;
2338        for (i, entry) in self.slides.iter().enumerate() {
2339            let partname = PartName::from_unchecked(entry.partname.clone());
2340            let rid = entry.rid.clone();
2341            // 给每个 slide 注入 layout_rid(指向 slideLayout1)。
2342            let mut sld = entry.sld.clone();
2343            sld.set_layout_rid("rId1".to_string());
2344
2345            // 该 slide 的 .rels:始终含 layout 关系;含图片再加 Image 关系;含 notes 再加 Notes 关系。
2346            let mut extra_rels = Relationships::new();
2347            extra_rels.add(Relationship::internal_str(
2348                "rId1",
2349                RelType::SlideLayout,
2350                "../slideLayouts/slideLayout1.xml",
2351            ))?;
2352            // 把"该 slide 用到的 media"挂上来。rId 形如 `rIdImg1` 以便与 layout 区分。
2353            for media in &entry.sld.media_entries {
2354                let media_target = format!(
2355                    "../media/{}",
2356                    media.partname.as_str().trim_start_matches("/ppt/media/")
2357                );
2358                extra_rels.add(Relationship::internal_str(
2359                    media.rid.clone(),
2360                    RelType::Image,
2361                    media_target,
2362                ))?;
2363            }
2364            // 如果该 slide 有 notes,挂上 Notes 关系(slide → notesSlide)。
2365            //
2366            // **关键**:写路径**优先复用**读路径保存的原始 OPC 元数据:
2367            //   - `notes_partname`:从 `Slide.notes_partname` 取(None 时按 `notesSlide{idx}.xml` 分配);
2368            //   - 反向 `notesSlideN.xml.rels` 的 Slide target:从 `Slide.notes_slide_rel_target` 取
2369            //     (None 时按 `../slides/slide{idx+1}.xml` 拼,保证指向真实写入的 slide 文件名)。
2370            // 这样 read→save→read 后 `notesSlideN.xml` 的 partname / 双向 rid / 反向 target 都保持稳定。
2371            let mut this_notes_rid: Option<String> = None;
2372            if let Some(notes_tb) = sld.notes() {
2373                notes_index += 1;
2374                // rid 与 target 在 slide 这一侧用 `rIdNotes{N}` + `../notesSlides/<part>` 即可,
2375                // 因为 slideN.xml.rels 总是**新写**的,无需保留历史 rid。
2376                let notes_rid = format!("rIdNotes{}", notes_index);
2377                // notes part 路径:优先复用原始 partname,缺失再按序号分配
2378                let notes_partname = match entry.sld.notes_partname() {
2379                    Some(p) => p.to_string(),
2380                    None => format!("/ppt/notesSlides/notesSlide{}.xml", notes_index),
2381                };
2382                // notesSlideN.xml.rels 中 Slide 关系的 target:优先复用原始,缺失再按 slide 编号拼
2383                let notes_slide_target = match entry.sld.notes_slide_rel_target() {
2384                    Some(t) => t.to_string(),
2385                    None => format!("../slides/slide{}.xml", i + 1),
2386                };
2387                this_notes_rid = Some(notes_rid.clone());
2388                // 关系挂在 slide 上 → 指向 notesSlide
2389                //   Target 是相对于 /ppt/slides/slideN.xml 的相对路径,需从 notes_partname 拆出 basename
2390                let notes_target_rel = {
2391                    // 例: "/ppt/notesSlides/notesSlide1.xml" → "../notesSlides/notesSlide1.xml"
2392                    let trimmed = notes_partname.trim_start_matches("/ppt/");
2393                    format!("../{}", trimmed)
2394                };
2395                extra_rels.add(Relationship::internal_str(
2396                    notes_rid.clone(),
2397                    RelType::NotesSlide,
2398                    notes_target_rel,
2399                ))?;
2400                // 同一份 notes 在 presentation.xml.rels 上也要出现一次
2401                // (PowerPoint 实际不需要,但本库保持 1:1 关系清晰)。
2402                // 这里的 target 用 presentation.xml.rels 视角的相对路径 `notesSlides/<file>`。
2403                let pres_target_rel = {
2404                    let trimmed = notes_partname.trim_start_matches("/ppt/");
2405                    trimmed.to_string()
2406                };
2407                pres_rels.add(Relationship::internal_str(
2408                    format!("rIdNotesPres{}", notes_index),
2409                    RelType::NotesSlide,
2410                    pres_target_rel,
2411                ))?;
2412                // notesSlideN.xml.rels:1 个 Slide 关系,指向所属 slideN.xml
2413                //   target 优先复用原始,缺失再按 i+1 拼。
2414                let mut notes_rels = Relationships::new();
2415                notes_rels.add(Relationship::internal_str(
2416                    "rId1",
2417                    RelType::Slide,
2418                    notes_slide_target,
2419                ))?;
2420                let notes_rels_partname = rels_partname_for(&notes_partname);
2421                pkg.put_part(Part::new(
2422                    PartName::from_unchecked(notes_rels_partname),
2423                    ct::RELATIONSHIPS,
2424                    notes_rels.to_xml().into_bytes(),
2425                ));
2426                // 写 notesSlideN.xml
2427                let notes_xml_str = crate::oxml::notes_xml(notes_tb);
2428                pkg.put_part(Part::new(
2429                    PartName::from_unchecked(notes_partname),
2430                    ct::NOTES_SLIDE,
2431                    notes_xml_str.into_bytes(),
2432                ));
2433            }
2434            // 把 notes_rid 同步回 entry(供后续访问)。
2435            if let Some(rid) = this_notes_rid {
2436                sld.set_notes_rid(rid);
2437            }
2438
2439            // ---------- 评论(comments)写入 ----------
2440            // 与 notes 类似:每个有评论的 slide 对应一个 `/ppt/comments/commentN.xml`。
2441            // 关系挂在 slideN.xml.rels 上(Type=comments)。
2442            let mut this_comments_rid: Option<String> = None;
2443            if let Some(comment_lst) = sld.comments() {
2444                comments_index += 1;
2445                let comments_rid = format!("rIdComments{}", comments_index);
2446                // partname:优先复用原始,缺失再按序号分配
2447                let comments_partname = match sld.comments_partname() {
2448                    Some(p) => p.to_string(),
2449                    None => format!("/ppt/comments/comment{}.xml", comments_index),
2450                };
2451                this_comments_rid = Some(comments_rid.clone());
2452                // 关系挂在 slide 上 → 指向 comments
2453                //   Target 是相对于 /ppt/slides/slideN.xml 的相对路径
2454                let comments_target_rel = {
2455                    let trimmed = comments_partname.trim_start_matches("/ppt/");
2456                    format!("../{}", trimmed)
2457                };
2458                extra_rels.add(Relationship::internal_str(
2459                    comments_rid.clone(),
2460                    RelType::Comments,
2461                    comments_target_rel,
2462                ))?;
2463                // 写 commentN.xml
2464                let comments_xml_str = comment_lst.to_xml();
2465                pkg.put_part(Part::new(
2466                    PartName::from_unchecked(comments_partname),
2467                    ct::COMMENTS,
2468                    comments_xml_str.into_bytes(),
2469                ));
2470            }
2471            if let Some(rid) = this_comments_rid {
2472                sld.set_comments_rid(rid);
2473            }
2474
2475            // ---------- 图表(chart)写入 ----------
2476            // 与 notes/comments 类似:每个 chart 对应一个独立的 `/ppt/charts/chartN.xml` part。
2477            // 关系挂在 slideN.xml.rels 上(Type=chart)。
2478            //
2479            // **关键**:chart 的 `<c:chart r:id="..."/>` 引用必须与 slideN.xml.rels 中的
2480            // `<Relationship Id="..."/>` 的 Id 完全一致——这里直接复用 ChartEntry.rid
2481            // (由 `ShapesMut::add_chart` 分配的 `rIdChartN`)。
2482            //
2483            // **partname 重写**:add_chart 时用 slide 局部索引占位,这里用全局索引
2484            // 重新分配 partname,避免多 slide 之间的 chartN.xml 冲突。
2485            for chart_entry in &entry.sld.chart_entries {
2486                chart_global_index += 1;
2487                let chart_partname = format!("/ppt/charts/chart{}.xml", chart_global_index);
2488                // 在 slideN.xml.rels 中添加 chart 关系
2489                // Target 是相对于 /ppt/slides/slideN.xml 的相对路径
2490                let chart_target_rel = format!("../charts/chart{}.xml", chart_global_index);
2491                extra_rels.add(Relationship::internal_str(
2492                    chart_entry.rid.clone(),
2493                    RelType::Chart,
2494                    chart_target_rel,
2495                ))?;
2496
2497                // 嵌入式 Excel 工作簿(TODO-004 Excel 嵌入)。
2498                //
2499                // 若 ChartEntry.xlsx_blob 非空,写出:
2500                // 1. `/ppt/embeddings/Microsoft_Excel_WorksheetN.xlsx` part(全局索引);
2501                // 2. `/ppt/charts/_rels/chartN.xml.rels` part(Type=Package,
2502                //    Target=`../embeddings/Microsoft_Excel_WorksheetN.xlsx`);
2503                // 3. 在 chart 模型上设置 external_data_rid 后重新 to_xml。
2504                //
2505                // **关键**:chartN.xml.rels 是 chart part 的**独立关系文件**,
2506                // 与 slideN.xml.rels 分离(chart part 自己的关系挂在 chart 的 _rels 目录)。
2507                let mut chart_model = chart_entry.chart.clone();
2508                if let Some(xlsx_blob) = &chart_entry.xlsx_blob {
2509                    chart_xlsx_global_index += 1;
2510                    let xlsx_partname = format!(
2511                        "/ppt/embeddings/Microsoft_Excel_Worksheet{}.xlsx",
2512                        chart_xlsx_global_index
2513                    );
2514                    let xlsx_target_rel = format!(
2515                        "../embeddings/Microsoft_Excel_Worksheet{}.xlsx",
2516                        chart_xlsx_global_index
2517                    );
2518                    // chart part 内部的关系 id(与 slideN 的 rIdChartN 命名空间分离)
2519                    let xlsx_rid = format!("rIdXlsx{}", chart_xlsx_global_index);
2520
2521                    // 写出 xlsx part
2522                    pkg.put_part(Part::new(
2523                        PartName::from_unchecked(xlsx_partname.clone()),
2524                        ct::SPREADSHEET_XLSX,
2525                        xlsx_blob.clone(),
2526                    ));
2527
2528                    // 构造 chartN.xml.rels part 内容
2529                    let mut chart_rels = crate::opc::rels::Relationships::new();
2530                    chart_rels.add(Relationship::internal_str(
2531                        xlsx_rid.clone(),
2532                        RelType::Package,
2533                        xlsx_target_rel,
2534                    ))?;
2535                    let chart_rels_partname =
2536                        format!("/ppt/charts/_rels/chart{}.xml.rels", chart_global_index);
2537                    pkg.put_part(Part::new(
2538                        PartName::from_unchecked(chart_rels_partname),
2539                        ct::RELATIONSHIPS,
2540                        chart_rels.to_xml().into_bytes(),
2541                    ));
2542
2543                    // 在 chart 模型上设置 external_data_rid,使 to_xml 输出 <c:externalData r:id="..."/>
2544                    chart_model.external_data_rid = Some(xlsx_rid);
2545                }
2546
2547                // 写出 chartN.xml part(内容为 Chart::to_xml(),可能含 externalData)
2548                let chart_xml = chart_model.to_xml();
2549                pkg.put_part(Part::new(
2550                    PartName::from_unchecked(chart_partname),
2551                    ct::CHART,
2552                    chart_xml.into_bytes(),
2553                ));
2554            }
2555
2556            // ---------- OLE 对象(oleObject)写入 ----------(TODO-043)
2557            // 与 chart 类似:每个 ole 对应一个独立的 `/ppt/embeddings/oleObjectN.bin` part。
2558            // 关系挂在 slideN.xml.rels 上(Type=oleObject)。
2559            //
2560            // **关键**:oleObj 的 `<p:oleObj r:id="..."/>` 引用必须与 slideN.xml.rels 中的
2561            // `<Relationship Id="..."/>` 的 Id 完全一致——这里直接复用 OleEntry.rid
2562            // (由 `ShapesMut::add_ole_object` 分配的 `rIdOleN`)。
2563            //
2564            // **partname 重写**:add_ole_object 时用 slide 局部索引占位,这里用全局索引
2565            // 重新分配 partname,避免多 slide 之间的 oleObjectN.bin 冲突。
2566            for ole_entry in &entry.sld.ole_entries {
2567                ole_global_index += 1;
2568                let ole_partname = format!("/ppt/embeddings/oleObject{}.bin", ole_global_index);
2569                // 在 slideN.xml.rels 中添加 oleObject 关系
2570                // Target 是相对于 /ppt/slides/slideN.xml 的相对路径
2571                let ole_target_rel = format!("../embeddings/oleObject{}.bin", ole_global_index);
2572                extra_rels.add(Relationship::internal_str(
2573                    ole_entry.rid.clone(),
2574                    RelType::OleObject,
2575                    ole_target_rel,
2576                ))?;
2577                // 写出 oleObjectN.bin part(内容为原始 OLE 二进制 blob)
2578                pkg.put_part(Part::new(
2579                    PartName::from_unchecked(ole_partname),
2580                    ct::OLE_OBJECT,
2581                    ole_entry.blob.clone(),
2582                ));
2583            }
2584
2585            // ---------- 视频(video)写入 ----------(TODO-033)
2586            // 与 ole 类似:每个 video 对应一个独立的 `/ppt/media/mediaN.mp4` part。
2587            // 关系挂在 slideN.xml.rels 上(Type=video),Target 用相对路径 `../media/mediaN.mp4`。
2588            //
2589            // **关键**:视频用 `r:link` 引用(不是 `r:embed`),所以关系类型是 `.../video`,
2590            // 而非 `.../image`。`<a:videoFile r:link="rIdVideoN"/>` 的 rIdVideoN 必须与
2591            // slideN.xml.rels 中的 `<Relationship Id="rIdVideoN"/>` 完全一致。
2592            //
2593            // **partname 重写**:add_video 时用 slide 局部索引占位,这里用全局索引
2594            // 重新分配 partname,避免多 slide 之间的 mediaN.mp4 冲突。
2595            for video_entry in &entry.sld.video_entries {
2596                video_global_index += 1;
2597                let video_partname = format!("/ppt/media/media{}.mp4", video_global_index);
2598                // 在 slideN.xml.rels 中添加 video 关系
2599                let video_target_rel = format!("../media/media{}.mp4", video_global_index);
2600                extra_rels.add(Relationship::internal_str(
2601                    video_entry.rid.clone(),
2602                    RelType::Video,
2603                    video_target_rel,
2604                ))?;
2605                // 写出 mediaN.mp4 part(内容为原始视频二进制 blob)
2606                pkg.put_part(Part::new(
2607                    PartName::from_unchecked(video_partname),
2608                    ct::VIDEO_MP4,
2609                    video_entry.blob.clone(),
2610                ));
2611            }
2612
2613            // ---------- 音频(audio)写入 ----------(TODO-033)
2614            // 与 video 完全对称,仅媒体类型与 Content-Type 不同。
2615            for audio_entry in &entry.sld.audio_entries {
2616                audio_global_index += 1;
2617                let audio_partname = format!("/ppt/media/media{}.mp3", audio_global_index);
2618                let audio_target_rel = format!("../media/media{}.mp3", audio_global_index);
2619                extra_rels.add(Relationship::internal_str(
2620                    audio_entry.rid.clone(),
2621                    RelType::Audio,
2622                    audio_target_rel,
2623                ))?;
2624                pkg.put_part(Part::new(
2625                    PartName::from_unchecked(audio_partname),
2626                    ct::AUDIO_MP3,
2627                    audio_entry.blob.clone(),
2628                ));
2629            }
2630
2631            // ---------- SmartArt(diagram)写入 ----------(TODO-037)
2632            // 与 chart/ole/video 不同:每个 diagram 对应 **4 个**独立 part
2633            // (`/ppt/diagrams/{data,layout,quickStyles,colors}N.xml`)。
2634            // 4 个关系都挂在 slideN.xml.rels 上:
2635            //   - `<Relationship Type=".../diagramData" Target="../diagrams/dataN.xml"/>`
2636            //   - `<Relationship Type=".../diagramLayout" Target="../diagrams/layoutN.xml"/>`
2637            //   - `<Relationship Type=".../diagramQuickStyle" Target="../diagrams/quickStylesN.xml"/>`
2638            //   - `<Relationship Type=".../diagramColors" Target="../diagrams/colorsN.xml"/>`
2639            //
2640            // slide xml 的 `<p:graphicFrame>` 内 `<dgm:relIds r:dm="..." r:lo="..." r:qs="..." r:cs="..."/>`
2641            // 4 个属性分别引用这 4 个 rId。
2642            //
2643            // **round-trip**:DiagramEntry 持有 4 份原始 XML 字符串,写出时直接写入 zip
2644            // (不重新序列化),保证任何 SmartArt 模板都能正确保留。
2645            //
2646            // **partname 重写**:add_diagram 时用 slide 局部索引占位,这里用全局索引
2647            // 重新分配 partname,避免多 slide 之间的 dataN.xml 冲突。
2648            for diagram_entry in &entry.sld.diagram_entries {
2649                diagram_global_index += 1;
2650                let idx = diagram_global_index;
2651
2652                // 4 个 partname(基于全局索引)
2653                let data_partname = format!("/ppt/diagrams/data{}.xml", idx);
2654                let layout_partname = format!("/ppt/diagrams/layout{}.xml", idx);
2655                let quick_style_partname = format!("/ppt/diagrams/quickStyles{}.xml", idx);
2656                let colors_partname = format!("/ppt/diagrams/colors{}.xml", idx);
2657
2658                // 4 个相对 Target 路径(相对于 /ppt/slides/slideN.xml)
2659                let data_target_rel = format!("../diagrams/data{}.xml", idx);
2660                let layout_target_rel = format!("../diagrams/layout{}.xml", idx);
2661                let quick_style_target_rel = format!("../diagrams/quickStyles{}.xml", idx);
2662                let colors_target_rel = format!("../diagrams/colors{}.xml", idx);
2663
2664                // 在 slideN.xml.rels 中添加 4 个关系
2665                extra_rels.add(Relationship::internal_str(
2666                    diagram_entry.data_rid.clone(),
2667                    RelType::DiagramData,
2668                    data_target_rel,
2669                ))?;
2670                extra_rels.add(Relationship::internal_str(
2671                    diagram_entry.layout_rid.clone(),
2672                    RelType::DiagramLayout,
2673                    layout_target_rel,
2674                ))?;
2675                extra_rels.add(Relationship::internal_str(
2676                    diagram_entry.quick_style_rid.clone(),
2677                    RelType::DiagramQuickStyle,
2678                    quick_style_target_rel,
2679                ))?;
2680                extra_rels.add(Relationship::internal_str(
2681                    diagram_entry.colors_rid.clone(),
2682                    RelType::DiagramColors,
2683                    colors_target_rel,
2684                ))?;
2685
2686                // 写出 4 个 diagram part(内容为原始 XML 字符串)
2687                pkg.put_part(Part::new(
2688                    PartName::from_unchecked(data_partname),
2689                    ct::DIAGRAM_DATA,
2690                    diagram_entry.data_xml.clone().into_bytes(),
2691                ));
2692                pkg.put_part(Part::new(
2693                    PartName::from_unchecked(layout_partname),
2694                    ct::DIAGRAM_LAYOUT,
2695                    diagram_entry.layout_xml.clone().into_bytes(),
2696                ));
2697                pkg.put_part(Part::new(
2698                    PartName::from_unchecked(quick_style_partname),
2699                    ct::DIAGRAM_QUICK_STYLE,
2700                    diagram_entry.quick_style_xml.clone().into_bytes(),
2701                ));
2702                pkg.put_part(Part::new(
2703                    PartName::from_unchecked(colors_partname),
2704                    ct::DIAGRAM_COLORS,
2705                    diagram_entry.colors_xml.clone().into_bytes(),
2706                ));
2707            }
2708
2709            let extra_rels_xml = extra_rels.to_xml();
2710            let extra_rels_partname = rels_partname_for(partname.as_str());
2711            pkg.put_part(Part::new(
2712                PartName::from_unchecked(extra_rels_partname),
2713                ct::RELATIONSHIPS,
2714                extra_rels_xml.into_bytes(),
2715            ));
2716
2717            // slide 本体 XML。
2718            pkg.put_part(Part::new(partname, ct::SLIDE, sld.to_xml().into_bytes()));
2719
2720            // 在 presentation.xml.rels 中注册该 slide。
2721            pres_rels.add(Relationship::internal_str(
2722                rid.clone(),
2723                RelType::Slide,
2724                format!("slides/slide{}.xml", i + 1),
2725            ))?;
2726            // 同步进 sldIdLst。
2727            pres_root.slide_ids.push(SlideIdEntry {
2728                id: entry.sld_id,
2729                rid: rid.clone(),
2730            });
2731        }
2732
2733        // ---------- 评论作者列表(commentAuthors.xml)----------
2734        // 全局共享的作者清单,非空时写出 `/ppt/commentAuthors.xml`,
2735        // 并在 `presentation.xml.rels` 添加 `commentAuthors` 关系。
2736        if !self.comment_authors.is_empty() {
2737            let authors_xml = self.comment_authors.to_xml();
2738            pkg.put_part(Part::new(
2739                new_part_name("/ppt/commentAuthors.xml"),
2740                ct::COMMENT_AUTHORS,
2741                authors_xml.into_bytes(),
2742            ));
2743            pres_rels.add(Relationship::internal_str(
2744                "rIdCommentAuthors",
2745                RelType::CommentAuthors,
2746                "commentAuthors.xml",
2747            ))?;
2748        }
2749
2750        // ---------------- 7) presentation.xml ----------------
2751        // 若设置了修改密码保护,注入 modifyVerifier 到 presentation.xml。
2752        let pres_xml = if let Some(ref mp) = self.modify_protection {
2753            inject_modify_verifier(&pres_root.to_xml(), &mp.to_xml_element())
2754        } else {
2755            pres_root.to_xml()
2756        };
2757        pkg.put_part(Part::new(
2758            new_part_name("/ppt/presentation.xml"),
2759            ct::PRESENTATION,
2760            pres_xml.into_bytes(),
2761        ));
2762        // ---------------- 8) presentation.xml.rels ----------------
2763        pkg.put_part(Part::new(
2764            new_part_name("/ppt/_rels/presentation.xml.rels"),
2765            ct::RELATIONSHIPS,
2766            pres_rels.to_xml().into_bytes(),
2767        ));
2768
2769        // ---------------- 9) 媒体(图片) ----------------
2770        // 收集所有 slide 的 media 一起写 zip(去重 by partname)。
2771        use std::collections::BTreeMap;
2772        let mut seen: BTreeMap<String, ()> = BTreeMap::new();
2773        for entry in self.slides.iter() {
2774            for m in &entry.sld.media_entries {
2775                if seen.insert(m.partname.as_str().to_string(), ()).is_none() {
2776                    pkg.put_part(Part::new(
2777                        m.partname.clone(),
2778                        m.content_type.clone(),
2779                        m.blob.clone(),
2780                    ));
2781                }
2782            }
2783        }
2784
2785        // ---------------- 10) presProps.xml ----------------
2786        // 仅保留 `<p:showPr/>`,PowerPoint 接受空集。
2787        let pres_props_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2788<p:presentationPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
2789  <p:showPr/>
2790</p:presentationPr>"#;
2791        pkg.put_part(Part::new(
2792            new_part_name("/ppt/presProps.xml"),
2793            "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml",
2794            pres_props_xml.as_bytes().to_vec(),
2795        ));
2796
2797        // ---------------- 11) viewProps.xml ----------------
2798        // 固定写法:normalViewPr 必填,否则 PowerPoint 会发出修复提示。
2799        let view_props_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2800<p:viewProps xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
2801  <p:normalViewPr><p:restoredLeft sz="15620"/><p:restoredTop sz="94660"/></p:normalViewPr>
2802</p:viewProps>"#;
2803        pkg.put_part(Part::new(
2804            new_part_name("/ppt/viewProps.xml"),
2805            "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml",
2806            view_props_xml.as_bytes().to_vec(),
2807        ));
2808
2809        // ---------------- 12) tableStyles.xml ----------------
2810        // 必备辅件 —— 留空表样式列表。
2811        let table_styles_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2812<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" defStyleStyle=""/>
2813"#;
2814        pkg.put_part(Part::new(
2815            new_part_name("/ppt/tableStyles.xml"),
2816            "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml",
2817            table_styles_xml.as_bytes().to_vec(),
2818        ));
2819
2820        Ok(pkg)
2821    }
2822
2823    /// 确保演示文稿**至少**有 1 个母版 + 1 个版式。
2824    ///
2825    /// # 行为
2826    ///
2827    /// - 若 `slide_masters` 为空,则 push 一个默认母版(指向 `slideMaster1.xml`);
2828    /// - 若 `slide_layouts` 为空,则 push 一个默认空白版式(指向 `slideLayout1.xml`)。
2829    ///
2830    /// 通常由 [`Presentation::new`] / [`Presentation::from_opc`] 调用,
2831    /// 但也可在 `open` 之后再次手动调用以"补全缺失项"。
2832    fn ensure_default_master_and_layout(&mut self) -> crate::Result<()> {
2833        if self.slide_masters.is_empty() {
2834            self.slide_masters.items.push(SlideMasterRef {
2835                idx: 0,
2836                partname: "/ppt/slideMasters/slideMaster1.xml".to_string(),
2837                rid: "rIdMaster1".to_string(),
2838                oxml: Rc::new(std::cell::RefCell::new(OxmlSldMaster::default())),
2839            });
2840        }
2841        if self.slide_layouts.is_empty() {
2842            self.slide_layouts.items.push(SlideLayoutRef {
2843                idx: 0,
2844                partname: "/ppt/slideLayouts/slideLayout1.xml".to_string(),
2845                rid: "rIdLayout1".to_string(),
2846                oxml: Rc::new(std::cell::RefCell::new(OxmlSldLayout::default())),
2847            });
2848        }
2849        Ok(())
2850    }
2851}
2852
2853/// 把 OPC 关系中的"相对 target"解析为绝对 partname。
2854///
2855/// # OPC 相对路径规则
2856/// 关系文件中 `Target` 相对于**父 part 的目录**(不是相对于 .rels 文件)。
2857///
2858/// # 示例
2859/// - 父 `/ppt/slides/slide1.xml` + 相对 `../notesSlides/notesSlide1.xml`
2860///   → `/ppt/notesSlides/notesSlide1.xml`
2861/// - 父 `/ppt/slides/slide1.xml` + 相对 `media/image1.png`
2862///   → `/ppt/slides/media/image1.png`
2863/// - 父 `/ppt/slides/slide1.xml` + 相对 `../media/image1.png`
2864///   → `/ppt/media/image1.png`
2865fn resolve_relative_partname(parent_partname: &str, rel_target: &str) -> String {
2866    // 父 partname 必须以 '/' 开头;如果是绝对路径,直接返回。
2867    if rel_target.starts_with('/') {
2868        return rel_target.to_string();
2869    }
2870    // 父 partname 的目录
2871    let parent_dir = match parent_partname.rfind('/') {
2872        Some(i) => &parent_partname[..i],
2873        None => "",
2874    };
2875    // 拼接并标准化
2876    let mut stack: Vec<&str> = Vec::new();
2877    let combined = if parent_dir.is_empty() {
2878        format!("/{}", rel_target)
2879    } else {
2880        format!("{}/{}", parent_dir, rel_target)
2881    };
2882    for seg in combined.split('/') {
2883        match seg {
2884            "" | "." => continue,
2885            ".." => {
2886                stack.pop();
2887            }
2888            _ => stack.push(seg),
2889        }
2890    }
2891    let mut out = String::from("/");
2892    out.push_str(&stack.join("/"));
2893    if out == "/" && !stack.is_empty() {
2894        // 不会发生,stack 至少 1 段
2895    }
2896    out
2897}
2898
2899/// 在 presentation.xml 中注入 `<p:modifyVerifier .../>`。
2900///
2901/// # 行为
2902///
2903/// 1. 若已含 `p:modifyVerifier`,**原样返回**(幂等保护);
2904/// 2. 否则把它插到 `<p:extLst` 之前(OOXML schema 顺序要求);
2905/// 3. 找不到 extLst 时兜底插到 `</p:presentation>` 之前。
2906fn inject_modify_verifier(pres_xml: &str, verifier_xml: &str) -> String {
2907    if pres_xml.contains("p:modifyVerifier") {
2908        return pres_xml.to_string();
2909    }
2910    // 优先级 1:插到 <p:extLst 之前
2911    if let Some(pos) = pres_xml.find("<p:extLst") {
2912        let mut out = String::with_capacity(pres_xml.len() + verifier_xml.len());
2913        out.push_str(&pres_xml[..pos]);
2914        out.push_str(verifier_xml);
2915        out.push_str(&pres_xml[pos..]);
2916        return out;
2917    }
2918    // 优先级 2:插到 </p:presentation> 之前
2919    if let Some(pos) = pres_xml.rfind("</p:presentation>") {
2920        let mut out = String::with_capacity(pres_xml.len() + verifier_xml.len());
2921        out.push_str(&pres_xml[..pos]);
2922        out.push_str(verifier_xml);
2923        out.push_str(&pres_xml[pos..]);
2924        return out;
2925    }
2926    // 兜底:未修改
2927    pres_xml.to_string()
2928}
2929
2930impl Default for Presentation {
2931    /// `Default` 等价于 [`Presentation::new`]。
2932    ///
2933    /// `Default` trait 签名无法返回 `Result`,因此此处使用 `expect`。
2934    /// 安全性保证:`Presentation::new()` 当前实现不会返回 `Err`(仅做
2935    /// 字段初始化),如果未来 `new()` 可能失败,应移除 `Default` impl
2936    /// 并改为关联常量或工厂方法。
2937    // 允许 expect:Default trait 签名限制,无法用 ? 传播错误
2938    #[allow(clippy::expect_used)]
2939    fn default() -> Self {
2940        Self::new().expect("Presentation::new() is infallible in current implementation")
2941    }
2942}
2943
2944#[cfg(test)]
2945mod tests {
2946    use super::*;
2947    use crate::units::Inches;
2948
2949    /// 验证 `resolve_relative_partname` 处理各种 OOXML 相对路径。
2950    #[test]
2951    fn resolve_relative_partname_examples() {
2952        assert_eq!(
2953            resolve_relative_partname("/ppt/slides/slide1.xml", "../notesSlides/notesSlide1.xml"),
2954            "/ppt/notesSlides/notesSlide1.xml"
2955        );
2956        assert_eq!(
2957            resolve_relative_partname("/ppt/slides/slide1.xml", "media/image1.png"),
2958            "/ppt/slides/media/image1.png"
2959        );
2960        assert_eq!(
2961            resolve_relative_partname("/ppt/slides/slide1.xml", "../media/image1.png"),
2962            "/ppt/media/image1.png"
2963        );
2964        // 绝对路径原样返回
2965        assert_eq!(
2966            resolve_relative_partname("/ppt/slides/slide1.xml", "/ppt/notesSlides/n1.xml"),
2967            "/ppt/notesSlides/n1.xml"
2968        );
2969        // 同目录
2970        assert_eq!(
2971            resolve_relative_partname("/ppt/slides/slide1.xml", "slide2.xml"),
2972            "/ppt/slides/slide2.xml"
2973        );
2974    }
2975
2976    /// 完整 read → modify → write 的端到端测试。
2977    ///
2978    /// # 流程
2979    /// 1. 新建一份带 1 张 slide(含文本框 + 备注)的演示文稿;
2980    /// 2. 序列化为 zip 字节;
2981    /// 3. 用 `load_bytes` 读回;
2982    /// 4. 验证:slide 数量、文本框文字、备注文本、画布尺寸都正确还原。
2983    #[test]
2984    fn roundtrip_presentation_loads_back() {
2985        // 1) 新建
2986        let mut p = Presentation::new().expect("new ok");
2987        let counter = p.id_counter();
2988        let slide = p.slides_mut().add_slide(counter).expect("add slide");
2989        slide
2990            .shapes_mut()
2991            .add_textbox_with_text(
2992                Inches(1.0),
2993                Inches(1.0),
2994                Inches(4.0),
2995                Inches(1.0),
2996                "Hello roundtrip",
2997            )
2998            .expect("textbox");
2999        slide.set_notes_text(Some("speaker note line 1\nline 2"));
3000        p.set_slide_size(Emu(7_000_000), Emu(5_000_000));
3001
3002        // 2) 序列化为字节
3003        let bytes = p.to_bytes().expect("to_bytes ok");
3004        assert!(!bytes.is_empty());
3005
3006        // 3) 读回
3007        let p2 = Presentation::load_bytes(&bytes).expect("load_bytes ok");
3008
3009        // 4) 验证
3010        assert_eq!(p2.slides().len(), 1);
3011        let s = p2.slides().get(0).expect("slide 0");
3012        // to_opc_package 内部把 inner.layout_rid 强制写为 "rId1"(slide rels 文件的固定 rId),
3013        // 所以加载后 laytout_rid 应为 "rId1",与 [Content_Types].xml 的 SlideLayout 关系对应。
3014        assert_eq!(s.sld.layout_rid(), "rId1");
3015        // 文本体(直接走内层 oxml,避开高阶 view 的类型复杂度)
3016        let inner_shapes = &s.sld.inner.shapes;
3017        assert_eq!(inner_shapes.len(), 1);
3018        if let crate::oxml::SlideShape::Sp(sp) = &inner_shapes[0] {
3019            assert_eq!(sp.text.paragraphs.len(), 1);
3020            assert_eq!(sp.text.paragraphs[0].runs[0].text, "Hello roundtrip");
3021        } else {
3022            panic!("expected Sp");
3023        }
3024        // 备注
3025        let notes = s.sld.notes_text().expect("notes present");
3026        assert!(notes.contains("speaker note line 1"));
3027        // 画布尺寸
3028        assert_eq!(p2.slide_width().0, 7_000_000);
3029        assert_eq!(p2.slide_height().0, 5_000_000);
3030    }
3031
3032    /// 验证在 from_opc 之后再次 save 仍能产出可解析的 pptx。
3033    #[test]
3034    fn load_then_resave_preserves_content() {
3035        let mut p = Presentation::new().expect("new ok");
3036        let counter = p.id_counter();
3037        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3038        slide
3039            .shapes_mut()
3040            .add_textbox_with_text(Inches(0.5), Inches(0.5), Inches(6.0), Inches(0.8), "stable")
3041            .expect("textbox");
3042        let bytes = p.to_bytes().expect("to_bytes");
3043
3044        // 二次 load + save(写到 Vec<u8>,避免依赖磁盘)
3045        let p2 = Presentation::load_bytes(&bytes).expect("load 1");
3046        let bytes2 = p2.to_bytes().expect("to_bytes 2");
3047        let p3 = Presentation::load_bytes(&bytes2).expect("load 2");
3048
3049        assert_eq!(p3.slides().len(), 1);
3050        let s = p3.slides().get(0).expect("slide 0");
3051        let inner_shapes = &s.sld.inner.shapes;
3052        if let crate::oxml::SlideShape::Sp(sp) = &inner_shapes[0] {
3053            assert_eq!(sp.text.paragraphs[0].runs[0].text, "stable");
3054        } else {
3055            panic!("expected Sp");
3056        }
3057    }
3058
3059    /// 验证 notes partname + 反向 rels target 在 read→save→read 后**保持稳定**。
3060    ///
3061    /// 关键点:
3062    /// - 第一次 save 后写入的 `notesSlideN.xml` 路径与第二次 load 看到的路径一致;
3063    /// - `notesSlideN.xml.rels` 中指向 slide 的 target 在二次 save 后不变;
3064    /// - 备注文本内容在三轮 round-trip 后正确还原。
3065    #[test]
3066    fn notes_roundtrip_preserves_partname_and_rels_target() {
3067        // 1) 构造原始 pptx(含 1 张带 notes 的 slide)
3068        let mut p = Presentation::new().expect("new ok");
3069        let counter = p.id_counter();
3070        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3071        slide
3072            .shapes_mut()
3073            .add_textbox_with_text(
3074                Inches(0.5),
3075                Inches(0.5),
3076                Inches(6.0),
3077                Inches(0.8),
3078                "roundtrip notes test",
3079            )
3080            .expect("textbox");
3081        slide.set_notes_text(Some("first notes\nline 2"));
3082        let bytes1 = p.to_bytes().expect("to_bytes 1");
3083
3084        // 2) load + 修改 notes
3085        let mut p2 = Presentation::load_bytes(&bytes1).expect("load 1");
3086        p2.slides_mut()
3087            .get_mut(0)
3088            .expect("slide 0")
3089            .sld
3090            .set_notes_text(Some("updated notes"));
3091        let bytes2 = p2.to_bytes().expect("to_bytes 2");
3092
3093        // 3) load + 校验
3094        let p3 = Presentation::load_bytes(&bytes2).expect("load 2");
3095        let s = p3.slides().get(0).expect("slide 0");
3096        let notes_text = s.sld.notes_text().expect("notes present");
3097        assert!(notes_text.contains("updated notes"));
3098
3099        // 4) 解析 bytes2 的 zip,校验 notesSlideN.xml.rels 中指向 slide 的 target 仍是 ../slides/slide1.xml
3100        let cursor = std::io::Cursor::new(bytes2);
3101        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3102        let mut nrels_xml = String::new();
3103        zip.by_name("ppt/notesSlides/_rels/notesSlide1.xml.rels")
3104            .expect("notesSlide1.xml.rels exists")
3105            .read_to_string(&mut nrels_xml)
3106            .expect("read notes rels");
3107        // 反向 target 应该指向 slide1.xml
3108        assert!(
3109            nrels_xml.contains("../slides/slide1.xml"),
3110            "notesSlide1.xml.rels missing slide rels, got: {nrels_xml}"
3111        );
3112    }
3113
3114    /// 验证新增的 slide(无历史 notes 元数据)也能正常序列化。
3115    ///
3116    /// 这是为了确认 `notes_partname=None` 的回退路径仍然按 `notesSlide{N}.xml` 分配。
3117    #[test]
3118    fn new_slide_with_notes_allocates_fresh_partname() {
3119        let mut p = Presentation::new().expect("new ok");
3120        let counter = p.id_counter();
3121        let s = p.slides_mut().add_slide(counter).expect("add slide");
3122        s.set_notes_text(Some("brand new"));
3123        let bytes = p.to_bytes().expect("to_bytes");
3124
3125        let p2 = Presentation::load_bytes(&bytes).expect("load");
3126        let s2 = p2.slides().get(0).expect("slide 0");
3127        let notes = s2.sld.notes_text().expect("notes present");
3128        assert!(notes.contains("brand new"));
3129
3130        // 校验 zip 中确实有 notesSlide1.xml
3131        let cursor = std::io::Cursor::new(bytes);
3132        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3133        assert!(zip.by_name("ppt/notesSlides/notesSlide1.xml").is_ok());
3134    }
3135
3136    /// 验证多 slide + 多 notes 时 read→save→read 的关系图谱都正确。
3137    ///
3138    /// 这是"补全 24"的主回归测试:确保新加的 partname / 反向 rels target
3139    /// 字段在多 slide 场景下不串号。
3140    #[test]
3141    fn notes_roundtrip_multiple_slides() {
3142        let mut p = Presentation::new().expect("new ok");
3143        let counter = p.id_counter();
3144        // 3 张 slide:notes 在 1 和 3,2 没有
3145        // `add_slide` 取得 `counter` 所有权,所以每次循环用 `counter.clone()`。
3146        for i in 0..3 {
3147            let s = p
3148                .slides_mut()
3149                .add_slide(counter.clone())
3150                .expect("add slide");
3151            if i != 1 {
3152                s.set_notes_text(Some(&format!("note for slide {}", i + 1)));
3153            }
3154        }
3155        let bytes1 = p.to_bytes().expect("to_bytes 1");
3156
3157        // read
3158        let mut p2 = Presentation::load_bytes(&bytes1).expect("load");
3159        // 修改所有有 notes 的 slide
3160        {
3161            let sm = p2.slides_mut();
3162            sm.get_mut(0).unwrap().sld.set_notes_text(Some("edited 1"));
3163            // skip idx 1
3164            sm.get_mut(2).unwrap().sld.set_notes_text(Some("edited 3"));
3165        }
3166        let bytes2 = p2.to_bytes().expect("to_bytes 2");
3167
3168        // read again
3169        let p3 = Presentation::load_bytes(&bytes2).expect("load 2");
3170        assert_eq!(p3.slides().len(), 3);
3171        let s0 = p3.slides().get(0).expect("s0");
3172        let s1 = p3.slides().get(1).expect("s1");
3173        let s2 = p3.slides().get(2).expect("s2");
3174        assert_eq!(s0.sld.notes_text().as_deref(), Some("edited 1"));
3175        assert_eq!(s1.sld.notes_text(), None);
3176        assert_eq!(s2.sld.notes_text().as_deref(), Some("edited 3"));
3177
3178        // 校验 zip 中 notesSlide1.xml 和 notesSlide2.xml 都存在
3179        // (第 2 张 slide 没 notes,所以 notesSlide2 对应 slide3 的 notes)
3180        let cursor = std::io::Cursor::new(bytes2);
3181        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3182        assert!(zip.by_name("ppt/notesSlides/notesSlide1.xml").is_ok());
3183        assert!(zip.by_name("ppt/notesSlides/notesSlide2.xml").is_ok());
3184        assert!(zip
3185            .by_name("ppt/notesSlides/_rels/notesSlide1.xml.rels")
3186            .is_ok());
3187        assert!(zip
3188            .by_name("ppt/notesSlides/_rels/notesSlide2.xml.rels")
3189            .is_ok());
3190    }
3191
3192    /// 文档测试:明确**记录** `layout_rid` 在 read→save 后会被**强制重置**为 `"rId1"`。
3193    ///
3194    /// # 背景
3195    /// 现实世界中的 .pptx 文件里,`slideN.xml.rels` 中的 layout 关系可能叫
3196    /// `rId1` / `rId2` / `rId3` 甚至 `rIdLayout`(自定义字符串)。本库**在
3197    /// `to_opc_package` 中强制把每个 slide 的 layout 关系重写为 `rId1`**,因为:
3198    /// 1. 0.1.0 默认仅生成一份 `slideLayout1.xml`;
3199    /// 2. 简化 read 路径,不再需要追踪 layout part 的"原始 rid";
3200    /// 3. 节省 1 个字段(`layout_partname`)的状态存储。
3201    ///
3202    /// # 含义
3203    /// - **不要**依赖 `layout_rid()` 在 read→save→read 后保持原始值;
3204    /// - 若未来需要保留原始 rid,应在 `Slide` 上新增 `layout_rid: Option<String>`
3205    ///   字段并在 `to_opc_package` 中按"非空则用,空则 rId1"的策略写。
3206    #[test]
3207    fn layout_rid_is_overwritten_to_rid1_on_save() {
3208        // 1) 构造原始 pptx
3209        let mut p = Presentation::new().expect("new ok");
3210        let counter = p.id_counter();
3211        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3212        slide
3213            .shapes_mut()
3214            .add_textbox_with_text(
3215                Inches(0.5),
3216                Inches(0.5),
3217                Inches(4.0),
3218                Inches(0.8),
3219                "layout_rid reset test",
3220            )
3221            .expect("textbox");
3222        // 故意把 layout_rid 改成奇怪的值,验证 save 会被覆盖
3223        slide.set_layout_rid("rIdLayout999".to_string());
3224        let bytes1 = p.to_bytes().expect("to_bytes 1");
3225
3226        // 2) load + 校验
3227        let p2 = Presentation::load_bytes(&bytes1).expect("load");
3228        let s = p2.slides().get(0).expect("slide 0");
3229        // 关键断言:read 后必为 "rId1"
3230        assert_eq!(
3231            s.sld.layout_rid(),
3232            "rId1",
3233            "to_opc_package 应把 layout_rid 重置为 rId1(与 slide rels 的固定 rId 对应)"
3234        );
3235
3236        // 3) 校验 zip 中 slide1.xml.rels 里 layout 关系的 id 也是 rId1
3237        let cursor = std::io::Cursor::new(bytes1);
3238        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3239        let mut srels_xml = String::new();
3240        zip.by_name("ppt/slides/_rels/slide1.xml.rels")
3241            .expect("slide1.xml.rels exists")
3242            .read_to_string(&mut srels_xml)
3243            .expect("read slide rels");
3244        assert!(
3245            srels_xml.contains("Id=\"rId1\""),
3246            "slide1.xml.rels 中 layout 关系应使用 rId1,实际:{srels_xml}"
3247        );
3248    }
3249
3250    // ==================== 自定义文档属性测试(TODO-034) ====================
3251
3252    /// `CustomProperties::set` / `get` / `remove` 基本 API。
3253    #[test]
3254    fn custom_properties_set_get_remove() {
3255        let mut props = CustomProperties::new();
3256        assert!(props.is_empty());
3257        assert_eq!(props.len(), 0);
3258
3259        props.set("Project", CustomPropertyValue::Text("Demo".to_string()));
3260        props.set("Version", CustomPropertyValue::Int(42));
3261        props.set("Active", CustomPropertyValue::Bool(true));
3262        assert_eq!(props.len(), 3);
3263
3264        // get
3265        match props.get("Project") {
3266            Some(CustomPropertyValue::Text(s)) => assert_eq!(s, "Demo"),
3267            other => panic!("期望 Text,得到 {:?}", other),
3268        }
3269        match props.get("Version") {
3270            Some(CustomPropertyValue::Int(i)) => assert_eq!(*i, 42),
3271            other => panic!("期望 Int,得到 {:?}", other),
3272        }
3273        assert!(props.get("NonExistent").is_none());
3274
3275        // 覆盖已有值
3276        props.set("Version", CustomPropertyValue::Int(100));
3277        match props.get("Version") {
3278            Some(CustomPropertyValue::Int(i)) => assert_eq!(*i, 100),
3279            other => panic!("期望 Int(100),得到 {:?}", other),
3280        }
3281        assert_eq!(props.len(), 3, "覆盖不应增加条目数");
3282
3283        // remove
3284        let removed = props.remove("Active");
3285        assert!(matches!(removed, Some(CustomPropertyValue::Bool(true))));
3286        assert_eq!(props.len(), 2);
3287        assert!(props.get("Active").is_none());
3288    }
3289
3290    /// `CustomProperties::to_xml` 正确序列化各种值类型。
3291    #[test]
3292    fn custom_properties_to_xml() {
3293        let mut props = CustomProperties::new();
3294        props.set(
3295            "Text",
3296            CustomPropertyValue::Text("Hello & World".to_string()),
3297        );
3298        props.set("Int", CustomPropertyValue::Int(42));
3299        // 注:避开 3.14(clippy::approx_constant 会误判为 π 近似值)。
3300        props.set("Float", CustomPropertyValue::Float(3.15));
3301        props.set("Bool", CustomPropertyValue::Bool(true));
3302
3303        let xml = props.to_xml();
3304        assert!(xml.contains("<Properties"));
3305        assert!(xml.contains("custom-properties"));
3306        // XML 转义
3307        assert!(xml.contains("Hello &amp; World"));
3308        // pid 从 2 开始
3309        assert!(xml.contains("pid=\"2\""));
3310        assert!(xml.contains("pid=\"3\""));
3311        // 各值类型
3312        assert!(xml.contains("<vt:lpwstr>Hello &amp; World</vt:lpwstr>"));
3313        assert!(xml.contains("<vt:i4>42</vt:i4>"));
3314        assert!(xml.contains("<vt:r8>3.15</vt:r8>"));
3315        assert!(xml.contains("<vt:bool>true</vt:bool>"));
3316        // fmtid
3317        assert!(xml.contains("fmtid=\"{D5CDD505-2E9C-101B-9397-08002B2CF9AE}\""));
3318    }
3319
3320    /// `CustomProperties::from_xml` 正确解析各种值类型。
3321    #[test]
3322    fn custom_properties_from_xml() {
3323        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3324<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"
3325            xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">
3326  <property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="Text">
3327    <vt:lpwstr>Value</vt:lpwstr>
3328  </property>
3329  <property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="3" name="Int">
3330    <vt:i4>42</vt:i4>
3331  </property>
3332  <property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="4" name="Bool">
3333    <vt:bool>false</vt:bool>
3334  </property>
3335  <property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="5" name="Float">
3336    <vt:r8>3.15</vt:r8>
3337  </property>
3338</Properties>"#;
3339        let props = CustomProperties::from_xml(xml);
3340        assert_eq!(props.len(), 4);
3341        match props.get("Text") {
3342            Some(CustomPropertyValue::Text(s)) => assert_eq!(s, "Value"),
3343            other => panic!("期望 Text,得到 {:?}", other),
3344        }
3345        match props.get("Int") {
3346            Some(CustomPropertyValue::Int(i)) => assert_eq!(*i, 42),
3347            other => panic!("期望 Int,得到 {:?}", other),
3348        }
3349        match props.get("Bool") {
3350            Some(CustomPropertyValue::Bool(b)) => assert!(!*b),
3351            other => panic!("期望 Bool(false),得到 {:?}", other),
3352        }
3353        match props.get("Float") {
3354            Some(CustomPropertyValue::Float(f)) => assert!((f - 3.15).abs() < 1e-6),
3355            other => panic!("期望 Float,得到 {:?}", other),
3356        }
3357    }
3358
3359    /// `CustomProperties` 的 XML 往返(to_xml → from_xml)保持一致。
3360    #[test]
3361    fn custom_properties_round_trip() {
3362        let mut props = CustomProperties::new();
3363        props.set("Key1", CustomPropertyValue::Text("Value1".to_string()));
3364        props.set("Key2", CustomPropertyValue::Int(123));
3365        props.set("Key3", CustomPropertyValue::Bool(true));
3366
3367        let xml = props.to_xml();
3368        let parsed = CustomProperties::from_xml(&xml);
3369        assert_eq!(parsed.len(), 3);
3370        assert_eq!(props.get("Key1"), parsed.get("Key1"));
3371        assert_eq!(props.get("Key2"), parsed.get("Key2"));
3372        assert_eq!(props.get("Key3"), parsed.get("Key3"));
3373    }
3374
3375    /// 空 `CustomProperties` 不写出 custom.xml。
3376    #[test]
3377    fn custom_properties_empty_not_serialized() {
3378        let p = Presentation::new().expect("new ok");
3379        let bytes = p.to_bytes().expect("to_bytes");
3380        let cursor = std::io::Cursor::new(bytes);
3381        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3382        // 空自定义属性时不应有 custom.xml
3383        assert!(zip.by_name("docProps/custom.xml").is_err());
3384    }
3385
3386    /// 非空 `CustomProperties` 写出 custom.xml 并在 _rels/.rels 中添加关系。
3387    #[test]
3388    fn custom_properties_serialized_to_pptx() {
3389        let mut p = Presentation::new().expect("new ok");
3390        p.custom_properties_mut()
3391            .set("Project", CustomPropertyValue::Text("Test".to_string()));
3392        p.custom_properties_mut()
3393            .set("Version", CustomPropertyValue::Int(1));
3394
3395        let bytes = p.to_bytes().expect("to_bytes");
3396        let cursor = std::io::Cursor::new(bytes);
3397        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3398
3399        // 1) custom.xml 存在
3400        let mut custom_xml = String::new();
3401        zip.by_name("docProps/custom.xml")
3402            .expect("custom.xml 应存在")
3403            .read_to_string(&mut custom_xml)
3404            .expect("read custom.xml");
3405        assert!(custom_xml.contains("name=\"Project\""));
3406        assert!(custom_xml.contains("<vt:lpwstr>Test</vt:lpwstr>"));
3407        assert!(custom_xml.contains("name=\"Version\""));
3408        assert!(custom_xml.contains("<vt:i4>1</vt:i4>"));
3409
3410        // 2) _rels/.rels 中有 custom-properties 关系
3411        let mut rels_xml = String::new();
3412        zip.by_name("_rels/.rels")
3413            .expect(".rels 应存在")
3414            .read_to_string(&mut rels_xml)
3415            .expect("read .rels");
3416        assert!(rels_xml.contains("custom-properties"), "rels: {}", rels_xml);
3417        assert!(
3418            rels_xml.contains("docProps/custom.xml"),
3419            "rels: {}",
3420            rels_xml
3421        );
3422    }
3423
3424    /// read→save→read 往返保持自定义属性。
3425    #[test]
3426    fn custom_properties_round_trip_through_pptx() {
3427        let mut p = Presentation::new().expect("new ok");
3428        p.custom_properties_mut()
3429            .set("Author", CustomPropertyValue::Text("TestUser".to_string()));
3430        p.custom_properties_mut()
3431            .set("Count", CustomPropertyValue::Int(99));
3432
3433        let bytes = p.to_bytes().expect("to_bytes");
3434        let p2 = Presentation::load_bytes(&bytes).expect("load");
3435
3436        assert_eq!(p2.custom_properties().len(), 2);
3437        match p2.custom_properties().get("Author") {
3438            Some(CustomPropertyValue::Text(s)) => assert_eq!(s, "TestUser"),
3439            other => panic!("期望 Text,得到 {:?}", other),
3440        }
3441        match p2.custom_properties().get("Count") {
3442            Some(CustomPropertyValue::Int(i)) => assert_eq!(*i, 99),
3443            other => panic!("期望 Int,得到 {:?}", other),
3444        }
3445    }
3446
3447    // ==================== 评论测试(TODO-036) ====================
3448
3449    /// `Slide::add_comment` 基本 API:添加评论后能读回。
3450    #[test]
3451    fn slide_add_comment_basic() {
3452        let mut p = Presentation::new().expect("new ok");
3453        let counter = p.id_counter();
3454        let author_id = p.comment_authors_mut().get_or_insert_id("张三", "ZS");
3455        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3456        let idx = slide.add_comment(
3457            author_id,
3458            crate::Emu::new(914400),
3459            crate::Emu::new(914400),
3460            "测试评论",
3461        );
3462        assert_eq!(idx, 1);
3463        let comments = slide.comments().expect("comments should exist");
3464        assert_eq!(comments.len(), 1);
3465        assert_eq!(comments.comments[0].text, "测试评论");
3466        assert_eq!(comments.comments[0].author_id, author_id);
3467        assert_eq!(comments.comments[0].idx, 1);
3468        assert_eq!(comments.comments[0].pos_x, 914400);
3469        assert_eq!(comments.comments[0].pos_y, 914400);
3470    }
3471
3472    /// 多条评论的 idx 自动递增。
3473    #[test]
3474    fn slide_add_comment_multiple_idx_increments() {
3475        let mut p = Presentation::new().expect("new ok");
3476        let counter = p.id_counter();
3477        let author_id = p.comment_authors_mut().get_or_insert_id("作者", "ZZ");
3478        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3479        let idx1 = slide.add_comment(author_id, 0, 0, "第一条");
3480        let idx2 = slide.add_comment(author_id, 100, 100, "第二条");
3481        let idx3 = slide.add_comment(author_id, 200, 200, "第三条");
3482        assert_eq!(idx1, 1);
3483        assert_eq!(idx2, 2);
3484        assert_eq!(idx3, 3);
3485        assert_eq!(slide.comments().unwrap().len(), 3);
3486    }
3487
3488    /// `clear_comments` 清除所有评论。
3489    #[test]
3490    fn slide_clear_comments() {
3491        let mut p = Presentation::new().expect("new ok");
3492        let counter = p.id_counter();
3493        let author_id = p.comment_authors_mut().get_or_insert_id("A", "A");
3494        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3495        slide.add_comment(author_id, 0, 0, "评论");
3496        assert!(slide.comments().is_some());
3497        slide.clear_comments();
3498        assert!(slide.comments().is_none());
3499    }
3500
3501    /// 评论 + 作者序列化到 PPTX 后能读回。
3502    #[test]
3503    fn comments_serialized_to_pptx() {
3504        let mut p = Presentation::new().expect("new ok");
3505        let counter = p.id_counter();
3506        let author_id = p.comment_authors_mut().get_or_insert_id("张三", "ZS");
3507        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3508        slide.add_comment(
3509            author_id,
3510            crate::Emu::new(914400),
3511            crate::Emu::new(914400),
3512            "PPTX 评论",
3513        );
3514
3515        let bytes = p.to_bytes().expect("to_bytes");
3516        let cursor = std::io::Cursor::new(bytes);
3517        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3518
3519        // 1) comment1.xml 存在
3520        let mut cxml = String::new();
3521        zip.by_name("ppt/comments/comment1.xml")
3522            .expect("comment1.xml 应存在")
3523            .read_to_string(&mut cxml)
3524            .expect("read comment1.xml");
3525        assert!(cxml.contains("<p:cmLst"), "comment xml: {}", cxml);
3526        assert!(cxml.contains("PPTX 评论"), "comment text: {}", cxml);
3527        assert!(cxml.contains("authorId=\""), "comment authorId: {}", cxml);
3528
3529        // 2) commentAuthors.xml 存在
3530        let mut axml = String::new();
3531        zip.by_name("ppt/commentAuthors.xml")
3532            .expect("commentAuthors.xml 应存在")
3533            .read_to_string(&mut axml)
3534            .expect("read commentAuthors.xml");
3535        assert!(axml.contains("<p:cmAuthorLst"), "authors xml: {}", axml);
3536        assert!(axml.contains("张三"), "author name: {}", axml);
3537
3538        // 3) slide1.xml.rels 包含 comments 关系
3539        let mut srels = String::new();
3540        zip.by_name("ppt/slides/_rels/slide1.xml.rels")
3541            .expect("slide1.xml.rels exists")
3542            .read_to_string(&mut srels)
3543            .expect("read slide rels");
3544        assert!(
3545            srels.contains("comments"),
3546            "slide rels should contain comments: {}",
3547            srels
3548        );
3549        assert!(
3550            srels.contains("../comments/comment1.xml"),
3551            "slide rels target: {}",
3552            srels
3553        );
3554
3555        // 4) presentation.xml.rels 包含 commentAuthors 关系
3556        let mut prels = String::new();
3557        zip.by_name("ppt/_rels/presentation.xml.rels")
3558            .expect("pres rels exists")
3559            .read_to_string(&mut prels)
3560            .expect("read pres rels");
3561        assert!(prels.contains("commentAuthors"), "pres rels: {}", prels);
3562    }
3563
3564    /// read→save→read 往返保持评论。
3565    #[test]
3566    fn comments_round_trip_through_pptx() {
3567        let mut p = Presentation::new().expect("new ok");
3568        let counter = p.id_counter();
3569        let author_id = p.comment_authors_mut().get_or_insert_id("李四", "LS");
3570        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3571        slide.add_comment(
3572            author_id,
3573            crate::Emu::new(100),
3574            crate::Emu::new(200),
3575            "往返评论",
3576        );
3577
3578        let bytes = p.to_bytes().expect("to_bytes");
3579        let p2 = Presentation::load_bytes(&bytes).expect("load");
3580
3581        // 校验评论内容
3582        let s2 = p2.slides().get(0).expect("slide 0");
3583        let comments = s2.sld.comments().expect("comments should exist");
3584        assert_eq!(comments.len(), 1);
3585        let c = &comments.comments[0];
3586        assert_eq!(c.text, "往返评论");
3587        assert_eq!(c.pos_x, 100);
3588        assert_eq!(c.pos_y, 200);
3589        assert_eq!(c.idx, 1);
3590
3591        // 校验作者
3592        let authors = p2.comment_authors();
3593        assert_eq!(authors.len(), 1);
3594        assert_eq!(authors.authors[0].name, "李四");
3595        assert_eq!(authors.authors[0].initials, "LS");
3596        assert_eq!(authors.authors[0].id, c.author_id);
3597    }
3598
3599    /// 空评论不写出 commentN.xml。
3600    #[test]
3601    fn no_comments_no_part() {
3602        let mut p = Presentation::new().expect("new ok");
3603        let counter = p.id_counter();
3604        p.slides_mut().add_slide(counter).expect("add slide");
3605
3606        let bytes = p.to_bytes().expect("to_bytes");
3607        let cursor = std::io::Cursor::new(bytes);
3608        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3609        // 无评论时不应有 comment1.xml
3610        assert!(zip.by_name("ppt/comments/comment1.xml").is_err());
3611        // 无评论作者时不应有 commentAuthors.xml
3612        assert!(zip.by_name("ppt/commentAuthors.xml").is_err());
3613    }
3614
3615    /// 多 slide 多评论的 partname 稳定性。
3616    #[test]
3617    fn comments_multiple_slides_partname_stable() {
3618        let mut p = Presentation::new().expect("new ok");
3619        let counter = p.id_counter();
3620        let aid = p.comment_authors_mut().get_or_insert_id("A", "A");
3621        let s1 = p.slides_mut().add_slide(counter.clone()).expect("slide 1");
3622        s1.add_comment(aid, 0, 0, "slide1 评论");
3623
3624        let s2 = p.slides_mut().add_slide(counter).expect("slide 2");
3625        s2.add_comment(aid, 0, 0, "slide2 评论");
3626
3627        let bytes = p.to_bytes().expect("to_bytes");
3628        let p2 = Presentation::load_bytes(&bytes).expect("load");
3629
3630        // 校验两个 slide 都有评论
3631        let s1b = p2.slides().get(0).expect("slide 0");
3632        let s2b = p2.slides().get(1).expect("slide 1");
3633        assert_eq!(s1b.sld.comments().unwrap().comments[0].text, "slide1 评论");
3634        assert_eq!(s2b.sld.comments().unwrap().comments[0].text, "slide2 评论");
3635
3636        // 再保存一次,验证 partname 不漂移
3637        let bytes2 = p2.to_bytes().expect("to_bytes 2");
3638        let cursor = std::io::Cursor::new(bytes2);
3639        let mut zip = zip::ZipArchive::new(cursor).expect("zip 2");
3640        assert!(zip.by_name("ppt/comments/comment1.xml").is_ok());
3641        assert!(zip.by_name("ppt/comments/comment2.xml").is_ok());
3642    }
3643
3644    // ==================== SmartArt round-trip 测试(TODO-037) ====================
3645
3646    /// `Slide::allocate_diagram_rids` 返回 4 个递增的 rId。
3647    #[test]
3648    fn slide_allocate_diagram_rids_increments() {
3649        let mut p = Presentation::new().expect("new ok");
3650        let counter = p.id_counter();
3651        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3652
3653        let (d1, l1, q1, c1) = slide.allocate_diagram_rids();
3654        assert_eq!(d1, "rIdDgmData1");
3655        assert_eq!(l1, "rIdDgmLayout1");
3656        assert_eq!(q1, "rIdDgmQs1");
3657        assert_eq!(c1, "rIdDgmColors1");
3658
3659        let (d2, l2, q2, c2) = slide.allocate_diagram_rids();
3660        assert_eq!(d2, "rIdDgmData2");
3661        assert_eq!(l2, "rIdDgmLayout2");
3662        assert_eq!(q2, "rIdDgmQs2");
3663        assert_eq!(c2, "rIdDgmColors2");
3664    }
3665
3666    /// `Slide::next_diagram_index` 返回递增索引。
3667    #[test]
3668    fn slide_next_diagram_index_increments() {
3669        let mut p = Presentation::new().expect("new ok");
3670        let counter = p.id_counter();
3671        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3672
3673        assert_eq!(slide.next_diagram_index(), 1);
3674        assert_eq!(slide.next_diagram_index(), 2);
3675        assert_eq!(slide.next_diagram_index(), 3);
3676    }
3677
3678    /// `Slide::register_diagram` 把 entry 推入 diagram_entries。
3679    #[test]
3680    fn slide_register_diagram_stores_entry() {
3681        let mut p = Presentation::new().expect("new ok");
3682        let counter = p.id_counter();
3683        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3684
3685        let entry = DiagramEntry {
3686            data_partname: crate::opc::part::new_part_name("/ppt/diagrams/data1.xml"),
3687            layout_partname: crate::opc::part::new_part_name("/ppt/diagrams/layout1.xml"),
3688            quick_style_partname: crate::opc::part::new_part_name("/ppt/diagrams/quickStyles1.xml"),
3689            colors_partname: crate::opc::part::new_part_name("/ppt/diagrams/colors1.xml"),
3690            data_xml: "<dgm:dataModel/>".to_string(),
3691            layout_xml: "<dgm:layoutDef/>".to_string(),
3692            quick_style_xml: "<dgm:styleData/>".to_string(),
3693            colors_xml: "<dgm:colorsDef/>".to_string(),
3694            data_rid: "rIdDgmData1".to_string(),
3695            layout_rid: "rIdDgmLayout1".to_string(),
3696            quick_style_rid: "rIdDgmQs1".to_string(),
3697            colors_rid: "rIdDgmColors1".to_string(),
3698        };
3699        slide.register_diagram(entry);
3700
3701        // 直接访问 slide.inner.diagram_entries(pub(crate))验证
3702        assert_eq!(slide.diagram_entries.len(), 1);
3703        assert_eq!(
3704            slide.diagram_entries[0].data_partname.as_str(),
3705            "/ppt/diagrams/data1.xml"
3706        );
3707        assert_eq!(slide.diagram_entries[0].data_xml, "<dgm:dataModel/>");
3708    }
3709
3710    /// 端到端:`register_diagram` 后 `to_bytes` 应在 zip 中写出 4 个 diagram parts,
3711    /// 且 `slide1.xml.rels` 含 4 个 diagram 关系。
3712    #[test]
3713    fn diagram_parts_written_to_zip_after_register() {
3714        let mut p = Presentation::new().expect("new ok");
3715        let counter = p.id_counter();
3716        let slide = p.slides_mut().add_slide(counter).expect("add slide");
3717
3718        let entry = DiagramEntry {
3719            data_partname: crate::opc::part::new_part_name("/ppt/diagrams/data1.xml"),
3720            layout_partname: crate::opc::part::new_part_name("/ppt/diagrams/layout1.xml"),
3721            quick_style_partname: crate::opc::part::new_part_name("/ppt/diagrams/quickStyles1.xml"),
3722            colors_partname: crate::opc::part::new_part_name("/ppt/diagrams/colors1.xml"),
3723            data_xml: "<?xml version=\"1.0\"?><dgm:dataModel xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\"/>".to_string(),
3724            layout_xml: "<?xml version=\"1.0\"?><dgm:layoutDef xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\"/>".to_string(),
3725            quick_style_xml: "<?xml version=\"1.0\"?><dgm:styleData xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\"/>".to_string(),
3726            colors_xml: "<?xml version=\"1.0\"?><dgm:colorsDef xmlns:dgm=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\"/>".to_string(),
3727            data_rid: "rIdDgmData1".to_string(),
3728            layout_rid: "rIdDgmLayout1".to_string(),
3729            quick_style_rid: "rIdDgmQs1".to_string(),
3730            colors_rid: "rIdDgmColors1".to_string(),
3731        };
3732        slide.register_diagram(entry);
3733
3734        let bytes = p.to_bytes().expect("to_bytes");
3735        let cursor = std::io::Cursor::new(bytes);
3736        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3737
3738        // 校验 4 个 diagram parts 都被写入
3739        assert!(
3740            zip.by_name("ppt/diagrams/data1.xml").is_ok(),
3741            "data1.xml missing"
3742        );
3743        assert!(
3744            zip.by_name("ppt/diagrams/layout1.xml").is_ok(),
3745            "layout1.xml missing"
3746        );
3747        assert!(
3748            zip.by_name("ppt/diagrams/quickStyles1.xml").is_ok(),
3749            "quickStyles1.xml missing"
3750        );
3751        assert!(
3752            zip.by_name("ppt/diagrams/colors1.xml").is_ok(),
3753            "colors1.xml missing"
3754        );
3755
3756        // 校验 slide1.xml.rels 中含 4 个 diagram 关系
3757        let mut srels_xml = String::new();
3758        zip.by_name("ppt/slides/_rels/slide1.xml.rels")
3759            .expect("slide1.xml.rels exists")
3760            .read_to_string(&mut srels_xml)
3761            .expect("read slide rels");
3762        assert!(
3763            srels_xml.contains("/diagramData"),
3764            "slide1.xml.rels 缺 diagramData 关系,实际:{srels_xml}"
3765        );
3766        assert!(
3767            srels_xml.contains("/diagramLayout"),
3768            "slide1.xml.rels 缺 diagramLayout 关系,实际:{srels_xml}"
3769        );
3770        assert!(
3771            srels_xml.contains("/diagramQuickStyle"),
3772            "slide1.xml.rels 缺 diagramQuickStyle 关系,实际:{srels_xml}"
3773        );
3774        assert!(
3775            srels_xml.contains("/diagramColors"),
3776            "slide1.xml.rels 缺 diagramColors 关系,实际:{srels_xml}"
3777        );
3778        // 校验 rId 正确
3779        assert!(srels_xml.contains("rIdDgmData1"), "缺 rIdDgmData1");
3780        assert!(srels_xml.contains("rIdDgmLayout1"), "缺 rIdDgmLayout1");
3781        assert!(srels_xml.contains("rIdDgmQs1"), "缺 rIdDgmQs1");
3782        assert!(srels_xml.contains("rIdDgmColors1"), "缺 rIdDgmColors1");
3783    }
3784
3785    /// 端到端:多 slide + 多 SmartArt 时,diagram partname 全局递增不冲突。
3786    #[test]
3787    fn diagram_parts_global_index_across_slides() {
3788        let mut p = Presentation::new().expect("new ok");
3789        let counter = p.id_counter();
3790
3791        // slide 1:1 个 SmartArt
3792        let s1 = p.slides_mut().add_slide(counter.clone()).expect("s1");
3793        let e1 = DiagramEntry {
3794            data_partname: crate::opc::part::new_part_name("/ppt/diagrams/data1.xml"),
3795            layout_partname: crate::opc::part::new_part_name("/ppt/diagrams/layout1.xml"),
3796            quick_style_partname: crate::opc::part::new_part_name("/ppt/diagrams/quickStyles1.xml"),
3797            colors_partname: crate::opc::part::new_part_name("/ppt/diagrams/colors1.xml"),
3798            data_xml: "<dgm:dataModel/>".to_string(),
3799            layout_xml: "<dgm:layoutDef/>".to_string(),
3800            quick_style_xml: "<dgm:styleData/>".to_string(),
3801            colors_xml: "<dgm:colorsDef/>".to_string(),
3802            data_rid: "rIdDgmData1".to_string(),
3803            layout_rid: "rIdDgmLayout1".to_string(),
3804            quick_style_rid: "rIdDgmQs1".to_string(),
3805            colors_rid: "rIdDgmColors1".to_string(),
3806        };
3807        s1.register_diagram(e1);
3808
3809        // slide 2:1 个 SmartArt(局部 rid 与 slide 1 重复,但全局 partname 应不同)
3810        let s2 = p.slides_mut().add_slide(counter).expect("s2");
3811        let e2 = DiagramEntry {
3812            data_partname: crate::opc::part::new_part_name("/ppt/diagrams/data1.xml"),
3813            layout_partname: crate::opc::part::new_part_name("/ppt/diagrams/layout1.xml"),
3814            quick_style_partname: crate::opc::part::new_part_name("/ppt/diagrams/quickStyles1.xml"),
3815            colors_partname: crate::opc::part::new_part_name("/ppt/diagrams/colors1.xml"),
3816            data_xml: "<dgm:dataModel/>".to_string(),
3817            layout_xml: "<dgm:layoutDef/>".to_string(),
3818            quick_style_xml: "<dgm:styleData/>".to_string(),
3819            colors_xml: "<dgm:colorsDef/>".to_string(),
3820            data_rid: "rIdDgmData1".to_string(),
3821            layout_rid: "rIdDgmLayout1".to_string(),
3822            quick_style_rid: "rIdDgmQs1".to_string(),
3823            colors_rid: "rIdDgmColors1".to_string(),
3824        };
3825        s2.register_diagram(e2);
3826
3827        let bytes = p.to_bytes().expect("to_bytes");
3828        let cursor = std::io::Cursor::new(bytes);
3829        let mut zip = zip::ZipArchive::new(cursor).expect("zip open");
3830
3831        // slide 1 的 SmartArt 应使用 data1.xml / layout1.xml / ...
3832        assert!(zip.by_name("ppt/diagrams/data1.xml").is_ok());
3833        assert!(zip.by_name("ppt/diagrams/layout1.xml").is_ok());
3834        // slide 2 的 SmartArt 应使用全局递增后的 data2.xml / layout2.xml / ...
3835        // (to_opc_package 用 diagram_global_index 重新分配 partname)
3836        assert!(
3837            zip.by_name("ppt/diagrams/data2.xml").is_ok(),
3838            "data2.xml missing"
3839        );
3840        assert!(
3841            zip.by_name("ppt/diagrams/layout2.xml").is_ok(),
3842            "layout2.xml missing"
3843        );
3844        assert!(zip.by_name("ppt/diagrams/quickStyles2.xml").is_ok());
3845        assert!(zip.by_name("ppt/diagrams/colors2.xml").is_ok());
3846    }
3847}