pptx_rs/oxml/shape.rs
1//! 形状 XML 模型:`p:sp` / `p:pic` / `p:grpSp` / `p:cxnSp` / `p:graphicFrame`。
2//!
3//! 包含 OOXML 中所有"可视对象"的强类型表达。每个类型都自带 `write_xml` 方法,
4//! 按规范要求的元素顺序输出。
5//!
6//! # 与 python-pptx 的对应
7//!
8//! - `pptx.shapes.shapetree.*` ←→ [`Sp`] / [`Pic`] / [`Group`] / [`Connector`];
9//! - `pptx.oxml.shape.*` ←→ 同名 oxml 模型;
10//! - `pptx.shapes.graphfrm.GraphicFrame` ←→ [`GraphicFrame`](承载表格/图表)。
11//!
12//! # 序列化约定
13//!
14//! - **非视觉属性隐藏**(`<p:nvSpPr>` 中的 cNvPr)必须最先输出;
15//! - **spPr**(几何/变换/填充/线)紧跟其后;
16//! - **txBody**(文本)最后输出;
17//! - **顺序错误**会导致 PowerPoint 报错 "Invalid OOXML"。
18
19use crate::oxml::sppr::ShapeProperties;
20use crate::oxml::txbody::TextBody;
21use crate::units::Emu;
22
23/// 形状锁定属性(`<a:spLocks>`)。
24///
25/// 对应 OOXML 中 `<p:cNvSpPr>` 内的 `<a:spLocks>` 元素。
26/// 所有属性均为布尔值,`true` 表示禁止对应操作。
27///
28/// 对标 python-pptx 中 `shape.locks` 的底层支撑(python-pptx 尚未暴露高阶 API,
29/// 但读写时保留这些属性)。
30#[derive(Clone, Debug, Default)]
31pub struct ShapeLocks {
32 /// 禁止组合(`noGrp="1"`)。
33 pub no_grp: bool,
34 /// 禁止进入组合(`noDrilldown="1"`)。
35 pub no_drilldown: bool,
36 /// 禁止选择(`noSelect="1"`)。
37 pub no_select: bool,
38 /// 禁止改变宽高比(`noChangeAspect="1"`)。
39 pub no_change_aspect: bool,
40 /// 禁止移动(`noMove="1"`)。
41 pub no_move: bool,
42 /// 禁止缩放(`noResize="1"`)。
43 pub no_resize: bool,
44 /// 禁止旋转(`noRot="1"`)。
45 pub no_rot: bool,
46 /// 禁止编辑顶点(`noEditPoints="1"`)。
47 pub no_edit_points: bool,
48 /// 禁止调整手柄(`noAdjustHandles="1"`)。
49 pub no_adjust_handles: bool,
50 /// 禁止修改箭头(`noChangeArrowheads="1"`)。
51 pub no_change_arrowheads: bool,
52 /// 禁止修改形状类型(`noChangeShapeType="1"`)。
53 pub no_change_shape_type: bool,
54 /// 禁止裁剪(`noCrop="1"`)。
55 pub no_crop: bool,
56}
57
58impl ShapeLocks {
59 /// 是否所有属性都为 false(即无任何锁定)。
60 pub fn is_empty(&self) -> bool {
61 !self.no_grp
62 && !self.no_drilldown
63 && !self.no_select
64 && !self.no_change_aspect
65 && !self.no_move
66 && !self.no_resize
67 && !self.no_rot
68 && !self.no_edit_points
69 && !self.no_adjust_handles
70 && !self.no_change_arrowheads
71 && !self.no_change_shape_type
72 && !self.no_crop
73 }
74
75 /// 写出 `<a:spLocks .../>` 自闭合标签。
76 ///
77 /// 若所有属性均为 false,则**不**写出(调用方应先调用 [`Self::is_empty`] 判断)。
78 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
79 let mut attrs: Vec<(&str, &str)> = Vec::new();
80 if self.no_grp {
81 attrs.push(("noGrp", "1"));
82 }
83 if self.no_drilldown {
84 attrs.push(("noDrilldown", "1"));
85 }
86 if self.no_select {
87 attrs.push(("noSelect", "1"));
88 }
89 if self.no_change_aspect {
90 attrs.push(("noChangeAspect", "1"));
91 }
92 if self.no_move {
93 attrs.push(("noMove", "1"));
94 }
95 if self.no_resize {
96 attrs.push(("noResize", "1"));
97 }
98 if self.no_rot {
99 attrs.push(("noRot", "1"));
100 }
101 if self.no_edit_points {
102 attrs.push(("noEditPoints", "1"));
103 }
104 if self.no_adjust_handles {
105 attrs.push(("noAdjustHandles", "1"));
106 }
107 if self.no_change_arrowheads {
108 attrs.push(("noChangeArrowheads", "1"));
109 }
110 if self.no_change_shape_type {
111 attrs.push(("noChangeShapeType", "1"));
112 }
113 if self.no_crop {
114 attrs.push(("noCrop", "1"));
115 }
116 w.empty_with("a:spLocks", &attrs);
117 }
118
119 /// 统一设置指定类型的锁定(TODO-027 高阶 API)。
120 ///
121 /// 对标 python-pptx 风格 `shape.set_lock(LockType::Select, true)`。
122 /// 此方法是所有 `no_*` 字段写入器的统一入口,避免调用方记忆 12 个字段名。
123 ///
124 /// # 参数
125 /// - `lock_type`:锁定类型(见 [`LockType`] 枚举);
126 /// - `locked`:`true` 启用该锁定,`false` 解除该锁定。
127 ///
128 /// # 示例
129 ///
130 /// ```no_run
131 /// # use pptx_rs::oxml::shape::{ShapeLocks, LockType};
132 /// let mut l = ShapeLocks::default();
133 /// l.set_lock(LockType::Select, true);
134 /// l.set_lock(LockType::Rotate, true);
135 /// assert!(l.no_select);
136 /// assert!(l.no_rot);
137 /// ```
138 pub fn set_lock(&mut self, lock_type: LockType, locked: bool) {
139 match lock_type {
140 LockType::Grouping => self.no_grp = locked,
141 LockType::Drilldown => self.no_drilldown = locked,
142 LockType::Select => self.no_select = locked,
143 LockType::ChangeAspect => self.no_change_aspect = locked,
144 LockType::Move => self.no_move = locked,
145 LockType::Resize => self.no_resize = locked,
146 LockType::Rotate => self.no_rot = locked,
147 LockType::EditPoints => self.no_edit_points = locked,
148 LockType::AdjustHandles => self.no_adjust_handles = locked,
149 LockType::ChangeArrowheads => self.no_change_arrowheads = locked,
150 LockType::ChangeShapeType => self.no_change_shape_type = locked,
151 LockType::Crop => self.no_crop = locked,
152 }
153 }
154
155 /// 读取指定类型的锁定状态。详见 [`ShapeLocks::set_lock`]。
156 pub fn get_lock(&self, lock_type: LockType) -> bool {
157 match lock_type {
158 LockType::Grouping => self.no_grp,
159 LockType::Drilldown => self.no_drilldown,
160 LockType::Select => self.no_select,
161 LockType::ChangeAspect => self.no_change_aspect,
162 LockType::Move => self.no_move,
163 LockType::Resize => self.no_resize,
164 LockType::Rotate => self.no_rot,
165 LockType::EditPoints => self.no_edit_points,
166 LockType::AdjustHandles => self.no_adjust_handles,
167 LockType::ChangeArrowheads => self.no_change_arrowheads,
168 LockType::ChangeShapeType => self.no_change_shape_type,
169 LockType::Crop => self.no_crop,
170 }
171 }
172}
173
174/// 形状锁定类型枚举(对应 `<a:spLocks>` 的 12 个属性)。
175///
176/// 用于 [`ShapeLocks::set_lock`] / [`ShapeLocks::get_lock`] 高阶 API,
177/// 避免调用方直接操作 12 个 `no_*` 字段。对标 python-pptx 的
178/// `MSO_SHAPE_LOCK_TYPE` 枚举。
179#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
180pub enum LockType {
181 /// 禁止组合(`noGrp`)。
182 Grouping,
183 /// 禁止进入组合(`noDrilldown`)。
184 Drilldown,
185 /// 禁止选择(`noSelect`)。
186 Select,
187 /// 禁止改变宽高比(`noChangeAspect`)。
188 ChangeAspect,
189 /// 禁止移动(`noMove`)。
190 Move,
191 /// 禁止缩放(`noResize`)。
192 Resize,
193 /// 禁止旋转(`noRot`)。
194 Rotate,
195 /// 禁止编辑顶点(`noEditPoints`)。
196 EditPoints,
197 /// 禁止调整手柄(`noAdjustHandles`)。
198 AdjustHandles,
199 /// 禁止修改箭头(`noChangeArrowheads`)。
200 ChangeArrowheads,
201 /// 禁止修改形状类型(`noChangeShapeType`)。
202 ChangeShapeType,
203 /// 禁止裁剪(`noCrop`)。
204 Crop,
205}
206
207/// `<p:sp>` 普通形状(如文本框、矩形、椭圆等)。
208#[derive(Clone, Debug, Default)]
209pub struct Sp {
210 /// 形状 ID(同一 slide 内唯一)。
211 pub id: u32,
212 /// 形状名(仅显示用)。
213 pub name: String,
214 /// 是否为占位符。
215 pub is_placeholder: bool,
216 /// 占位符 idx(placeholder idx,仅当 `is_placeholder`)。
217 pub ph_idx: Option<u32>,
218 /// 占位符类型(`title` / `body` / ...)。
219 ///
220 /// 完整取值见 `PP_PLACEHOLDER_TYPE` 枚举:
221 /// - `title` / `ctrTitle` / `subTitle`
222 /// - `body` / `ftr` / `dt` / `sldNum` / `hdr`
223 /// - `tbl` / `chart` / `pic` / `sldImg` / `media`
224 /// - `obj` / `vertAlign` 等等
225 ///
226 /// 当 `is_placeholder=true` 但 `ph_type=None` 时,写 XML 时**默认**按 `body` 写出。
227 pub ph_type: Option<String>,
228 /// 形状属性(xfrm / prstGeom / 填充 / 边框)。
229 pub properties: ShapeProperties,
230 /// 主题样式引用(`p:style`,可选)。
231 pub style: Option<ShapeStyle>,
232 /// 文本体(`p:txBody`)。
233 pub text: TextBody,
234 /// 扩展列表(`p:extLst`),用于承载 PowerPoint 私有扩展。
235 pub ext_lst: Option<ExtensionList>,
236 /// 标记该 sp 为**纯文本框**(对应 `<p:cNvSpPr txBox="1"/>`)。
237 ///
238 /// python-pptx 中 `shapes.add_textbox(...)` 创建的就是带这个标志的 `p:sp`。
239 /// 该标志让 PowerPoint 把它识别为"自由文本框"而非"自选图形 + 文本",从而
240 /// 走**非自动布局**(不套用母版占位符约束)。
241 pub c_nv_sp_pr_tx_box: bool,
242 /// 形状锁定属性(`<a:spLocks>`,可选)。
243 ///
244 /// `None` 表示不写出 `<a:spLocks>` 元素。
245 /// `Some(ShapeLocks { .. })` 且 `is_empty() == false` 时写出。
246 pub locks: Option<ShapeLocks>,
247 // 非视觉属性(隐藏、跳转等)——目前不实现。
248}
249
250impl Sp {
251 /// 写 XML。
252 ///
253 /// 按 OOXML `CT_Shape` 的子元素顺序:
254 ///
255 /// ```text
256 /// <p:sp>
257 /// <p:nvSpPr>...</p:nvSpPr>
258 /// <p:spPr>...</p:spPr> ← spPr 必填
259 /// <p:style>...</p:style> ← 可选:主题样式引用
260 /// <p:txBody>...</p:txBody> ← 可选:文本体
261 /// <p:extLst>...</p:extLst> ← 可选:扩展
262 /// </p:sp>
263 /// ```
264 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
265 w.open("p:sp");
266 // nvSpPr
267 w.open("p:nvSpPr");
268 let id_s = self.id.to_string();
269 w.empty_with(
270 "p:cNvPr",
271 &[("id", id_s.as_str()), ("name", self.name.as_str())],
272 );
273 // 提前取出 idx 字符串,扩展到 if 块外
274 let idx_s = self.ph_idx.map(|v| v.to_string());
275 if self.is_placeholder {
276 let mut pattrs: Vec<(&str, &str)> = Vec::new();
277 // ph_type 缺省回落到 `body`——这是 OOXML 规范的默认 placeholder 类型,
278 // 也是 PowerPoint 在用户新增占位符时实际写入的值。
279 // 显式 None 会让 PowerPoint 弹"无法识别的占位符"警告。
280 let type_str: &str = self.ph_type.as_deref().unwrap_or("body");
281 pattrs.push(("type", type_str));
282 if let Some(s) = &idx_s {
283 pattrs.push(("idx", s.as_str()));
284 }
285 w.empty_with("p:ph", &pattrs);
286 }
287 // 纯文本框:写 `txBox="1"` 让 PowerPoint 识别为 textbox(不应用母版占位符)。
288 // 若有形状锁定(spLocks),则 cNvSpPr 需要写成 open-close 形式以容纳子元素。
289 let has_locks = self.locks.as_ref().map(|l| !l.is_empty()).unwrap_or(false);
290 if has_locks {
291 // 有 spLocks 子元素:写成 open-close
292 if self.c_nv_sp_pr_tx_box {
293 w.open_with("p:cNvSpPr", &[("txBox", "1")]);
294 } else {
295 w.open("p:cNvSpPr");
296 }
297 self.locks.as_ref().unwrap().write_xml(w);
298 w.close("p:cNvSpPr");
299 } else if self.c_nv_sp_pr_tx_box {
300 w.empty_with("p:cNvSpPr", &[("txBox", "1")]);
301 } else {
302 w.empty_with("p:cNvSpPr", &[]);
303 }
304 w.empty("p:nvPr");
305 w.close("p:nvSpPr");
306 // spPr
307 self.properties.write_xml(w, "p:spPr");
308 // p:style(暂未填充具体内容;保留位置供后续主题样式引用)
309 if let Some(style) = &self.style {
310 style.write_xml(w);
311 }
312 // txBody
313 self.text.write_xml(w, "p:txBody");
314 // extLst
315 write_extlst(w, self.ext_lst.as_ref());
316 w.close("p:sp");
317 }
318}
319
320/// `<p:pic>` 图片。
321#[derive(Clone, Debug, Default)]
322pub struct Pic {
323 pub id: u32,
324 pub name: String,
325 pub rid: String, // r:embed 关系 id
326 /// 是否为占位符(TODO-007)。
327 ///
328 /// `true` 时 `write_xml` 会在 `<p:nvPicPr>/<p:nvPr>` 内写出 `<p:ph .../>`。
329 /// 图片占位符对应版式中的 `<p:ph type="pic"/>`,用户点击后弹出"插入图片"对话框。
330 pub is_placeholder: bool,
331 /// 占位符 idx(`<p:ph idx="..."/>`,仅当 `is_placeholder` 为 true 时有效)。
332 pub ph_idx: Option<u32>,
333 /// 占位符类型(`<p:ph type="..."/>`)。
334 ///
335 /// 图片占位符通常为 `"pic"`;`None` 时 `write_xml` 不写出 `type` 属性。
336 pub ph_type: Option<String>,
337 pub properties: ShapeProperties,
338 /// 图片裁剪。
339 pub src_rect: Option<(i32, i32, i32, i32)>, // l%, t%, r%, b% (100000=100%)
340 /// 填充模式(拉伸/平铺/无)。
341 pub fill_mode: crate::oxml::sppr::BlipFillMode,
342 /// 图片透明度(0-100000,30000 = 30% 不透明 / 70% 透明)。
343 ///
344 /// 对应 `<a:blip>` 内的 `<a:alphaModFix amt="..."/>` 元素。
345 /// 常用于图片水印场景:`alpha = Some(30_000)` 表示 30% 不透明。
346 pub alpha: Option<i32>,
347 /// 主题样式引用(`p:style`,可选)。
348 pub style: Option<ShapeStyle>,
349 /// 扩展列表。
350 pub ext_lst: Option<ExtensionList>,
351 /// 媒体引用(TODO-033 音视频嵌入)。
352 ///
353 /// `None` 表示普通图片;`Some(MediaKind::Video { rid })` / `Some(MediaKind::Audio { rid })`
354 /// 表示该 `<p:pic>` 实际是视频/音频形状,`write_xml` 会在 `<p:nvPr>` 内写出
355 /// `<a:videoFile r:link="..."/>` 或 `<a:audioFile r:link="..."/>`。
356 /// `rid` 指向媒体 part 的关系 id(与 `self.rid` 指向海报帧图片不同)。
357 pub media: Option<MediaKind>,
358}
359
360/// 媒体类型(TODO-033 音视频嵌入)。
361///
362/// OOXML 中视频/音频形状实际上是带 `<a:videoFile>` / `<a:audioFile>` 的 `<p:pic>`,
363/// 用海报帧图片(`<a:blip r:embed="..."/>`)作为视觉占位。
364#[derive(Clone, Debug, PartialEq, Eq)]
365pub enum MediaKind {
366 /// 视频(`<a:videoFile r:link="..."/>`)。
367 Video {
368 /// 媒体 part 的关系 id(指向 `/ppt/media/mediaN.mp4`)。
369 rid: String,
370 },
371 /// 音频(`<a:audioFile r:link="..."/>`)。
372 Audio {
373 /// 媒体 part 的关系 id(指向 `/ppt/media/mediaN.mp3`)。
374 rid: String,
375 },
376}
377
378impl Default for MediaKind {
379 fn default() -> Self {
380 // 默认视频空 rid(避免 Option 双层包装)
381 MediaKind::Video { rid: String::new() }
382 }
383}
384
385impl Pic {
386 /// 写 XML。
387 ///
388 /// 按 OOXML `CT_Picture` 的子元素顺序:
389 ///
390 /// ```text
391 /// <p:pic>
392 /// <p:nvPicPr>...</p:nvPicPr>
393 /// <p:blipFill>...</p:blipFill>
394 /// <p:spPr>...</p:spPr>
395 /// <p:style>...</p:style> ← 可选
396 /// <p:extLst>...</p:extLst> ← 可选
397 /// </p:pic>
398 /// ```
399 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
400 w.open("p:pic");
401 w.open("p:nvPicPr");
402 let id_s = self.id.to_string();
403 w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
404 w.empty("p:cNvPicPr");
405 // nvPr:若 is_placeholder,写出 <p:ph type="..." idx="..."/>(TODO-007)。
406 // 若有 media(TODO-033),写出 <a:videoFile r:link="..."/> 或 <a:audioFile r:link="..."/>。
407 // OOXML 顺序:<p:cNvPr> → <p:cNvPicPr> → <p:nvPr>(含 <p:ph> / <a:videoFile> 等)。
408 if self.is_placeholder || self.media.is_some() {
409 w.open_with("p:nvPr", &[("xmlns:r", crate::oxml::ns::NS_DRAWING_RELS)]);
410 if self.is_placeholder {
411 // idx_s 提到 pattrs 之前,保证生命周期覆盖 empty_with 调用
412 let idx_s = self.ph_idx.map(|i| i.to_string());
413 let mut pattrs: Vec<(&str, &str)> = Vec::new();
414 if let Some(t) = self.ph_type.as_deref() {
415 pattrs.push(("type", t));
416 }
417 if let Some(s) = idx_s.as_deref() {
418 pattrs.push(("idx", s));
419 }
420 w.empty_with("p:ph", &pattrs);
421 }
422 // 媒体引用(TODO-033)
423 if let Some(media) = &self.media {
424 match media {
425 MediaKind::Video { rid } => {
426 w.empty_with("a:videoFile", &[("r:link", rid.as_str())]);
427 }
428 MediaKind::Audio { rid } => {
429 w.empty_with("a:audioFile", &[("r:link", rid.as_str())]);
430 }
431 }
432 }
433 w.close("p:nvPr");
434 } else {
435 w.empty("p:nvPr");
436 }
437 w.close("p:nvPicPr");
438 // blipFill:开始标签 + 子元素 + 结束标签
439 // 子元素是 `<a:blip r:embed="rIdN"/>`(自闭合)和可选的 `<a:stretch>` / `<a:srcRect>`。
440 // `xmlns:r` 放在最外层 blipFill 上以声明 r: 前缀。
441 w.open_with(
442 "p:blipFill",
443 &[("xmlns:r", crate::oxml::ns::NS_DRAWING_RELS)],
444 );
445 // a:blip:若有 alpha 透明度,需要开标签 + 子元素 + 关标签
446 if let Some(alpha_val) = self.alpha {
447 w.open_with("a:blip", &[("r:embed", self.rid.as_str())]);
448 let amt_s = alpha_val.to_string();
449 w.empty_with("a:alphaModFix", &[("amt", amt_s.as_str())]);
450 w.close("a:blip");
451 } else {
452 w.empty_with("a:blip", &[("r:embed", self.rid.as_str())]);
453 }
454 // srcRect 用于裁剪:l/t/r/b 均为 0..=100000 的千分比(100000=100%)。
455 if let Some((l, t, r, b)) = self.src_rect {
456 let l_s = l.to_string();
457 let t_s = t.to_string();
458 let r_s = r.to_string();
459 let b_s = b.to_string();
460 w.empty_with(
461 "a:srcRect",
462 &[("l", &l_s), ("t", &t_s), ("r", &r_s), ("b", &b_s)],
463 );
464 }
465 // 写出填充模式(拉伸/平铺/无)
466 self.fill_mode.write_xml(w);
467 w.close("p:blipFill");
468 // spPr
469 self.properties.write_xml(w, "p:spPr");
470 // p:style
471 if let Some(style) = &self.style {
472 style.write_xml(w);
473 }
474 // extLst
475 write_extlst(w, self.ext_lst.as_ref());
476 w.close("p:pic");
477 }
478}
479
480/// `<p:grpSp>` 组合形状。
481#[derive(Clone, Debug, Default)]
482pub struct Group {
483 pub id: u32,
484 pub name: String,
485 pub properties: ShapeProperties,
486 /// 子形状(递归)。
487 pub children: Vec<GroupChild>,
488 /// 组合自己的大小(写在 a:xfrm 上)。
489 pub ext: (Emu, Emu),
490 pub off: (Emu, Emu),
491 /// 主题样式引用(`p:style`,可选)。
492 pub style: Option<ShapeStyle>,
493 /// 扩展列表。
494 pub ext_lst: Option<ExtensionList>,
495}
496
497#[derive(Clone, Debug)]
498pub enum GroupChild {
499 Sp(Sp),
500 Pic(Pic),
501 CxnSp(Connector),
502 Group(Box<Group>),
503 /// 图形框(承载表格/图表等)。
504 GraphicFrame(GraphicFrame),
505}
506
507impl Group {
508 /// 写 XML。
509 ///
510 /// 按 OOXML `CT_GroupShape` 的子元素顺序:
511 ///
512 /// ```text
513 /// <p:grpSp>
514 /// <p:nvGrpSpPr>...</p:nvGrpSpPr>
515 /// <p:grpSpPr>...</p:grpSpPr>
516 /// 子形状 (sp/pic/cxnSp/grpSp/graphicFrame/contentPart)
517 /// <p:extLst>...</p:extLst> ← 可选
518 /// </p:grpSp>
519 /// ```
520 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
521 w.open("p:grpSp");
522 w.open("p:nvGrpSpPr");
523 let id_s = self.id.to_string();
524 w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
525 w.empty("p:cNvGrpSpPr");
526 w.empty("p:nvPr");
527 w.close("p:nvGrpSpPr");
528 // 组合本身 spPr 中有 chExt / chOff
529 w.open("p:grpSpPr");
530 w.open("a:xfrm");
531 let off_x_s = self.off.0.value().to_string();
532 let off_y_s = self.off.1.value().to_string();
533 let ext_cx_s = self.ext.0.value().to_string();
534 let ext_cy_s = self.ext.1.value().to_string();
535 w.empty_with("a:off", &[("x", &off_x_s), ("y", &off_y_s)]);
536 w.empty_with("a:ext", &[("cx", &ext_cx_s), ("cy", &ext_cy_s)]);
537 w.empty_with("a:chOff", &[("x", "0"), ("y", "0")]);
538 w.empty_with("a:chExt", &[("cx", &ext_cx_s), ("cy", &ext_cy_s)]);
539 w.close("a:xfrm");
540 w.close("p:grpSpPr");
541 for c in &self.children {
542 match c {
543 GroupChild::Sp(s) => s.write_xml(w),
544 GroupChild::Pic(p) => p.write_xml(w),
545 GroupChild::CxnSp(c) => c.write_xml(w),
546 GroupChild::Group(g) => g.write_xml(w),
547 GroupChild::GraphicFrame(g) => g.write_xml(w),
548 }
549 }
550 // p:style
551 if let Some(style) = &self.style {
552 style.write_xml(w);
553 }
554 // extLst(组合末尾)
555 write_extlst(w, self.ext_lst.as_ref());
556 w.close("p:grpSp");
557 }
558}
559
560/// `<p:cxnSp>` 连接器。
561#[derive(Clone, Debug, Default)]
562pub struct Connector {
563 pub id: u32,
564 pub name: String,
565 pub properties: ShapeProperties,
566 /// 起点几何坐标(EMU,绝对值,相对于 slide 原点)。
567 /// 仅在 `begin_type == None` 时由 OOXML 渲染。
568 pub begin: Option<(Emu, Emu)>,
569 /// 终点几何坐标(EMU)。
570 pub end: Option<(Emu, Emu)>,
571 /// 起点挂接(`stCxn`)——`Some((shape_id, idx))` 表示挂接到某 shape 的连接点。
572 pub st_cxn: Option<(u32, u32)>,
573 /// 终点挂接(`endCxn`)。
574 pub end_cxn: Option<(u32, u32)>,
575 /// 显式几何类型(直线 / 折线 / 曲线)。`None` 则用 `properties.xfrm.prst_geom`。
576 pub connector_type: Option<crate::oxml::simpletypes::MsoConnectorType>,
577 /// 主题样式引用(`p:style`,可选)。
578 pub style: Option<ShapeStyle>,
579 /// 扩展列表。
580 pub ext_lst: Option<ExtensionList>,
581}
582
583impl Connector {
584 /// 写 XML。
585 ///
586 /// 按 OOXML `CT_Connector` 的子元素顺序:
587 ///
588 /// ```text
589 /// <p:cxnSp>
590 /// <p:nvCxnSpPr>...</p:nvCxnSpPr>
591 /// <p:spPr>...</p:spPr>
592 /// <p:style>...</p:style> ← 可选
593 /// <p:extLst>...</p:extLst> ← 可选
594 /// </p:cxnSp>
595 /// ```
596 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
597 w.open("p:cxnSp");
598 w.open("p:nvCxnSpPr");
599 let id_s = self.id.to_string();
600 w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
601 w.empty("p:cNvCxnSpPr");
602 w.empty("p:nvPr");
603 if let Some((shape_id, idx)) = &self.st_cxn {
604 let sid = shape_id.to_string();
605 let idxs = idx.to_string();
606 w.empty_with("p:stCxn", &[("id", &sid), ("idx", &idxs)]);
607 }
608 if let Some((shape_id, idx)) = &self.end_cxn {
609 let sid = shape_id.to_string();
610 let idxs = idx.to_string();
611 w.empty_with("p:endCxn", &[("id", &sid), ("idx", &idxs)]);
612 }
613 w.close("p:nvCxnSpPr");
614 self.properties.write_xml(w, "p:spPr");
615 // p:style
616 if let Some(style) = &self.style {
617 style.write_xml(w);
618 }
619 // extLst
620 write_extlst(w, self.ext_lst.as_ref());
621 w.close("p:cxnSp");
622 }
623}
624
625/// `<p:graphicFrame>` 容器:表格、图表等使用。
626#[derive(Clone, Debug, Default)]
627pub struct GraphicFrame {
628 /// 图形框 ID(同一 slide 内唯一)。
629 pub id: u32,
630 /// 图形框名(仅显示用)。
631 pub name: String,
632 /// 形状属性(仅 xfrm 有效,不含 prstGeom/填充/边框)。
633 pub properties: ShapeProperties,
634 /// 内部图形。
635 pub graphic: Graphic,
636 /// 扩展列表。
637 pub ext_lst: Option<ExtensionList>,
638 /// **是否**为占位符(TODO-007 图表/表格占位符填充)。
639 ///
640 /// 为 `true` 时 `write_xml` 会在 `<p:nvPr>` 内写出 `<p:ph .../>`。
641 /// 对应 OOXML 中 `<p:nvGraphicFramePr>/<p:nvPr>/<p:ph type="..." idx="..."/>`。
642 pub is_placeholder: bool,
643 /// 占位符索引(对应 `<p:ph idx="..."/>`)。
644 pub ph_idx: Option<u32>,
645 /// 占位符类型(对应 `<p:ph type="..."/>`,如 `"chart"` / `"tbl"` / `"obj"`)。
646 pub ph_type: Option<String>,
647}
648
649#[derive(Clone, Debug)]
650pub enum Graphic {
651 Table(super::table::Table),
652 /// 图表(`<c:chart>` 引用 chart part)。TODO-004 基础图表支持。
653 ///
654 /// 与 Table 不同,Chart 数据**不**直接嵌在 `<a:graphicData>` 中,
655 /// 而是通过 `r:id="..."` 引用独立的 chartN.xml part。
656 /// 序列化时只写出 `<c:chart xmlns:c=... xmlns:r=... r:id="..."/>` 自闭合元素。
657 Chart(super::chart::Chart),
658 /// OLE 对象嵌入(`<p:oleObj>` 引用 oleObjectN.bin)。TODO-043。
659 ///
660 /// 与 Chart 类似,OLE 数据**不**直接嵌在 `<a:graphicData>` 中,
661 /// 而是通过 `r:id="..."` 引用独立的 `/ppt/embeddings/oleObjectN.bin` part。
662 /// 序列化时写出 `<p:oleObj ...>` 完整元素(含 `<p:embed/>` 与可选 `<p:pic>` 图标)。
663 OleObject(super::ole::OleObject),
664 /// SmartArt 图形(`<dgm:relIds>` 引用 4 个 diagram parts)。TODO-037 最小保留。
665 ///
666 /// SmartArt 是 PowerPoint 的"智能图形"(流程图/层次结构/循环/矩阵等),
667 /// 由 4 个独立 part 组成:
668 /// - `diagramDataN.xml`(数据模型,r:dm);
669 /// - `diagramLayoutN.xml`(布局定义,r:lo);
670 /// - `diagramQuickStyleN.xml`(样式,r:qs);
671 /// - `diagramColorsN.xml`(颜色,r:cs)。
672 ///
673 /// **当前实现(最小保留)**:
674 /// - 读路径:识别 `<a:graphicData uri=".../diagram">`,保留完整 XML(含 graphicData 外壳);
675 /// - 写路径:按原样输出 `raw_xml`,保证 slide XML 中的 SmartArt 引用 byte-exact 保留;
676 /// - **限制**:不解析也不保留 4 个 diagram parts,read→save 后 SmartArt 会因为
677 /// diagram parts 丢失而无法渲染。完整 round-trip 需要 OPC 层保留未识别关系,
678 /// 计划在 0.2.x 实现。
679 SmartArt(SmartArtRef),
680}
681
682/// SmartArt 引用(TODO-037 最小保留)。
683///
684/// 持有完整的 `<a:graphicData uri=".../diagram">...</a:graphicData>` 元素 XML,
685/// 用于 read→save 时 byte-exact 保留 SmartArt 在 slide XML 中的引用。
686///
687/// # 字段说明
688///
689/// - `raw_xml`:完整的 `<a:graphicData>` 元素 XML(**含** `<a:graphicData>` 外壳),
690/// 通常形如 `<a:graphicData uri=".../diagram"><dgm:relIds r:dm="rId1" r:lo="rId2" r:qs="rId3" r:cs="rId4"/></a:graphicData>`。
691/// 序列化时由 `GraphicFrame::write_xml` 直接 raw 输出,**跳过** open_with/close 流程。
692/// - `dm_rid` / `lo_rid` / `qs_rid` / `cs_rid`:从 raw_xml 中提取的 4 个关系 id,
693/// 仅供调用方查询使用,序列化时**不**单独输出(直接走 raw_xml)。
694#[derive(Clone, Debug, Default)]
695pub struct SmartArtRef {
696 /// 完整的 `<a:graphicData>` 元素 XML(byte-exact 保留,含外壳)。
697 pub raw_xml: String,
698 /// 数据模型关系 id(`r:dm`,指向 `/ppt/diagrams/diagramDataN.xml`)。
699 pub dm_rid: Option<String>,
700 /// 布局定义关系 id(`r:lo`,指向 `/ppt/diagrams/diagramLayoutN.xml`)。
701 pub lo_rid: Option<String>,
702 /// 样式关系 id(`r:qs`,指向 `/ppt/diagrams/diagramQuickStyleN.xml`)。
703 pub qs_rid: Option<String>,
704 /// 颜色关系 id(`r:cs`,指向 `/ppt/diagrams/diagramColorsN.xml`)。
705 pub cs_rid: Option<String>,
706}
707
708impl SmartArtRef {
709 /// 从 4 个关系 id 构造 SmartArtRef(用于创建路径,TODO-037 创建 API)。
710 ///
711 /// 用 [`XmlWriter`](crate::oxml::writer::XmlWriter) 链式 API 构造 `raw_xml`,
712 /// **不**用 `format!` 字符串拼接(遵守 `.trae/rules/project_rules.md` §5 安全红线)。
713 ///
714 /// # 参数
715 /// - `dm_rid` / `lo_rid` / `qs_rid` / `cs_rid`:4 个关系 id,分别指向
716 /// data / layout / quickStyle / colors 四个 diagram part。
717 ///
718 /// # 生成内容
719 ///
720 /// ```xml
721 /// <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram">
722 /// <dgm:relIds xmlns:dgm="..." xmlns:r="..."
723 /// r:dm="..." r:lo="..." r:qs="..." r:cs="..."/>
724 /// </a:graphicData>
725 /// ```
726 ///
727 /// # 示例
728 ///
729 /// ```no_run
730 /// use pptx_rs::oxml::shape::SmartArtRef;
731 /// let r = SmartArtRef::from_rids("rId1", "rId2", "rId3", "rId4");
732 /// assert!(r.raw_xml.contains("r:dm=\"rId1\""));
733 /// ```
734 pub fn from_rids(dm_rid: &str, lo_rid: &str, qs_rid: &str, cs_rid: &str) -> Self {
735 let mut w = super::writer::XmlWriter::new();
736 w.open_with(
737 "a:graphicData",
738 &[(
739 "uri",
740 "http://schemas.openxmlformats.org/drawingml/2006/diagram",
741 )],
742 );
743 w.empty_with(
744 "dgm:relIds",
745 &[
746 ("xmlns:dgm", crate::oxml::ns::NS_DIAGRAM),
747 ("xmlns:r", crate::oxml::ns::NS_DRAWING_RELS),
748 ("r:dm", dm_rid),
749 ("r:lo", lo_rid),
750 ("r:qs", qs_rid),
751 ("r:cs", cs_rid),
752 ],
753 );
754 w.close("a:graphicData");
755 Self {
756 raw_xml: w.into_string(),
757 dm_rid: Some(dm_rid.to_string()),
758 lo_rid: Some(lo_rid.to_string()),
759 qs_rid: Some(qs_rid.to_string()),
760 cs_rid: Some(cs_rid.to_string()),
761 }
762 }
763}
764
765// 由于 `#[default]` 仅支持单元变体,Graphic 需手动实现 Default。
766impl Default for Graphic {
767 fn default() -> Self {
768 Graphic::Table(super::table::Table::default())
769 }
770}
771
772impl GraphicFrame {
773 /// 写 XML。
774 ///
775 /// 按 OOXML `CT_GraphicalObjectFrame` 的子元素顺序:
776 ///
777 /// ```text
778 /// <p:graphicFrame>
779 /// <p:nvGraphicFramePr>...</p:nvGraphicFramePr>
780 /// <p:xfrm>...</p:xfrm> ← 用 ShapeProperties::write_xfrm_only
781 /// <a:graphic>
782 /// <a:graphicData uri="..."> ← uri 必填
783 /// <a:tbl>...</a:tbl> ← 或 chart / smartArt
784 /// </a:graphicData>
785 /// </a:graphic>
786 /// <p:extLst>...</p:extLst> ← 可选
787 /// </p:graphicFrame>
788 /// ```
789 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
790 w.open("p:graphicFrame");
791 // nvGraphicFramePr
792 w.open("p:nvGraphicFramePr");
793 let id_s = self.id.to_string();
794 w.empty_with("p:cNvPr", &[("id", &id_s), ("name", &self.name)]);
795 w.empty("p:cNvGraphicFramePr");
796 // nvPr:若 is_placeholder=true,写出 <p:ph type="..." idx="..."/>(TODO-007 图表/表格占位符填充)
797 if self.is_placeholder {
798 w.open("p:nvPr");
799 // idx_s 提到 pattrs 之前,保证生命周期覆盖 empty_with 调用
800 let idx_s = self.ph_idx.map(|i| i.to_string());
801 let mut pattrs: Vec<(&str, &str)> = Vec::new();
802 if let Some(t) = self.ph_type.as_deref() {
803 pattrs.push(("type", t));
804 }
805 if let Some(s) = idx_s.as_deref() {
806 pattrs.push(("idx", s));
807 }
808 w.empty_with("p:ph", &pattrs);
809 w.close("p:nvPr");
810 } else {
811 w.empty("p:nvPr");
812 }
813 w.close("p:nvGraphicFramePr");
814 // xfrm(用专用方法,不输出 prstGeom/填充/边框)
815 self.properties.write_xfrm_only(w, "p:xfrm");
816 // graphic
817 w.open("a:graphic");
818 // SmartArt 走完整 raw_xml 输出(含 graphicData 外壳),其它类型走 open_with + 子元素 + close。
819 // 这样设计是因为 SmartArt 的 raw_xml 在读路径上 byte-exact 保留了完整的 <a:graphicData> 元素,
820 // 重新拆解为 open_with + 子元素会丢失原始格式(如命名空间声明位置、属性顺序等)。
821 if let Graphic::SmartArt(s) = &self.graphic {
822 // SmartArt 最小保留:直接输出读路径保存的 raw_xml(byte-exact)。
823 // raw_xml 是完整的 <a:graphicData uri=".../diagram">...</a:graphicData> 元素。
824 w.raw(&s.raw_xml);
825 } else {
826 // a:graphicData 的 uri 标识内部图形类型(表/图/OLE/...),
827 // 必须在 `<a:graphicData uri="...">` 元素上指定,不能放到子元素上。
828 let uri = match &self.graphic {
829 Graphic::Table(_) => "http://schemas.openxmlformats.org/drawingml/2006/table",
830 Graphic::Chart(_) => "http://schemas.openxmlformats.org/drawingml/2006/chart",
831 Graphic::OleObject(_) => super::ole::OLE_GRAPHIC_DATA_URI,
832 Graphic::SmartArt(_) => unreachable!(), // 已在 if 分支处理
833 };
834 w.open_with("a:graphicData", &[("uri", uri)]);
835 match &self.graphic {
836 Graphic::Table(t) => {
837 // table 自带 <a:tbl> 顶层元素
838 t.write_xml(w);
839 }
840 Graphic::Chart(c) => {
841 // chart 仅写出引用元素:<c:chart xmlns:c=... xmlns:r=... r:id="rid"/>
842 // 实际 chart 数据在独立的 chartN.xml part 中。
843 let rid = if c.rid.is_empty() {
844 "rId1"
845 } else {
846 c.rid.as_str()
847 };
848 w.empty_with(
849 "c:chart",
850 &[
851 ("xmlns:c", "http://schemas.openxmlformats.org/drawingml/2006/chart"),
852 ("xmlns:r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
853 ("r:id", rid),
854 ],
855 );
856 }
857 Graphic::OleObject(o) => {
858 // oleObj 写出完整 <p:oleObj ...> 元素(含 <p:embed/> 与可选 <p:pic>)。
859 // 实际 OLE 二进制数据在独立的 oleObjectN.bin part 中。
860 // 注意:oleObj 元素需要 r 命名空间,但已在 <p:graphicFrame> 外层默认声明,
861 // 这里不再重复声明 xmlns:r。
862 o.write_xml(w);
863 }
864 Graphic::SmartArt(_) => unreachable!(),
865 }
866 w.close("a:graphicData");
867 }
868 w.close("a:graphic");
869 // extLst
870 write_extlst(w, self.ext_lst.as_ref());
871 w.close("p:graphicFrame");
872 }
873}
874
875// ============== 形状样式(p:style) ==============
876
877/// 形状主题样式引用(`<p:style>`)。
878///
879/// OOXML 规范定义 `CT_ShapeStyle` 由 4 个引用组成:
880/// `<a:lnRef>`、`<a:fillRef>`、`<a:effectRef>`、`<a:fontRef>`。
881/// 在 PowerPoint 中,把"主题样式"应用到形状时,**就是写入这 4 个引用**;
882/// 一旦 `<p:style>` 存在,spPr 中的 prstGeom/fill/ln 会被其覆盖。
883///
884/// # 与 python-pptx 的对应
885///
886/// - python-pptx 同样在 `pptx.oxml.shape.CT_Shape` 上提供 `style` 属性;
887/// - python-pptx 通过 `shape.style = "..."` 或读取主题样式来设置。
888/// - 本库采用**强类型**字段,便于序列化与回读。
889#[derive(Clone, Debug, Default)]
890pub struct ShapeStyle {
891 /// `<a:lnRef>` 线条样式引用(`idx` + `schemeClr`)。
892 pub line_ref: Option<StyleRef>,
893 /// `<a:fillRef>` 填充样式引用。
894 pub fill_ref: Option<StyleRef>,
895 /// `<a:effectRef>` 效果样式引用。
896 pub effect_ref: Option<StyleRef>,
897 /// `<a:fontRef>` 字体样式引用(minor/major)。
898 pub font_ref: Option<StyleRef>,
899}
900
901impl ShapeStyle {
902 /// 写 XML。
903 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
904 // 即便 4 个引用都是 None,也按 OOXML 规范至少输出 `<p:style>` 外壳以占位。
905 w.open("p:style");
906 if let Some(r) = &self.line_ref {
907 write_style_ref(w, "a:lnRef", r);
908 }
909 if let Some(r) = &self.fill_ref {
910 write_style_ref(w, "a:fillRef", r);
911 }
912 if let Some(r) = &self.effect_ref {
913 write_style_ref(w, "a:effectRef", r);
914 }
915 if let Some(r) = &self.font_ref {
916 // fontRef 特殊:`idx` 取值是 `"minor"` / `"major"` 而非数字
917 let idx_attr = r.idx.as_deref().unwrap_or("minor");
918 w.open_with("a:fontRef", &[("idx", idx_attr)]);
919 if let Some(scheme) = &r.scheme_color {
920 w.empty_with("a:schemeClr", &[("val", scheme)]);
921 }
922 w.close("a:fontRef");
923 }
924 w.close("p:style");
925 }
926}
927
928/// 4 个样式引用的通用结构。
929#[derive(Clone, Debug, Default)]
930pub struct StyleRef {
931 /// 索引(数字索引或 `"minor"` / `"major"` 字面量)。
932 pub idx: Option<String>,
933 /// 关联的主题色(`schemeClr val`)。
934 pub scheme_color: Option<String>,
935}
936
937fn write_style_ref(w: &mut super::writer::XmlWriter, tag: &str, r: &StyleRef) {
938 let idx_s = r.idx.clone().unwrap_or_else(|| "1".to_string());
939 w.open_with(tag, &[("idx", &idx_s)]);
940 if let Some(scheme) = &r.scheme_color {
941 w.empty_with("a:schemeClr", &[("val", scheme)]);
942 }
943 w.close(tag);
944}
945
946// ============== 扩展列表(p:extLst) ==============
947
948/// 扩展列表(`<p:extLst>`)。
949///
950/// OOXML 中"扩展"是 PowerPoint 用来存放私有属性 / future 兼容字段的地方,
951/// 大多数"插入 → 背景效果" 等 UI 操作都会在 extLst 写一条 Microsoft 私有
952/// 扩展。**只要形状被 UI 触碰过**,extLst 就很可能非空。
953///
954/// # 与 python-pptx 的对应
955///
956/// python-pptx 中 `CT_Shape` / `CT_GroupShape` / `CT_GraphicalObjectFrame`
957/// 都有 `ext_lst` 属性;本库同等暴露。
958#[derive(Clone, Debug, Default)]
959pub struct ExtensionList {
960 /// 扩展条目。每个条目包含 `uri`(必填)和原始 XML 字符串。
961 pub entries: Vec<ExtensionEntry>,
962}
963
964#[derive(Clone, Debug)]
965pub struct ExtensionEntry {
966 /// 扩展的 URI(命名空间 GUID)。
967 pub uri: String,
968 /// 扩展 XML 内容(**已转义**的合法 XML 子串)。
969 /// 通常为 `<a:...>...</a:...>` 或 `<p14:...>...</p14:...>`。
970 pub xml: String,
971}
972
973impl ExtensionList {
974 /// 写 XML。
975 pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
976 if self.entries.is_empty() {
977 return;
978 }
979 w.open("p:extLst");
980 for e in &self.entries {
981 w.open_with("p:ext", &[("uri", e.uri.as_str())]);
982 w.raw(&e.xml);
983 w.close("p:ext");
984 }
985 w.close("p:extLst");
986 }
987}
988
989/// extLst 便捷写出:`Some` 时写出,`None` 时跳过。
990fn write_extlst(w: &mut super::writer::XmlWriter, ext: Option<&ExtensionList>) {
991 if let Some(e) = ext {
992 e.write_xml(w);
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999
1000 /// 验证 `ShapeLocks` 的序列化。
1001 ///
1002 /// 这是 TODO-027 的测试。
1003 #[test]
1004 fn shape_locks_serialization() {
1005 let locks = ShapeLocks {
1006 no_grp: true,
1007 no_select: true,
1008 no_resize: true,
1009 ..Default::default()
1010 };
1011 let mut w = super::super::writer::XmlWriter::new();
1012 locks.write_xml(&mut w);
1013 let s = w.into_string();
1014 assert!(s.contains("noGrp=\"1\""), "应包含 noGrp=1,实际: {s}");
1015 assert!(s.contains("noSelect=\"1\""), "应包含 noSelect=1,实际: {s}");
1016 assert!(s.contains("noResize=\"1\""), "应包含 noResize=1,实际: {s}");
1017 assert!(!s.contains("noMove"), "不应包含 noMove,实际: {s}");
1018 assert!(!s.contains("noRot"), "不应包含 noRot,实际: {s}");
1019 }
1020
1021 /// 验证空 `ShapeLocks` 的 `is_empty` 判断。
1022 ///
1023 /// 这是 TODO-027 的测试。
1024 #[test]
1025 fn shape_locks_is_empty() {
1026 let empty = ShapeLocks::default();
1027 assert!(empty.is_empty());
1028
1029 let non_empty = ShapeLocks {
1030 no_grp: true,
1031 ..Default::default()
1032 };
1033 assert!(!non_empty.is_empty());
1034 }
1035
1036 /// 验证 `Sp` 带 locks 的完整序列化。
1037 ///
1038 /// 这是 TODO-027 的测试。
1039 #[test]
1040 fn sp_with_locks_serialization() {
1041 let sp = Sp {
1042 id: 1,
1043 name: "Test".into(),
1044 locks: Some(ShapeLocks {
1045 no_select: true,
1046 no_move: true,
1047 ..Default::default()
1048 }),
1049 ..Default::default()
1050 };
1051 let mut w = super::super::writer::XmlWriter::new();
1052 sp.write_xml(&mut w);
1053 let s = w.into_string();
1054 assert!(s.contains("a:spLocks"), "应包含 a:spLocks,实际: {s}");
1055 assert!(s.contains("noSelect=\"1\""), "应包含 noSelect=1,实际: {s}");
1056 assert!(s.contains("noMove=\"1\""), "应包含 noMove=1,实际: {s}");
1057 // cNvSpPr 应为 open-close 形式(因为有子元素)
1058 assert!(
1059 s.contains("<p:cNvSpPr>") || s.contains("<p:cNvSpPr "),
1060 "cNvSpPr 应为 open 标签,实际: {s}"
1061 );
1062 assert!(s.contains("</p:cNvSpPr>"), "应包含 </p:cNvSpPr>,实际: {s}");
1063 }
1064
1065 /// 验证 `Pic` 带 `MediaKind::Video` 的序列化(TODO-033)。
1066 ///
1067 /// 关键断言:
1068 /// - `<a:videoFile r:link="rIdVideo1"/>` 必须出现在 `<p:nvPr>` 内;
1069 /// - `r:link` 属性(不是 `r:embed`);
1070 /// - `xmlns:r` 命名空间声明必须出现在 `<p:nvPr>` 上。
1071 #[test]
1072 fn pic_with_video_media_serialize() {
1073 let pic = Pic {
1074 id: 1,
1075 name: "Video 1".into(),
1076 rid: "rIdImg1".into(),
1077 media: Some(MediaKind::Video {
1078 rid: "rIdVideo1".into(),
1079 }),
1080 ..Default::default()
1081 };
1082 let mut w = super::super::writer::XmlWriter::new();
1083 pic.write_xml(&mut w);
1084 let s = w.into_string();
1085 assert!(s.contains("<a:videoFile"), "应包含 a:videoFile,实际: {s}");
1086 assert!(
1087 s.contains("r:link=\"rIdVideo1\""),
1088 "应包含 r:link=\"rIdVideo1\",实际: {s}"
1089 );
1090 assert!(
1091 !s.contains("r:embed=\"rIdVideo1\""),
1092 "视频不应使用 r:embed,实际: {s}"
1093 );
1094 assert!(
1095 s.contains("xmlns:r="),
1096 "nvPr 应声明 xmlns:r 命名空间,实际: {s}"
1097 );
1098 }
1099
1100 /// 验证 `Pic` 带 `MediaKind::Audio` 的序列化(TODO-033)。
1101 #[test]
1102 fn pic_with_audio_media_serialize() {
1103 let pic = Pic {
1104 id: 2,
1105 name: "Audio 1".into(),
1106 rid: "rIdImg2".into(),
1107 media: Some(MediaKind::Audio {
1108 rid: "rIdAudio1".into(),
1109 }),
1110 ..Default::default()
1111 };
1112 let mut w = super::super::writer::XmlWriter::new();
1113 pic.write_xml(&mut w);
1114 let s = w.into_string();
1115 assert!(s.contains("<a:audioFile"), "应包含 a:audioFile,实际: {s}");
1116 assert!(
1117 s.contains("r:link=\"rIdAudio1\""),
1118 "应包含 r:link=\"rIdAudio1\",实际: {s}"
1119 );
1120 }
1121
1122 /// 验证普通 `Pic`(无 media)不写出 videoFile/audioFile(TODO-033)。
1123 #[test]
1124 fn pic_without_media_no_video_file() {
1125 let pic = Pic {
1126 id: 3,
1127 name: "Plain Picture".into(),
1128 rid: "rIdImg3".into(),
1129 // media = None
1130 ..Default::default()
1131 };
1132 let mut w = super::super::writer::XmlWriter::new();
1133 pic.write_xml(&mut w);
1134 let s = w.into_string();
1135 assert!(
1136 !s.contains("videoFile"),
1137 "普通图片不应包含 videoFile,实际: {s}"
1138 );
1139 assert!(
1140 !s.contains("audioFile"),
1141 "普通图片不应包含 audioFile,实际: {s}"
1142 );
1143 }
1144
1145 /// 验证 `MediaKind` 的 `PartialEq` 与 `Default`(TODO-033)。
1146 #[test]
1147 fn media_kind_eq_and_default() {
1148 // Default 应为 Video { rid: "" }
1149 let d = MediaKind::default();
1150 assert!(matches!(d, MediaKind::Video { ref rid } if rid.is_empty()));
1151
1152 // PartialEq
1153 let v1 = MediaKind::Video { rid: "rId1".into() };
1154 let v2 = MediaKind::Video { rid: "rId1".into() };
1155 let v3 = MediaKind::Video { rid: "rId2".into() };
1156 let a1 = MediaKind::Audio { rid: "rId1".into() };
1157 assert_eq!(v1, v2, "相同 rid 的 Video 应相等");
1158 assert_ne!(v1, v3, "不同 rid 的 Video 应不等");
1159 assert_ne!(v1, a1, "Video 与 Audio 即使 rid 相同也不等");
1160 }
1161
1162 /// 验证 `GraphicFrame` 带 `Graphic::SmartArt` 的序列化(TODO-037 最小保留)。
1163 ///
1164 /// 关键断言:
1165 /// - `<a:graphicData uri=".../diagram">` 必须出现(uri 来自 raw_xml,不再由 write_xml 生成);
1166 /// - `<dgm:relIds r:dm="rId1" r:lo="rId2" r:qs="rId3" r:cs="rId4"/>` 必须 byte-exact 保留;
1167 /// - 不应出现重复的 `<a:graphicData>` 外壳(write_xml 跳过 open_with/close)。
1168 #[test]
1169 fn graphic_frame_with_smartart_serialize() {
1170 // 模拟读路径保存的 raw_xml(完整 graphicData 元素,含外壳)
1171 let raw_xml =
1172 "<a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\">\
1173 <dgm:relIds r:dm=\"rId1\" r:lo=\"rId2\" r:qs=\"rId3\" r:cs=\"rId4\"/>\
1174 </a:graphicData>";
1175 let frame = GraphicFrame {
1176 id: 100,
1177 name: "SmartArt 1".into(),
1178 graphic: Graphic::SmartArt(SmartArtRef {
1179 raw_xml: raw_xml.into(),
1180 dm_rid: Some("rId1".into()),
1181 lo_rid: Some("rId2".into()),
1182 qs_rid: Some("rId3".into()),
1183 cs_rid: Some("rId4".into()),
1184 }),
1185 ..Default::default()
1186 };
1187 let mut w = super::super::writer::XmlWriter::new();
1188 frame.write_xml(&mut w);
1189 let s = w.into_string();
1190 // 应包含 uri
1191 assert!(
1192 s.contains("uri=\"http://schemas.openxmlformats.org/drawingml/2006/diagram\""),
1193 "应包含 diagram uri,实际: {s}"
1194 );
1195 // 应包含 dgm:relIds(byte-exact)
1196 assert!(
1197 s.contains("<dgm:relIds r:dm=\"rId1\" r:lo=\"rId2\" r:qs=\"rId3\" r:cs=\"rId4\"/>"),
1198 "应包含原始 dgm:relIds,实际: {s}"
1199 );
1200 // 不应出现重复的 graphicData 外壳(write_xml 跳过 open_with/close)
1201 // 统计 <a:graphicData 出现次数:应为 1
1202 let count = s.matches("<a:graphicData").count();
1203 assert_eq!(
1204 count, 1,
1205 "SmartArt 不应重复写出 graphicData 外壳,实际出现 {} 次: {s}",
1206 count
1207 );
1208 }
1209
1210 /// 验证 `SmartArtRef` 的 Default 与字段访问(TODO-037)。
1211 #[test]
1212 fn smartart_ref_default() {
1213 let r = SmartArtRef::default();
1214 assert!(r.raw_xml.is_empty(), "默认 raw_xml 应为空");
1215 assert!(r.dm_rid.is_none(), "默认 dm_rid 应为 None");
1216 assert!(r.lo_rid.is_none(), "默认 lo_rid 应为 None");
1217 assert!(r.qs_rid.is_none(), "默认 qs_rid 应为 None");
1218 assert!(r.cs_rid.is_none(), "默认 cs_rid 应为 None");
1219 }
1220}