Skip to main content

pptx_rs/oxml/
txbody.rs

1//! 文本体(`<p:txBody>`):段落、Run、字体属性等。
2//!
3//! 对应 python-pptx 中 `text.py`/`txbody.py`/`font.py` 三层对象的合并。
4//!
5//! # 元素结构(OOXML 规范)
6//!
7//! ```text
8//! <p:txBody>
9//!   <a:bodyPr .../>            文本框属性
10//!   <a:p>                       段落
11//!     <a:pPr .../>              段落属性
12//!     <a:r>                     Run(可多个)
13//!       <a:rPr .../>            Run 属性
14//!       <a:t>文本</a:t>
15//!     </a:r>
16//!     <a:endParaRPr .../>       段落末尾默认属性
17//!   </a:p>
18//! </p:txBody>
19//! ```
20//!
21//! # 与 python-pptx 的对应
22//!
23//! - `pptx.text.text._Paragraph` ←→ [`Paragraph`];
24//! - `pptx.text.text._Run` ←→ [`Run`];
25//! - `pptx.text.text._ParagraphFormat` ←→ [`ParagraphProperties`];
26//! - `pptx.text.text._Font` ←→ [`RunProperties`];
27//! - `pptx.text.textframe.TextFrame` ←→ [`TextBody`]。
28//!
29//! # 序列化约束
30//!
31//! - Run 属性若全部为默认(无 size/bold/color/...)则**不**写出 `<a:rPr/>`;
32//! - 段落若整段为空也必须输出(PowerPoint 期望至少一个 `<a:p>`);
33//! - `<a:t>` 内的文本必须用 [`super::writer::XmlWriter::text`] 自动转义。
34
35use crate::oxml::color::Color;
36use crate::oxml::simpletypes::{
37    Alignment, MsoAnchor, MsoAutoSize, TabAlignment, TextWrapping, Underline,
38};
39use crate::units::{Emu, Pt, RGBColor};
40
41/// 制表位(`<a:tab pos="..." algn="..."/>`)。
42///
43/// 对标 python-pptx `_TabStop` 对象。
44/// 一个制表位由位置(EMU)和对齐类型(左/居中/右/小数点)决定。
45#[derive(Copy, Clone, Debug, Default)]
46pub struct TabStop {
47    /// 制表位位置(EMU)。
48    pub pos: Emu,
49    /// 对齐类型。
50    pub alignment: TabAlignment,
51}
52
53/// 段落水平缩进 / 悬挂缩进(EMU)。
54#[derive(Copy, Clone, Debug, Default)]
55pub struct Indent {
56    /// 左边缩进(EMU)。
57    pub left: Option<Emu>,
58    /// 右边缩进(EMU)。
59    pub right: Option<Emu>,
60    /// 首行缩进(EMU)。
61    pub first_line: Option<Emu>,
62    /// 自定义悬挂量(in 100ths of a line)。
63    pub hanging: Option<i32>,
64}
65
66/// 项目符号样式(`<a:buChar>` / `<a:buAutoNum>` / `<a:buNone>` 等)。
67///
68/// 对应 python-pptx 中 `_ParagraphFormat.bullet` 的详细控制。
69#[derive(Clone, Debug, Default)]
70pub enum BulletStyle {
71    /// 无项目符号(`<a:buNone/>`)。
72    #[default]
73    None,
74    /// 自定义字符项目符号(`<a:buChar char="..."/>`)。
75    Char {
76        /// 项目符号字符(如 `"•"` / `"▪"` / `"→"`)。
77        char: String,
78    },
79    /// 自动编号(`<a:buAutoNum type="..." startAt="..."/>`)。
80    AutoNum {
81        /// 编号类型(如 `"arabicPeriod"` / `"alphaLcParenR"` / `"romanLcParenBoth"`)。
82        auto_num_type: String,
83        /// 起始编号(可选)。
84        start_at: Option<u32>,
85    },
86}
87
88/// 段落属性 `<a:pPr>`。
89#[derive(Clone, Debug, Default)]
90pub struct ParagraphProperties {
91    /// 水平对齐方式(`<a:pPr algn="...">`)。`None` 表示不写出 algn 属性。
92    pub alignment: Option<Alignment>,
93    /// 缩进(左/右/首行/悬挂)。
94    pub indent: Indent,
95    /// 行距(固定值,百分之一磅)。与 `line_spacing_pct` 互斥。`None` 表示不写出。
96    pub line_spacing: Option<i32>,
97    /// 行距(百分比,1000 = 100%)。与 `line_spacing` 互斥。`None` 表示不写出。
98    pub line_spacing_pct: Option<i32>,
99    /// 段前间距(EMU)。`None` 表示不写出。
100    pub space_before: Option<Emu>,
101    /// 段后间距(EMU)。`None` 表示不写出。
102    pub space_after: Option<Emu>,
103    /// 是否项目符号列表(兼容旧字段,建议使用 `bullet_style`)。
104    pub bullet: bool,
105    /// 项目符号详细样式(`<a:buChar>` / `<a:buAutoNum>` / `<a:buNone>`)。
106    pub bullet_style: Option<BulletStyle>,
107    /// 段落级别(0-8)。
108    pub level: u8,
109    /// 默认 Run 属性(缺省时应用于段落末尾)。
110    pub default_run_properties: Option<RunProperties>,
111    /// 制表位列表(`<a:tabLst><a:tab .../></a:tabLst>`)。
112    ///
113    /// 对标 python-pptx `_ParagraphFormat.tab_stops`。
114    /// 空列表表示不写出 `<a:tabLst>`。
115    pub tab_stops: Vec<TabStop>,
116}
117
118impl ParagraphProperties {
119    /// 写 XML。
120    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
121        if self.alignment.is_none()
122            && self.indent.left.is_none()
123            && self.indent.right.is_none()
124            && self.indent.first_line.is_none()
125            && self.indent.hanging.is_none()
126            && self.line_spacing.is_none()
127            && self.line_spacing_pct.is_none()
128            && self.space_before.is_none()
129            && self.space_after.is_none()
130            && !self.bullet
131            && self.bullet_style.is_none()
132            && self.level == 0
133            && self.default_run_properties.is_none()
134            && self.tab_stops.is_empty()
135        {
136            return;
137        }
138        let mut attrs: Vec<(&str, &str)> = Vec::new();
139        if let Some(a) = self.alignment {
140            attrs.push(("algn", a.as_str()));
141        }
142        // 元组赋值:先取出所有要用的字符串到块外,扩展生命周期
143        let lvl_s = if self.level != 0 {
144            Some(self.level.to_string())
145        } else {
146            None
147        };
148        if let Some(s) = &lvl_s {
149            attrs.push(("lvl", s));
150        }
151        w.open_with("a:pPr", &attrs);
152        // indent
153        if self.indent.left.is_some()
154            || self.indent.right.is_some()
155            || self.indent.first_line.is_some()
156            || self.indent.hanging.is_some()
157        {
158            // 把所有字符串先取到块外
159            let l_s = self.indent.left.map(|v| v.value().to_string());
160            let r_s = self.indent.right.map(|v| v.value().to_string());
161            let first_s = self.indent.first_line.map(|v| v.value().to_string());
162            let hang_s = self.indent.hanging.map(|v| v.to_string());
163            let mut iattrs: Vec<(&str, &str)> = Vec::new();
164            if let Some(s) = &l_s {
165                iattrs.push(("l", s));
166            }
167            if let Some(s) = &r_s {
168                iattrs.push(("r", s));
169            }
170            if let Some(s) = &first_s {
171                iattrs.push(("firstLine", s));
172            }
173            if let Some(s) = &hang_s {
174                iattrs.push(("hanging", s));
175            }
176            w.empty_with("a:indent", &iattrs);
177        }
178        if self.line_spacing.is_some() || self.line_spacing_pct.is_some() {
179            // 注意:OOXML 中 <a:lnSpc> 内只能含一个子元素(<a:spcPct> 或 <a:spcPts>),
180            // 因此当同时设置 pct 和 pts 时必须**分别**输出两个 <a:lnSpc> 块,
181            // 而不能把多个子元素塞到同一个 <a:lnSpc> 里。
182            if let Some(p) = self.line_spacing_pct {
183                let s = p.to_string();
184                w.open("a:lnSpc");
185                w.empty_with("a:spcPct", &[("val", &s)]);
186                w.close("a:lnSpc");
187            }
188            if let Some(sp) = self.line_spacing {
189                let s = sp.to_string();
190                w.open("a:lnSpc");
191                w.empty_with("a:spcPts", &[("val", &s)]);
192                w.close("a:lnSpc");
193            }
194        }
195        if self.space_before.is_some() || self.space_after.is_some() {
196            // 把所有字符串先取到块外
197            let sb_s = self.space_before.map(|v| v.value().to_string());
198            let sa_s = self.space_after.map(|v| v.value().to_string());
199            let mut sattrs: Vec<(&str, &str)> = Vec::new();
200            if let Some(s) = &sb_s {
201                sattrs.push(("spcBef", s));
202            }
203            if let Some(s) = &sa_s {
204                sattrs.push(("spcAft", s));
205            }
206            w.empty_with("a:spcBef", &sattrs);
207        }
208        // 项目符号样式(OOXML 顺序:buNone/buChar/buAutoNum 在 defRPr 之前)
209        if let Some(bs) = &self.bullet_style {
210            match bs {
211                BulletStyle::None => {
212                    w.empty("a:buNone");
213                }
214                BulletStyle::Char { char } => {
215                    w.empty_with("a:buChar", &[("char", char.as_str())]);
216                }
217                BulletStyle::AutoNum {
218                    auto_num_type,
219                    start_at,
220                } => {
221                    if let Some(sa) = start_at {
222                        let sa_s = sa.to_string();
223                        w.empty_with(
224                            "a:buAutoNum",
225                            &[("type", auto_num_type.as_str()), ("startAt", sa_s.as_str())],
226                        );
227                    } else {
228                        w.empty_with("a:buAutoNum", &[("type", auto_num_type.as_str())]);
229                    }
230                }
231            }
232        }
233        // 制表位列表(OOXML 顺序:tabLst 在 bullet 之后、defRPr 之前)
234        if !self.tab_stops.is_empty() {
235            w.open("a:tabLst");
236            for tab in &self.tab_stops {
237                let pos_s = tab.pos.value().to_string();
238                w.empty_with(
239                    "a:tab",
240                    &[("pos", pos_s.as_str()), ("algn", tab.alignment.as_str())],
241                );
242            }
243            w.close("a:tabLst");
244        }
245        if let Some(rpr) = &self.default_run_properties {
246            rpr.write_xml(w, "a:defRPr");
247        }
248        w.close("a:pPr");
249    }
250}
251
252/// 超链接(`<a:hlinkClick>` / `<a:hlinkHover>`)。
253///
254/// 对应 python-pptx `Hyperlink` 对象。通过 `r:id` 引用 `rels` 中的目标 URL。
255#[derive(Clone, Debug, Default)]
256pub struct Hyperlink {
257    /// 关系 ID(`r:id`,指向 `.rels` 中的 target URL)。
258    pub rid: Option<String>,
259    /// 鼠标悬停提示(`tooltip` 属性)。
260    pub tooltip: Option<String>,
261    /// 动作类型(`action` 属性,如 `"ppaction://hlinksldjump"` 跳转幻灯片)。
262    pub action: Option<String>,
263    /// 是否无效的超链接(仅写出 `<a:hlinkClick/>` 无属性,用于继承)。
264    pub invalid: bool,
265}
266
267impl Hyperlink {
268    /// 创建一个指向 URL 的超链接。
269    pub fn new(rid: impl Into<String>) -> Self {
270        Self {
271            rid: Some(rid.into()),
272            ..Default::default()
273        }
274    }
275
276    /// 创建一个跳转幻灯片的动作超链接。
277    pub fn new_slide_jump() -> Self {
278        Self {
279            action: Some("ppaction://hlinksldjump".to_string()),
280            ..Default::default()
281        }
282    }
283}
284
285/// Run 属性 `<a:rPr>`。
286#[derive(Clone, Debug, Default)]
287pub struct RunProperties {
288    /// 字号(Pt)。`None` 表示走主题继承。
289    pub size: Option<Pt>,
290    /// 是否加粗。
291    pub bold: bool,
292    /// 是否斜体。
293    pub italic: bool,
294    /// 下划线样式。`None` 表示不下划线。
295    pub underline: Option<Underline>,
296    /// 单删除线。
297    pub strike: bool,
298    /// 双删除线。
299    pub strike_dbl: bool,
300    /// 文本颜色。`Color::None` 表示走主题继承。
301    pub color: Color,
302    /// 高亮背景色。`None` 表示不高亮。
303    pub highlight: Option<Color>,
304    /// 字体名(拉丁)。
305    pub latin_font: Option<String>,
306    /// 东亚字体。
307    pub eastasia_font: Option<String>,
308    /// 复杂脚本字体。
309    pub cs_font: Option<String>,
310    /// baseline 偏移(百分比,正=上标,负=下标)。
311    pub baseline: Option<i32>,
312    /// 字距(百分之一磅)。
313    pub kerning: Option<i32>,
314    /// 字符间距。
315    pub spc: Option<i32>,
316    /// 大写(small caps / all caps)。
317    pub caps: Caps,
318    /// `lang`:英文/中文/...语言。
319    pub lang: Option<String>,
320    /// 透明度(0-100000,0=不透明,100000=完全透明)。
321    ///
322    /// 对标 DrawingML `<a:solidFill><a:srgbClr val="..."><a:alpha val="30000"/></a:srgbClr></a:solidFill>`。
323    /// 常用值:`30_000` = 30% 不透明(70% 透明),用于水印等场景。
324    pub alpha: Option<i32>,
325    /// 点击超链接(`<a:hlinkClick>`)。
326    pub hlink_click: Option<Hyperlink>,
327    /// 悬停超链接(`<a:hlinkHover>`)。
328    pub hlink_hover: Option<Hyperlink>,
329}
330
331#[derive(Copy, Clone, Debug, Eq, PartialEq, Default)]
332pub enum Caps {
333    #[default]
334    None,
335    Small,
336    All,
337}
338
339impl Caps {
340    pub fn as_str(self) -> &'static str {
341        match self {
342            Caps::None => "none",
343            Caps::Small => "small",
344            Caps::All => "all",
345        }
346    }
347}
348
349impl RunProperties {
350    /// 写 XML。
351    pub fn write_xml(&self, w: &mut super::writer::XmlWriter, tag: &str) {
352        // 提前取出所有要序列化的字符串,扩展到函数末尾
353        let sz_s = self
354            .size
355            .map(|sz| ((sz.value() * 100.0) as i32).to_string());
356        let baseline_s = self.baseline.map(|v| v.to_string());
357        let kerning_s = self.kerning.map(|v| v.to_string());
358        let spc_s = self.spc.map(|v| v.to_string());
359
360        let mut attrs: Vec<(&str, &str)> = Vec::new();
361        if let Some(s) = &sz_s {
362            attrs.push(("sz", s));
363        }
364        if self.bold {
365            attrs.push(("b", "1"));
366        }
367        if self.italic {
368            attrs.push(("i", "1"));
369        }
370        if let Some(u) = self.underline {
371            attrs.push(("u", u.as_str()));
372        }
373        if self.strike {
374            attrs.push(("strike", "sngStrike"));
375        }
376        if self.strike_dbl {
377            attrs.push(("strike", "dblStrike"));
378        }
379        if self.caps != Caps::None {
380            attrs.push(("cap", self.caps.as_str()));
381        }
382        if let Some(s) = &baseline_s {
383            attrs.push(("baseline", s));
384        }
385        if let Some(s) = &kerning_s {
386            attrs.push(("kern", s));
387        }
388        if let Some(s) = &spc_s {
389            attrs.push(("spc", s));
390        }
391        if let Some(l) = &self.lang {
392            attrs.push(("lang", l));
393        }
394        // 严格 OOXML 顺序: <a:ln> <a:noFill>/<a:solidFill> ... <a:highlight> <a:latin> ...
395        w.open_with(tag, &attrs);
396        if !matches!(self.color, Color::None) {
397            self.color.write_solid_fill_with_alpha(w, self.alpha);
398        } else if self.alpha.is_some() {
399            // 有 alpha 但无颜色——写一个空 solidFill + alpha(罕见但合法)
400            w.open("a:solidFill");
401            w.empty_with("a:srgbClr", &[("val", "000000")]);
402            if let Some(a) = self.alpha {
403                w.empty_with("a:alpha", &[("val", a.to_string().as_str())]);
404            }
405            w.close("a:srgbClr");
406            w.close("a:solidFill");
407        }
408        if let Some(h) = &self.highlight {
409            h.write_solid_fill(w);
410        }
411        if let Some(latin) = &self.latin_font {
412            w.empty_with("a:latin", &[("typeface", latin)]);
413        }
414        if let Some(ea) = &self.eastasia_font {
415            w.empty_with("a:ea", &[("typeface", ea)]);
416        }
417        if let Some(cs) = &self.cs_font {
418            w.empty_with("a:cs", &[("typeface", cs)]);
419        }
420        // 超链接(OOXML 顺序:hlinkClick → hlinkHover,在字体之后)
421        if let Some(h) = &self.hlink_click {
422            let mut hattrs: Vec<(&str, &str)> = Vec::new();
423            if let Some(rid) = &h.rid {
424                hattrs.push(("r:id", rid.as_str()));
425            }
426            if let Some(tip) = &h.tooltip {
427                hattrs.push(("tooltip", tip.as_str()));
428            }
429            if let Some(act) = &h.action {
430                hattrs.push(("action", act.as_str()));
431            }
432            if hattrs.is_empty() {
433                w.empty("a:hlinkClick");
434            } else {
435                w.empty_with("a:hlinkClick", &hattrs);
436            }
437        }
438        if let Some(h) = &self.hlink_hover {
439            let mut hattrs: Vec<(&str, &str)> = Vec::new();
440            if let Some(rid) = &h.rid {
441                hattrs.push(("r:id", rid.as_str()));
442            }
443            if let Some(tip) = &h.tooltip {
444                hattrs.push(("tooltip", tip.as_str()));
445            }
446            if hattrs.is_empty() {
447                w.empty("a:hlinkHover");
448            } else {
449                w.empty_with("a:hlinkHover", &hattrs);
450            }
451        }
452        w.close(tag);
453    }
454
455    /// 从一段 XML 属性中解析(用于读取时)。`src` 来自父级解析器,限于 rPr 属性。
456    #[doc(hidden)]
457    pub fn from_attrs_unused(_attrs: &super::parser::AttrMap) -> Self {
458        // 暂留接口;读路径走 [`crate::oxml::parse_sld::parse_run_properties`]
459        RunProperties::default()
460    }
461
462    // --------------------- 高阶便捷 API ---------------------
463
464    /// 克隆出 `RunProperties`(含同名字段)。
465    pub fn copy(&self) -> Self {
466        self.clone()
467    }
468}
469
470/// 一个 Run:`<a:r>` + 文本。
471#[derive(Clone, Debug, Default)]
472pub struct Run {
473    /// 文本内容。若以 `"\n"` 开头(且不包含其他非空白字符)则被识别为换行 Run。
474    pub text: String,
475    /// Run 属性。
476    pub properties: RunProperties,
477}
478
479impl Run {
480    /// 构造一个普通 Run。
481    pub fn new(text: impl Into<String>) -> Self {
482        Run {
483            text: text.into(),
484            properties: RunProperties::default(),
485        }
486    }
487    /// 构造一个**换行** Run(等价于"软回车",OOXML 表达为 `<a:br/>`)。
488    pub fn line_break() -> Self {
489        Run {
490            text: String::from("\n"),
491            properties: RunProperties::default(),
492        }
493    }
494
495    /// 写 XML。
496    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
497        // 软换行 Run:写出 `<a:br/>`,**不**套 `<a:t>`
498        if self.text == "\n" {
499            if !is_default_rpr(&self.properties) {
500                // 即使有属性也要写出,但 OOXML 规范 `<a:br/>` 在 rPr 内——这里给最简版本
501                w.empty("a:br");
502            } else {
503                w.empty("a:br");
504            }
505            return;
506        }
507        w.open("a:r");
508        if !is_default_rpr(&self.properties) {
509            self.properties.write_xml(w, "a:rPr");
510        }
511        w.open("a:t");
512        w.text(&self.text);
513        w.close("a:t");
514        w.close("a:r");
515    }
516
517    // --------------------- 高阶 API(对齐 python-pptx _Run) ---------------------
518
519    /// 取 Run 文本。
520    pub fn text(&self) -> &str {
521        &self.text
522    }
523    /// 设置 Run 文本(**不**支持 `"\n"`,换行请用 [`Run::line_break`])。
524    pub fn set_text(&mut self, t: impl Into<String>) {
525        self.text = t.into();
526    }
527
528    /// Run 字体大小(`Pt`)。
529    pub fn size(&self) -> Option<Pt> {
530        self.properties.size
531    }
532    /// 设置 Run 字体大小。
533    pub fn set_size(&mut self, v: Pt) {
534        self.properties.size = Some(v);
535    }
536
537    /// 是否加粗。
538    pub fn bold(&self) -> bool {
539        self.properties.bold
540    }
541    /// 设置加粗。
542    pub fn set_bold(&mut self, v: bool) {
543        self.properties.bold = v;
544    }
545
546    /// 是否斜体。
547    pub fn italic(&self) -> bool {
548        self.properties.italic
549    }
550    /// 设置斜体。
551    pub fn set_italic(&mut self, v: bool) {
552        self.properties.italic = v;
553    }
554
555    /// 颜色便捷访问(拷贝)。
556    pub fn color(&self) -> Color {
557        self.properties.color.clone()
558    }
559    /// 设置颜色(接受 `RGBColor` / `SchemeColor` / `PresetColor` 任一)。
560    pub fn set_color(&mut self, c: impl Into<Color>) {
561        self.properties.color = c.into();
562    }
563
564    /// 字体名(拉丁 / 主体)。
565    pub fn font_name(&self) -> Option<&str> {
566        self.properties.latin_font.as_deref()
567    }
568    /// 设置字体名。
569    pub fn set_font_name(&mut self, name: impl Into<String>) {
570        self.properties.latin_font = Some(name.into());
571    }
572
573    /// 东亚字体名(`<a:ea typeface="..."/>`,对应中文/日文/韩文字体)。
574    ///
575    /// 与 [`Run::font_name`](拉丁字体)配对使用:PowerPoint 根据字符脚本
576    /// 自动切换 latin/ea 字体。例如同一 Run 内 "Hello 你好" 会用 latin 字体
577    /// 渲染 "Hello",用 ea 字体渲染 "你好"。
578    pub fn eastasia_name(&self) -> Option<&str> {
579        self.properties.eastasia_font.as_deref()
580    }
581    /// 设置东亚字体名。
582    ///
583    /// # 参数
584    /// - `name`:东亚字体名称(如 `"宋体"` / `"Microsoft YaHei"` / `"MS Mincho"`)。
585    pub fn set_eastasia_name(&mut self, name: impl Into<String>) {
586        self.properties.eastasia_font = Some(name.into());
587    }
588
589    /// 复杂脚本字体名(`<a:cs typeface="..."/>`,对应阿拉伯语/希伯来语/泰语等)。
590    ///
591    /// 复杂脚本需要双向排版(RTL)或连字处理,PowerPoint 用 cs 字体渲染这类字符。
592    pub fn complex_script_name(&self) -> Option<&str> {
593        self.properties.cs_font.as_deref()
594    }
595    /// 设置复杂脚本字体名。
596    ///
597    /// # 参数
598    /// - `name`:复杂脚本字体名称(如 `"Arial"` / `"Tahoma"` / `"Traditional Arabic"`)。
599    pub fn set_complex_script_name(&mut self, name: impl Into<String>) {
600        self.properties.cs_font = Some(name.into());
601    }
602
603    /// 下划线便捷访问。
604    pub fn underline(&self) -> Option<Underline> {
605        self.properties.underline
606    }
607    /// 设置下划线。
608    pub fn set_underline(&mut self, v: Underline) {
609        self.properties.underline = Some(v);
610    }
611
612    /// 是否删除线。
613    pub fn strike(&self) -> bool {
614        self.properties.strike
615    }
616    /// 设置删除线。
617    pub fn set_strike(&mut self, v: bool) {
618        self.properties.strike = v;
619    }
620
621    /// 是否双删除线(TODO-017 高阶 API)。
622    pub fn double_strike(&self) -> bool {
623        self.properties.strike_dbl
624    }
625    /// 设置双删除线(TODO-017 高阶 API)。
626    pub fn set_double_strike(&mut self, v: bool) {
627        self.properties.strike_dbl = v;
628    }
629
630    /// 取高亮背景色(TODO-018 高阶 API)。
631    ///
632    /// 返回 `None` 表示未设置高亮。
633    pub fn highlight(&self) -> Option<&Color> {
634        self.properties.highlight.as_ref()
635    }
636    /// 设置高亮背景色(TODO-018 高阶 API)。
637    ///
638    /// 传入 `None` 清除高亮。
639    pub fn set_highlight(&mut self, color: Option<Color>) {
640        self.properties.highlight = color;
641    }
642    /// 清除高亮(TODO-018 高阶 API 便捷方法)。
643    pub fn clear_highlight(&mut self) {
644        self.properties.highlight = None;
645    }
646
647    /// 取点击超链接(TODO-026 高阶 API)。
648    pub fn hlink_click(&self) -> Option<&Hyperlink> {
649        self.properties.hlink_click.as_ref()
650    }
651    /// 设置点击超链接(TODO-026 高阶 API)。
652    pub fn set_hlink_click(&mut self, hl: Hyperlink) {
653        self.properties.hlink_click = Some(hl);
654    }
655    /// 清除点击超链接(TODO-026 高阶 API)。
656    pub fn clear_hlink_click(&mut self) {
657        self.properties.hlink_click = None;
658    }
659
660    /// 取悬停超链接(TODO-026 高阶 API)。
661    pub fn hlink_hover(&self) -> Option<&Hyperlink> {
662        self.properties.hlink_hover.as_ref()
663    }
664    /// 设置悬停超链接(TODO-026 高阶 API)。
665    pub fn set_hlink_hover(&mut self, hl: Hyperlink) {
666        self.properties.hlink_hover = Some(hl);
667    }
668    /// 清除悬停超链接(TODO-026 高阶 API)。
669    pub fn clear_hlink_hover(&mut self) {
670        self.properties.hlink_hover = None;
671    }
672
673    /// 设置外部 URL 超链接(TODO-026 高阶 API 便捷方法)。
674    ///
675    /// 对标 python-pptx `run.hyperlink.address = url`。
676    ///
677    /// # 参数
678    /// - `rid`:关系 id(指向 slide `.rels` 中注册的外部 URL)。
679    ///   **注意**:本方法只设置 run 的 `hlinkClick`,不负责创建 OPC 关系。
680    ///   调用方需自行在 slide 的 `.rels` 中注册该 URL 并取得 rid。
681    /// - `tooltip`:可选悬停提示文本;`None` 表示不写出 tooltip 属性。
682    ///
683    /// # 示例
684    /// ```no_run
685    /// # use pptx_rs::oxml::txbody::Run;
686    /// let mut run = Run::new("点击");
687    /// run.set_hyperlink("rIdHlink1", Some("打开链接"));
688    /// ```
689    pub fn set_hyperlink(&mut self, rid: impl Into<String>, tooltip: Option<&str>) {
690        let mut hl = Hyperlink::new(rid);
691        if let Some(t) = tooltip {
692            hl.tooltip = Some(t.to_string());
693        }
694        self.properties.hlink_click = Some(hl);
695    }
696
697    /// 设置跳转幻灯片动作超链接(TODO-026 高阶 API 便捷方法)。
698    ///
699    /// 对标 python-pptx `run.hyperlink.action = "ppaction://hlinksldjump"`。
700    ///
701    /// # 说明
702    /// 仅设置 `action = ppaction://hlinksldjump`,具体跳转目标由 slide `.rels`
703    /// 中的关系决定(调用方需自行注册)。若要同时绑定目标 slide,需配合
704    /// `Hyperlink::new(rid)` 手动设置 rid + action。
705    pub fn set_slide_jump(&mut self) {
706        self.properties.hlink_click = Some(Hyperlink::new_slide_jump());
707    }
708
709    /// 取 `Font` 高阶视图(用于批量设置字体属性、超链接等)。
710    ///
711    /// 对标 python-pptx `run.font`。
712    pub fn font(&mut self) -> Font<'_> {
713        Font::new(&mut self.properties)
714    }
715}
716
717fn is_default_rpr(p: &RunProperties) -> bool {
718    p.size.is_none()
719        && !p.bold
720        && !p.italic
721        && p.underline.is_none()
722        && !p.strike
723        && !p.strike_dbl
724        && matches!(p.color, Color::None)
725        && p.highlight.is_none()
726        && p.latin_font.is_none()
727        && p.eastasia_font.is_none()
728        && p.cs_font.is_none()
729        && p.baseline.is_none()
730        && p.kerning.is_none()
731        && p.spc.is_none()
732        && p.caps == Caps::None
733        && p.lang.is_none()
734        && p.alpha.is_none()
735        && p.hlink_click.is_none()
736        && p.hlink_hover.is_none()
737}
738
739/// 字段类型(`<a:fld type="...">`)。
740///
741/// 对标 python-pptx 中字段类型的常用子集。
742/// OOXML 中 `type` 属性的取值较多,这里枚举最常见的几种。
743#[derive(Clone, Debug, Default, PartialEq)]
744pub enum FieldType {
745    /// 幻灯片编号(`type="slidenum"`)。
746    #[default]
747    SlideNumber,
748    /// 日期时间(`type="datetime"`),使用默认格式。
749    DateTime,
750    /// 日期时间格式 1(`type="datetime1"`,如 `1/1/2024`)。
751    DateTime1,
752    /// 日期时间格式 2(`type="datetime2"`,如 `Monday, January 1, 2024`)。
753    DateTime2,
754    /// 日期时间格式 3(`type="datetime3"`,如 `January 1, 2024`)。
755    DateTime3,
756    /// 页脚(`type="footer"`)。
757    Footer,
758    /// 自定义字段类型(任意字符串)。
759    Custom(String),
760}
761
762impl FieldType {
763    /// 转 OOXML `type` 属性字面量。
764    pub fn as_str(&self) -> &str {
765        match self {
766            FieldType::SlideNumber => "slidenum",
767            FieldType::DateTime => "datetime",
768            FieldType::DateTime1 => "datetime1",
769            FieldType::DateTime2 => "datetime2",
770            FieldType::DateTime3 => "datetime3",
771            FieldType::Footer => "footer",
772            FieldType::Custom(s) => s.as_str(),
773        }
774    }
775
776    /// 从 OOXML `type` 属性字面量构造。
777    pub fn from_str_value(s: &str) -> Self {
778        match s {
779            "slidenum" => FieldType::SlideNumber,
780            "datetime" => FieldType::DateTime,
781            "datetime1" => FieldType::DateTime1,
782            "datetime2" => FieldType::DateTime2,
783            "datetime3" => FieldType::DateTime3,
784            "footer" => FieldType::Footer,
785            other => FieldType::Custom(other.to_string()),
786        }
787    }
788}
789
790/// 字段(`<a:fld>`)。
791///
792/// 对标 python-pptx 中字段对象。字段是段落中特殊的"动态文本",
793/// 如幻灯片编号、日期时间、页脚等,由 PowerPoint 在渲染时自动填充。
794///
795/// # OOXML 结构
796/// ```xml
797/// <a:fld id="{GUID}" type="slidenum">
798///   <a:rPr .../>
799///   <a:t>1</a:t>
800/// </a:fld>
801/// ```
802#[derive(Clone, Debug, Default)]
803pub struct Field {
804    /// 字段 GUID(`id` 属性)。PowerPoint 用此关联字段实例。
805    pub id: String,
806    /// 字段类型(`type` 属性)。
807    pub field_type: FieldType,
808    /// Run 属性(`<a:rPr>`)。
809    pub properties: RunProperties,
810    /// 字段文本(`<a:t>`)——渲染前的占位文本。
811    pub text: String,
812}
813
814impl Field {
815    /// 创建一个指定类型的字段。
816    ///
817    /// # 参数
818    /// - `field_type`:字段类型;
819    /// - `text`:占位文本(如幻灯片编号用 `"1"`,日期用 `"1/1/2024"`)。
820    pub fn new(field_type: FieldType, text: impl Into<String>) -> Self {
821        Field {
822            id: format!("{{{}}}", uuid_like()),
823            field_type,
824            properties: RunProperties::default(),
825            text: text.into(),
826        }
827    }
828
829    /// 写 XML。
830    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
831        w.open_with(
832            "a:fld",
833            &[("id", self.id.as_str()), ("type", self.field_type.as_str())],
834        );
835        self.properties.write_xml(w, "a:rPr");
836        w.open("a:t");
837        w.text(&self.text);
838        w.close("a:t");
839        w.close("a:fld");
840    }
841}
842
843/// 生成一个简易的 GUID 风格字符串(非标准 UUID,仅用于字段 id 占位)。
844///
845/// PowerPoint 对 `id` 属性的格式要求是 `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}`,
846/// 但实际上只要是大括号包裹的唯一字符串即可被正确识别。
847/// 这里用时间戳 + 计数器生成,避免引入 uuid 依赖。
848fn uuid_like() -> String {
849    use std::sync::atomic::{AtomicU64, Ordering};
850    static COUNTER: AtomicU64 = AtomicU64::new(1);
851    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
852    let ts = std::time::SystemTime::now()
853        .duration_since(std::time::UNIX_EPOCH)
854        .map(|d| d.as_nanos() as u64)
855        .unwrap_or(0);
856    format!("{:016x}-{:016x}", ts, n)
857}
858
859/// 段落中的"结束段落"Run(`</a:p>` 之前可附带 `a:endParaRPr`)。
860#[derive(Clone, Debug, Default)]
861pub struct Paragraph {
862    /// 段落属性。
863    pub properties: ParagraphProperties,
864    /// 段落中的 Run 序列(按出现顺序)。
865    pub runs: Vec<Run>,
866    /// 段落中的字段序列(`<a:fld>`)。
867    ///
868    /// 字段在 Run 之后序列化。OOXML 允许 `<a:r>` 和 `<a:fld>` 交错出现,
869    /// 但本实现简化为 runs 先于 fields 输出。
870    pub fields: Vec<Field>,
871    /// 段落末尾的属性(`a:endParaRPr`)。
872    pub end_properties: Option<RunProperties>,
873}
874
875impl Paragraph {
876    /// 新建一个空段落。
877    pub fn new() -> Self {
878        Paragraph::default()
879    }
880
881    /// 写 XML。
882    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
883        w.open("a:p");
884        self.properties.write_xml(w);
885        for r in &self.runs {
886            r.write_xml(w);
887        }
888        for f in &self.fields {
889            f.write_xml(w);
890        }
891        if let Some(rpr) = &self.end_properties {
892            rpr.write_xml(w, "a:endParaRPr");
893        }
894        w.close("a:p");
895    }
896
897    // --------------------- 高阶 API(对齐 python-pptx _Paragraph) ---------------------
898
899    /// 新增一个空 Run,返回其可变引用。
900    ///
901    /// 对应 python-pptx 中 `paragraph.add_run()`。
902    pub fn add_run(&mut self) -> &mut Run {
903        self.runs.push(Run::default());
904        // push 后直接按索引取最后一个元素,避免 expect
905        let idx = self.runs.len() - 1;
906        &mut self.runs[idx]
907    }
908
909    /// 新增一个**带文本**的 Run,返回其可变引用。
910    ///
911    /// 对应 python-pptx 中 `paragraph.add_run(text)`。
912    pub fn add_run_with_text(&mut self, text: impl Into<String>) -> &mut Run {
913        self.runs.push(Run::new(text));
914        // push 后直接按索引取最后一个元素,避免 expect
915        let idx = self.runs.len() - 1;
916        &mut self.runs[idx]
917    }
918
919    /// 追加一个**换行符 Run**(`<a:br/>`)。
920    ///
921    /// python-pptx 中由 `paragraph.add_run().text = "\n"` 触发;在
922    /// 本库中换行被建模为独立 Run——这样既保留属性语义又简化序列化。
923    pub fn add_line_break(&mut self) -> &mut Run {
924        self.runs.push(Run {
925            text: String::from("\n"),
926            properties: RunProperties::default(),
927        });
928        // push 后直接按索引取最后一个元素,避免 expect
929        let idx = self.runs.len() - 1;
930        &mut self.runs[idx]
931    }
932
933    /// 清空段落中的全部 Run(保留段落属性)。
934    ///
935    /// 对应 python-pptx 中对 `_Paragraph` 重新赋值的常见用法。
936    pub fn clear_runs(&mut self) {
937        self.runs.clear();
938    }
939
940    /// 新增一个字段(`<a:fld>`),返回其可变引用。
941    ///
942    /// 对标 python-pptx 中通过 `paragraph.add_field()` 添加幻灯片编号/日期等动态字段。
943    ///
944    /// # 参数
945    /// - `field_type`:字段类型(如 [`FieldType::SlideNumber`]);
946    /// - `text`:占位文本(如 `"1"`)。
947    pub fn add_field(&mut self, field_type: FieldType, text: impl Into<String>) -> &mut Field {
948        self.fields.push(Field::new(field_type, text));
949        let idx = self.fields.len() - 1;
950        &mut self.fields[idx]
951    }
952
953    /// 清空段落中的全部字段。
954    pub fn clear_fields(&mut self) {
955        self.fields.clear();
956    }
957
958    /// 替换为单段单 Run 的纯文本(保留段落属性)。
959    ///
960    /// 与 `TextBox::set_text` 不同,**不**按 `\n` 切分多段;
961    /// 适合"修改 Run 文本"场景。
962    pub fn set_text(&mut self, text: impl Into<String>) -> &mut Run {
963        self.runs.clear();
964        self.runs.push(Run::new(text));
965        // push 后直接按索引取最后一个元素,避免 expect
966        let idx = self.runs.len() - 1;
967        &mut self.runs[idx]
968    }
969
970    /// 取段落全部 Run 文本拼接(不含 `\n`)。
971    pub fn text(&self) -> String {
972        let mut out = String::new();
973        for r in &self.runs {
974            // 把 Run 内含的 "\n" 视作软换行 → 真实换行
975            out.push_str(&r.text);
976        }
977        out
978    }
979
980    /// 水平对齐(便捷访问)。
981    pub fn alignment(&self) -> Option<Alignment> {
982        self.properties.alignment
983    }
984    /// 设置水平对齐。
985    pub fn set_alignment(&mut self, v: Alignment) {
986        self.properties.alignment = Some(v);
987    }
988
989    /// 段落级别(0-8)。
990    pub fn level(&self) -> u8 {
991        self.properties.level
992    }
993    /// 设置段落级别。
994    pub fn set_level(&mut self, lvl: u8) {
995        self.properties.level = lvl;
996    }
997
998    /// 行距(点数,固定值)。与 `set_line_spacing_pct` 互斥。
999    pub fn line_spacing(&self) -> Option<Pt> {
1000        // OOXML 内部用 EMU(百分之一磅 1pt = 12700 EMU),对外用 Pt
1001        self.properties
1002            .line_spacing
1003            .map(|emu| Pt(emu as f64 / 12_700.0))
1004    }
1005    /// 设置行距为**固定点数**(1 pt = 12700 EMU)。
1006    pub fn set_line_spacing(&mut self, v: Pt) {
1007        let emu = (v.value() * 12_700.0) as i32;
1008        self.properties.line_spacing = Some(emu);
1009        self.properties.line_spacing_pct = None;
1010    }
1011
1012    /// 行距(百分比,1.0 = 100%)。与 `set_line_spacing` 互斥。
1013    pub fn line_spacing_pct(&self) -> Option<f32> {
1014        self.properties.line_spacing_pct.map(|v| v as f32 / 1000.0)
1015    }
1016    /// 设置行距为**倍数**(1.0 = 100% = `1000`,1.5 = 150% = `1500`)。
1017    pub fn set_line_spacing_pct(&mut self, v: f32) {
1018        // python-pptx 的 line_spacing = 1.5 ⇒ 150000;这里用 v * 1000 与 OOXML 百分位对齐
1019        self.properties.line_spacing_pct = Some((v * 1000.0) as i32);
1020        self.properties.line_spacing = None;
1021    }
1022
1023    /// 段前间距(EMU)。
1024    pub fn space_before(&self) -> Option<Emu> {
1025        self.properties.space_before
1026    }
1027    /// 设置段前间距。
1028    pub fn set_space_before(&mut self, emu: Emu) {
1029        self.properties.space_before = Some(emu);
1030    }
1031
1032    /// 段后间距(EMU)。
1033    pub fn space_after(&self) -> Option<Emu> {
1034        self.properties.space_after
1035    }
1036    /// 设置段后间距。
1037    pub fn set_space_after(&mut self, emu: Emu) {
1038        self.properties.space_after = Some(emu);
1039    }
1040
1041    /// 缩进便捷访问。
1042    pub fn indent(&self) -> Indent {
1043        self.properties.indent
1044    }
1045    /// 设置缩进(一次性给 4 个字段)。
1046    pub fn set_indent(
1047        &mut self,
1048        left: Option<Emu>,
1049        right: Option<Emu>,
1050        first_line: Option<Emu>,
1051        hanging: Option<i32>,
1052    ) {
1053        self.properties.indent = Indent {
1054            left,
1055            right,
1056            first_line,
1057            hanging,
1058        };
1059    }
1060
1061    // --------------------- 段落末尾属性(endParaRPr,TODO-047) ---------------------
1062
1063    /// 取段落末尾属性(`<a:endParaRPr>`)的不可变引用。
1064    ///
1065    /// `endParaRPr` 用于指定段落末尾(最后一个 Run 之后)的默认 Run 属性。
1066    /// PowerPoint 在用户把光标移到段落末尾时,会用此属性渲染后续输入的文本。
1067    ///
1068    /// 对应 OOXML 元素 `<a:endParaRPr>`,结构与 `<a:rPr>` 完全一致。
1069    pub fn end_para_rpr(&self) -> Option<&RunProperties> {
1070        self.end_properties.as_ref()
1071    }
1072
1073    /// 取段落末尾属性的可变引用。
1074    pub fn end_para_rpr_mut(&mut self) -> Option<&mut RunProperties> {
1075        self.end_properties.as_mut()
1076    }
1077
1078    /// 设置段落末尾属性(`<a:endParaRPr>`)。
1079    ///
1080    /// 若段落已有 `endParaRPr`,会被覆盖。
1081    ///
1082    /// # 示例
1083    /// ```no_run
1084    /// # use pptx_rs::oxml::txbody::{Paragraph, RunProperties};
1085    /// # use pptx_rs::Pt;
1086    /// let mut p = Paragraph::new();
1087    /// let mut rpr = RunProperties::default();
1088    /// rpr.size = Some(Pt(24.0));
1089    /// rpr.latin_font = Some("Calibri".to_string());
1090    /// p.set_end_para_rpr(rpr);
1091    /// assert!(p.end_para_rpr().is_some());
1092    /// ```
1093    pub fn set_end_para_rpr(&mut self, rpr: RunProperties) {
1094        self.end_properties = Some(rpr);
1095    }
1096
1097    /// 清除段落末尾属性(删除 `<a:endParaRPr>`)。
1098    pub fn clear_end_para_rpr(&mut self) {
1099        self.end_properties = None;
1100    }
1101}
1102
1103/// 文本体 `<p:txBody>`。
1104///
1105/// 对应 python-pptx 的 `TextFrame` 类:提供段落列表 + 自动调整 / 边距 /
1106/// 垂直对齐 / 换行等"文本框级"属性。
1107#[derive(Clone, Debug, Default)]
1108pub struct TextBody {
1109    pub body_properties: Option<BodyProperties>,
1110    pub paragraphs: Vec<Paragraph>,
1111}
1112
1113impl TextBody {
1114    pub fn new() -> Self {
1115        TextBody::default()
1116    }
1117
1118    /// 写 XML。
1119    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
1120        w.open("p:txBody");
1121        if let Some(bp) = &self.body_properties {
1122            bp.write_xml(w);
1123        }
1124        for p in &self.paragraphs {
1125            p.write_xml(w);
1126        }
1127        w.close("p:txBody");
1128    }
1129
1130    // --------------------- 高阶 API(对齐 python-pptx TextFrame) ---------------------
1131
1132    /// 取所有文本(段间 `\n`)。
1133    pub fn text(&self) -> String {
1134        let mut out = String::new();
1135        for (i, p) in self.paragraphs.iter().enumerate() {
1136            if i > 0 {
1137                out.push('\n');
1138            }
1139            for r in &p.runs {
1140                out.push_str(&r.text);
1141            }
1142        }
1143        out
1144    }
1145
1146    /// 取首个段落(`TextFrame.paragraphs[0]` 的等价物)。
1147    pub fn first_paragraph(&self) -> Option<&Paragraph> {
1148        self.paragraphs.first()
1149    }
1150
1151    /// 取首个段落的可变引用。
1152    pub fn first_paragraph_mut(&mut self) -> Option<&mut Paragraph> {
1153        self.paragraphs.first_mut()
1154    }
1155
1156    /// 取第 idx 个段落的不可变引用(越界返回 None)。
1157    pub fn paragraph(&self, idx: usize) -> Option<&Paragraph> {
1158        self.paragraphs.get(idx)
1159    }
1160
1161    /// 取第 idx 个段落的可变引用(越界返回 None)。
1162    pub fn paragraph_mut(&mut self, idx: usize) -> Option<&mut Paragraph> {
1163        self.paragraphs.get_mut(idx)
1164    }
1165
1166    /// 新增一个空段落并返回其可变引用。
1167    ///
1168    /// 对应 `TextFrame.add_paragraph()`。
1169    pub fn add_paragraph(&mut self) -> &mut Paragraph {
1170        self.paragraphs.push(Paragraph::new());
1171        // push 后直接按索引取最后一个元素,避免 expect
1172        let idx = self.paragraphs.len() - 1;
1173        &mut self.paragraphs[idx]
1174    }
1175
1176    /// 新增一个**带文本**的段落,返回其可变引用。
1177    ///
1178    /// 文本按 `\n` 切分为多段,与 python-pptx 的 `TextFrame.text = "a\nb"` 行为对齐。
1179    pub fn add_paragraph_with_text(&mut self, text: &str) -> &mut Paragraph {
1180        for line in text.split('\n') {
1181            let mut p = Paragraph::new();
1182            p.runs.push(Run::new(line));
1183            self.paragraphs.push(p);
1184        }
1185        // split('\n') 至少产生一个元素(空字符串也会产生一段),所以 paragraphs 非空
1186        let idx = self.paragraphs.len() - 1;
1187        &mut self.paragraphs[idx]
1188    }
1189
1190    /// 移除第 idx 个段落。
1191    pub fn remove_paragraph(&mut self, idx: usize) -> Option<Paragraph> {
1192        if idx < self.paragraphs.len() {
1193            Some(self.paragraphs.remove(idx))
1194        } else {
1195            None
1196        }
1197    }
1198
1199    /// 清空段落(保留一个空段,对应 python-pptx `TextFrame.clear()` 语义)。
1200    pub fn clear(&mut self) {
1201        self.paragraphs.clear();
1202        self.paragraphs.push(Paragraph::new());
1203    }
1204
1205    /// 整体替换文本(按 `\n` 切分为多段,丢弃原段落属性)。
1206    ///
1207    /// 对应 python-pptx `text_frame.text = "a\nb"`。
1208    pub fn set_text(&mut self, text: &str) {
1209        self.paragraphs.clear();
1210        for line in text.split('\n') {
1211            let mut p = Paragraph::new();
1212            p.runs.push(Run::new(line));
1213            self.paragraphs.push(p);
1214        }
1215    }
1216
1217    // --------------------- BodyProperties 便捷访问 ---------------------
1218
1219    /// 取/建 `body_properties`。
1220    fn ensure_body_properties(&mut self) -> &mut BodyProperties {
1221        if self.body_properties.is_none() {
1222            self.body_properties = Some(BodyProperties::default());
1223        }
1224        // 安全:上方 if 确保了 body_properties 为 Some;使用 match 避免 expect
1225        match &mut self.body_properties {
1226            Some(bp) => bp,
1227            None => unreachable!("body_properties was just initialized above"),
1228        }
1229    }
1230
1231    /// 自动调整策略(`TextFrame.auto_size`)。
1232    pub fn auto_size(&self) -> MsoAutoSize {
1233        self.body_properties
1234            .as_ref()
1235            .and_then(|b| b.auto_size())
1236            .unwrap_or(MsoAutoSize::None)
1237    }
1238    /// 设置自动调整策略。
1239    pub fn set_auto_size(&mut self, v: MsoAutoSize) {
1240        self.ensure_body_properties().set_auto_size(v);
1241    }
1242
1243    /// 垂直对齐(`TextFrame.vertical_anchor`)。
1244    pub fn vertical_anchor(&self) -> Option<MsoAnchor> {
1245        self.body_properties.as_ref().and_then(|b| b.anchor)
1246    }
1247    /// 设置垂直对齐。
1248    pub fn set_vertical_anchor(&mut self, v: MsoAnchor) {
1249        self.ensure_body_properties().anchor = Some(v);
1250    }
1251
1252    /// 是否自动换行(`TextFrame.word_wrap`)。
1253    ///
1254    /// 返回 `None` 表示走默认值 / 继承。
1255    pub fn word_wrap(&self) -> Option<bool> {
1256        self.body_properties.as_ref().and_then(|b| match b.wrap {
1257            Some(TextWrapping::Square) => Some(true),
1258            Some(TextWrapping::None) => Some(false),
1259            None => None,
1260        })
1261    }
1262    /// 设置是否自动换行。
1263    pub fn set_word_wrap(&mut self, v: bool) {
1264        self.ensure_body_properties().wrap = Some(if v {
1265            TextWrapping::Square
1266        } else {
1267            TextWrapping::None
1268        });
1269    }
1270
1271    /// 左边距(EMU)。
1272    pub fn margin_left(&self) -> Option<Emu> {
1273        self.body_properties
1274            .as_ref()
1275            .and_then(|b| b.insets.as_ref().map(|i| i.left))
1276    }
1277    /// 右边距。
1278    pub fn margin_right(&self) -> Option<Emu> {
1279        self.body_properties
1280            .as_ref()
1281            .and_then(|b| b.insets.as_ref().map(|i| i.right))
1282    }
1283    /// 上边距。
1284    pub fn margin_top(&self) -> Option<Emu> {
1285        self.body_properties
1286            .as_ref()
1287            .and_then(|b| b.insets.as_ref().map(|i| i.top))
1288    }
1289    /// 下边距。
1290    pub fn margin_bottom(&self) -> Option<Emu> {
1291        self.body_properties
1292            .as_ref()
1293            .and_then(|b| b.insets.as_ref().map(|i| i.bottom))
1294    }
1295    /// 一次性设置四向边距。
1296    pub fn set_margins(&mut self, left: Emu, top: Emu, right: Emu, bottom: Emu) {
1297        let bp = self.ensure_body_properties();
1298        bp.insets = Some(Inset {
1299            left,
1300            top,
1301            right,
1302            bottom,
1303        });
1304    }
1305    /// 设置左边距。
1306    pub fn set_margin_left(&mut self, emu: Emu) {
1307        let bp = self.ensure_body_properties();
1308        let i = bp.insets.get_or_insert(Inset::default());
1309        i.left = emu;
1310    }
1311    /// 设置右边距。
1312    pub fn set_margin_right(&mut self, emu: Emu) {
1313        let bp = self.ensure_body_properties();
1314        let i = bp.insets.get_or_insert(Inset::default());
1315        i.right = emu;
1316    }
1317    /// 设置上边距。
1318    pub fn set_margin_top(&mut self, emu: Emu) {
1319        let bp = self.ensure_body_properties();
1320        let i = bp.insets.get_or_insert(Inset::default());
1321        i.top = emu;
1322    }
1323    /// 设置下边距。
1324    pub fn set_margin_bottom(&mut self, emu: Emu) {
1325        let bp = self.ensure_body_properties();
1326        let i = bp.insets.get_or_insert(Inset::default());
1327        i.bottom = emu;
1328    }
1329
1330    /// 文本列数(`numCol` 属性)。
1331    ///
1332    /// 返回 `None` 表示未设置(默认单列);`Some(1)` 等价于单列。
1333    pub fn num_cols(&self) -> Option<u32> {
1334        self.body_properties.as_ref().and_then(|b| b.num_cols)
1335    }
1336    /// 设置文本列数。
1337    pub fn set_num_cols(&mut self, count: u32) {
1338        let bp = self.ensure_body_properties();
1339        bp.num_cols = Some(count);
1340    }
1341    /// 列间距(`spcCol` 属性,EMU)。
1342    pub fn col_spacing(&self) -> Option<Emu> {
1343        self.body_properties.as_ref().and_then(|b| b.col_spacing)
1344    }
1345    /// 设置列间距。
1346    pub fn set_col_spacing(&mut self, emu: Emu) {
1347        let bp = self.ensure_body_properties();
1348        bp.col_spacing = Some(emu);
1349    }
1350}
1351
1352/// 文本体属性 `<a:bodyPr>`。
1353#[derive(Clone, Debug, Default)]
1354pub struct BodyProperties {
1355    /// 文本与边界框的内边距。
1356    pub insets: Option<Inset>,
1357    /// 文字方向。
1358    pub vertical: Option<String>,
1359    /// 文字方向(rotation)。
1360    pub rotation: Option<i32>,
1361    /// 自动换行(`wrap="square"/"none"`)。
1362    pub wrap: Option<TextWrapping>,
1363    /// 文本框在溢出时的行为。
1364    pub sp_auto_fit: bool,
1365    /// 形状自适应文字。
1366    pub norm_autofit: bool,
1367    /// 锚定(top/ctr/b)。
1368    pub anchor: Option<MsoAnchor>,
1369    /// 水平对齐(l/ctr/just/dist)——仅占位/组合时使用。
1370    pub anchor_ctr: bool,
1371    /// 文本列数(`numCol` 属性,1 表示单列即默认)。
1372    ///
1373    /// 对应 python-pptx `TextFrame.word_wrap` 相关的多列布局。
1374    /// 多列时文本从左到右依次填充各列。
1375    pub num_cols: Option<u32>,
1376    /// 列间距(`spcCol` 属性,EMU)。
1377    ///
1378    /// 仅当 `num_cols > 1` 时有效。
1379    pub col_spacing: Option<Emu>,
1380}
1381
1382/// 文本框内边距(`<a:bodyPr lIns tIns rIns bIns>`)。
1383///
1384/// 在 `<a:bodyPr>` 元素上以 4 个独立属性表示,单位 EMU。
1385/// python-pptx 中 `TextFrame.margin_left/right/top/bottom` 即对应这 4 个值。
1386#[derive(Copy, Clone, Debug, Default)]
1387pub struct Inset {
1388    /// 左内边距(EMU)。
1389    pub left: Emu,
1390    /// 上内边距(EMU)。
1391    pub top: Emu,
1392    /// 右内边距(EMU)。
1393    pub right: Emu,
1394    /// 下内边距(EMU)。
1395    pub bottom: Emu,
1396}
1397
1398impl BodyProperties {
1399    /// 写 XML。
1400    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
1401        // 提前取出所有要序列化的字符串,扩展到函数末尾
1402        let l_s = self.insets.as_ref().map(|i| i.left.value().to_string());
1403        let t_s = self.insets.as_ref().map(|i| i.top.value().to_string());
1404        let r_s = self.insets.as_ref().map(|i| i.right.value().to_string());
1405        let b_s = self.insets.as_ref().map(|i| i.bottom.value().to_string());
1406        let rot_s = self.rotation.map(|v| v.to_string());
1407        let wrap_s = self.wrap.map(|v| v.as_str());
1408        let anchor_s = self.anchor.map(|v| v.as_str());
1409        let numcol_s = self.num_cols.map(|v| v.to_string());
1410        let spccol_s = self.col_spacing.map(|v| v.value().to_string());
1411
1412        let mut attrs: Vec<(&str, &str)> = Vec::new();
1413        if let Some(s) = &l_s {
1414            attrs.push(("lIns", s));
1415        }
1416        if let Some(s) = &t_s {
1417            attrs.push(("tIns", s));
1418        }
1419        if let Some(s) = &r_s {
1420            attrs.push(("rIns", s));
1421        }
1422        if let Some(s) = &b_s {
1423            attrs.push(("bIns", s));
1424        }
1425        if let Some(v) = &self.vertical {
1426            attrs.push(("vert", v));
1427        }
1428        if let Some(s) = &rot_s {
1429            attrs.push(("rot", s));
1430        }
1431        if let Some(s) = &wrap_s {
1432            attrs.push(("wrap", s));
1433        }
1434        if let Some(s) = &anchor_s {
1435            attrs.push(("anchor", s));
1436        }
1437        if let Some(s) = &numcol_s {
1438            attrs.push(("numCol", s));
1439        }
1440        if let Some(s) = &spccol_s {
1441            attrs.push(("spcCol", s));
1442        }
1443        w.open_with("a:bodyPr", &attrs);
1444        // 自动调整子元素:优先按 sp_auto_fit / norm_autofit 标志位写出(保留兼容路径)
1445        if self.sp_auto_fit {
1446            w.empty("a:spAutoFit");
1447        } else if self.norm_autofit {
1448            w.empty("a:normAutofit");
1449        }
1450        w.close("a:bodyPr");
1451    }
1452
1453    /// 取 [`MsoAutoSize`] 视图(合并 `sp_auto_fit` / `norm_autofit` 两个 bool)。
1454    pub fn auto_size(&self) -> Option<MsoAutoSize> {
1455        if self.sp_auto_fit {
1456            Some(MsoAutoSize::ShapeToFitText)
1457        } else if self.norm_autofit {
1458            Some(MsoAutoSize::TextToFitShape)
1459        } else {
1460            None
1461        }
1462    }
1463    /// 设置 [`MsoAutoSize`] 视图(同时清空另一个标志位以保持互斥)。
1464    pub fn set_auto_size(&mut self, v: MsoAutoSize) {
1465        match v {
1466            MsoAutoSize::None => {
1467                self.sp_auto_fit = false;
1468                self.norm_autofit = false;
1469            }
1470            MsoAutoSize::ShapeToFitText => {
1471                self.sp_auto_fit = true;
1472                self.norm_autofit = false;
1473            }
1474            MsoAutoSize::TextToFitShape => {
1475                self.sp_auto_fit = false;
1476                self.norm_autofit = true;
1477            }
1478        }
1479    }
1480}
1481
1482impl From<RGBColor> for Color {
1483    fn from(c: RGBColor) -> Self {
1484        Color::RGB(c)
1485    }
1486}
1487
1488// ====================================================================
1489// 高阶 Font 视图(python-pptx 风格 `pptx.text.text.Font`)
1490// ====================================================================
1491
1492use crate::oxml::color::ColorFormat;
1493
1494/// 字体高阶视图(`pptx.text.text.Font`)。
1495///
1496/// # 与 python-pptx 的对应
1497///
1498/// - `pptx.text.text.Font` ←→ [`Font`];
1499/// - `font.bold = True` / `font.size = Pt(24)` / `font.color.rgb = RGBColor(...)`
1500///   ←→ [`Font::set_bold`] / [`Font::set_size`] / [`Font::color`].set_rgb()。
1501///
1502/// # 设计要点
1503///
1504/// - **借用 + 透明代理**:构造时传入 `&mut RunProperties`;
1505/// - **零分配**:颜色走 [`ColorFormat`] 借用;
1506/// - **可空值语义**:`name` / `size` / `lang` 等字段用 `Option<T>`,
1507///   `None` 表示"走主题继承"——与 python-pptx `None` 一致。
1508#[derive(Debug)]
1509pub struct Font<'a> {
1510    /// 底层 [`RunProperties`] 引用。
1511    rpr: &'a mut RunProperties,
1512}
1513
1514impl<'a> Font<'a> {
1515    /// 构造。
1516    pub fn new(rpr: &'a mut RunProperties) -> Self {
1517        Font { rpr }
1518    }
1519    /// 底层 rpr 不可变引用。
1520    pub fn rpr(&self) -> &RunProperties {
1521        self.rpr
1522    }
1523    /// 底层 rpr 可变引用。
1524    pub fn rpr_mut(&mut self) -> &mut RunProperties {
1525        self.rpr
1526    }
1527
1528    /// 颜色 [`ColorFormat`] 代理。
1529    pub fn color(&mut self) -> ColorFormat<'_> {
1530        ColorFormat::new(&mut self.rpr.color)
1531    }
1532
1533    /// 字号(Pt)。
1534    pub fn size(&self) -> Option<Pt> {
1535        self.rpr.size
1536    }
1537    /// 设置字号。
1538    pub fn set_size(&mut self, v: Pt) {
1539        self.rpr.size = Some(v);
1540    }
1541    /// 清空字号(走主题继承)。
1542    pub fn clear_size(&mut self) {
1543        self.rpr.size = None;
1544    }
1545
1546    /// 加粗。
1547    pub fn bold(&self) -> bool {
1548        self.rpr.bold
1549    }
1550    /// 设置加粗。
1551    pub fn set_bold(&mut self, v: bool) {
1552        self.rpr.bold = v;
1553    }
1554
1555    /// 斜体。
1556    pub fn italic(&self) -> bool {
1557        self.rpr.italic
1558    }
1559    /// 设置斜体。
1560    pub fn set_italic(&mut self, v: bool) {
1561        self.rpr.italic = v;
1562    }
1563
1564    /// 删除线。
1565    pub fn strike(&self) -> bool {
1566        self.rpr.strike
1567    }
1568    /// 设置删除线。
1569    pub fn set_strike(&mut self, v: bool) {
1570        self.rpr.strike = v;
1571    }
1572
1573    /// 双删除线。
1574    ///
1575    /// 对标 python-pptx `font._rPr.attrib['strike'] == 'dblStrike'`。
1576    /// 当为 `true` 时,写出 `strike="dblStrike"`;普通删除线请用 [`Self::set_strike`]。
1577    pub fn double_strike(&self) -> bool {
1578        self.rpr.strike_dbl
1579    }
1580    /// 设置双删除线。
1581    pub fn set_double_strike(&mut self, v: bool) {
1582        self.rpr.strike_dbl = v;
1583    }
1584
1585    /// 高亮色。
1586    ///
1587    /// 对标 python-pptx `Font.highlight_color`(v0.6.21+)。
1588    /// 返回 `None` 表示未设置高亮。
1589    pub fn highlight(&self) -> Option<&Color> {
1590        self.rpr.highlight.as_ref()
1591    }
1592    /// 设置高亮色。
1593    ///
1594    /// 传入 `None` 清除高亮;传入 `Color::None` 也清除高亮。
1595    pub fn set_highlight(&mut self, color: Option<Color>) {
1596        match color {
1597            None => self.rpr.highlight = None,
1598            Some(Color::None) => self.rpr.highlight = None,
1599            Some(c) => self.rpr.highlight = Some(c),
1600        }
1601    }
1602
1603    /// 下划线。
1604    pub fn underline(&self) -> Option<Underline> {
1605        self.rpr.underline
1606    }
1607    /// 设置下划线(`None` 表示清除)。
1608    pub fn set_underline(&mut self, v: Option<Underline>) {
1609        self.rpr.underline = v;
1610    }
1611
1612    /// 字体名(拉丁)。
1613    pub fn name(&self) -> Option<&str> {
1614        self.rpr.latin_font.as_deref()
1615    }
1616    /// 设置字体名。
1617    pub fn set_name(&mut self, n: impl Into<String>) {
1618        self.rpr.latin_font = Some(n.into());
1619    }
1620    /// 清空字体名(走主题继承)。
1621    pub fn clear_name(&mut self) {
1622        self.rpr.latin_font = None;
1623    }
1624
1625    /// 东亚字体。
1626    pub fn eastasia_name(&self) -> Option<&str> {
1627        self.rpr.eastasia_font.as_deref()
1628    }
1629    /// 设置东亚字体。
1630    pub fn set_eastasia_name(&mut self, n: impl Into<String>) {
1631        self.rpr.eastasia_font = Some(n.into());
1632    }
1633    /// 清空东亚字体(走主题继承)。
1634    ///
1635    /// 对应 OOXML 中删除 `<a:ea>` 元素,PowerPoint 将使用主题的
1636    /// `minorFont.ea` / `majorFont.ea` 作为回退。
1637    pub fn clear_eastasia_name(&mut self) {
1638        self.rpr.eastasia_font = None;
1639    }
1640
1641    /// 复杂脚本字体。
1642    pub fn complex_script_name(&self) -> Option<&str> {
1643        self.rpr.cs_font.as_deref()
1644    }
1645    /// 设置复杂脚本字体。
1646    pub fn set_complex_script_name(&mut self, n: impl Into<String>) {
1647        self.rpr.cs_font = Some(n.into());
1648    }
1649    /// 清空复杂脚本字体(走主题继承)。
1650    ///
1651    /// 对应 OOXML 中删除 `<a:cs>` 元素,PowerPoint 将使用主题的
1652    /// `minorFont.cs` / `majorFont.cs` 作为回退。
1653    pub fn clear_complex_script_name(&mut self) {
1654        self.rpr.cs_font = None;
1655    }
1656
1657    /// baseline 偏移(百分比,正=上标,负=下标)。
1658    pub fn baseline(&self) -> Option<i32> {
1659        self.rpr.baseline
1660    }
1661    /// 设置 baseline。
1662    pub fn set_baseline(&mut self, v: i32) {
1663        self.rpr.baseline = Some(v);
1664    }
1665
1666    /// 字符间距(百分之一磅)。
1667    pub fn spacing(&self) -> Option<i32> {
1668        self.rpr.spc
1669    }
1670    /// 设置字符间距。
1671    pub fn set_spacing(&mut self, v: i32) {
1672        self.rpr.spc = Some(v);
1673    }
1674
1675    // ===== 超链接 API(TODO-026)=====
1676
1677    /// 取点击超链接(`<a:hlinkClick>`)。
1678    pub fn hlink_click(&self) -> Option<&Hyperlink> {
1679        self.rpr.hlink_click.as_ref()
1680    }
1681    /// 设置点击超链接(直接传入 [`Hyperlink`])。
1682    pub fn set_hlink_click(&mut self, hl: Hyperlink) {
1683        self.rpr.hlink_click = Some(hl);
1684    }
1685    /// 清除点击超链接。
1686    pub fn clear_hlink_click(&mut self) {
1687        self.rpr.hlink_click = None;
1688    }
1689
1690    /// 取悬停超链接(`<a:hlinkHover>`)。
1691    pub fn hlink_hover(&self) -> Option<&Hyperlink> {
1692        self.rpr.hlink_hover.as_ref()
1693    }
1694    /// 设置悬停超链接。
1695    pub fn set_hlink_hover(&mut self, hl: Hyperlink) {
1696        self.rpr.hlink_hover = Some(hl);
1697    }
1698    /// 清除悬停超链接。
1699    pub fn clear_hlink_hover(&mut self) {
1700        self.rpr.hlink_hover = None;
1701    }
1702
1703    /// 便捷方法:设置一个指向 URL 的点击超链接。
1704    ///
1705    /// # 参数
1706    /// - `rid`:关系 ID(指向 `.rels` 中的目标 URL);
1707    /// - `tooltip`:可选的鼠标悬停提示。
1708    ///
1709    /// # 示例
1710    /// ```no_run
1711    /// # use pptx_rs::oxml::txbody::{Run, Font};
1712    /// # let mut run = Run::new("链接文本");
1713    /// run.font().set_hyperlink("rId1", Some("点击访问"));
1714    /// ```
1715    pub fn set_hyperlink(&mut self, rid: impl Into<String>, tooltip: Option<&str>) {
1716        let mut hl = Hyperlink::new(rid);
1717        if let Some(t) = tooltip {
1718            hl.tooltip = Some(t.to_string());
1719        }
1720        self.rpr.hlink_click = Some(hl);
1721    }
1722
1723    /// 便捷方法:设置一个跳转幻灯片的动作超链接。
1724    ///
1725    /// 对应 OOXML `action="ppaction://hlinksldjump"`。
1726    pub fn set_slide_jump(&mut self) {
1727        self.rpr.hlink_click = Some(Hyperlink::new_slide_jump());
1728    }
1729}
1730
1731impl<'a> From<&'a mut RunProperties> for Font<'a> {
1732    fn from(r: &'a mut RunProperties) -> Self {
1733        Font::new(r)
1734    }
1735}
1736
1737// ====================================================================
1738// 高阶 ParagraphFormat 视图(python-pptx 风格 `pptx.text.text._ParagraphFormat`)
1739// ====================================================================
1740
1741/// 段落格式高阶视图(`pptx.text.text._ParagraphFormat`)。
1742///
1743/// # 与 python-pptx 的对应
1744///
1745/// - `paragraph.alignment = PP_ALIGN.CENTER` ←→ [`ParagraphFormat::set_alignment`];
1746/// - `paragraph.line_spacing = 1.5` ←→ [`ParagraphFormat::set_line_spacing`];
1747/// - `paragraph.space_before = Pt(6)` ←→ [`ParagraphFormat::set_space_before`]。
1748///
1749/// # 设计要点
1750///
1751/// - **借用 + 透明代理**:构造时传入 `&mut ParagraphProperties`;
1752/// - **零分配**:纯字段操作;
1753/// - **互斥语义**:`set_line_spacing` / `set_line_spacing_pct` 互斥;
1754///   后调用的会清空前者。
1755#[derive(Debug)]
1756pub struct ParagraphFormat<'a> {
1757    /// 底层 [`ParagraphProperties`] 引用。
1758    ppr: &'a mut ParagraphProperties,
1759}
1760
1761impl<'a> ParagraphFormat<'a> {
1762    /// 构造。
1763    pub fn new(ppr: &'a mut ParagraphProperties) -> Self {
1764        ParagraphFormat { ppr }
1765    }
1766    /// 底层 ppr 不可变引用。
1767    pub fn ppr(&self) -> &ParagraphProperties {
1768        self.ppr
1769    }
1770    /// 底层 ppr 可变引用。
1771    pub fn ppr_mut(&mut self) -> &mut ParagraphProperties {
1772        self.ppr
1773    }
1774
1775    /// 水平对齐。
1776    pub fn alignment(&self) -> Option<Alignment> {
1777        self.ppr.alignment
1778    }
1779    /// 设置水平对齐。
1780    pub fn set_alignment(&mut self, v: Alignment) {
1781        self.ppr.alignment = Some(v);
1782    }
1783    /// 清除水平对齐(走默认)。
1784    pub fn clear_alignment(&mut self) {
1785        self.ppr.alignment = None;
1786    }
1787
1788    /// 段落级别(0-8)。
1789    pub fn level(&self) -> u8 {
1790        self.ppr.level
1791    }
1792    /// 设置段落级别。
1793    pub fn set_level(&mut self, lvl: u8) {
1794        self.ppr.level = lvl;
1795    }
1796
1797    /// 行距(固定值,Pt)。
1798    pub fn line_spacing(&self) -> Option<Pt> {
1799        self.ppr.line_spacing.map(|emu| Pt(emu as f64 / 12_700.0))
1800    }
1801    /// 设置行距为**固定点数**(与 `set_line_spacing_pct` 互斥)。
1802    pub fn set_line_spacing(&mut self, v: Pt) {
1803        let emu = (v.value() * 12_700.0) as i32;
1804        self.ppr.line_spacing = Some(emu);
1805        self.ppr.line_spacing_pct = None;
1806    }
1807    /// 行距(倍数,1.0 = 100%)。
1808    pub fn line_spacing_pct(&self) -> Option<f32> {
1809        self.ppr.line_spacing_pct.map(|v| v as f32 / 1000.0)
1810    }
1811    /// 设置行距为**倍数**(与 `set_line_spacing` 互斥)。
1812    pub fn set_line_spacing_pct(&mut self, v: f32) {
1813        self.ppr.line_spacing_pct = Some((v * 1000.0) as i32);
1814        self.ppr.line_spacing = None;
1815    }
1816    /// 清除行距。
1817    pub fn clear_line_spacing(&mut self) {
1818        self.ppr.line_spacing = None;
1819        self.ppr.line_spacing_pct = None;
1820    }
1821
1822    /// 段前(EMU)。
1823    pub fn space_before(&self) -> Option<Emu> {
1824        self.ppr.space_before
1825    }
1826    /// 设置段前。
1827    pub fn set_space_before(&mut self, emu: Emu) {
1828        self.ppr.space_before = Some(emu);
1829    }
1830    /// 段后(EMU)。
1831    pub fn space_after(&self) -> Option<Emu> {
1832        self.ppr.space_after
1833    }
1834    /// 设置段后。
1835    pub fn set_space_after(&mut self, emu: Emu) {
1836        self.ppr.space_after = Some(emu);
1837    }
1838
1839    /// 一次性设置缩进。
1840    pub fn set_indent(
1841        &mut self,
1842        left: Option<Emu>,
1843        right: Option<Emu>,
1844        first_line: Option<Emu>,
1845        hanging: Option<i32>,
1846    ) {
1847        self.ppr.indent = Indent {
1848            left,
1849            right,
1850            first_line,
1851            hanging,
1852        };
1853    }
1854
1855    /// 缩进(EMU 视图)。
1856    pub fn indent(&self) -> Indent {
1857        self.ppr.indent
1858    }
1859
1860    // --------- 项目符号样式(TODO-014) ---------
1861
1862    /// 项目符号样式。
1863    ///
1864    /// 返回 `None` 表示未设置(走默认继承);`Some(BulletStyle::None)` 表示显式无项目符号。
1865    pub fn bullet_style(&self) -> Option<&BulletStyle> {
1866        self.ppr.bullet_style.as_ref()
1867    }
1868
1869    /// 设置自定义字符项目符号(`<a:buChar char="..."/>`)。
1870    ///
1871    /// 对标 python-pptx `paragraph.font` + bullet 字符设置。
1872    /// 常用字符:`"•"` / `"▪"` / `"→"` / `"○"` / `"♦"`。
1873    pub fn set_bullet_char(&mut self, ch: impl Into<String>) {
1874        self.ppr.bullet = true;
1875        self.ppr.bullet_style = Some(BulletStyle::Char { char: ch.into() });
1876    }
1877
1878    /// 设置自动编号项目符号(`<a:buAutoNum type="..." startAt="..."/>`)。
1879    ///
1880    /// 对标 python-pptx 编号列表。
1881    /// 常用 type:`"arabicPeriod"` (1.) / `"alphaLcParenR"` (a)) / `"romanLcParenBoth"` ((i))。
1882    ///
1883    /// # 参数
1884    /// - `auto_num_type`:编号类型字面量。
1885    /// - `start_at`:起始编号(可选,`None` 表示从 1 开始)。
1886    pub fn set_bullet_numbered(&mut self, auto_num_type: impl Into<String>, start_at: Option<u32>) {
1887        self.ppr.bullet = true;
1888        self.ppr.bullet_style = Some(BulletStyle::AutoNum {
1889            auto_num_type: auto_num_type.into(),
1890            start_at,
1891        });
1892    }
1893
1894    /// 清除项目符号(`<a:buNone/>`)。
1895    pub fn clear_bullet(&mut self) {
1896        self.ppr.bullet = false;
1897        self.ppr.bullet_style = Some(BulletStyle::None);
1898    }
1899
1900    /// 是否有项目符号(`bullet=true` 且 `bullet_style` 非 `None`)。
1901    pub fn has_bullet(&self) -> bool {
1902        self.ppr.bullet
1903            && self
1904                .ppr
1905                .bullet_style
1906                .as_ref()
1907                .map(|bs| !matches!(bs, BulletStyle::None))
1908                .unwrap_or(false)
1909    }
1910
1911    // --------- 制表位(TODO-015) ---------
1912
1913    /// 制表位列表(不可变)。
1914    ///
1915    /// 对标 python-pptx `_ParagraphFormat.tab_stops`。
1916    pub fn tab_stops(&self) -> &[TabStop] {
1917        &self.ppr.tab_stops
1918    }
1919
1920    /// 添加一个制表位(`<a:tab pos="..." algn="..."/>`)。
1921    ///
1922    /// 对标 python-pptx `paragraph.tab_stops.add_tab_stop(position, alignment)`。
1923    ///
1924    /// # 参数
1925    /// - `pos`:制表位位置(EMU);
1926    /// - `alignment`:对齐类型(左/居中/右/小数点)。
1927    pub fn add_tab_stop(&mut self, pos: Emu, alignment: TabAlignment) {
1928        self.ppr.tab_stops.push(TabStop { pos, alignment });
1929    }
1930
1931    /// 清除所有制表位。
1932    pub fn clear_tab_stops(&mut self) {
1933        self.ppr.tab_stops.clear();
1934    }
1935}
1936
1937impl<'a> From<&'a mut ParagraphProperties> for ParagraphFormat<'a> {
1938    fn from(p: &'a mut ParagraphProperties) -> Self {
1939        ParagraphFormat::new(p)
1940    }
1941}
1942
1943// ====================================================================
1944// 高阶 TextFrame 视图(python-pptx 风格 `pptx.text.textframe.TextFrame`)
1945// ====================================================================
1946
1947/// 文本框高阶视图(`pptx.text.textframe.TextFrame`)。
1948///
1949/// # 与 python-pptx 的对应
1950///
1951/// - `shape.text_frame` ←→ `text_frame_mut().as_text_frame()`(薄包装);
1952/// - `text_frame.text` ←→ [`TextFrame::text_getter`] / [`TextFrame::set_text`];
1953/// - `text_frame.paragraphs[0]` ←→ [`TextFrame::paragraphs`];
1954/// - `text_frame.add_paragraph()` ←→ [`TextFrame::add_paragraph`];
1955/// - `text_frame.word_wrap` ←→ [`TextFrame::word_wrap`] / [`TextFrame::set_word_wrap`];
1956/// - `text_frame.auto_size` ←→ [`TextFrame::auto_size`] / [`TextFrame::set_auto_size`];
1957/// - `text_frame.vertical_anchor` ←→ [`TextFrame::vertical_anchor`] / [`TextFrame::set_vertical_anchor`];
1958/// - `text_frame.margin_left/...` ←→ [`TextFrame::margin_left`] / ... / [`TextFrame::set_margins`]。
1959///
1960/// # 设计要点
1961///
1962/// - **借用**:构造时传入 `&mut TextBody`;
1963/// - **零分配**:所有方法均不触发堆分配;
1964/// - **互斥语义**:[`TextFrame::add_paragraph`] 不会自动 trim,已有段落不会被移除。
1965#[derive(Debug)]
1966pub struct TextFrame<'a> {
1967    /// 底层 [`TextBody`] 引用。
1968    body: &'a mut TextBody,
1969}
1970
1971impl<'a> TextFrame<'a> {
1972    /// 构造。
1973    pub fn new(body: &'a mut TextBody) -> Self {
1974        TextFrame { body }
1975    }
1976
1977    /// 底层 body 不可变引用。
1978    pub fn body(&self) -> &TextBody {
1979        self.body
1980    }
1981    /// 底层 body 可变引用。
1982    pub fn body_mut(&mut self) -> &mut TextBody {
1983        self.body
1984    }
1985
1986    // --------- 段落集合 ---------
1987
1988    /// 段落数。
1989    pub fn len(&self) -> usize {
1990        self.body.paragraphs.len()
1991    }
1992    /// 是否为空。
1993    pub fn is_empty(&self) -> bool {
1994        self.body.paragraphs.is_empty()
1995    }
1996    /// 不可变段落迭代器。
1997    pub fn paragraphs(&self) -> std::slice::Iter<'_, Paragraph> {
1998        self.body.paragraphs.iter()
1999    }
2000    /// 可变段落迭代器。
2001    pub fn paragraphs_mut(&mut self) -> std::slice::IterMut<'_, Paragraph> {
2002        self.body.paragraphs.iter_mut()
2003    }
2004    /// 按下标取不可变段落。
2005    pub fn paragraph(&self, idx: usize) -> Option<&Paragraph> {
2006        self.body.paragraphs.get(idx)
2007    }
2008    /// 按下标取可变段落。
2009    pub fn paragraph_mut(&mut self, idx: usize) -> Option<&mut Paragraph> {
2010        self.body.paragraphs.get_mut(idx)
2011    }
2012    /// 首个段落。
2013    pub fn first_paragraph(&self) -> Option<&Paragraph> {
2014        self.body.paragraphs.first()
2015    }
2016    /// 首个段落的可变引用。
2017    pub fn first_paragraph_mut(&mut self) -> Option<&mut Paragraph> {
2018        self.body.paragraphs.first_mut()
2019    }
2020
2021    /// 新增空段落(python-pptx `text_frame.add_paragraph()`)。
2022    pub fn add_paragraph(&mut self) -> &mut Paragraph {
2023        self.body.add_paragraph()
2024    }
2025
2026    /// 新增带文本段落(按 `\n` 切分为多段),返回**最后**一段的可变引用。
2027    pub fn add_paragraph_with_text(&mut self, text: &str) -> &mut Paragraph {
2028        self.body.add_paragraph_with_text(text)
2029    }
2030
2031    /// 按下标移除段落。
2032    pub fn remove_paragraph(&mut self, idx: usize) -> Option<Paragraph> {
2033        self.body.remove_paragraph(idx)
2034    }
2035
2036    /// 清空段落(保留一个空段,对应 python-pptx `text_frame.clear()`)。
2037    pub fn clear(&mut self) {
2038        self.body.clear();
2039    }
2040
2041    // --------- 文本便捷 ---------
2042
2043    /// 整体取文本(段间 `\n`,与 python-pptx `text_frame.text` 一致)。
2044    pub fn text_getter(&self) -> String {
2045        self.body.text()
2046    }
2047
2048    /// 整体替换文本(按 `\n` 切分;旧段落属性会被丢弃)。
2049    pub fn set_text(&mut self, text: &str) {
2050        self.body.set_text(text);
2051    }
2052
2053    // --------- BodyProperties 便捷 ---------
2054
2055    /// 自动调整策略。
2056    pub fn auto_size(&self) -> MsoAutoSize {
2057        self.body.auto_size()
2058    }
2059    /// 设置自动调整策略。
2060    pub fn set_auto_size(&mut self, v: MsoAutoSize) {
2061        self.body.set_auto_size(v);
2062    }
2063
2064    /// 垂直对齐。
2065    pub fn vertical_anchor(&self) -> Option<MsoAnchor> {
2066        self.body.vertical_anchor()
2067    }
2068    /// 设置垂直对齐。
2069    pub fn set_vertical_anchor(&mut self, v: MsoAnchor) {
2070        self.body.set_vertical_anchor(v);
2071    }
2072
2073    /// 是否自动换行(None = 走默认)。
2074    pub fn word_wrap(&self) -> Option<bool> {
2075        self.body.word_wrap()
2076    }
2077    /// 设置是否自动换行。
2078    pub fn set_word_wrap(&mut self, v: bool) {
2079        self.body.set_word_wrap(v);
2080    }
2081
2082    /// 左边距。
2083    pub fn margin_left(&self) -> Option<Emu> {
2084        self.body.margin_left()
2085    }
2086    /// 右边距。
2087    pub fn margin_right(&self) -> Option<Emu> {
2088        self.body.margin_right()
2089    }
2090    /// 上边距。
2091    pub fn margin_top(&self) -> Option<Emu> {
2092        self.body.margin_top()
2093    }
2094    /// 下边距。
2095    pub fn margin_bottom(&self) -> Option<Emu> {
2096        self.body.margin_bottom()
2097    }
2098    /// 一次性设置四向边距。
2099    pub fn set_margins(&mut self, l: Emu, t: Emu, r: Emu, b: Emu) {
2100        self.body.set_margins(l, t, r, b);
2101    }
2102    /// 设置单边边距(left)。
2103    pub fn set_margin_left(&mut self, emu: Emu) {
2104        self.body.set_margin_left(emu);
2105    }
2106    /// 设置单边边距(right)。
2107    pub fn set_margin_right(&mut self, emu: Emu) {
2108        self.body.set_margin_right(emu);
2109    }
2110    /// 设置单边边距(top)。
2111    pub fn set_margin_top(&mut self, emu: Emu) {
2112        self.body.set_margin_top(emu);
2113    }
2114    /// 设置单边边距(bottom)。
2115    pub fn set_margin_bottom(&mut self, emu: Emu) {
2116        self.body.set_margin_bottom(emu);
2117    }
2118
2119    // --------- 多列布局 ---------
2120
2121    /// 文本列数(`numCol` 属性)。
2122    ///
2123    /// `None` 表示未设置(默认单列);`Some(1)` 等同于单列。
2124    pub fn num_cols(&self) -> Option<u32> {
2125        self.body.num_cols()
2126    }
2127
2128    /// 设置文本列数(`numCol` 属性)。
2129    pub fn set_num_cols(&mut self, count: u32) {
2130        self.body.set_num_cols(count);
2131    }
2132
2133    /// 列间距(`spcCol` 属性,EMU)。
2134    pub fn col_spacing(&self) -> Option<Emu> {
2135        self.body.col_spacing()
2136    }
2137
2138    /// 设置列间距(`spcCol` 属性,EMU)。
2139    pub fn set_col_spacing(&mut self, emu: Emu) {
2140        self.body.set_col_spacing(emu);
2141    }
2142
2143    /// 一次性设置多列布局(列数 + 可选列间距)。
2144    ///
2145    /// 对标 python-pptx 中 `text_frame` 的多列设置。
2146    /// 当 `spacing` 为 `None` 时仅设置列数,保留已有列间距。
2147    pub fn set_columns(&mut self, count: u32, spacing: Option<Emu>) {
2148        self.body.set_num_cols(count);
2149        if let Some(s) = spacing {
2150            self.body.set_col_spacing(s);
2151        }
2152    }
2153}
2154
2155impl<'a> From<&'a mut TextBody> for TextFrame<'a> {
2156    fn from(b: &'a mut TextBody) -> Self {
2157        TextFrame::new(b)
2158    }
2159}
2160
2161#[cfg(test)]
2162mod tests {
2163    use super::*;
2164
2165    #[test]
2166    fn write_paragraph_simple() {
2167        let mut p = Paragraph::new();
2168        let mut r = Run::new("Hello");
2169        r.properties.size = Some(Pt(24.0));
2170        r.properties.bold = true;
2171        r.properties.color = RGBColor(0xFF, 0, 0).into();
2172        p.runs.push(r);
2173        let mut w = super::super::writer::XmlWriter::new();
2174        p.write_xml(&mut w);
2175        let s = w.into_string();
2176        assert!(s.contains("Hello"));
2177        assert!(s.contains("sz=\"2400\""));
2178        assert!(s.contains("b=\"1\""));
2179        assert!(s.contains("a:srgbClr"));
2180    }
2181
2182    /// 验证 `TextFrame` / `ParagraphFormat` 视图能联动到底层 `TextBody`。
2183    #[test]
2184    fn textframe_view_mirrors_body() {
2185        let mut tb = TextBody::new();
2186        {
2187            let mut tf = TextFrame::new(&mut tb);
2188            // 走 view 加一段 + 走 view 设 word_wrap
2189            let p = tf.add_paragraph();
2190            p.add_run_with_text("first").set_bold(true);
2191            tf.add_paragraph_with_text("second\nthird");
2192            tf.set_word_wrap(false);
2193            tf.set_margins(Emu(91440), Emu(45720), Emu(91440), Emu(45720));
2194        }
2195        // 验证 view 写入已生效
2196        assert_eq!(tb.paragraphs.len(), 3);
2197        assert_eq!(tb.paragraphs[0].runs[0].text, "first");
2198        assert!(tb.paragraphs[0].runs[0].properties.bold);
2199        assert_eq!(tb.paragraphs[1].runs[0].text, "second");
2200        assert_eq!(tb.paragraphs[2].runs[0].text, "third");
2201        // 自动换行被设成 false
2202        assert_eq!(tb.word_wrap(), Some(false));
2203    }
2204
2205    /// 验证 `ParagraphFormat` 的 line_spacing 互斥语义。
2206    #[test]
2207    fn paragraph_format_line_spacing_mutex() {
2208        let mut p = Paragraph::new();
2209        p.set_line_spacing(Pt(20.0));
2210        assert!(p.line_spacing().is_some());
2211        assert!(p.line_spacing_pct().is_none());
2212        // 改设倍数:清空固定值
2213        p.set_line_spacing_pct(1.5);
2214        assert!(p.line_spacing().is_none());
2215        assert_eq!(p.line_spacing_pct(), Some(1.5));
2216        // 走 view 验证同样互斥
2217        let mut p2 = Paragraph::new();
2218        {
2219            let mut pf = ParagraphFormat::new(&mut p2.properties);
2220            pf.set_line_spacing(Pt(15.0));
2221        }
2222        assert_eq!(p2.line_spacing(), Some(Pt(15.0)));
2223        {
2224            let mut pf = ParagraphFormat::new(&mut p2.properties);
2225            pf.set_line_spacing_pct(2.0);
2226        }
2227        assert!(p2.line_spacing().is_none());
2228        assert_eq!(p2.line_spacing_pct(), Some(2.0));
2229    }
2230
2231    /// 验证 `Font` 的删除线/双删除线 API。
2232    ///
2233    /// 这是 TODO-017 的测试。
2234    #[test]
2235    fn font_strikethrough_api() {
2236        let mut r = Run::new("text");
2237        {
2238            let mut f = Font::new(&mut r.properties);
2239            f.set_strike(true);
2240        }
2241        assert!(r.properties.strike);
2242        assert!(!r.properties.strike_dbl);
2243
2244        // 双删除线
2245        {
2246            let mut f = Font::new(&mut r.properties);
2247            f.set_double_strike(true);
2248        }
2249        assert!(r.properties.strike_dbl);
2250
2251        // 验证 Font 读取
2252        let f = Font::new(&mut r.properties);
2253        assert!(f.strike());
2254        assert!(f.double_strike());
2255    }
2256
2257    /// 验证 `Font` 的高亮色 API。
2258    ///
2259    /// 这是 TODO-018 的测试。
2260    #[test]
2261    fn font_highlight_api() {
2262        let mut r = Run::new("text");
2263        // 默认无高亮
2264        assert!(r.properties.highlight.is_none());
2265
2266        // 设置高亮
2267        {
2268            let mut f = Font::new(&mut r.properties);
2269            f.set_highlight(Some(Color::RGB(RGBColor(0xFF, 0xFF, 0x00))));
2270        }
2271        assert!(r.properties.highlight.is_some());
2272
2273        // 验证 Font 读取
2274        let f = Font::new(&mut r.properties);
2275        let hl = f.highlight().expect("应有高亮色");
2276        assert!(matches!(hl, Color::RGB(c) if c.0 == 0xFF && c.1 == 0xFF && c.2 == 0x00));
2277
2278        // 清除高亮
2279        {
2280            let mut f = Font::new(&mut r.properties);
2281            f.set_highlight(None);
2282        }
2283        assert!(r.properties.highlight.is_none());
2284
2285        // 用 Color::None 清除
2286        {
2287            let mut f = Font::new(&mut r.properties);
2288            f.set_highlight(Some(Color::RGB(RGBColor(0xFF, 0x00, 0x00))));
2289            f.set_highlight(Some(Color::None));
2290        }
2291        assert!(r.properties.highlight.is_none());
2292    }
2293
2294    /// 验证 `TextBody` / `TextFrame` 的多列布局 API 与序列化。
2295    ///
2296    /// 这是 TODO-019 的测试。
2297    #[test]
2298    fn text_body_multi_column_api() {
2299        let mut tb = TextBody::new();
2300        // 默认无列数
2301        assert!(tb.num_cols().is_none());
2302        assert!(tb.col_spacing().is_none());
2303
2304        // 设置 3 列 + 列间距
2305        tb.set_num_cols(3);
2306        tb.set_col_spacing(Emu(91440));
2307        assert_eq!(tb.num_cols(), Some(3));
2308        assert_eq!(tb.col_spacing(), Some(Emu(91440)));
2309
2310        // 序列化验证
2311        let mut w = super::super::writer::XmlWriter::new();
2312        tb.body_properties
2313            .as_ref()
2314            .expect("应有 body_properties")
2315            .write_xml(&mut w);
2316        let s = w.into_string();
2317        assert!(s.contains("numCol=\"3\""), "应输出 numCol=\"3\",实际: {s}");
2318        assert!(
2319            s.contains("spcCol=\"91440\""),
2320            "应输出 spcCol=\"91440\",实际: {s}"
2321        );
2322    }
2323
2324    /// 验证 `TextFrame::set_columns` 一次性设置列数和列间距。
2325    ///
2326    /// 这是 TODO-019 的测试。
2327    #[test]
2328    fn text_frame_set_columns() {
2329        let mut tb = TextBody::new();
2330        {
2331            let mut tf = TextFrame::new(&mut tb);
2332            // 设置 2 列 + 列间距
2333            tf.set_columns(2, Some(Emu(45720)));
2334        }
2335        assert_eq!(tb.num_cols(), Some(2));
2336        assert_eq!(tb.col_spacing(), Some(Emu(45720)));
2337
2338        // 仅设置列数,不设置列间距
2339        {
2340            let mut tf = TextFrame::new(&mut tb);
2341            tf.set_columns(4, None);
2342        }
2343        assert_eq!(tb.num_cols(), Some(4));
2344        // 列间距应保持上次的值
2345        assert_eq!(tb.col_spacing(), Some(Emu(45720)));
2346
2347        // TextFrame 代理方法
2348        {
2349            let tf = TextFrame::new(&mut tb);
2350            assert_eq!(tf.num_cols(), Some(4));
2351            assert_eq!(tf.col_spacing(), Some(Emu(45720)));
2352        }
2353    }
2354
2355    /// 验证 `ParagraphFormat` 的项目符号字符 API。
2356    ///
2357    /// 这是 TODO-014 的测试。
2358    #[test]
2359    fn paragraph_format_bullet_char() {
2360        let mut ppr = ParagraphProperties::default();
2361        {
2362            let mut pf = ParagraphFormat::new(&mut ppr);
2363            pf.set_bullet_char("•");
2364        }
2365        assert!(ppr.bullet, "bullet 应为 true");
2366        assert!(pf_has_bullet(&ppr), "has_bullet 应为 true");
2367        // 验证序列化
2368        let mut w = super::super::writer::XmlWriter::new();
2369        ppr.write_xml(&mut w);
2370        let s = w.into_string();
2371        assert!(s.contains("buChar"), "应输出 buChar,实际: {s}");
2372        assert!(s.contains("char=\"•\""), "应包含 char=\"•\",实际: {s}");
2373    }
2374
2375    /// 验证 `ParagraphFormat` 的编号项目符号 API。
2376    ///
2377    /// 这是 TODO-014 的测试。
2378    #[test]
2379    fn paragraph_format_bullet_numbered() {
2380        let mut ppr = ParagraphProperties::default();
2381        {
2382            let mut pf = ParagraphFormat::new(&mut ppr);
2383            pf.set_bullet_numbered("arabicPeriod", Some(3));
2384        }
2385        assert!(ppr.bullet);
2386        match &ppr.bullet_style {
2387            Some(BulletStyle::AutoNum {
2388                auto_num_type,
2389                start_at,
2390            }) => {
2391                assert_eq!(auto_num_type, "arabicPeriod");
2392                assert_eq!(*start_at, Some(3));
2393            }
2394            other => panic!("期望 AutoNum,实际: {other:?}"),
2395        }
2396        // 验证序列化
2397        let mut w = super::super::writer::XmlWriter::new();
2398        ppr.write_xml(&mut w);
2399        let s = w.into_string();
2400        assert!(s.contains("buAutoNum"), "应输出 buAutoNum,实际: {s}");
2401        assert!(
2402            s.contains("type=\"arabicPeriod\""),
2403            "应包含 type,实际: {s}"
2404        );
2405        assert!(s.contains("startAt=\"3\""), "应包含 startAt,实际: {s}");
2406    }
2407
2408    /// 验证 `ParagraphFormat::clear_bullet` 清除项目符号。
2409    ///
2410    /// 这是 TODO-014 的测试。
2411    #[test]
2412    fn paragraph_format_clear_bullet() {
2413        let mut ppr = ParagraphProperties::default();
2414        {
2415            let mut pf = ParagraphFormat::new(&mut ppr);
2416            pf.set_bullet_char("•");
2417        }
2418        assert!(pf_has_bullet(&ppr));
2419        {
2420            let mut pf = ParagraphFormat::new(&mut ppr);
2421            pf.clear_bullet();
2422        }
2423        assert!(!ppr.bullet, "bullet 应为 false");
2424        assert!(!pf_has_bullet(&ppr), "has_bullet 应为 false");
2425        // 验证序列化输出 buNone
2426        let mut w = super::super::writer::XmlWriter::new();
2427        ppr.write_xml(&mut w);
2428        let s = w.into_string();
2429        assert!(s.contains("buNone"), "应输出 buNone,实际: {s}");
2430    }
2431
2432    /// 辅助函数:检查 ParagraphProperties 是否有项目符号。
2433    fn pf_has_bullet(ppr: &ParagraphProperties) -> bool {
2434        ppr.bullet
2435            && ppr
2436                .bullet_style
2437                .as_ref()
2438                .map(|bs| !matches!(bs, BulletStyle::None))
2439                .unwrap_or(false)
2440    }
2441
2442    /// 验证 `ParagraphFormat` 的制表位 API。
2443    ///
2444    /// 这是 TODO-015 的测试。
2445    #[test]
2446    fn paragraph_format_tab_stops() {
2447        let mut ppr = ParagraphProperties::default();
2448        {
2449            let mut pf = ParagraphFormat::new(&mut ppr);
2450            pf.add_tab_stop(Emu(914400), TabAlignment::Left);
2451            pf.add_tab_stop(Emu(1828800), TabAlignment::Right);
2452            pf.add_tab_stop(Emu(2743200), TabAlignment::Center);
2453        }
2454        assert_eq!(ppr.tab_stops.len(), 3);
2455        assert_eq!(ppr.tab_stops[0].pos.value(), 914400);
2456        assert_eq!(ppr.tab_stops[0].alignment, TabAlignment::Left);
2457        assert_eq!(ppr.tab_stops[1].pos.value(), 1828800);
2458        assert_eq!(ppr.tab_stops[1].alignment, TabAlignment::Right);
2459        assert_eq!(ppr.tab_stops[2].pos.value(), 2743200);
2460        assert_eq!(ppr.tab_stops[2].alignment, TabAlignment::Center);
2461
2462        // 验证序列化
2463        let mut w = super::super::writer::XmlWriter::new();
2464        ppr.write_xml(&mut w);
2465        let s = w.into_string();
2466        assert!(s.contains("tabLst"), "应输出 tabLst,实际: {s}");
2467        assert!(s.contains("pos=\"914400\""), "应包含 pos=914400,实际: {s}");
2468        assert!(s.contains("algn=\"l\""), "应包含 algn=l,实际: {s}");
2469        assert!(s.contains("algn=\"r\""), "应包含 algn=r,实际: {s}");
2470        assert!(s.contains("algn=\"ctr\""), "应包含 algn=ctr,实际: {s}");
2471
2472        // 清除
2473        {
2474            let mut pf = ParagraphFormat::new(&mut ppr);
2475            pf.clear_tab_stops();
2476        }
2477        assert!(ppr.tab_stops.is_empty());
2478    }
2479
2480    /// 验证 `Field` 的序列化和 `Paragraph::add_field` API。
2481    ///
2482    /// 这是 TODO-016 的测试。
2483    #[test]
2484    fn paragraph_field_serialization() {
2485        let mut p = Paragraph::new();
2486        p.add_field(FieldType::SlideNumber, "1");
2487        p.add_field(FieldType::DateTime, "1/1/2024");
2488
2489        assert_eq!(p.fields.len(), 2);
2490        assert_eq!(p.fields[0].field_type, FieldType::SlideNumber);
2491        assert_eq!(p.fields[0].text, "1");
2492        assert_eq!(p.fields[1].field_type, FieldType::DateTime);
2493        assert_eq!(p.fields[1].text, "1/1/2024");
2494
2495        // 验证序列化
2496        let mut w = super::super::writer::XmlWriter::new();
2497        p.write_xml(&mut w);
2498        let s = w.into_string();
2499        assert!(s.contains("a:fld"), "应输出 a:fld,实际: {s}");
2500        assert!(
2501            s.contains("type=\"slidenum\""),
2502            "应包含 type=slidenum,实际: {s}"
2503        );
2504        assert!(
2505            s.contains("type=\"datetime\""),
2506            "应包含 type=datetime,实际: {s}"
2507        );
2508        assert!(s.contains(">1<"), "应包含文本 1,实际: {s}");
2509        assert!(s.contains(">1/1/2024<"), "应包含文本 1/1/2024,实际: {s}");
2510    }
2511
2512    /// 验证 `FieldType` 的转换方法。
2513    ///
2514    /// 这是 TODO-016 的测试。
2515    #[test]
2516    fn field_type_conversion() {
2517        // as_str
2518        assert_eq!(FieldType::SlideNumber.as_str(), "slidenum");
2519        assert_eq!(FieldType::DateTime.as_str(), "datetime");
2520        assert_eq!(FieldType::DateTime1.as_str(), "datetime1");
2521        assert_eq!(FieldType::Footer.as_str(), "footer");
2522        assert_eq!(FieldType::Custom("custom1".to_string()).as_str(), "custom1");
2523
2524        // from_str_value
2525        assert_eq!(
2526            FieldType::from_str_value("slidenum"),
2527            FieldType::SlideNumber
2528        );
2529        assert_eq!(FieldType::from_str_value("datetime"), FieldType::DateTime);
2530        assert_eq!(FieldType::from_str_value("datetime1"), FieldType::DateTime1);
2531        assert_eq!(FieldType::from_str_value("footer"), FieldType::Footer);
2532        assert_eq!(
2533            FieldType::from_str_value("unknown"),
2534            FieldType::Custom("unknown".to_string())
2535        );
2536    }
2537
2538    /// 验证 `Font` 超链接高阶 API(set_hyperlink / set_slide_jump)。
2539    ///
2540    /// 这是 TODO-026 的测试。
2541    #[test]
2542    fn font_hyperlink_api() {
2543        let mut run = Run::new("链接文本");
2544        // 设置 URL 超链接
2545        run.font().set_hyperlink("rId3", Some("点击访问"));
2546        let hl = run
2547            .properties
2548            .hlink_click
2549            .as_ref()
2550            .expect("hlink_click 应存在");
2551        assert_eq!(hl.rid.as_deref(), Some("rId3"));
2552        assert_eq!(hl.tooltip.as_deref(), Some("点击访问"));
2553
2554        // 设置跳转幻灯片动作
2555        run.font().set_slide_jump();
2556        let hl = run
2557            .properties
2558            .hlink_click
2559            .as_ref()
2560            .expect("hlink_click 应存在");
2561        assert_eq!(hl.action.as_deref(), Some("ppaction://hlinksldjump"));
2562
2563        // 清除
2564        run.font().clear_hlink_click();
2565        assert!(run.properties.hlink_click.is_none());
2566    }
2567
2568    /// 验证超链接的序列化 round-trip。
2569    ///
2570    /// 这是 TODO-026 的测试。
2571    #[test]
2572    fn hyperlink_serialization_roundtrip() {
2573        let mut run = Run::new("点击这里");
2574        run.font().set_hyperlink("rId7", Some("提示文字"));
2575
2576        // 序列化
2577        let mut w = super::super::writer::XmlWriter::new();
2578        run.write_xml(&mut w);
2579        let s = w.into_string();
2580        assert!(s.contains("a:hlinkClick"), "应输出 a:hlinkClick,实际: {s}");
2581        assert!(s.contains("r:id=\"rId7\""), "应包含 r:id=rId7,实际: {s}");
2582        assert!(
2583            s.contains("tooltip=\"提示文字\""),
2584            "应包含 tooltip,实际: {s}"
2585        );
2586    }
2587
2588    // --------------------- TODO-047: endParaRPr 测试 ---------------------
2589
2590    /// 验证 `Paragraph::set_end_para_rpr` 高阶 API 与序列化。
2591    ///
2592    /// 设置带属性 + 子元素的 endParaRPr,验证序列化输出正确。
2593    #[test]
2594    fn end_para_rpr_set_and_serialize() {
2595        let mut p = Paragraph::new();
2596        let rpr = RunProperties {
2597            size: Some(Pt(24.0)),
2598            latin_font: Some("Calibri".to_string()),
2599            lang: Some("en-US".to_string()),
2600            ..Default::default()
2601        };
2602        p.set_end_para_rpr(rpr);
2603
2604        // 验证 getter
2605        assert!(p.end_para_rpr().is_some());
2606        let got = p.end_para_rpr().unwrap();
2607        assert_eq!(got.size, Some(Pt(24.0)));
2608        assert_eq!(got.latin_font.as_deref(), Some("Calibri"));
2609        assert_eq!(got.lang.as_deref(), Some("en-US"));
2610
2611        // 序列化
2612        let mut w = super::super::writer::XmlWriter::new();
2613        p.write_xml(&mut w);
2614        let s = w.into_string();
2615        assert!(s.contains("a:endParaRPr"), "应输出 a:endParaRPr,实际: {s}");
2616        assert!(s.contains("sz=\"2400\""), "应包含 sz=2400,实际: {s}");
2617        assert!(s.contains("lang=\"en-US\""), "应包含 lang=en-US,实际: {s}");
2618        assert!(s.contains("a:latin"), "应包含 a:latin,实际: {s}");
2619        assert!(
2620            s.contains("typeface=\"Calibri\""),
2621            "应包含 typeface=Calibri,实际: {s}"
2622        );
2623    }
2624
2625    /// 验证 `Paragraph::clear_end_para_rpr` 清除 endParaRPr。
2626    #[test]
2627    fn end_para_rpr_clear() {
2628        let mut p = Paragraph::new();
2629        let rpr = RunProperties {
2630            size: Some(Pt(18.0)),
2631            ..Default::default()
2632        };
2633        p.set_end_para_rpr(rpr);
2634        assert!(p.end_para_rpr().is_some());
2635
2636        p.clear_end_para_rpr();
2637        assert!(p.end_para_rpr().is_none());
2638
2639        // 序列化不应包含 endParaRPr
2640        let mut w = super::super::writer::XmlWriter::new();
2641        p.write_xml(&mut w);
2642        let s = w.into_string();
2643        assert!(
2644            !s.contains("a:endParaRPr"),
2645            "不应输出 a:endParaRPr,实际: {s}"
2646        );
2647    }
2648
2649    /// 验证 `Paragraph::end_para_rpr_mut` 可变引用修改。
2650    #[test]
2651    fn end_para_rpr_mut_modify() {
2652        let mut p = Paragraph::new();
2653        let rpr = RunProperties::default();
2654        p.set_end_para_rpr(rpr);
2655
2656        // 通过可变引用修改
2657        if let Some(rpr) = p.end_para_rpr_mut() {
2658            rpr.size = Some(Pt(32.0));
2659            rpr.bold = true;
2660        }
2661
2662        let got = p.end_para_rpr().expect("end_para_rpr 应存在");
2663        assert_eq!(got.size, Some(Pt(32.0)));
2664        assert!(got.bold);
2665    }
2666
2667    /// 验证带子元素的 endParaRPr 的 round-trip(序列化 → 解析 → 比对)。
2668    ///
2669    /// 这是 TODO-047 的核心测试:确保带 solidFill/latin 等子元素的
2670    /// `<a:endParaRPr>` 能被正确解析。
2671    #[test]
2672    fn end_para_rpr_roundtrip_with_children() {
2673        // 1. 构造段落并设置 endParaRPr
2674        let mut p = Paragraph::new();
2675        p.add_run_with_text("hello");
2676        let rpr = RunProperties {
2677            size: Some(Pt(20.0)),
2678            bold: true,
2679            latin_font: Some("Arial".to_string()),
2680            lang: Some("en-US".to_string()),
2681            ..Default::default()
2682        };
2683        p.set_end_para_rpr(rpr);
2684
2685        // 2. 序列化
2686        let mut w = super::super::writer::XmlWriter::new();
2687        p.write_xml(&mut w);
2688        let xml = w.into_string();
2689
2690        // 3. 解析回来
2691        let parsed = crate::oxml::parse_sld::parse_paragraph(&xml).expect("解析应成功");
2692
2693        // 4. 验证 endParaRPr 的属性和子元素都被正确解析
2694        let got = parsed.end_properties.expect("end_properties 应存在");
2695        assert_eq!(got.size, Some(Pt(20.0)), "size 应为 20pt");
2696        assert!(got.bold, "bold 应为 true");
2697        assert_eq!(
2698            got.latin_font.as_deref(),
2699            Some("Arial"),
2700            "latin_font 应为 Arial"
2701        );
2702        assert_eq!(got.lang.as_deref(), Some("en-US"), "lang 应为 en-US");
2703    }
2704
2705    /// 验证自闭合 endParaRPr(仅属性,无子元素)的解析。
2706    ///
2707    /// 这是 TODO-047 的回归测试:确保原有的 Empty 事件处理仍正常工作。
2708    #[test]
2709    fn end_para_rpr_self_closing_parse() {
2710        let xml = r#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2711<a:r><a:t>text</a:t></a:r>
2712<a:endParaRPr lang="en-US" sz="1800"/>
2713</a:p>"#;
2714        let p = crate::oxml::parse_sld::parse_paragraph(xml).expect("解析应成功");
2715        let got = p.end_properties.expect("end_properties 应存在");
2716        assert_eq!(got.size, Some(Pt(18.0)));
2717        assert_eq!(got.lang.as_deref(), Some("en-US"));
2718    }
2719
2720    /// 验证带 solidFill 子元素的 endParaRPr 解析。
2721    #[test]
2722    fn end_para_rpr_with_solid_fill() {
2723        let xml = r#"<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
2724<a:endParaRPr lang="en-US" sz="2400"><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></a:endParaRPr>
2725</a:p>"#;
2726        let p = crate::oxml::parse_sld::parse_paragraph(xml).expect("解析应成功");
2727        let got = p.end_properties.expect("end_properties 应存在");
2728        assert_eq!(got.size, Some(Pt(24.0)));
2729        assert_eq!(got.lang.as_deref(), Some("en-US"));
2730        // 验证 solidFill 子元素被解析
2731        assert!(
2732            matches!(got.color, crate::oxml::color::Color::RGB(c) if c.0 == 0xFF && c.1 == 0x00 && c.2 == 0x00)
2733        );
2734    }
2735
2736    // ===================== 东亚字体便捷 API 测试(TODO-005 剩余) =====================
2737
2738    /// `Run::eastasia_name` / `set_eastasia_name` 往返:设置后能正确读取。
2739    #[test]
2740    fn run_eastasia_name_setter_and_getter() {
2741        let mut r = Run::new("你好");
2742        assert!(r.eastasia_name().is_none());
2743        r.set_eastasia_name("宋体");
2744        assert_eq!(r.eastasia_name(), Some("宋体"));
2745        assert_eq!(r.properties.eastasia_font.as_deref(), Some("宋体"));
2746    }
2747
2748    /// `Run::complex_script_name` / `set_complex_script_name` 往返。
2749    #[test]
2750    fn run_complex_script_name_setter_and_getter() {
2751        let mut r = Run::new("مرحبا");
2752        assert!(r.complex_script_name().is_none());
2753        r.set_complex_script_name("Traditional Arabic");
2754        assert_eq!(r.complex_script_name(), Some("Traditional Arabic"));
2755        assert_eq!(r.properties.cs_font.as_deref(), Some("Traditional Arabic"));
2756    }
2757
2758    /// `Run` 上 latin / ea / cs 三种字体可独立设置,互不干扰。
2759    #[test]
2760    fn run_three_fonts_independent() {
2761        let mut r = Run::new("Hello 你好 مرحبا");
2762        r.set_font_name("Arial");
2763        r.set_eastasia_name("Microsoft YaHei");
2764        r.set_complex_script_name("Tahoma");
2765        assert_eq!(r.font_name(), Some("Arial"));
2766        assert_eq!(r.eastasia_name(), Some("Microsoft YaHei"));
2767        assert_eq!(r.complex_script_name(), Some("Tahoma"));
2768        // 验证底层 properties 字段独立
2769        assert_eq!(r.properties.latin_font.as_deref(), Some("Arial"));
2770        assert_eq!(
2771            r.properties.eastasia_font.as_deref(),
2772            Some("Microsoft YaHei")
2773        );
2774        assert_eq!(r.properties.cs_font.as_deref(), Some("Tahoma"));
2775    }
2776
2777    /// `Font::clear_eastasia_name` 清空东亚字体字段。
2778    #[test]
2779    fn font_clear_eastasia_name() {
2780        let mut r = Run::new("文本");
2781        r.set_eastasia_name("宋体");
2782        assert_eq!(r.eastasia_name(), Some("宋体"));
2783        // 通过 Font 视图清空
2784        {
2785            let mut f = Font::new(&mut r.properties);
2786            f.clear_eastasia_name();
2787        }
2788        assert!(r.eastasia_name().is_none());
2789        assert!(r.properties.eastasia_font.is_none());
2790    }
2791
2792    /// `Font::clear_complex_script_name` 清空复杂脚本字体字段。
2793    #[test]
2794    fn font_clear_complex_script_name() {
2795        let mut r = Run::new("text");
2796        r.set_complex_script_name("Arial");
2797        assert_eq!(r.complex_script_name(), Some("Arial"));
2798        // 通过 Font 视图清空
2799        {
2800            let mut f = Font::new(&mut r.properties);
2801            f.clear_complex_script_name();
2802        }
2803        assert!(r.complex_script_name().is_none());
2804        assert!(r.properties.cs_font.is_none());
2805    }
2806
2807    /// `Font` 视图的 eastasia/cs 访问器与 `Run` 便捷方法一致。
2808    #[test]
2809    fn font_view_eastasia_cs_consistent_with_run() {
2810        let mut r = Run::new("混合文本");
2811        r.set_eastasia_name("黑体");
2812        r.set_complex_script_name("Tahoma");
2813        // 先用 Run 便捷方法读取(避免与 Font 视图的可变借用冲突)
2814        let run_ea = r.eastasia_name().map(|s| s.to_string());
2815        let run_cs = r.complex_script_name().map(|s| s.to_string());
2816        // Font 视图读取应与 Run 便捷方法一致
2817        let f = Font::new(&mut r.properties);
2818        assert_eq!(f.eastasia_name(), run_ea.as_deref());
2819        assert_eq!(f.complex_script_name(), run_cs.as_deref());
2820    }
2821
2822    /// 东亚字体序列化:`<a:ea typeface="..."/>` 应在 `<a:latin>` 之后写出。
2823    #[test]
2824    fn eastasia_font_serialization_order() {
2825        let mut r = Run::new("你好");
2826        r.set_font_name("Arial");
2827        r.set_eastasia_name("宋体");
2828        r.set_complex_script_name("Tahoma");
2829        let mut p = Paragraph::new();
2830        p.runs.push(r);
2831        let mut w = crate::oxml::writer::XmlWriter::new();
2832        p.write_xml(&mut w);
2833        let xml = w.into_string();
2834        // 验证三种字体元素都存在
2835        assert!(
2836            xml.contains(r#"<a:latin typeface="Arial"/>"#),
2837            "xml: {}",
2838            xml
2839        );
2840        assert!(xml.contains(r#"<a:ea typeface="宋体"/>"#), "xml: {}", xml);
2841        assert!(xml.contains(r#"<a:cs typeface="Tahoma"/>"#), "xml: {}", xml);
2842        // 验证 OOXML 顺序:latin → ea → cs
2843        let pos_latin = xml.find("<a:latin").expect("latin should exist");
2844        let pos_ea = xml.find("<a:ea").expect("ea should exist");
2845        let pos_cs = xml.find("<a:cs").expect("cs should exist");
2846        assert!(pos_latin < pos_ea, "latin must come before ea: {}", xml);
2847        assert!(pos_ea < pos_cs, "ea must come before cs: {}", xml);
2848    }
2849}