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