Skip to main content

ooxml_core/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 ooxml_core::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 ooxml_core::oxml::txbody::{Paragraph, RunProperties};
1085    /// # use ooxml_core::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    ///
1120    /// `tag` 参数指定外层元素标签名(如 `"p:txBody"` 用于 PPTX,
1121    /// `"c:txBody"` 用于 chart 标题等)。这样本类型可在不同格式间复用。
1122    pub fn write_xml(&self, w: &mut super::writer::XmlWriter, tag: &str) {
1123        w.open(tag);
1124        if let Some(bp) = &self.body_properties {
1125            bp.write_xml(w);
1126        }
1127        for p in &self.paragraphs {
1128            p.write_xml(w);
1129        }
1130        w.close(tag);
1131    }
1132
1133    // --------------------- 高阶 API(对齐 python-pptx TextFrame) ---------------------
1134
1135    /// 取所有文本(段间 `\n`)。
1136    pub fn text(&self) -> String {
1137        let mut out = String::new();
1138        for (i, p) in self.paragraphs.iter().enumerate() {
1139            if i > 0 {
1140                out.push('\n');
1141            }
1142            for r in &p.runs {
1143                out.push_str(&r.text);
1144            }
1145        }
1146        out
1147    }
1148
1149    /// 取首个段落(`TextFrame.paragraphs[0]` 的等价物)。
1150    pub fn first_paragraph(&self) -> Option<&Paragraph> {
1151        self.paragraphs.first()
1152    }
1153
1154    /// 取首个段落的可变引用。
1155    pub fn first_paragraph_mut(&mut self) -> Option<&mut Paragraph> {
1156        self.paragraphs.first_mut()
1157    }
1158
1159    /// 取第 idx 个段落的不可变引用(越界返回 None)。
1160    pub fn paragraph(&self, idx: usize) -> Option<&Paragraph> {
1161        self.paragraphs.get(idx)
1162    }
1163
1164    /// 取第 idx 个段落的可变引用(越界返回 None)。
1165    pub fn paragraph_mut(&mut self, idx: usize) -> Option<&mut Paragraph> {
1166        self.paragraphs.get_mut(idx)
1167    }
1168
1169    /// 新增一个空段落并返回其可变引用。
1170    ///
1171    /// 对应 `TextFrame.add_paragraph()`。
1172    pub fn add_paragraph(&mut self) -> &mut Paragraph {
1173        self.paragraphs.push(Paragraph::new());
1174        // push 后直接按索引取最后一个元素,避免 expect
1175        let idx = self.paragraphs.len() - 1;
1176        &mut self.paragraphs[idx]
1177    }
1178
1179    /// 新增一个**带文本**的段落,返回其可变引用。
1180    ///
1181    /// 文本按 `\n` 切分为多段,与 python-pptx 的 `TextFrame.text = "a\nb"` 行为对齐。
1182    pub fn add_paragraph_with_text(&mut self, text: &str) -> &mut Paragraph {
1183        for line in text.split('\n') {
1184            let mut p = Paragraph::new();
1185            p.runs.push(Run::new(line));
1186            self.paragraphs.push(p);
1187        }
1188        // split('\n') 至少产生一个元素(空字符串也会产生一段),所以 paragraphs 非空
1189        let idx = self.paragraphs.len() - 1;
1190        &mut self.paragraphs[idx]
1191    }
1192
1193    /// 移除第 idx 个段落。
1194    pub fn remove_paragraph(&mut self, idx: usize) -> Option<Paragraph> {
1195        if idx < self.paragraphs.len() {
1196            Some(self.paragraphs.remove(idx))
1197        } else {
1198            None
1199        }
1200    }
1201
1202    /// 清空段落(保留一个空段,对应 python-pptx `TextFrame.clear()` 语义)。
1203    pub fn clear(&mut self) {
1204        self.paragraphs.clear();
1205        self.paragraphs.push(Paragraph::new());
1206    }
1207
1208    /// 整体替换文本(按 `\n` 切分为多段,丢弃原段落属性)。
1209    ///
1210    /// 对应 python-pptx `text_frame.text = "a\nb"`。
1211    pub fn set_text(&mut self, text: &str) {
1212        self.paragraphs.clear();
1213        for line in text.split('\n') {
1214            let mut p = Paragraph::new();
1215            p.runs.push(Run::new(line));
1216            self.paragraphs.push(p);
1217        }
1218    }
1219
1220    // --------------------- BodyProperties 便捷访问 ---------------------
1221
1222    /// 取/建 `body_properties`。
1223    fn ensure_body_properties(&mut self) -> &mut BodyProperties {
1224        if self.body_properties.is_none() {
1225            self.body_properties = Some(BodyProperties::default());
1226        }
1227        // 安全:上方 if 确保了 body_properties 为 Some;使用 match 避免 expect
1228        match &mut self.body_properties {
1229            Some(bp) => bp,
1230            None => unreachable!("body_properties was just initialized above"),
1231        }
1232    }
1233
1234    /// 自动调整策略(`TextFrame.auto_size`)。
1235    pub fn auto_size(&self) -> MsoAutoSize {
1236        self.body_properties
1237            .as_ref()
1238            .and_then(|b| b.auto_size())
1239            .unwrap_or(MsoAutoSize::None)
1240    }
1241    /// 设置自动调整策略。
1242    pub fn set_auto_size(&mut self, v: MsoAutoSize) {
1243        self.ensure_body_properties().set_auto_size(v);
1244    }
1245
1246    /// 垂直对齐(`TextFrame.vertical_anchor`)。
1247    pub fn vertical_anchor(&self) -> Option<MsoAnchor> {
1248        self.body_properties.as_ref().and_then(|b| b.anchor)
1249    }
1250    /// 设置垂直对齐。
1251    pub fn set_vertical_anchor(&mut self, v: MsoAnchor) {
1252        self.ensure_body_properties().anchor = Some(v);
1253    }
1254
1255    /// 是否自动换行(`TextFrame.word_wrap`)。
1256    ///
1257    /// 返回 `None` 表示走默认值 / 继承。
1258    pub fn word_wrap(&self) -> Option<bool> {
1259        self.body_properties.as_ref().and_then(|b| match b.wrap {
1260            Some(TextWrapping::Square) => Some(true),
1261            Some(TextWrapping::None) => Some(false),
1262            None => None,
1263        })
1264    }
1265    /// 设置是否自动换行。
1266    pub fn set_word_wrap(&mut self, v: bool) {
1267        self.ensure_body_properties().wrap = Some(if v {
1268            TextWrapping::Square
1269        } else {
1270            TextWrapping::None
1271        });
1272    }
1273
1274    /// 左边距(EMU)。
1275    pub fn margin_left(&self) -> Option<Emu> {
1276        self.body_properties
1277            .as_ref()
1278            .and_then(|b| b.insets.as_ref().map(|i| i.left))
1279    }
1280    /// 右边距。
1281    pub fn margin_right(&self) -> Option<Emu> {
1282        self.body_properties
1283            .as_ref()
1284            .and_then(|b| b.insets.as_ref().map(|i| i.right))
1285    }
1286    /// 上边距。
1287    pub fn margin_top(&self) -> Option<Emu> {
1288        self.body_properties
1289            .as_ref()
1290            .and_then(|b| b.insets.as_ref().map(|i| i.top))
1291    }
1292    /// 下边距。
1293    pub fn margin_bottom(&self) -> Option<Emu> {
1294        self.body_properties
1295            .as_ref()
1296            .and_then(|b| b.insets.as_ref().map(|i| i.bottom))
1297    }
1298    /// 一次性设置四向边距。
1299    pub fn set_margins(&mut self, left: Emu, top: Emu, right: Emu, bottom: Emu) {
1300        let bp = self.ensure_body_properties();
1301        bp.insets = Some(Inset {
1302            left,
1303            top,
1304            right,
1305            bottom,
1306        });
1307    }
1308    /// 设置左边距。
1309    pub fn set_margin_left(&mut self, emu: Emu) {
1310        let bp = self.ensure_body_properties();
1311        let i = bp.insets.get_or_insert(Inset::default());
1312        i.left = emu;
1313    }
1314    /// 设置右边距。
1315    pub fn set_margin_right(&mut self, emu: Emu) {
1316        let bp = self.ensure_body_properties();
1317        let i = bp.insets.get_or_insert(Inset::default());
1318        i.right = emu;
1319    }
1320    /// 设置上边距。
1321    pub fn set_margin_top(&mut self, emu: Emu) {
1322        let bp = self.ensure_body_properties();
1323        let i = bp.insets.get_or_insert(Inset::default());
1324        i.top = emu;
1325    }
1326    /// 设置下边距。
1327    pub fn set_margin_bottom(&mut self, emu: Emu) {
1328        let bp = self.ensure_body_properties();
1329        let i = bp.insets.get_or_insert(Inset::default());
1330        i.bottom = emu;
1331    }
1332
1333    /// 文本列数(`numCol` 属性)。
1334    ///
1335    /// 返回 `None` 表示未设置(默认单列);`Some(1)` 等价于单列。
1336    pub fn num_cols(&self) -> Option<u32> {
1337        self.body_properties.as_ref().and_then(|b| b.num_cols)
1338    }
1339    /// 设置文本列数。
1340    pub fn set_num_cols(&mut self, count: u32) {
1341        let bp = self.ensure_body_properties();
1342        bp.num_cols = Some(count);
1343    }
1344    /// 列间距(`spcCol` 属性,EMU)。
1345    pub fn col_spacing(&self) -> Option<Emu> {
1346        self.body_properties.as_ref().and_then(|b| b.col_spacing)
1347    }
1348    /// 设置列间距。
1349    pub fn set_col_spacing(&mut self, emu: Emu) {
1350        let bp = self.ensure_body_properties();
1351        bp.col_spacing = Some(emu);
1352    }
1353}
1354
1355/// 文本体属性 `<a:bodyPr>`。
1356#[derive(Clone, Debug, Default)]
1357pub struct BodyProperties {
1358    /// 文本与边界框的内边距。
1359    pub insets: Option<Inset>,
1360    /// 文字方向。
1361    pub vertical: Option<String>,
1362    /// 文字方向(rotation)。
1363    pub rotation: Option<i32>,
1364    /// 自动换行(`wrap="square"/"none"`)。
1365    pub wrap: Option<TextWrapping>,
1366    /// 文本框在溢出时的行为。
1367    pub sp_auto_fit: bool,
1368    /// 形状自适应文字。
1369    pub norm_autofit: bool,
1370    /// 锚定(top/ctr/b)。
1371    pub anchor: Option<MsoAnchor>,
1372    /// 水平对齐(l/ctr/just/dist)——仅占位/组合时使用。
1373    pub anchor_ctr: bool,
1374    /// 文本列数(`numCol` 属性,1 表示单列即默认)。
1375    ///
1376    /// 对应 python-pptx `TextFrame.word_wrap` 相关的多列布局。
1377    /// 多列时文本从左到右依次填充各列。
1378    pub num_cols: Option<u32>,
1379    /// 列间距(`spcCol` 属性,EMU)。
1380    ///
1381    /// 仅当 `num_cols > 1` 时有效。
1382    pub col_spacing: Option<Emu>,
1383}
1384
1385/// 文本框内边距(`<a:bodyPr lIns tIns rIns bIns>`)。
1386///
1387/// 在 `<a:bodyPr>` 元素上以 4 个独立属性表示,单位 EMU。
1388/// python-pptx 中 `TextFrame.margin_left/right/top/bottom` 即对应这 4 个值。
1389#[derive(Copy, Clone, Debug, Default)]
1390pub struct Inset {
1391    /// 左内边距(EMU)。
1392    pub left: Emu,
1393    /// 上内边距(EMU)。
1394    pub top: Emu,
1395    /// 右内边距(EMU)。
1396    pub right: Emu,
1397    /// 下内边距(EMU)。
1398    pub bottom: Emu,
1399}
1400
1401impl BodyProperties {
1402    /// 写 XML。
1403    pub fn write_xml(&self, w: &mut super::writer::XmlWriter) {
1404        // 提前取出所有要序列化的字符串,扩展到函数末尾
1405        let l_s = self.insets.as_ref().map(|i| i.left.value().to_string());
1406        let t_s = self.insets.as_ref().map(|i| i.top.value().to_string());
1407        let r_s = self.insets.as_ref().map(|i| i.right.value().to_string());
1408        let b_s = self.insets.as_ref().map(|i| i.bottom.value().to_string());
1409        let rot_s = self.rotation.map(|v| v.to_string());
1410        let wrap_s = self.wrap.map(|v| v.as_str());
1411        let anchor_s = self.anchor.map(|v| v.as_str());
1412        let numcol_s = self.num_cols.map(|v| v.to_string());
1413        let spccol_s = self.col_spacing.map(|v| v.value().to_string());
1414
1415        let mut attrs: Vec<(&str, &str)> = Vec::new();
1416        if let Some(s) = &l_s {
1417            attrs.push(("lIns", s));
1418        }
1419        if let Some(s) = &t_s {
1420            attrs.push(("tIns", s));
1421        }
1422        if let Some(s) = &r_s {
1423            attrs.push(("rIns", s));
1424        }
1425        if let Some(s) = &b_s {
1426            attrs.push(("bIns", s));
1427        }
1428        if let Some(v) = &self.vertical {
1429            attrs.push(("vert", v));
1430        }
1431        if let Some(s) = &rot_s {
1432            attrs.push(("rot", s));
1433        }
1434        if let Some(s) = &wrap_s {
1435            attrs.push(("wrap", s));
1436        }
1437        if let Some(s) = &anchor_s {
1438            attrs.push(("anchor", s));
1439        }
1440        if let Some(s) = &numcol_s {
1441            attrs.push(("numCol", s));
1442        }
1443        if let Some(s) = &spccol_s {
1444            attrs.push(("spcCol", s));
1445        }
1446        w.open_with("a:bodyPr", &attrs);
1447        // 自动调整子元素:优先按 sp_auto_fit / norm_autofit 标志位写出(保留兼容路径)
1448        if self.sp_auto_fit {
1449            w.empty("a:spAutoFit");
1450        } else if self.norm_autofit {
1451            w.empty("a:normAutofit");
1452        }
1453        w.close("a:bodyPr");
1454    }
1455
1456    /// 取 [`MsoAutoSize`] 视图(合并 `sp_auto_fit` / `norm_autofit` 两个 bool)。
1457    pub fn auto_size(&self) -> Option<MsoAutoSize> {
1458        if self.sp_auto_fit {
1459            Some(MsoAutoSize::ShapeToFitText)
1460        } else if self.norm_autofit {
1461            Some(MsoAutoSize::TextToFitShape)
1462        } else {
1463            None
1464        }
1465    }
1466    /// 设置 [`MsoAutoSize`] 视图(同时清空另一个标志位以保持互斥)。
1467    pub fn set_auto_size(&mut self, v: MsoAutoSize) {
1468        match v {
1469            MsoAutoSize::None => {
1470                self.sp_auto_fit = false;
1471                self.norm_autofit = false;
1472            }
1473            MsoAutoSize::ShapeToFitText => {
1474                self.sp_auto_fit = true;
1475                self.norm_autofit = false;
1476            }
1477            MsoAutoSize::TextToFitShape => {
1478                self.sp_auto_fit = false;
1479                self.norm_autofit = true;
1480            }
1481        }
1482    }
1483}
1484
1485impl From<RGBColor> for Color {
1486    fn from(c: RGBColor) -> Self {
1487        Color::RGB(c)
1488    }
1489}
1490
1491// ====================================================================
1492// 高阶 Font 视图(python-pptx 风格 `pptx.text.text.Font`)
1493// ====================================================================
1494
1495use crate::oxml::color::ColorFormat;
1496
1497/// 字体高阶视图(`pptx.text.text.Font`)。
1498///
1499/// # 与 python-pptx 的对应
1500///
1501/// - `pptx.text.text.Font` ←→ [`Font`];
1502/// - `font.bold = True` / `font.size = Pt(24)` / `font.color.rgb = RGBColor(...)`
1503///   ←→ [`Font::set_bold`] / [`Font::set_size`] / [`Font::color`].set_rgb()。
1504///
1505/// # 设计要点
1506///
1507/// - **借用 + 透明代理**:构造时传入 `&mut RunProperties`;
1508/// - **零分配**:颜色走 [`ColorFormat`] 借用;
1509/// - **可空值语义**:`name` / `size` / `lang` 等字段用 `Option<T>`,
1510///   `None` 表示"走主题继承"——与 python-pptx `None` 一致。
1511#[derive(Debug)]
1512pub struct Font<'a> {
1513    /// 底层 [`RunProperties`] 引用。
1514    rpr: &'a mut RunProperties,
1515}
1516
1517impl<'a> Font<'a> {
1518    /// 构造。
1519    pub fn new(rpr: &'a mut RunProperties) -> Self {
1520        Font { rpr }
1521    }
1522    /// 底层 rpr 不可变引用。
1523    pub fn rpr(&self) -> &RunProperties {
1524        self.rpr
1525    }
1526    /// 底层 rpr 可变引用。
1527    pub fn rpr_mut(&mut self) -> &mut RunProperties {
1528        self.rpr
1529    }
1530
1531    /// 颜色 [`ColorFormat`] 代理。
1532    pub fn color(&mut self) -> ColorFormat<'_> {
1533        ColorFormat::new(&mut self.rpr.color)
1534    }
1535
1536    /// 字号(Pt)。
1537    pub fn size(&self) -> Option<Pt> {
1538        self.rpr.size
1539    }
1540    /// 设置字号。
1541    pub fn set_size(&mut self, v: Pt) {
1542        self.rpr.size = Some(v);
1543    }
1544    /// 清空字号(走主题继承)。
1545    pub fn clear_size(&mut self) {
1546        self.rpr.size = None;
1547    }
1548
1549    /// 加粗。
1550    pub fn bold(&self) -> bool {
1551        self.rpr.bold
1552    }
1553    /// 设置加粗。
1554    pub fn set_bold(&mut self, v: bool) {
1555        self.rpr.bold = v;
1556    }
1557
1558    /// 斜体。
1559    pub fn italic(&self) -> bool {
1560        self.rpr.italic
1561    }
1562    /// 设置斜体。
1563    pub fn set_italic(&mut self, v: bool) {
1564        self.rpr.italic = v;
1565    }
1566
1567    /// 删除线。
1568    pub fn strike(&self) -> bool {
1569        self.rpr.strike
1570    }
1571    /// 设置删除线。
1572    pub fn set_strike(&mut self, v: bool) {
1573        self.rpr.strike = v;
1574    }
1575
1576    /// 双删除线。
1577    ///
1578    /// 对标 python-pptx `font._rPr.attrib['strike'] == 'dblStrike'`。
1579    /// 当为 `true` 时,写出 `strike="dblStrike"`;普通删除线请用 [`Self::set_strike`]。
1580    pub fn double_strike(&self) -> bool {
1581        self.rpr.strike_dbl
1582    }
1583    /// 设置双删除线。
1584    pub fn set_double_strike(&mut self, v: bool) {
1585        self.rpr.strike_dbl = v;
1586    }
1587
1588    /// 高亮色。
1589    ///
1590    /// 对标 python-pptx `Font.highlight_color`(v0.6.21+)。
1591    /// 返回 `None` 表示未设置高亮。
1592    pub fn highlight(&self) -> Option<&Color> {
1593        self.rpr.highlight.as_ref()
1594    }
1595    /// 设置高亮色。
1596    ///
1597    /// 传入 `None` 清除高亮;传入 `Color::None` 也清除高亮。
1598    pub fn set_highlight(&mut self, color: Option<Color>) {
1599        match color {
1600            None => self.rpr.highlight = None,
1601            Some(Color::None) => self.rpr.highlight = None,
1602            Some(c) => self.rpr.highlight = Some(c),
1603        }
1604    }
1605
1606    /// 下划线。
1607    pub fn underline(&self) -> Option<Underline> {
1608        self.rpr.underline
1609    }
1610    /// 设置下划线(`None` 表示清除)。
1611    pub fn set_underline(&mut self, v: Option<Underline>) {
1612        self.rpr.underline = v;
1613    }
1614
1615    /// 字体名(拉丁)。
1616    pub fn name(&self) -> Option<&str> {
1617        self.rpr.latin_font.as_deref()
1618    }
1619    /// 设置字体名。
1620    pub fn set_name(&mut self, n: impl Into<String>) {
1621        self.rpr.latin_font = Some(n.into());
1622    }
1623    /// 清空字体名(走主题继承)。
1624    pub fn clear_name(&mut self) {
1625        self.rpr.latin_font = None;
1626    }
1627
1628    /// 东亚字体。
1629    pub fn eastasia_name(&self) -> Option<&str> {
1630        self.rpr.eastasia_font.as_deref()
1631    }
1632    /// 设置东亚字体。
1633    pub fn set_eastasia_name(&mut self, n: impl Into<String>) {
1634        self.rpr.eastasia_font = Some(n.into());
1635    }
1636    /// 清空东亚字体(走主题继承)。
1637    ///
1638    /// 对应 OOXML 中删除 `<a:ea>` 元素,PowerPoint 将使用主题的
1639    /// `minorFont.ea` / `majorFont.ea` 作为回退。
1640    pub fn clear_eastasia_name(&mut self) {
1641        self.rpr.eastasia_font = None;
1642    }
1643
1644    /// 复杂脚本字体。
1645    pub fn complex_script_name(&self) -> Option<&str> {
1646        self.rpr.cs_font.as_deref()
1647    }
1648    /// 设置复杂脚本字体。
1649    pub fn set_complex_script_name(&mut self, n: impl Into<String>) {
1650        self.rpr.cs_font = Some(n.into());
1651    }
1652    /// 清空复杂脚本字体(走主题继承)。
1653    ///
1654    /// 对应 OOXML 中删除 `<a:cs>` 元素,PowerPoint 将使用主题的
1655    /// `minorFont.cs` / `majorFont.cs` 作为回退。
1656    pub fn clear_complex_script_name(&mut self) {
1657        self.rpr.cs_font = None;
1658    }
1659
1660    /// baseline 偏移(百分比,正=上标,负=下标)。
1661    pub fn baseline(&self) -> Option<i32> {
1662        self.rpr.baseline
1663    }
1664    /// 设置 baseline。
1665    pub fn set_baseline(&mut self, v: i32) {
1666        self.rpr.baseline = Some(v);
1667    }
1668
1669    /// 字符间距(百分之一磅)。
1670    pub fn spacing(&self) -> Option<i32> {
1671        self.rpr.spc
1672    }
1673    /// 设置字符间距。
1674    pub fn set_spacing(&mut self, v: i32) {
1675        self.rpr.spc = Some(v);
1676    }
1677
1678    // ===== 超链接 API(TODO-026)=====
1679
1680    /// 取点击超链接(`<a:hlinkClick>`)。
1681    pub fn hlink_click(&self) -> Option<&Hyperlink> {
1682        self.rpr.hlink_click.as_ref()
1683    }
1684    /// 设置点击超链接(直接传入 [`Hyperlink`])。
1685    pub fn set_hlink_click(&mut self, hl: Hyperlink) {
1686        self.rpr.hlink_click = Some(hl);
1687    }
1688    /// 清除点击超链接。
1689    pub fn clear_hlink_click(&mut self) {
1690        self.rpr.hlink_click = None;
1691    }
1692
1693    /// 取悬停超链接(`<a:hlinkHover>`)。
1694    pub fn hlink_hover(&self) -> Option<&Hyperlink> {
1695        self.rpr.hlink_hover.as_ref()
1696    }
1697    /// 设置悬停超链接。
1698    pub fn set_hlink_hover(&mut self, hl: Hyperlink) {
1699        self.rpr.hlink_hover = Some(hl);
1700    }
1701    /// 清除悬停超链接。
1702    pub fn clear_hlink_hover(&mut self) {
1703        self.rpr.hlink_hover = None;
1704    }
1705
1706    /// 便捷方法:设置一个指向 URL 的点击超链接。
1707    ///
1708    /// # 参数
1709    /// - `rid`:关系 ID(指向 `.rels` 中的目标 URL);
1710    /// - `tooltip`:可选的鼠标悬停提示。
1711    ///
1712    /// # 示例
1713    /// ```no_run
1714    /// # use ooxml_core::oxml::txbody::{Run, Font};
1715    /// # let mut run = Run::new("链接文本");
1716    /// run.font().set_hyperlink("rId1", Some("点击访问"));
1717    /// ```
1718    pub fn set_hyperlink(&mut self, rid: impl Into<String>, tooltip: Option<&str>) {
1719        let mut hl = Hyperlink::new(rid);
1720        if let Some(t) = tooltip {
1721            hl.tooltip = Some(t.to_string());
1722        }
1723        self.rpr.hlink_click = Some(hl);
1724    }
1725
1726    /// 便捷方法:设置一个跳转幻灯片的动作超链接。
1727    ///
1728    /// 对应 OOXML `action="ppaction://hlinksldjump"`。
1729    pub fn set_slide_jump(&mut self) {
1730        self.rpr.hlink_click = Some(Hyperlink::new_slide_jump());
1731    }
1732}
1733
1734impl<'a> From<&'a mut RunProperties> for Font<'a> {
1735    fn from(r: &'a mut RunProperties) -> Self {
1736        Font::new(r)
1737    }
1738}
1739
1740// ====================================================================
1741// 高阶 ParagraphFormat 视图(python-pptx 风格 `pptx.text.text._ParagraphFormat`)
1742// ====================================================================
1743
1744/// 段落格式高阶视图(`pptx.text.text._ParagraphFormat`)。
1745///
1746/// # 与 python-pptx 的对应
1747///
1748/// - `paragraph.alignment = PP_ALIGN.CENTER` ←→ [`ParagraphFormat::set_alignment`];
1749/// - `paragraph.line_spacing = 1.5` ←→ [`ParagraphFormat::set_line_spacing`];
1750/// - `paragraph.space_before = Pt(6)` ←→ [`ParagraphFormat::set_space_before`]。
1751///
1752/// # 设计要点
1753///
1754/// - **借用 + 透明代理**:构造时传入 `&mut ParagraphProperties`;
1755/// - **零分配**:纯字段操作;
1756/// - **互斥语义**:`set_line_spacing` / `set_line_spacing_pct` 互斥;
1757///   后调用的会清空前者。
1758#[derive(Debug)]
1759pub struct ParagraphFormat<'a> {
1760    /// 底层 [`ParagraphProperties`] 引用。
1761    ppr: &'a mut ParagraphProperties,
1762}
1763
1764impl<'a> ParagraphFormat<'a> {
1765    /// 构造。
1766    pub fn new(ppr: &'a mut ParagraphProperties) -> Self {
1767        ParagraphFormat { ppr }
1768    }
1769    /// 底层 ppr 不可变引用。
1770    pub fn ppr(&self) -> &ParagraphProperties {
1771        self.ppr
1772    }
1773    /// 底层 ppr 可变引用。
1774    pub fn ppr_mut(&mut self) -> &mut ParagraphProperties {
1775        self.ppr
1776    }
1777
1778    /// 水平对齐。
1779    pub fn alignment(&self) -> Option<Alignment> {
1780        self.ppr.alignment
1781    }
1782    /// 设置水平对齐。
1783    pub fn set_alignment(&mut self, v: Alignment) {
1784        self.ppr.alignment = Some(v);
1785    }
1786    /// 清除水平对齐(走默认)。
1787    pub fn clear_alignment(&mut self) {
1788        self.ppr.alignment = None;
1789    }
1790
1791    /// 段落级别(0-8)。
1792    pub fn level(&self) -> u8 {
1793        self.ppr.level
1794    }
1795    /// 设置段落级别。
1796    pub fn set_level(&mut self, lvl: u8) {
1797        self.ppr.level = lvl;
1798    }
1799
1800    /// 行距(固定值,Pt)。
1801    pub fn line_spacing(&self) -> Option<Pt> {
1802        self.ppr.line_spacing.map(|emu| Pt(emu as f64 / 12_700.0))
1803    }
1804    /// 设置行距为**固定点数**(与 `set_line_spacing_pct` 互斥)。
1805    pub fn set_line_spacing(&mut self, v: Pt) {
1806        let emu = (v.value() * 12_700.0) as i32;
1807        self.ppr.line_spacing = Some(emu);
1808        self.ppr.line_spacing_pct = None;
1809    }
1810    /// 行距(倍数,1.0 = 100%)。
1811    pub fn line_spacing_pct(&self) -> Option<f32> {
1812        self.ppr.line_spacing_pct.map(|v| v as f32 / 1000.0)
1813    }
1814    /// 设置行距为**倍数**(与 `set_line_spacing` 互斥)。
1815    pub fn set_line_spacing_pct(&mut self, v: f32) {
1816        self.ppr.line_spacing_pct = Some((v * 1000.0) as i32);
1817        self.ppr.line_spacing = None;
1818    }
1819    /// 清除行距。
1820    pub fn clear_line_spacing(&mut self) {
1821        self.ppr.line_spacing = None;
1822        self.ppr.line_spacing_pct = None;
1823    }
1824
1825    /// 段前(EMU)。
1826    pub fn space_before(&self) -> Option<Emu> {
1827        self.ppr.space_before
1828    }
1829    /// 设置段前。
1830    pub fn set_space_before(&mut self, emu: Emu) {
1831        self.ppr.space_before = Some(emu);
1832    }
1833    /// 段后(EMU)。
1834    pub fn space_after(&self) -> Option<Emu> {
1835        self.ppr.space_after
1836    }
1837    /// 设置段后。
1838    pub fn set_space_after(&mut self, emu: Emu) {
1839        self.ppr.space_after = Some(emu);
1840    }
1841
1842    /// 一次性设置缩进。
1843    pub fn set_indent(
1844        &mut self,
1845        left: Option<Emu>,
1846        right: Option<Emu>,
1847        first_line: Option<Emu>,
1848        hanging: Option<i32>,
1849    ) {
1850        self.ppr.indent = Indent {
1851            left,
1852            right,
1853            first_line,
1854            hanging,
1855        };
1856    }
1857
1858    /// 缩进(EMU 视图)。
1859    pub fn indent(&self) -> Indent {
1860        self.ppr.indent
1861    }
1862
1863    // --------- 项目符号样式(TODO-014) ---------
1864
1865    /// 项目符号样式。
1866    ///
1867    /// 返回 `None` 表示未设置(走默认继承);`Some(BulletStyle::None)` 表示显式无项目符号。
1868    pub fn bullet_style(&self) -> Option<&BulletStyle> {
1869        self.ppr.bullet_style.as_ref()
1870    }
1871
1872    /// 设置自定义字符项目符号(`<a:buChar char="..."/>`)。
1873    ///
1874    /// 对标 python-pptx `paragraph.font` + bullet 字符设置。
1875    /// 常用字符:`"•"` / `"▪"` / `"→"` / `"○"` / `"♦"`。
1876    pub fn set_bullet_char(&mut self, ch: impl Into<String>) {
1877        self.ppr.bullet = true;
1878        self.ppr.bullet_style = Some(BulletStyle::Char { char: ch.into() });
1879    }
1880
1881    /// 设置自动编号项目符号(`<a:buAutoNum type="..." startAt="..."/>`)。
1882    ///
1883    /// 对标 python-pptx 编号列表。
1884    /// 常用 type:`"arabicPeriod"` (1.) / `"alphaLcParenR"` (a)) / `"romanLcParenBoth"` ((i))。
1885    ///
1886    /// # 参数
1887    /// - `auto_num_type`:编号类型字面量。
1888    /// - `start_at`:起始编号(可选,`None` 表示从 1 开始)。
1889    pub fn set_bullet_numbered(&mut self, auto_num_type: impl Into<String>, start_at: Option<u32>) {
1890        self.ppr.bullet = true;
1891        self.ppr.bullet_style = Some(BulletStyle::AutoNum {
1892            auto_num_type: auto_num_type.into(),
1893            start_at,
1894        });
1895    }
1896
1897    /// 清除项目符号(`<a:buNone/>`)。
1898    pub fn clear_bullet(&mut self) {
1899        self.ppr.bullet = false;
1900        self.ppr.bullet_style = Some(BulletStyle::None);
1901    }
1902
1903    /// 是否有项目符号(`bullet=true` 且 `bullet_style` 非 `None`)。
1904    pub fn has_bullet(&self) -> bool {
1905        self.ppr.bullet
1906            && self
1907                .ppr
1908                .bullet_style
1909                .as_ref()
1910                .map(|bs| !matches!(bs, BulletStyle::None))
1911                .unwrap_or(false)
1912    }
1913
1914    // --------- 制表位(TODO-015) ---------
1915
1916    /// 制表位列表(不可变)。
1917    ///
1918    /// 对标 python-pptx `_ParagraphFormat.tab_stops`。
1919    pub fn tab_stops(&self) -> &[TabStop] {
1920        &self.ppr.tab_stops
1921    }
1922
1923    /// 添加一个制表位(`<a:tab pos="..." algn="..."/>`)。
1924    ///
1925    /// 对标 python-pptx `paragraph.tab_stops.add_tab_stop(position, alignment)`。
1926    ///
1927    /// # 参数
1928    /// - `pos`:制表位位置(EMU);
1929    /// - `alignment`:对齐类型(左/居中/右/小数点)。
1930    pub fn add_tab_stop(&mut self, pos: Emu, alignment: TabAlignment) {
1931        self.ppr.tab_stops.push(TabStop { pos, alignment });
1932    }
1933
1934    /// 清除所有制表位。
1935    pub fn clear_tab_stops(&mut self) {
1936        self.ppr.tab_stops.clear();
1937    }
1938}
1939
1940impl<'a> From<&'a mut ParagraphProperties> for ParagraphFormat<'a> {
1941    fn from(p: &'a mut ParagraphProperties) -> Self {
1942        ParagraphFormat::new(p)
1943    }
1944}
1945
1946// ====================================================================
1947// 高阶 TextFrame 视图(python-pptx 风格 `pptx.text.textframe.TextFrame`)
1948// ====================================================================
1949
1950/// 文本框高阶视图(`pptx.text.textframe.TextFrame`)。
1951///
1952/// # 与 python-pptx 的对应
1953///
1954/// - `shape.text_frame` ←→ `text_frame_mut().as_text_frame()`(薄包装);
1955/// - `text_frame.text` ←→ [`TextFrame::text_getter`] / [`TextFrame::set_text`];
1956/// - `text_frame.paragraphs[0]` ←→ [`TextFrame::paragraphs`];
1957/// - `text_frame.add_paragraph()` ←→ [`TextFrame::add_paragraph`];
1958/// - `text_frame.word_wrap` ←→ [`TextFrame::word_wrap`] / [`TextFrame::set_word_wrap`];
1959/// - `text_frame.auto_size` ←→ [`TextFrame::auto_size`] / [`TextFrame::set_auto_size`];
1960/// - `text_frame.vertical_anchor` ←→ [`TextFrame::vertical_anchor`] / [`TextFrame::set_vertical_anchor`];
1961/// - `text_frame.margin_left/...` ←→ [`TextFrame::margin_left`] / ... / [`TextFrame::set_margins`]。
1962///
1963/// # 设计要点
1964///
1965/// - **借用**:构造时传入 `&mut TextBody`;
1966/// - **零分配**:所有方法均不触发堆分配;
1967/// - **互斥语义**:[`TextFrame::add_paragraph`] 不会自动 trim,已有段落不会被移除。
1968#[derive(Debug)]
1969pub struct TextFrame<'a> {
1970    /// 底层 [`TextBody`] 引用。
1971    body: &'a mut TextBody,
1972}
1973
1974impl<'a> TextFrame<'a> {
1975    /// 构造。
1976    pub fn new(body: &'a mut TextBody) -> Self {
1977        TextFrame { body }
1978    }
1979
1980    /// 底层 body 不可变引用。
1981    pub fn body(&self) -> &TextBody {
1982        self.body
1983    }
1984    /// 底层 body 可变引用。
1985    pub fn body_mut(&mut self) -> &mut TextBody {
1986        self.body
1987    }
1988
1989    // --------- 段落集合 ---------
1990
1991    /// 段落数。
1992    pub fn len(&self) -> usize {
1993        self.body.paragraphs.len()
1994    }
1995    /// 是否为空。
1996    pub fn is_empty(&self) -> bool {
1997        self.body.paragraphs.is_empty()
1998    }
1999    /// 不可变段落迭代器。
2000    pub fn paragraphs(&self) -> std::slice::Iter<'_, Paragraph> {
2001        self.body.paragraphs.iter()
2002    }
2003    /// 可变段落迭代器。
2004    pub fn paragraphs_mut(&mut self) -> std::slice::IterMut<'_, Paragraph> {
2005        self.body.paragraphs.iter_mut()
2006    }
2007    /// 按下标取不可变段落。
2008    pub fn paragraph(&self, idx: usize) -> Option<&Paragraph> {
2009        self.body.paragraphs.get(idx)
2010    }
2011    /// 按下标取可变段落。
2012    pub fn paragraph_mut(&mut self, idx: usize) -> Option<&mut Paragraph> {
2013        self.body.paragraphs.get_mut(idx)
2014    }
2015    /// 首个段落。
2016    pub fn first_paragraph(&self) -> Option<&Paragraph> {
2017        self.body.paragraphs.first()
2018    }
2019    /// 首个段落的可变引用。
2020    pub fn first_paragraph_mut(&mut self) -> Option<&mut Paragraph> {
2021        self.body.paragraphs.first_mut()
2022    }
2023
2024    /// 新增空段落(python-pptx `text_frame.add_paragraph()`)。
2025    pub fn add_paragraph(&mut self) -> &mut Paragraph {
2026        self.body.add_paragraph()
2027    }
2028
2029    /// 新增带文本段落(按 `\n` 切分为多段),返回**最后**一段的可变引用。
2030    pub fn add_paragraph_with_text(&mut self, text: &str) -> &mut Paragraph {
2031        self.body.add_paragraph_with_text(text)
2032    }
2033
2034    /// 按下标移除段落。
2035    pub fn remove_paragraph(&mut self, idx: usize) -> Option<Paragraph> {
2036        self.body.remove_paragraph(idx)
2037    }
2038
2039    /// 清空段落(保留一个空段,对应 python-pptx `text_frame.clear()`)。
2040    pub fn clear(&mut self) {
2041        self.body.clear();
2042    }
2043
2044    // --------- 文本便捷 ---------
2045
2046    /// 整体取文本(段间 `\n`,与 python-pptx `text_frame.text` 一致)。
2047    pub fn text_getter(&self) -> String {
2048        self.body.text()
2049    }
2050
2051    /// 整体替换文本(按 `\n` 切分;旧段落属性会被丢弃)。
2052    pub fn set_text(&mut self, text: &str) {
2053        self.body.set_text(text);
2054    }
2055
2056    // --------- BodyProperties 便捷 ---------
2057
2058    /// 自动调整策略。
2059    pub fn auto_size(&self) -> MsoAutoSize {
2060        self.body.auto_size()
2061    }
2062    /// 设置自动调整策略。
2063    pub fn set_auto_size(&mut self, v: MsoAutoSize) {
2064        self.body.set_auto_size(v);
2065    }
2066
2067    /// 垂直对齐。
2068    pub fn vertical_anchor(&self) -> Option<MsoAnchor> {
2069        self.body.vertical_anchor()
2070    }
2071    /// 设置垂直对齐。
2072    pub fn set_vertical_anchor(&mut self, v: MsoAnchor) {
2073        self.body.set_vertical_anchor(v);
2074    }
2075
2076    /// 是否自动换行(None = 走默认)。
2077    pub fn word_wrap(&self) -> Option<bool> {
2078        self.body.word_wrap()
2079    }
2080    /// 设置是否自动换行。
2081    pub fn set_word_wrap(&mut self, v: bool) {
2082        self.body.set_word_wrap(v);
2083    }
2084
2085    /// 左边距。
2086    pub fn margin_left(&self) -> Option<Emu> {
2087        self.body.margin_left()
2088    }
2089    /// 右边距。
2090    pub fn margin_right(&self) -> Option<Emu> {
2091        self.body.margin_right()
2092    }
2093    /// 上边距。
2094    pub fn margin_top(&self) -> Option<Emu> {
2095        self.body.margin_top()
2096    }
2097    /// 下边距。
2098    pub fn margin_bottom(&self) -> Option<Emu> {
2099        self.body.margin_bottom()
2100    }
2101    /// 一次性设置四向边距。
2102    pub fn set_margins(&mut self, l: Emu, t: Emu, r: Emu, b: Emu) {
2103        self.body.set_margins(l, t, r, b);
2104    }
2105    /// 设置单边边距(left)。
2106    pub fn set_margin_left(&mut self, emu: Emu) {
2107        self.body.set_margin_left(emu);
2108    }
2109    /// 设置单边边距(right)。
2110    pub fn set_margin_right(&mut self, emu: Emu) {
2111        self.body.set_margin_right(emu);
2112    }
2113    /// 设置单边边距(top)。
2114    pub fn set_margin_top(&mut self, emu: Emu) {
2115        self.body.set_margin_top(emu);
2116    }
2117    /// 设置单边边距(bottom)。
2118    pub fn set_margin_bottom(&mut self, emu: Emu) {
2119        self.body.set_margin_bottom(emu);
2120    }
2121
2122    // --------- 多列布局 ---------
2123
2124    /// 文本列数(`numCol` 属性)。
2125    ///
2126    /// `None` 表示未设置(默认单列);`Some(1)` 等同于单列。
2127    pub fn num_cols(&self) -> Option<u32> {
2128        self.body.num_cols()
2129    }
2130
2131    /// 设置文本列数(`numCol` 属性)。
2132    pub fn set_num_cols(&mut self, count: u32) {
2133        self.body.set_num_cols(count);
2134    }
2135
2136    /// 列间距(`spcCol` 属性,EMU)。
2137    pub fn col_spacing(&self) -> Option<Emu> {
2138        self.body.col_spacing()
2139    }
2140
2141    /// 设置列间距(`spcCol` 属性,EMU)。
2142    pub fn set_col_spacing(&mut self, emu: Emu) {
2143        self.body.set_col_spacing(emu);
2144    }
2145
2146    /// 一次性设置多列布局(列数 + 可选列间距)。
2147    ///
2148    /// 对标 python-pptx 中 `text_frame` 的多列设置。
2149    /// 当 `spacing` 为 `None` 时仅设置列数,保留已有列间距。
2150    pub fn set_columns(&mut self, count: u32, spacing: Option<Emu>) {
2151        self.body.set_num_cols(count);
2152        if let Some(s) = spacing {
2153            self.body.set_col_spacing(s);
2154        }
2155    }
2156}
2157
2158impl<'a> From<&'a mut TextBody> for TextFrame<'a> {
2159    fn from(b: &'a mut TextBody) -> Self {
2160        TextFrame::new(b)
2161    }
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166    use super::*;
2167
2168    #[test]
2169    fn write_paragraph_simple() {
2170        let mut p = Paragraph::new();
2171        let mut r = Run::new("Hello");
2172        r.properties.size = Some(Pt(24.0));
2173        r.properties.bold = true;
2174        r.properties.color = RGBColor(0xFF, 0, 0).into();
2175        p.runs.push(r);
2176        let mut w = super::super::writer::XmlWriter::new();
2177        p.write_xml(&mut w);
2178        let s = w.into_string();
2179        assert!(s.contains("Hello"));
2180        assert!(s.contains("sz=\"2400\""));
2181        assert!(s.contains("b=\"1\""));
2182        assert!(s.contains("a:srgbClr"));
2183    }
2184
2185    /// 验证 `TextFrame` / `ParagraphFormat` 视图能联动到底层 `TextBody`。
2186    #[test]
2187    fn textframe_view_mirrors_body() {
2188        let mut tb = TextBody::new();
2189        {
2190            let mut tf = TextFrame::new(&mut tb);
2191            // 走 view 加一段 + 走 view 设 word_wrap
2192            let p = tf.add_paragraph();
2193            p.add_run_with_text("first").set_bold(true);
2194            tf.add_paragraph_with_text("second\nthird");
2195            tf.set_word_wrap(false);
2196            tf.set_margins(Emu(91440), Emu(45720), Emu(91440), Emu(45720));
2197        }
2198        // 验证 view 写入已生效
2199        assert_eq!(tb.paragraphs.len(), 3);
2200        assert_eq!(tb.paragraphs[0].runs[0].text, "first");
2201        assert!(tb.paragraphs[0].runs[0].properties.bold);
2202        assert_eq!(tb.paragraphs[1].runs[0].text, "second");
2203        assert_eq!(tb.paragraphs[2].runs[0].text, "third");
2204        // 自动换行被设成 false
2205        assert_eq!(tb.word_wrap(), Some(false));
2206    }
2207
2208    /// 验证 `ParagraphFormat` 的 line_spacing 互斥语义。
2209    #[test]
2210    fn paragraph_format_line_spacing_mutex() {
2211        let mut p = Paragraph::new();
2212        p.set_line_spacing(Pt(20.0));
2213        assert!(p.line_spacing().is_some());
2214        assert!(p.line_spacing_pct().is_none());
2215        // 改设倍数:清空固定值
2216        p.set_line_spacing_pct(1.5);
2217        assert!(p.line_spacing().is_none());
2218        assert_eq!(p.line_spacing_pct(), Some(1.5));
2219        // 走 view 验证同样互斥
2220        let mut p2 = Paragraph::new();
2221        {
2222            let mut pf = ParagraphFormat::new(&mut p2.properties);
2223            pf.set_line_spacing(Pt(15.0));
2224        }
2225        assert_eq!(p2.line_spacing(), Some(Pt(15.0)));
2226        {
2227            let mut pf = ParagraphFormat::new(&mut p2.properties);
2228            pf.set_line_spacing_pct(2.0);
2229        }
2230        assert!(p2.line_spacing().is_none());
2231        assert_eq!(p2.line_spacing_pct(), Some(2.0));
2232    }
2233
2234    /// 验证 `Font` 的删除线/双删除线 API。
2235    ///
2236    /// 这是 TODO-017 的测试。
2237    #[test]
2238    fn font_strikethrough_api() {
2239        let mut r = Run::new("text");
2240        {
2241            let mut f = Font::new(&mut r.properties);
2242            f.set_strike(true);
2243        }
2244        assert!(r.properties.strike);
2245        assert!(!r.properties.strike_dbl);
2246
2247        // 双删除线
2248        {
2249            let mut f = Font::new(&mut r.properties);
2250            f.set_double_strike(true);
2251        }
2252        assert!(r.properties.strike_dbl);
2253
2254        // 验证 Font 读取
2255        let f = Font::new(&mut r.properties);
2256        assert!(f.strike());
2257        assert!(f.double_strike());
2258    }
2259
2260    /// 验证 `Font` 的高亮色 API。
2261    ///
2262    /// 这是 TODO-018 的测试。
2263    #[test]
2264    fn font_highlight_api() {
2265        let mut r = Run::new("text");
2266        // 默认无高亮
2267        assert!(r.properties.highlight.is_none());
2268
2269        // 设置高亮
2270        {
2271            let mut f = Font::new(&mut r.properties);
2272            f.set_highlight(Some(Color::RGB(RGBColor(0xFF, 0xFF, 0x00))));
2273        }
2274        assert!(r.properties.highlight.is_some());
2275
2276        // 验证 Font 读取
2277        let f = Font::new(&mut r.properties);
2278        let hl = f.highlight().expect("应有高亮色");
2279        assert!(matches!(hl, Color::RGB(c) if c.0 == 0xFF && c.1 == 0xFF && c.2 == 0x00));
2280
2281        // 清除高亮
2282        {
2283            let mut f = Font::new(&mut r.properties);
2284            f.set_highlight(None);
2285        }
2286        assert!(r.properties.highlight.is_none());
2287
2288        // 用 Color::None 清除
2289        {
2290            let mut f = Font::new(&mut r.properties);
2291            f.set_highlight(Some(Color::RGB(RGBColor(0xFF, 0x00, 0x00))));
2292            f.set_highlight(Some(Color::None));
2293        }
2294        assert!(r.properties.highlight.is_none());
2295    }
2296
2297    /// 验证 `TextBody` / `TextFrame` 的多列布局 API 与序列化。
2298    ///
2299    /// 这是 TODO-019 的测试。
2300    #[test]
2301    fn text_body_multi_column_api() {
2302        let mut tb = TextBody::new();
2303        // 默认无列数
2304        assert!(tb.num_cols().is_none());
2305        assert!(tb.col_spacing().is_none());
2306
2307        // 设置 3 列 + 列间距
2308        tb.set_num_cols(3);
2309        tb.set_col_spacing(Emu(91440));
2310        assert_eq!(tb.num_cols(), Some(3));
2311        assert_eq!(tb.col_spacing(), Some(Emu(91440)));
2312
2313        // 序列化验证
2314        let mut w = super::super::writer::XmlWriter::new();
2315        tb.body_properties
2316            .as_ref()
2317            .expect("应有 body_properties")
2318            .write_xml(&mut w);
2319        let s = w.into_string();
2320        assert!(s.contains("numCol=\"3\""), "应输出 numCol=\"3\",实际: {s}");
2321        assert!(
2322            s.contains("spcCol=\"91440\""),
2323            "应输出 spcCol=\"91440\",实际: {s}"
2324        );
2325    }
2326
2327    /// 验证 `TextFrame::set_columns` 一次性设置列数和列间距。
2328    ///
2329    /// 这是 TODO-019 的测试。
2330    #[test]
2331    fn text_frame_set_columns() {
2332        let mut tb = TextBody::new();
2333        {
2334            let mut tf = TextFrame::new(&mut tb);
2335            // 设置 2 列 + 列间距
2336            tf.set_columns(2, Some(Emu(45720)));
2337        }
2338        assert_eq!(tb.num_cols(), Some(2));
2339        assert_eq!(tb.col_spacing(), Some(Emu(45720)));
2340
2341        // 仅设置列数,不设置列间距
2342        {
2343            let mut tf = TextFrame::new(&mut tb);
2344            tf.set_columns(4, None);
2345        }
2346        assert_eq!(tb.num_cols(), Some(4));
2347        // 列间距应保持上次的值
2348        assert_eq!(tb.col_spacing(), Some(Emu(45720)));
2349
2350        // TextFrame 代理方法
2351        {
2352            let tf = TextFrame::new(&mut tb);
2353            assert_eq!(tf.num_cols(), Some(4));
2354            assert_eq!(tf.col_spacing(), Some(Emu(45720)));
2355        }
2356    }
2357
2358    /// 验证 `ParagraphFormat` 的项目符号字符 API。
2359    ///
2360    /// 这是 TODO-014 的测试。
2361    #[test]
2362    fn paragraph_format_bullet_char() {
2363        let mut ppr = ParagraphProperties::default();
2364        {
2365            let mut pf = ParagraphFormat::new(&mut ppr);
2366            pf.set_bullet_char("•");
2367        }
2368        assert!(ppr.bullet, "bullet 应为 true");
2369        assert!(pf_has_bullet(&ppr), "has_bullet 应为 true");
2370        // 验证序列化
2371        let mut w = super::super::writer::XmlWriter::new();
2372        ppr.write_xml(&mut w);
2373        let s = w.into_string();
2374        assert!(s.contains("buChar"), "应输出 buChar,实际: {s}");
2375        assert!(s.contains("char=\"•\""), "应包含 char=\"•\",实际: {s}");
2376    }
2377
2378    /// 验证 `ParagraphFormat` 的编号项目符号 API。
2379    ///
2380    /// 这是 TODO-014 的测试。
2381    #[test]
2382    fn paragraph_format_bullet_numbered() {
2383        let mut ppr = ParagraphProperties::default();
2384        {
2385            let mut pf = ParagraphFormat::new(&mut ppr);
2386            pf.set_bullet_numbered("arabicPeriod", Some(3));
2387        }
2388        assert!(ppr.bullet);
2389        match &ppr.bullet_style {
2390            Some(BulletStyle::AutoNum {
2391                auto_num_type,
2392                start_at,
2393            }) => {
2394                assert_eq!(auto_num_type, "arabicPeriod");
2395                assert_eq!(*start_at, Some(3));
2396            }
2397            other => panic!("期望 AutoNum,实际: {other:?}"),
2398        }
2399        // 验证序列化
2400        let mut w = super::super::writer::XmlWriter::new();
2401        ppr.write_xml(&mut w);
2402        let s = w.into_string();
2403        assert!(s.contains("buAutoNum"), "应输出 buAutoNum,实际: {s}");
2404        assert!(
2405            s.contains("type=\"arabicPeriod\""),
2406            "应包含 type,实际: {s}"
2407        );
2408        assert!(s.contains("startAt=\"3\""), "应包含 startAt,实际: {s}");
2409    }
2410
2411    /// 验证 `ParagraphFormat::clear_bullet` 清除项目符号。
2412    ///
2413    /// 这是 TODO-014 的测试。
2414    #[test]
2415    fn paragraph_format_clear_bullet() {
2416        let mut ppr = ParagraphProperties::default();
2417        {
2418            let mut pf = ParagraphFormat::new(&mut ppr);
2419            pf.set_bullet_char("•");
2420        }
2421        assert!(pf_has_bullet(&ppr));
2422        {
2423            let mut pf = ParagraphFormat::new(&mut ppr);
2424            pf.clear_bullet();
2425        }
2426        assert!(!ppr.bullet, "bullet 应为 false");
2427        assert!(!pf_has_bullet(&ppr), "has_bullet 应为 false");
2428        // 验证序列化输出 buNone
2429        let mut w = super::super::writer::XmlWriter::new();
2430        ppr.write_xml(&mut w);
2431        let s = w.into_string();
2432        assert!(s.contains("buNone"), "应输出 buNone,实际: {s}");
2433    }
2434
2435    /// 辅助函数:检查 ParagraphProperties 是否有项目符号。
2436    fn pf_has_bullet(ppr: &ParagraphProperties) -> bool {
2437        ppr.bullet
2438            && ppr
2439                .bullet_style
2440                .as_ref()
2441                .map(|bs| !matches!(bs, BulletStyle::None))
2442                .unwrap_or(false)
2443    }
2444
2445    /// 验证 `ParagraphFormat` 的制表位 API。
2446    ///
2447    /// 这是 TODO-015 的测试。
2448    #[test]
2449    fn paragraph_format_tab_stops() {
2450        let mut ppr = ParagraphProperties::default();
2451        {
2452            let mut pf = ParagraphFormat::new(&mut ppr);
2453            pf.add_tab_stop(Emu(914400), TabAlignment::Left);
2454            pf.add_tab_stop(Emu(1828800), TabAlignment::Right);
2455            pf.add_tab_stop(Emu(2743200), TabAlignment::Center);
2456        }
2457        assert_eq!(ppr.tab_stops.len(), 3);
2458        assert_eq!(ppr.tab_stops[0].pos.value(), 914400);
2459        assert_eq!(ppr.tab_stops[0].alignment, TabAlignment::Left);
2460        assert_eq!(ppr.tab_stops[1].pos.value(), 1828800);
2461        assert_eq!(ppr.tab_stops[1].alignment, TabAlignment::Right);
2462        assert_eq!(ppr.tab_stops[2].pos.value(), 2743200);
2463        assert_eq!(ppr.tab_stops[2].alignment, TabAlignment::Center);
2464
2465        // 验证序列化
2466        let mut w = super::super::writer::XmlWriter::new();
2467        ppr.write_xml(&mut w);
2468        let s = w.into_string();
2469        assert!(s.contains("tabLst"), "应输出 tabLst,实际: {s}");
2470        assert!(s.contains("pos=\"914400\""), "应包含 pos=914400,实际: {s}");
2471        assert!(s.contains("algn=\"l\""), "应包含 algn=l,实际: {s}");
2472        assert!(s.contains("algn=\"r\""), "应包含 algn=r,实际: {s}");
2473        assert!(s.contains("algn=\"ctr\""), "应包含 algn=ctr,实际: {s}");
2474
2475        // 清除
2476        {
2477            let mut pf = ParagraphFormat::new(&mut ppr);
2478            pf.clear_tab_stops();
2479        }
2480        assert!(ppr.tab_stops.is_empty());
2481    }
2482
2483    /// 验证 `Field` 的序列化和 `Paragraph::add_field` API。
2484    ///
2485    /// 这是 TODO-016 的测试。
2486    #[test]
2487    fn paragraph_field_serialization() {
2488        let mut p = Paragraph::new();
2489        p.add_field(FieldType::SlideNumber, "1");
2490        p.add_field(FieldType::DateTime, "1/1/2024");
2491
2492        assert_eq!(p.fields.len(), 2);
2493        assert_eq!(p.fields[0].field_type, FieldType::SlideNumber);
2494        assert_eq!(p.fields[0].text, "1");
2495        assert_eq!(p.fields[1].field_type, FieldType::DateTime);
2496        assert_eq!(p.fields[1].text, "1/1/2024");
2497
2498        // 验证序列化
2499        let mut w = super::super::writer::XmlWriter::new();
2500        p.write_xml(&mut w);
2501        let s = w.into_string();
2502        assert!(s.contains("a:fld"), "应输出 a:fld,实际: {s}");
2503        assert!(
2504            s.contains("type=\"slidenum\""),
2505            "应包含 type=slidenum,实际: {s}"
2506        );
2507        assert!(
2508            s.contains("type=\"datetime\""),
2509            "应包含 type=datetime,实际: {s}"
2510        );
2511        assert!(s.contains(">1<"), "应包含文本 1,实际: {s}");
2512        assert!(s.contains(">1/1/2024<"), "应包含文本 1/1/2024,实际: {s}");
2513    }
2514
2515    /// 验证 `FieldType` 的转换方法。
2516    ///
2517    /// 这是 TODO-016 的测试。
2518    #[test]
2519    fn field_type_conversion() {
2520        // as_str
2521        assert_eq!(FieldType::SlideNumber.as_str(), "slidenum");
2522        assert_eq!(FieldType::DateTime.as_str(), "datetime");
2523        assert_eq!(FieldType::DateTime1.as_str(), "datetime1");
2524        assert_eq!(FieldType::Footer.as_str(), "footer");
2525        assert_eq!(FieldType::Custom("custom1".to_string()).as_str(), "custom1");
2526
2527        // from_str_value
2528        assert_eq!(
2529            FieldType::from_str_value("slidenum"),
2530            FieldType::SlideNumber
2531        );
2532        assert_eq!(FieldType::from_str_value("datetime"), FieldType::DateTime);
2533        assert_eq!(FieldType::from_str_value("datetime1"), FieldType::DateTime1);
2534        assert_eq!(FieldType::from_str_value("footer"), FieldType::Footer);
2535        assert_eq!(
2536            FieldType::from_str_value("unknown"),
2537            FieldType::Custom("unknown".to_string())
2538        );
2539    }
2540
2541    /// 验证 `Font` 超链接高阶 API(set_hyperlink / set_slide_jump)。
2542    ///
2543    /// 这是 TODO-026 的测试。
2544    #[test]
2545    fn font_hyperlink_api() {
2546        let mut run = Run::new("链接文本");
2547        // 设置 URL 超链接
2548        run.font().set_hyperlink("rId3", Some("点击访问"));
2549        let hl = run
2550            .properties
2551            .hlink_click
2552            .as_ref()
2553            .expect("hlink_click 应存在");
2554        assert_eq!(hl.rid.as_deref(), Some("rId3"));
2555        assert_eq!(hl.tooltip.as_deref(), Some("点击访问"));
2556
2557        // 设置跳转幻灯片动作
2558        run.font().set_slide_jump();
2559        let hl = run
2560            .properties
2561            .hlink_click
2562            .as_ref()
2563            .expect("hlink_click 应存在");
2564        assert_eq!(hl.action.as_deref(), Some("ppaction://hlinksldjump"));
2565
2566        // 清除
2567        run.font().clear_hlink_click();
2568        assert!(run.properties.hlink_click.is_none());
2569    }
2570
2571    /// 验证超链接的序列化 round-trip。
2572    ///
2573    /// 这是 TODO-026 的测试。
2574    #[test]
2575    fn hyperlink_serialization_roundtrip() {
2576        let mut run = Run::new("点击这里");
2577        run.font().set_hyperlink("rId7", Some("提示文字"));
2578
2579        // 序列化
2580        let mut w = super::super::writer::XmlWriter::new();
2581        run.write_xml(&mut w);
2582        let s = w.into_string();
2583        assert!(s.contains("a:hlinkClick"), "应输出 a:hlinkClick,实际: {s}");
2584        assert!(s.contains("r:id=\"rId7\""), "应包含 r:id=rId7,实际: {s}");
2585        assert!(
2586            s.contains("tooltip=\"提示文字\""),
2587            "应包含 tooltip,实际: {s}"
2588        );
2589    }
2590
2591    // --------------------- TODO-047: endParaRPr 测试 ---------------------
2592
2593    /// 验证 `Paragraph::set_end_para_rpr` 高阶 API 与序列化。
2594    ///
2595    /// 设置带属性 + 子元素的 endParaRPr,验证序列化输出正确。
2596    #[test]
2597    fn end_para_rpr_set_and_serialize() {
2598        let mut p = Paragraph::new();
2599        let rpr = RunProperties {
2600            size: Some(Pt(24.0)),
2601            latin_font: Some("Calibri".to_string()),
2602            lang: Some("en-US".to_string()),
2603            ..Default::default()
2604        };
2605        p.set_end_para_rpr(rpr);
2606
2607        // 验证 getter
2608        assert!(p.end_para_rpr().is_some());
2609        let got = p.end_para_rpr().unwrap();
2610        assert_eq!(got.size, Some(Pt(24.0)));
2611        assert_eq!(got.latin_font.as_deref(), Some("Calibri"));
2612        assert_eq!(got.lang.as_deref(), Some("en-US"));
2613
2614        // 序列化
2615        let mut w = super::super::writer::XmlWriter::new();
2616        p.write_xml(&mut w);
2617        let s = w.into_string();
2618        assert!(s.contains("a:endParaRPr"), "应输出 a:endParaRPr,实际: {s}");
2619        assert!(s.contains("sz=\"2400\""), "应包含 sz=2400,实际: {s}");
2620        assert!(s.contains("lang=\"en-US\""), "应包含 lang=en-US,实际: {s}");
2621        assert!(s.contains("a:latin"), "应包含 a:latin,实际: {s}");
2622        assert!(
2623            s.contains("typeface=\"Calibri\""),
2624            "应包含 typeface=Calibri,实际: {s}"
2625        );
2626    }
2627
2628    /// 验证 `Paragraph::clear_end_para_rpr` 清除 endParaRPr。
2629    #[test]
2630    fn end_para_rpr_clear() {
2631        let mut p = Paragraph::new();
2632        let rpr = RunProperties {
2633            size: Some(Pt(18.0)),
2634            ..Default::default()
2635        };
2636        p.set_end_para_rpr(rpr);
2637        assert!(p.end_para_rpr().is_some());
2638
2639        p.clear_end_para_rpr();
2640        assert!(p.end_para_rpr().is_none());
2641
2642        // 序列化不应包含 endParaRPr
2643        let mut w = super::super::writer::XmlWriter::new();
2644        p.write_xml(&mut w);
2645        let s = w.into_string();
2646        assert!(
2647            !s.contains("a:endParaRPr"),
2648            "不应输出 a:endParaRPr,实际: {s}"
2649        );
2650    }
2651
2652    /// 验证 `Paragraph::end_para_rpr_mut` 可变引用修改。
2653    #[test]
2654    fn end_para_rpr_mut_modify() {
2655        let mut p = Paragraph::new();
2656        let rpr = RunProperties::default();
2657        p.set_end_para_rpr(rpr);
2658
2659        // 通过可变引用修改
2660        if let Some(rpr) = p.end_para_rpr_mut() {
2661            rpr.size = Some(Pt(32.0));
2662            rpr.bold = true;
2663        }
2664
2665        let got = p.end_para_rpr().expect("end_para_rpr 应存在");
2666        assert_eq!(got.size, Some(Pt(32.0)));
2667        assert!(got.bold);
2668    }
2669
2670    // 注:end_para_rpr_roundtrip_with_children / end_para_rpr_self_closing_parse /
2671    // end_para_rpr_with_solid_fill 三个 round-trip 测试依赖 PPTX 特有的
2672    // parse_sld::parse_paragraph,保留在 pptx-rs 主 crate 的 txbody.rs 中。
2673
2674    // ===================== 东亚字体便捷 API 测试(TODO-005 剩余) =====================
2675
2676    /// `Run::eastasia_name` / `set_eastasia_name` 往返:设置后能正确读取。
2677    #[test]
2678    fn run_eastasia_name_setter_and_getter() {
2679        let mut r = Run::new("你好");
2680        assert!(r.eastasia_name().is_none());
2681        r.set_eastasia_name("宋体");
2682        assert_eq!(r.eastasia_name(), Some("宋体"));
2683        assert_eq!(r.properties.eastasia_font.as_deref(), Some("宋体"));
2684    }
2685
2686    /// `Run::complex_script_name` / `set_complex_script_name` 往返。
2687    #[test]
2688    fn run_complex_script_name_setter_and_getter() {
2689        let mut r = Run::new("مرحبا");
2690        assert!(r.complex_script_name().is_none());
2691        r.set_complex_script_name("Traditional Arabic");
2692        assert_eq!(r.complex_script_name(), Some("Traditional Arabic"));
2693        assert_eq!(r.properties.cs_font.as_deref(), Some("Traditional Arabic"));
2694    }
2695
2696    /// `Run` 上 latin / ea / cs 三种字体可独立设置,互不干扰。
2697    #[test]
2698    fn run_three_fonts_independent() {
2699        let mut r = Run::new("Hello 你好 مرحبا");
2700        r.set_font_name("Arial");
2701        r.set_eastasia_name("Microsoft YaHei");
2702        r.set_complex_script_name("Tahoma");
2703        assert_eq!(r.font_name(), Some("Arial"));
2704        assert_eq!(r.eastasia_name(), Some("Microsoft YaHei"));
2705        assert_eq!(r.complex_script_name(), Some("Tahoma"));
2706        // 验证底层 properties 字段独立
2707        assert_eq!(r.properties.latin_font.as_deref(), Some("Arial"));
2708        assert_eq!(
2709            r.properties.eastasia_font.as_deref(),
2710            Some("Microsoft YaHei")
2711        );
2712        assert_eq!(r.properties.cs_font.as_deref(), Some("Tahoma"));
2713    }
2714
2715    /// `Font::clear_eastasia_name` 清空东亚字体字段。
2716    #[test]
2717    fn font_clear_eastasia_name() {
2718        let mut r = Run::new("文本");
2719        r.set_eastasia_name("宋体");
2720        assert_eq!(r.eastasia_name(), Some("宋体"));
2721        // 通过 Font 视图清空
2722        {
2723            let mut f = Font::new(&mut r.properties);
2724            f.clear_eastasia_name();
2725        }
2726        assert!(r.eastasia_name().is_none());
2727        assert!(r.properties.eastasia_font.is_none());
2728    }
2729
2730    /// `Font::clear_complex_script_name` 清空复杂脚本字体字段。
2731    #[test]
2732    fn font_clear_complex_script_name() {
2733        let mut r = Run::new("text");
2734        r.set_complex_script_name("Arial");
2735        assert_eq!(r.complex_script_name(), Some("Arial"));
2736        // 通过 Font 视图清空
2737        {
2738            let mut f = Font::new(&mut r.properties);
2739            f.clear_complex_script_name();
2740        }
2741        assert!(r.complex_script_name().is_none());
2742        assert!(r.properties.cs_font.is_none());
2743    }
2744
2745    /// `Font` 视图的 eastasia/cs 访问器与 `Run` 便捷方法一致。
2746    #[test]
2747    fn font_view_eastasia_cs_consistent_with_run() {
2748        let mut r = Run::new("混合文本");
2749        r.set_eastasia_name("黑体");
2750        r.set_complex_script_name("Tahoma");
2751        // 先用 Run 便捷方法读取(避免与 Font 视图的可变借用冲突)
2752        let run_ea = r.eastasia_name().map(|s| s.to_string());
2753        let run_cs = r.complex_script_name().map(|s| s.to_string());
2754        // Font 视图读取应与 Run 便捷方法一致
2755        let f = Font::new(&mut r.properties);
2756        assert_eq!(f.eastasia_name(), run_ea.as_deref());
2757        assert_eq!(f.complex_script_name(), run_cs.as_deref());
2758    }
2759
2760    /// 东亚字体序列化:`<a:ea typeface="..."/>` 应在 `<a:latin>` 之后写出。
2761    #[test]
2762    fn eastasia_font_serialization_order() {
2763        let mut r = Run::new("你好");
2764        r.set_font_name("Arial");
2765        r.set_eastasia_name("宋体");
2766        r.set_complex_script_name("Tahoma");
2767        let mut p = Paragraph::new();
2768        p.runs.push(r);
2769        let mut w = crate::oxml::writer::XmlWriter::new();
2770        p.write_xml(&mut w);
2771        let xml = w.into_string();
2772        // 验证三种字体元素都存在
2773        assert!(
2774            xml.contains(r#"<a:latin typeface="Arial"/>"#),
2775            "xml: {}",
2776            xml
2777        );
2778        assert!(xml.contains(r#"<a:ea typeface="宋体"/>"#), "xml: {}", xml);
2779        assert!(xml.contains(r#"<a:cs typeface="Tahoma"/>"#), "xml: {}", xml);
2780        // 验证 OOXML 顺序:latin → ea → cs
2781        let pos_latin = xml.find("<a:latin").expect("latin should exist");
2782        let pos_ea = xml.find("<a:ea").expect("ea should exist");
2783        let pos_cs = xml.find("<a:cs").expect("cs should exist");
2784        assert!(pos_latin < pos_ea, "latin must come before ea: {}", xml);
2785        assert!(pos_ea < pos_cs, "ea must come before cs: {}", xml);
2786    }
2787}