Skip to main content

ooxml_dml/
ext.rs

1//! Extension traits for DrawingML types.
2//!
3//! Provides convenience methods for working with generated DML types.
4
5use crate::types::CTShapeProperties;
6#[cfg(feature = "dml-text")]
7use crate::types::*;
8
9/// Extension trait for [`TextBody`] providing convenience methods.
10#[cfg(feature = "dml-text")]
11pub trait TextBodyExt {
12    /// Get all paragraphs in the text body.
13    fn paragraphs(&self) -> &[TextParagraph];
14
15    /// Extract all text from the text body.
16    fn text(&self) -> String;
17}
18
19#[cfg(feature = "dml-text")]
20impl TextBodyExt for TextBody {
21    fn paragraphs(&self) -> &[TextParagraph] {
22        &self.p
23    }
24
25    fn text(&self) -> String {
26        self.p
27            .iter()
28            .map(|p| p.text())
29            .collect::<Vec<_>>()
30            .join("\n")
31    }
32}
33
34/// Extension trait for [`TextParagraph`] providing convenience methods.
35#[cfg(feature = "dml-text")]
36pub trait TextParagraphExt {
37    /// Get all text runs in the paragraph.
38    fn runs(&self) -> Vec<&TextRun>;
39
40    /// Extract all text from the paragraph.
41    fn text(&self) -> String;
42
43    /// Get the paragraph level (for bullets/numbering).
44    fn level(&self) -> Option<i32>;
45
46    /// Get the text alignment.
47    fn alignment(&self) -> Option<STTextAlignType>;
48}
49
50#[cfg(feature = "dml-text")]
51impl TextParagraphExt for TextParagraph {
52    fn runs(&self) -> Vec<&TextRun> {
53        self.text_run
54            .iter()
55            .filter_map(|tr| match tr {
56                EGTextRun::R(run) => Some(run.as_ref()),
57                _ => None,
58            })
59            .collect()
60    }
61
62    fn text(&self) -> String {
63        self.text_run
64            .iter()
65            .filter_map(|tr| match tr {
66                EGTextRun::R(run) => Some(run.t.as_str()),
67                EGTextRun::Br(_) => Some("\n"),
68                EGTextRun::Fld(fld) => fld.t.as_deref(),
69            })
70            .collect()
71    }
72
73    fn level(&self) -> Option<i32> {
74        self.p_pr.as_ref().and_then(|p| p.lvl)
75    }
76
77    fn alignment(&self) -> Option<STTextAlignType> {
78        self.p_pr.as_ref().and_then(|p| p.algn)
79    }
80}
81
82/// Extension trait for [`TextRun`] providing convenience methods.
83#[cfg(feature = "dml-text")]
84pub trait TextRunExt {
85    /// Get the text content.
86    fn text(&self) -> &str;
87
88    /// Check if the text is bold.
89    fn is_bold(&self) -> bool;
90
91    /// Check if the text is italic.
92    fn is_italic(&self) -> bool;
93
94    /// Check if the text is underlined.
95    fn is_underlined(&self) -> bool;
96
97    /// Check if the text has strikethrough.
98    fn is_strikethrough(&self) -> bool;
99
100    /// Get the font size in hundredths of a point.
101    fn font_size(&self) -> Option<i32>;
102
103    /// Check if the run has a hyperlink.
104    fn has_hyperlink(&self) -> bool;
105
106    /// Get the hyperlink relationship ID.
107    fn hyperlink_rel_id(&self) -> Option<&str>;
108
109    /// Check if all-caps formatting is set for this run.
110    fn is_all_caps(&self) -> bool;
111
112    /// Check if small-caps formatting is set for this run.
113    fn is_small_caps(&self) -> bool;
114
115    /// Get the BCP 47 language tag for this run (e.g. `"en-US"`).
116    fn language(&self) -> Option<&str>;
117
118    /// Get the baseline shift as a signed percentage (positive = superscript).
119    fn baseline_shift_pct(&self) -> Option<i32>;
120}
121
122#[cfg(feature = "dml-text")]
123impl TextRunExt for TextRun {
124    fn text(&self) -> &str {
125        &self.t
126    }
127
128    fn is_bold(&self) -> bool {
129        self.r_pr.as_ref().and_then(|p| p.b).unwrap_or(false)
130    }
131
132    fn is_italic(&self) -> bool {
133        self.r_pr.as_ref().and_then(|p| p.i).unwrap_or(false)
134    }
135
136    fn is_underlined(&self) -> bool {
137        self.r_pr
138            .as_ref()
139            .and_then(|p| p.u.as_ref())
140            .is_some_and(|u| *u != STTextUnderlineType::None)
141    }
142
143    fn is_strikethrough(&self) -> bool {
144        self.r_pr
145            .as_ref()
146            .and_then(|p| p.strike.as_ref())
147            .is_some_and(|s| *s != STTextStrikeType::NoStrike)
148    }
149
150    fn font_size(&self) -> Option<i32> {
151        self.r_pr.as_ref().and_then(|p| p.sz)
152    }
153
154    fn has_hyperlink(&self) -> bool {
155        self.r_pr
156            .as_ref()
157            .and_then(|p| p.hlink_click.as_ref())
158            .is_some()
159    }
160
161    fn hyperlink_rel_id(&self) -> Option<&str> {
162        self.r_pr
163            .as_ref()
164            .and_then(|p| p.hlink_click.as_ref())
165            .and_then(|h| h.id.as_deref())
166    }
167
168    fn is_all_caps(&self) -> bool {
169        self.r_pr
170            .as_ref()
171            .and_then(|p| p.cap.as_ref())
172            .is_some_and(|c| *c == STTextCapsType::All)
173    }
174
175    fn is_small_caps(&self) -> bool {
176        self.r_pr
177            .as_ref()
178            .and_then(|p| p.cap.as_ref())
179            .is_some_and(|c| *c == STTextCapsType::Small)
180    }
181
182    fn language(&self) -> Option<&str> {
183        self.r_pr.as_ref().and_then(|p| p.lang.as_deref())
184    }
185
186    fn baseline_shift_pct(&self) -> Option<i32> {
187        self.r_pr
188            .as_ref()
189            .and_then(|p| p.baseline.as_deref())
190            .and_then(|s| s.parse().ok())
191    }
192}
193
194/// Extension trait for [`CTTable`] providing convenience methods.
195#[cfg(feature = "dml-tables")]
196pub trait TableExt {
197    /// Get all rows in the table.
198    fn rows(&self) -> &[CTTableRow];
199
200    /// Get the number of rows.
201    fn row_count(&self) -> usize;
202
203    /// Get the number of columns (from grid, or first row if empty).
204    fn col_count(&self) -> usize;
205
206    /// Get a cell by row and column index (0-based).
207    fn cell(&self, row: usize, col: usize) -> Option<&CTTableCell>;
208
209    /// Get all cell text as a 2D vector.
210    fn to_text_grid(&self) -> Vec<Vec<String>>;
211
212    /// Get plain text representation (tab-separated values).
213    fn text(&self) -> String;
214}
215
216#[cfg(feature = "dml-tables")]
217impl TableExt for CTTable {
218    fn rows(&self) -> &[CTTableRow] {
219        &self.tr
220    }
221
222    fn row_count(&self) -> usize {
223        self.tr.len()
224    }
225
226    fn col_count(&self) -> usize {
227        self.tbl_grid.grid_col.len()
228    }
229
230    fn cell(&self, row: usize, col: usize) -> Option<&CTTableCell> {
231        self.tr.get(row).and_then(|r| r.tc.get(col))
232    }
233
234    fn to_text_grid(&self) -> Vec<Vec<String>> {
235        self.tr
236            .iter()
237            .map(|row| row.tc.iter().map(|c| c.text()).collect())
238            .collect()
239    }
240
241    fn text(&self) -> String {
242        self.tr
243            .iter()
244            .map(|row| {
245                row.tc
246                    .iter()
247                    .map(|c| c.text())
248                    .collect::<Vec<_>>()
249                    .join("\t")
250            })
251            .collect::<Vec<_>>()
252            .join("\n")
253    }
254}
255
256/// Extension trait for [`CTTableRow`] providing convenience methods.
257#[cfg(feature = "dml-tables")]
258pub trait TableRowExt {
259    /// Get all cells in this row.
260    fn cells(&self) -> &[CTTableCell];
261
262    /// Get a cell by column index (0-based).
263    fn cell(&self, col: usize) -> Option<&CTTableCell>;
264
265    /// Get the row height in EMUs (if specified).
266    fn height_emu(&self) -> Option<i64>;
267}
268
269#[cfg(feature = "dml-tables")]
270impl TableRowExt for CTTableRow {
271    fn cells(&self) -> &[CTTableCell] {
272        &self.tc
273    }
274
275    fn cell(&self, col: usize) -> Option<&CTTableCell> {
276        self.tc.get(col)
277    }
278
279    fn height_emu(&self) -> Option<i64> {
280        self.height.parse::<i64>().ok()
281    }
282}
283
284/// Extension trait for [`CTTableCell`] providing convenience methods.
285#[cfg(feature = "dml-tables")]
286pub trait TableCellExt {
287    /// Get the text body (paragraphs) if present.
288    fn text_body(&self) -> Option<&TextBody>;
289
290    /// Get the cell text (paragraphs joined with newlines).
291    fn text(&self) -> String;
292
293    /// Get the row span (number of rows this cell spans).
294    fn row_span(&self) -> u32;
295
296    /// Get the column span (number of columns this cell spans).
297    fn col_span(&self) -> u32;
298
299    /// Check if this cell spans multiple rows.
300    fn has_row_span(&self) -> bool;
301
302    /// Check if this cell spans multiple columns.
303    fn has_col_span(&self) -> bool;
304
305    /// Check if this cell is merged horizontally (continuation of previous cell).
306    fn is_h_merge(&self) -> bool;
307
308    /// Check if this cell is merged vertically (continuation of cell above).
309    fn is_v_merge(&self) -> bool;
310}
311
312#[cfg(feature = "dml-tables")]
313impl TableCellExt for CTTableCell {
314    fn text_body(&self) -> Option<&TextBody> {
315        self.tx_body.as_deref()
316    }
317
318    fn text(&self) -> String {
319        self.tx_body
320            .as_ref()
321            .map(|tb| tb.text())
322            .unwrap_or_default()
323    }
324
325    fn row_span(&self) -> u32 {
326        self.row_span.map(|s| s.max(1) as u32).unwrap_or(1)
327    }
328
329    fn col_span(&self) -> u32 {
330        self.grid_span.map(|s| s.max(1) as u32).unwrap_or(1)
331    }
332
333    fn has_row_span(&self) -> bool {
334        self.row_span.is_some_and(|s| s > 1)
335    }
336
337    fn has_col_span(&self) -> bool {
338        self.grid_span.is_some_and(|s| s > 1)
339    }
340
341    fn is_h_merge(&self) -> bool {
342        self.h_merge.unwrap_or(false)
343    }
344
345    fn is_v_merge(&self) -> bool {
346        self.v_merge.unwrap_or(false)
347    }
348}
349
350/// The kind of chart contained in a plot area.
351///
352/// Corresponds to the chart element types defined in ECMA-376 §21.2.
353#[cfg(feature = "dml-charts")]
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum ChartKind {
356    /// Bar or column chart (CT_BarChart).
357    Bar,
358    /// 3D bar chart (CT_Bar3DChart).
359    Bar3D,
360    /// Line chart (CT_LineChart).
361    Line,
362    /// 3D line chart (CT_Line3DChart).
363    Line3D,
364    /// Pie chart (CT_PieChart).
365    Pie,
366    /// 3D pie chart (CT_Pie3DChart).
367    Pie3D,
368    /// Scatter / XY chart (CT_ScatterChart).
369    Scatter,
370    /// Area chart (CT_AreaChart).
371    Area,
372    /// 3D area chart (CT_Area3DChart).
373    Area3D,
374    /// Bubble chart (CT_BubbleChart).
375    Bubble,
376    /// Doughnut chart (CT_DoughnutChart).
377    Doughnut,
378    /// Radar / spider chart (CT_RadarChart).
379    Radar,
380    /// Stock chart (CT_StockChart).
381    Stock,
382    /// Surface chart (CT_SurfaceChart).
383    Surface,
384    /// 3D surface chart (CT_Surface3DChart).
385    Surface3D,
386    /// Pie-of-pie or bar-of-pie chart (CT_OfPieChart).
387    OfPie,
388}
389
390/// Extension trait for [`ChartSpace`] (the root element of a chart part).
391///
392/// Corresponds to ECMA-376 §21.2.2.29 (CT_ChartSpace).
393#[cfg(feature = "dml-charts")]
394pub trait ChartSpaceExt {
395    /// The inner chart definition.
396    fn chart(&self) -> &crate::types::Chart;
397
398    /// All chart kinds present in this chart space's plot area.
399    ///
400    /// A chart space can contain multiple chart types (e.g. a combined bar+line chart).
401    fn chart_types(&self) -> Vec<ChartKind>;
402
403    /// The chart title text, if the title contains rich text content.
404    ///
405    /// Returns `None` if there is no title, or if the title references an external
406    /// cell range rather than inline text.
407    fn title_text(&self) -> Option<String>;
408}
409
410#[cfg(feature = "dml-charts")]
411impl ChartSpaceExt for crate::types::ChartSpace {
412    fn chart(&self) -> &crate::types::Chart {
413        &self.chart
414    }
415
416    fn chart_types(&self) -> Vec<ChartKind> {
417        self.chart.chart_types()
418    }
419
420    fn title_text(&self) -> Option<String> {
421        self.chart.title_text()
422    }
423}
424
425/// Extension trait for [`Chart`] providing convenience access to chart content.
426///
427/// Corresponds to ECMA-376 §21.2.2.27 (CT_Chart).
428#[cfg(feature = "dml-charts")]
429pub trait ChartExt {
430    /// The plot area containing the chart series and axes.
431    fn plot_area(&self) -> &crate::types::PlotArea;
432
433    /// The chart legend, if present.
434    fn legend(&self) -> Option<&crate::types::Legend>;
435
436    /// All chart kinds present in this chart's plot area.
437    fn chart_types(&self) -> Vec<ChartKind>;
438
439    /// The chart title text, if the title contains rich text content.
440    fn title_text(&self) -> Option<String>;
441}
442
443#[cfg(feature = "dml-charts")]
444impl ChartExt for crate::types::Chart {
445    fn plot_area(&self) -> &crate::types::PlotArea {
446        &self.plot_area
447    }
448
449    fn legend(&self) -> Option<&crate::types::Legend> {
450        self.legend.as_deref()
451    }
452
453    fn chart_types(&self) -> Vec<ChartKind> {
454        self.plot_area.chart_types()
455    }
456
457    fn title_text(&self) -> Option<String> {
458        self.title.as_deref().and_then(|t| t.title_text())
459    }
460}
461
462/// Extension trait for [`PlotArea`] providing access to contained chart types.
463///
464/// Corresponds to ECMA-376 §21.2.2.145 (CT_PlotArea).
465#[cfg(feature = "dml-charts")]
466pub trait PlotAreaExt {
467    /// All chart kinds present in this plot area.
468    ///
469    /// Returns one entry per chart type present. A combined chart (e.g. bar + line)
470    /// returns multiple entries in the order they appear in the XML.
471    fn chart_types(&self) -> Vec<ChartKind>;
472}
473
474#[cfg(feature = "dml-charts")]
475impl PlotAreaExt for crate::types::PlotArea {
476    fn chart_types(&self) -> Vec<ChartKind> {
477        let mut kinds = Vec::new();
478        if !self.bar_chart.is_empty() {
479            kinds.push(ChartKind::Bar);
480        }
481        if !self.bar3_d_chart.is_empty() {
482            kinds.push(ChartKind::Bar3D);
483        }
484        if !self.line_chart.is_empty() {
485            kinds.push(ChartKind::Line);
486        }
487        if !self.line3_d_chart.is_empty() {
488            kinds.push(ChartKind::Line3D);
489        }
490        if !self.pie_chart.is_empty() {
491            kinds.push(ChartKind::Pie);
492        }
493        if !self.pie3_d_chart.is_empty() {
494            kinds.push(ChartKind::Pie3D);
495        }
496        if !self.scatter_chart.is_empty() {
497            kinds.push(ChartKind::Scatter);
498        }
499        if !self.area_chart.is_empty() {
500            kinds.push(ChartKind::Area);
501        }
502        if !self.area3_d_chart.is_empty() {
503            kinds.push(ChartKind::Area3D);
504        }
505        if !self.bubble_chart.is_empty() {
506            kinds.push(ChartKind::Bubble);
507        }
508        if !self.doughnut_chart.is_empty() {
509            kinds.push(ChartKind::Doughnut);
510        }
511        if !self.radar_chart.is_empty() {
512            kinds.push(ChartKind::Radar);
513        }
514        if !self.stock_chart.is_empty() {
515            kinds.push(ChartKind::Stock);
516        }
517        if !self.surface_chart.is_empty() {
518            kinds.push(ChartKind::Surface);
519        }
520        if !self.surface3_d_chart.is_empty() {
521            kinds.push(ChartKind::Surface3D);
522        }
523        if !self.of_pie_chart.is_empty() {
524            kinds.push(ChartKind::OfPie);
525        }
526        kinds
527    }
528}
529
530/// Extension trait for [`ChartTitle`] providing text extraction.
531///
532/// Corresponds to ECMA-376 §21.2.2.210 (CT_Title).
533#[cfg(feature = "dml-charts")]
534pub trait ChartTitleExt {
535    /// Extract the title text if it is stored as inline rich text.
536    ///
537    /// Returns `None` if:
538    /// - There is no `tx` child element.
539    /// - The `tx` element references a cell range (via `strRef`) rather than inline text.
540    fn title_text(&self) -> Option<String>;
541}
542
543#[cfg(all(feature = "dml-charts", feature = "dml-text"))]
544impl ChartTitleExt for crate::types::ChartTitle {
545    fn title_text(&self) -> Option<String> {
546        self.tx.as_deref().and_then(|tx| {
547            tx.rich.as_deref().map(|body| {
548                body.p
549                    .iter()
550                    .map(|p| {
551                        p.text_run
552                            .iter()
553                            .filter_map(|tr| match tr {
554                                EGTextRun::R(run) => Some(run.t.as_str()),
555                                EGTextRun::Br(_) => Some("\n"),
556                                EGTextRun::Fld(fld) => fld.t.as_deref(),
557                            })
558                            .collect::<String>()
559                    })
560                    .collect::<Vec<_>>()
561                    .join("\n")
562            })
563        })
564    }
565}
566
567// When dml-text is not enabled, provide a fallback that always returns None.
568#[cfg(all(feature = "dml-charts", not(feature = "dml-text")))]
569impl ChartTitleExt for crate::types::ChartTitle {
570    fn title_text(&self) -> Option<String> {
571        None
572    }
573}
574
575/// Extension trait for [`DataModel`] providing convenience access to SmartArt content.
576///
577/// Corresponds to ECMA-376 §21.4.2.8 (CT_DataModel).
578#[cfg(feature = "dml-diagrams")]
579pub trait DataModelExt {
580    /// Returns all diagram points that represent actual content nodes.
581    ///
582    /// Filters out connector/transition points (`parTrans`, `sibTrans`, `pres`) and
583    /// returns only points of type `node`, `asst`, or `doc`.
584    fn content_points(&self) -> Vec<&crate::types::DiagramPoint>;
585
586    /// Returns all connections between diagram points.
587    fn connections(&self) -> Vec<&crate::types::DiagramConnection>;
588
589    /// Extracts all text from diagram content nodes, in order.
590    ///
591    /// Each node's text paragraphs are joined with newlines. Nodes are separated
592    /// by newlines in the returned string.
593    fn text(&self) -> Vec<String>;
594}
595
596#[cfg(feature = "dml-diagrams")]
597fn diagram_content_points(model: &crate::types::DataModel) -> Vec<&crate::types::DiagramPoint> {
598    use crate::types::STPtType;
599    model
600        .pt_lst
601        .pt
602        .iter()
603        .filter(|pt| {
604            // Include points without a type (defaults to "node") and explicit
605            // node/asst/doc types. Exclude parTrans, sibTrans, pres connectors.
606            matches!(
607                pt.r#type,
608                None | Some(STPtType::Node) | Some(STPtType::Asst) | Some(STPtType::Doc)
609            )
610        })
611        .collect()
612}
613
614#[cfg(all(feature = "dml-diagrams", feature = "dml-text"))]
615impl DataModelExt for crate::types::DataModel {
616    fn content_points(&self) -> Vec<&crate::types::DiagramPoint> {
617        diagram_content_points(self)
618    }
619
620    fn connections(&self) -> Vec<&crate::types::DiagramConnection> {
621        self.cxn_lst
622            .as_deref()
623            .map(|lst| lst.cxn.iter().collect())
624            .unwrap_or_default()
625    }
626
627    fn text(&self) -> Vec<String> {
628        self.content_points()
629            .iter()
630            .filter_map(|pt| {
631                pt.t.as_deref().map(|body| {
632                    body.p
633                        .iter()
634                        .map(|p| {
635                            p.text_run
636                                .iter()
637                                .filter_map(|tr| match tr {
638                                    crate::types::EGTextRun::R(run) => Some(run.t.as_str()),
639                                    crate::types::EGTextRun::Br(_) => Some("\n"),
640                                    crate::types::EGTextRun::Fld(fld) => fld.t.as_deref(),
641                                })
642                                .collect::<String>()
643                        })
644                        .collect::<Vec<_>>()
645                        .join("\n")
646                })
647            })
648            .filter(|s| !s.is_empty())
649            .collect()
650    }
651}
652
653// Fallback implementation when dml-text is not enabled: text() always returns empty.
654#[cfg(all(feature = "dml-diagrams", not(feature = "dml-text")))]
655impl DataModelExt for crate::types::DataModel {
656    fn content_points(&self) -> Vec<&crate::types::DiagramPoint> {
657        diagram_content_points(self)
658    }
659
660    fn connections(&self) -> Vec<&crate::types::DiagramConnection> {
661        self.cxn_lst
662            .as_deref()
663            .map(|lst| lst.cxn.iter().collect())
664            .unwrap_or_default()
665    }
666
667    fn text(&self) -> Vec<String> {
668        Vec::new()
669    }
670}
671
672// =============================================================================
673// TextCharacterProperties / TextParagraphProperties / ShapeProperties traits
674// =============================================================================
675
676/// Extension methods for [`TextCharacterProperties`] (ECMA-376 §21.1.2.3.9, CT_TextCharacterProperties).
677///
678/// Provides typed access to per-run formatting properties like language, font
679/// size, caps, underline, strikethrough, and spacing.
680/// All accessors are gated on `dml-text`.
681#[cfg(feature = "dml-text")]
682pub trait TextCharacterPropertiesExt {
683    /// Get the BCP 47 language tag for this run (e.g. `"en-US"`).
684    fn language(&self) -> Option<&str>;
685    /// Get the font size in points (sz / 100.0).
686    fn font_size_pt(&self) -> Option<f64>;
687    /// Check if bold is set.
688    fn is_bold(&self) -> bool;
689    /// Check if italic is set.
690    fn is_italic(&self) -> bool;
691    /// Check if all-caps is set.
692    fn is_all_caps(&self) -> bool;
693    /// Check if small-caps is set.
694    fn is_small_caps(&self) -> bool;
695    /// Get the underline style.
696    fn underline_style(&self) -> Option<STTextUnderlineType>;
697    /// Get the strikethrough style.
698    fn strike_type(&self) -> Option<STTextStrikeType>;
699    /// Get the kerning pair gap in points (kern / 100.0).
700    fn kerning_pt(&self) -> Option<f64>;
701    /// Get the baseline shift as a signed percentage (positive = superscript).
702    fn baseline_shift_pct(&self) -> Option<i32>;
703    /// Get the character spacing in points (spc / 100.0 when it is a point value).
704    fn character_spacing_pt(&self) -> Option<f64>;
705}
706
707#[cfg(feature = "dml-text")]
708impl TextCharacterPropertiesExt for TextCharacterProperties {
709    fn language(&self) -> Option<&str> {
710        self.lang.as_deref()
711    }
712
713    fn font_size_pt(&self) -> Option<f64> {
714        self.sz.map(|s| s as f64 / 100.0)
715    }
716
717    fn is_bold(&self) -> bool {
718        self.b.unwrap_or(false)
719    }
720
721    fn is_italic(&self) -> bool {
722        self.i.unwrap_or(false)
723    }
724
725    fn is_all_caps(&self) -> bool {
726        self.cap.as_ref().is_some_and(|c| *c == STTextCapsType::All)
727    }
728
729    fn is_small_caps(&self) -> bool {
730        self.cap
731            .as_ref()
732            .is_some_and(|c| *c == STTextCapsType::Small)
733    }
734
735    fn underline_style(&self) -> Option<STTextUnderlineType> {
736        self.u
737    }
738
739    fn strike_type(&self) -> Option<STTextStrikeType> {
740        self.strike
741    }
742
743    fn kerning_pt(&self) -> Option<f64> {
744        self.kern.map(|k| k as f64 / 100.0)
745    }
746
747    fn baseline_shift_pct(&self) -> Option<i32> {
748        self.baseline.as_deref().and_then(|s| s.parse().ok())
749    }
750
751    fn character_spacing_pt(&self) -> Option<f64> {
752        // STTextPoint is a String — parse as number (hundredths of a point)
753        self.spc
754            .as_deref()
755            .and_then(|s| s.parse::<f64>().ok().map(|v| v / 100.0))
756    }
757}
758
759/// Extension methods for [`TextParagraphProperties`] (ECMA-376 §21.1.2.2.7, CT_TextParagraphProperties).
760///
761/// Provides typed access to paragraph-level formatting properties such as
762/// margins, indentation, line spacing, and bullets.
763/// All accessors are gated on `dml-text`.
764#[cfg(feature = "dml-text")]
765pub trait TextParagraphPropertiesExt {
766    /// Get the left margin in EMU.
767    fn margin_left_emu(&self) -> Option<i32>;
768    /// Get the right margin in EMU.
769    fn margin_right_emu(&self) -> Option<i32>;
770    /// Get the first-line indent in EMU.
771    fn indent_emu(&self) -> Option<i32>;
772    /// Get the line spacing definition.
773    fn line_spacing(&self) -> Option<&CTTextSpacing>;
774    /// Get the space-before definition.
775    fn space_before(&self) -> Option<&CTTextSpacing>;
776    /// Get the space-after definition.
777    fn space_after(&self) -> Option<&CTTextSpacing>;
778    /// Get the paragraph level (0-based; 0 = body text).
779    fn paragraph_level(&self) -> i32;
780    /// Get the bullet character, if the bullet is a char bullet (`buChar`).
781    fn bullet_char(&self) -> Option<&str>;
782    /// Get the text alignment.
783    fn text_alignment(&self) -> Option<STTextAlignType>;
784}
785
786#[cfg(feature = "dml-text")]
787impl TextParagraphPropertiesExt for TextParagraphProperties {
788    fn margin_left_emu(&self) -> Option<i32> {
789        self.mar_l
790    }
791
792    fn margin_right_emu(&self) -> Option<i32> {
793        self.mar_r
794    }
795
796    fn indent_emu(&self) -> Option<i32> {
797        self.indent
798    }
799
800    fn line_spacing(&self) -> Option<&CTTextSpacing> {
801        self.ln_spc.as_deref()
802    }
803
804    fn space_before(&self) -> Option<&CTTextSpacing> {
805        self.spc_bef.as_deref()
806    }
807
808    fn space_after(&self) -> Option<&CTTextSpacing> {
809        self.spc_aft.as_deref()
810    }
811
812    fn paragraph_level(&self) -> i32 {
813        self.lvl.unwrap_or(0)
814    }
815
816    fn bullet_char(&self) -> Option<&str> {
817        self.text_bullet.as_ref().and_then(|b| {
818            if let EGTextBullet::BuChar(bc) = b.as_ref() {
819                Some(bc.char.as_str())
820            } else {
821                None
822            }
823        })
824    }
825
826    fn text_alignment(&self) -> Option<STTextAlignType> {
827        self.algn
828    }
829}
830
831/// Extension methods for [`CTShapeProperties`] (ECMA-376 §20.1.6.6, CT_ShapeProperties).
832///
833/// Provides convenient access to position, size, rotation, and line
834/// properties. All coordinate values are in EMU (914400 per inch).
835pub trait ShapePropertiesExt {
836    /// Get the position offset in EMU as (x, y).
837    ///
838    /// Returns `None` if no transform or offset is set.
839    fn offset_emu(&self) -> Option<(i64, i64)>;
840    /// Get the extent (width, height) in EMU.
841    ///
842    /// Returns `None` if no transform or extents are set.
843    fn extent_emu(&self) -> Option<(i64, i64)>;
844    /// Get the rotation angle in degrees (rot / 60000.0).
845    fn rotation_angle_deg(&self) -> Option<f64>;
846    /// Check if the shape is flipped horizontally.
847    fn is_flip_h(&self) -> bool;
848    /// Check if the shape is flipped vertically.
849    fn is_flip_v(&self) -> bool;
850    /// Check if the shape has an explicit line (stroke) defined.
851    fn has_line(&self) -> bool;
852}
853
854impl ShapePropertiesExt for CTShapeProperties {
855    fn offset_emu(&self) -> Option<(i64, i64)> {
856        let xfrm = self.transform.as_ref()?;
857        let off = xfrm.offset.as_ref()?;
858        let x = off.x.parse::<i64>().ok()?;
859        let y = off.y.parse::<i64>().ok()?;
860        Some((x, y))
861    }
862
863    fn extent_emu(&self) -> Option<(i64, i64)> {
864        let xfrm = self.transform.as_ref()?;
865        let ext = xfrm.extents.as_ref()?;
866        Some((ext.cx, ext.cy))
867    }
868
869    fn rotation_angle_deg(&self) -> Option<f64> {
870        self.transform
871            .as_ref()
872            .and_then(|xfrm| xfrm.rot)
873            .map(|rot| rot as f64 / 60000.0)
874    }
875
876    fn is_flip_h(&self) -> bool {
877        self.transform
878            .as_ref()
879            .and_then(|xfrm| xfrm.flip_h)
880            .unwrap_or(false)
881    }
882
883    fn is_flip_v(&self) -> bool {
884        self.transform
885            .as_ref()
886            .and_then(|xfrm| xfrm.flip_v)
887            .unwrap_or(false)
888    }
889
890    #[cfg(feature = "dml-lines")]
891    fn has_line(&self) -> bool {
892        self.line.is_some()
893    }
894
895    #[cfg(not(feature = "dml-lines"))]
896    fn has_line(&self) -> bool {
897        false
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    #[test]
906    fn test_text_paragraph_text() {
907        let para = TextParagraph {
908            #[cfg(feature = "dml-text")]
909            p_pr: None,
910            text_run: vec![
911                EGTextRun::R(Box::new(TextRun {
912                    #[cfg(feature = "dml-text")]
913                    r_pr: None,
914                    #[cfg(feature = "dml-text")]
915                    t: "Hello ".to_string(),
916                    #[cfg(feature = "extra-children")]
917                    extra_children: Vec::new(),
918                })),
919                EGTextRun::R(Box::new(TextRun {
920                    #[cfg(feature = "dml-text")]
921                    r_pr: None,
922                    #[cfg(feature = "dml-text")]
923                    t: "World".to_string(),
924                    #[cfg(feature = "extra-children")]
925                    extra_children: Vec::new(),
926                })),
927            ],
928            #[cfg(feature = "dml-text")]
929            end_para_r_pr: None,
930            #[cfg(feature = "extra-children")]
931            extra_children: Vec::new(),
932        };
933
934        assert_eq!(para.text(), "Hello World");
935        assert_eq!(para.runs().len(), 2);
936    }
937
938    #[cfg(feature = "dml-text")]
939    #[test]
940    fn test_text_run_formatting() {
941        let run = TextRun {
942            r_pr: Some(Box::new(TextCharacterProperties {
943                b: Some(true),
944                i: Some(true),
945                ..Default::default()
946            })),
947            t: "Bold Italic".to_string(),
948            #[cfg(feature = "extra-children")]
949            extra_children: Vec::new(),
950        };
951
952        assert!(run.is_bold());
953        assert!(run.is_italic());
954        assert!(!run.is_underlined());
955        assert_eq!(run.text(), "Bold Italic");
956    }
957
958    #[cfg(feature = "dml-tables")]
959    fn make_cell(text: &str) -> CTTableCell {
960        CTTableCell {
961            row_span: None,
962            grid_span: None,
963            h_merge: None,
964            v_merge: None,
965            id: None,
966            tx_body: Some(Box::new(TextBody {
967                body_pr: Box::new(CTTextBodyProperties::default()),
968                lst_style: None,
969                p: vec![TextParagraph {
970                    #[cfg(feature = "dml-text")]
971                    p_pr: None,
972                    text_run: vec![EGTextRun::R(Box::new(TextRun {
973                        #[cfg(feature = "dml-text")]
974                        r_pr: None,
975                        #[cfg(feature = "dml-text")]
976                        t: text.to_string(),
977                        #[cfg(feature = "extra-children")]
978                        extra_children: Vec::new(),
979                    }))],
980                    #[cfg(feature = "dml-text")]
981                    end_para_r_pr: None,
982                    #[cfg(feature = "extra-children")]
983                    extra_children: Vec::new(),
984                }],
985                #[cfg(feature = "extra-children")]
986                extra_children: Vec::new(),
987            })),
988            tc_pr: None,
989            ext_lst: None,
990            #[cfg(feature = "extra-attrs")]
991            extra_attrs: Default::default(),
992            #[cfg(feature = "extra-children")]
993            extra_children: Vec::new(),
994        }
995    }
996
997    #[cfg(feature = "dml-tables")]
998    fn make_table(data: &[&[&str]]) -> CTTable {
999        let rows: Vec<CTTableRow> = data
1000            .iter()
1001            .map(|row_data| CTTableRow {
1002                height: "370840".to_string(), // ~0.26 inches
1003                tc: row_data.iter().map(|&text| make_cell(text)).collect(),
1004                ext_lst: None,
1005                #[cfg(feature = "extra-attrs")]
1006                extra_attrs: Default::default(),
1007                #[cfg(feature = "extra-children")]
1008                extra_children: Vec::new(),
1009            })
1010            .collect();
1011
1012        let col_count = data.first().map(|r| r.len()).unwrap_or(0);
1013        let grid_cols: Vec<CTTableCol> = (0..col_count)
1014            .map(|_| CTTableCol {
1015                width: "914400".to_string(), // 1 inch
1016                ext_lst: None,
1017                #[cfg(feature = "extra-attrs")]
1018                extra_attrs: Default::default(),
1019                #[cfg(feature = "extra-children")]
1020                extra_children: Vec::new(),
1021            })
1022            .collect();
1023
1024        CTTable {
1025            tbl_pr: None,
1026            tbl_grid: Box::new(CTTableGrid {
1027                grid_col: grid_cols,
1028                #[cfg(feature = "extra-children")]
1029                extra_children: Vec::new(),
1030            }),
1031            tr: rows,
1032            #[cfg(feature = "extra-children")]
1033            extra_children: Vec::new(),
1034        }
1035    }
1036
1037    #[cfg(feature = "dml-tables")]
1038    #[test]
1039    fn test_table_ext() {
1040        let table = make_table(&[&["A", "B", "C"], &["1", "2", "3"], &["X", "Y", "Z"]]);
1041
1042        assert_eq!(table.row_count(), 3);
1043        assert_eq!(table.col_count(), 3);
1044        assert_eq!(table.rows().len(), 3);
1045
1046        // Test cell access
1047        assert_eq!(table.cell(0, 0).unwrap().text(), "A");
1048        assert_eq!(table.cell(1, 1).unwrap().text(), "2");
1049        assert_eq!(table.cell(2, 2).unwrap().text(), "Z");
1050        assert!(table.cell(3, 0).is_none()); // Out of bounds
1051
1052        // Test text grid
1053        let grid = table.to_text_grid();
1054        assert_eq!(
1055            grid,
1056            vec![
1057                vec!["A", "B", "C"],
1058                vec!["1", "2", "3"],
1059                vec!["X", "Y", "Z"],
1060            ]
1061        );
1062
1063        // Test text output
1064        assert_eq!(table.text(), "A\tB\tC\n1\t2\t3\nX\tY\tZ");
1065    }
1066
1067    #[cfg(feature = "dml-tables")]
1068    #[test]
1069    fn test_table_row_ext() {
1070        let table = make_table(&[&["Hello", "World"]]);
1071        let row = &table.tr[0];
1072
1073        assert_eq!(row.cells().len(), 2);
1074        assert_eq!(row.cell(0).unwrap().text(), "Hello");
1075        assert_eq!(row.cell(1).unwrap().text(), "World");
1076        assert!(row.cell(2).is_none());
1077        assert_eq!(row.height_emu(), Some(370840));
1078    }
1079
1080    #[cfg(feature = "dml-tables")]
1081    #[test]
1082    fn test_table_cell_ext() {
1083        let cell = make_cell("Test Content");
1084
1085        assert_eq!(cell.text(), "Test Content");
1086        assert!(cell.text_body().is_some());
1087        assert_eq!(cell.row_span(), 1);
1088        assert_eq!(cell.col_span(), 1);
1089        assert!(!cell.has_row_span());
1090        assert!(!cell.has_col_span());
1091        assert!(!cell.is_h_merge());
1092        assert!(!cell.is_v_merge());
1093    }
1094
1095    #[cfg(feature = "dml-tables")]
1096    #[test]
1097    fn test_table_cell_spanning() {
1098        let mut cell = make_cell("Merged");
1099        cell.row_span = Some(2);
1100        cell.grid_span = Some(3);
1101        cell.h_merge = Some(true);
1102
1103        assert_eq!(cell.row_span(), 2);
1104        assert_eq!(cell.col_span(), 3);
1105        assert!(cell.has_row_span());
1106        assert!(cell.has_col_span());
1107        assert!(cell.is_h_merge());
1108        assert!(!cell.is_v_merge());
1109    }
1110
1111    // -------------------------------------------------------------------------
1112    // Chart extension trait tests
1113    // -------------------------------------------------------------------------
1114
1115    #[cfg(feature = "dml-charts")]
1116    fn make_chart_with_bar() -> crate::types::Chart {
1117        use crate::types::*;
1118        Chart {
1119            #[cfg(feature = "dml-charts")]
1120            title: None,
1121            #[cfg(feature = "dml-charts")]
1122            auto_title_deleted: None,
1123            #[cfg(feature = "dml-charts")]
1124            pivot_fmts: None,
1125            #[cfg(feature = "dml-charts")]
1126            view3_d: None,
1127            #[cfg(feature = "dml-charts")]
1128            floor: None,
1129            #[cfg(feature = "dml-charts")]
1130            side_wall: None,
1131            #[cfg(feature = "dml-charts")]
1132            back_wall: None,
1133            #[cfg(feature = "dml-charts")]
1134            plot_area: Box::new(PlotArea {
1135                #[cfg(feature = "dml-charts")]
1136                bar_chart: vec![BarChart {
1137                    #[cfg(feature = "dml-charts")]
1138                    bar_dir: Box::new(BarDirection::default()),
1139                    #[cfg(feature = "dml-charts")]
1140                    grouping: None,
1141                    #[cfg(feature = "dml-charts")]
1142                    vary_colors: None,
1143                    #[cfg(feature = "dml-charts")]
1144                    ser: Vec::new(),
1145                    #[cfg(feature = "dml-charts")]
1146                    d_lbls: None,
1147                    #[cfg(feature = "dml-charts")]
1148                    gap_width: None,
1149                    #[cfg(feature = "dml-charts")]
1150                    overlap: None,
1151                    #[cfg(feature = "dml-charts")]
1152                    ser_lines: Vec::new(),
1153                    #[cfg(feature = "dml-charts")]
1154                    ax_id: Vec::new(),
1155                    #[cfg(feature = "dml-charts")]
1156                    ext_lst: None,
1157                    #[cfg(feature = "extra-children")]
1158                    extra_children: Vec::new(),
1159                }],
1160                ..Default::default()
1161            }),
1162            #[cfg(feature = "dml-charts")]
1163            legend: None,
1164            #[cfg(feature = "dml-charts")]
1165            plot_vis_only: None,
1166            #[cfg(feature = "dml-charts")]
1167            disp_blanks_as: None,
1168            #[cfg(feature = "dml-charts")]
1169            show_d_lbls_over_max: None,
1170            #[cfg(feature = "dml-charts")]
1171            ext_lst: None,
1172            #[cfg(feature = "extra-children")]
1173            extra_children: Vec::new(),
1174        }
1175    }
1176
1177    #[cfg(feature = "dml-charts")]
1178    #[test]
1179    fn test_chart_kind_from_bar_chart() {
1180        use crate::ext::{ChartExt, ChartKind};
1181        let chart = make_chart_with_bar();
1182        let kinds = chart.chart_types();
1183        assert_eq!(kinds, vec![ChartKind::Bar]);
1184    }
1185
1186    #[cfg(feature = "dml-charts")]
1187    #[test]
1188    fn test_plot_area_empty_has_no_kinds() {
1189        use crate::ext::PlotAreaExt;
1190        use crate::types::PlotArea;
1191        let area = PlotArea::default();
1192        assert!(area.chart_types().is_empty());
1193    }
1194
1195    #[cfg(feature = "dml-charts")]
1196    #[test]
1197    fn test_chart_space_delegates_to_chart() {
1198        use crate::ext::{ChartKind, ChartSpaceExt};
1199        use crate::types::ChartSpace;
1200        let space = ChartSpace {
1201            #[cfg(feature = "dml-charts")]
1202            date1904: None,
1203            #[cfg(feature = "dml-charts")]
1204            lang: None,
1205            #[cfg(feature = "dml-charts")]
1206            rounded_corners: None,
1207            #[cfg(feature = "dml-charts")]
1208            style: None,
1209            #[cfg(feature = "dml-charts")]
1210            clr_map_ovr: None,
1211            #[cfg(feature = "dml-charts")]
1212            pivot_source: None,
1213            #[cfg(feature = "dml-charts")]
1214            protection: None,
1215            #[cfg(feature = "dml-charts")]
1216            chart: Box::new(make_chart_with_bar()),
1217            #[cfg(feature = "dml-charts")]
1218            sp_pr: None,
1219            #[cfg(feature = "dml-charts")]
1220            tx_pr: None,
1221            #[cfg(feature = "dml-charts")]
1222            external_data: None,
1223            #[cfg(feature = "dml-charts")]
1224            print_settings: None,
1225            #[cfg(feature = "dml-charts")]
1226            user_shapes: None,
1227            #[cfg(feature = "dml-charts")]
1228            ext_lst: None,
1229            #[cfg(feature = "extra-children")]
1230            extra_children: Vec::new(),
1231        };
1232        assert_eq!(space.chart_types(), vec![ChartKind::Bar]);
1233        assert!(space.title_text().is_none());
1234    }
1235
1236    #[cfg(all(feature = "dml-charts", feature = "dml-text"))]
1237    #[test]
1238    fn test_chart_title_text_rich() {
1239        use crate::ext::ChartTitleExt;
1240        use crate::types::*;
1241
1242        // Build a chart title with inline rich text
1243        let title = ChartTitle {
1244            #[cfg(feature = "dml-charts")]
1245            tx: Some(Box::new(ChartText {
1246                #[cfg(feature = "dml-charts")]
1247                str_ref: None,
1248                #[cfg(feature = "dml-charts")]
1249                rich: Some(Box::new(TextBody {
1250                    body_pr: Box::new(CTTextBodyProperties::default()),
1251                    lst_style: None,
1252                    p: vec![TextParagraph {
1253                        #[cfg(feature = "dml-text")]
1254                        p_pr: None,
1255                        text_run: vec![EGTextRun::R(Box::new(TextRun {
1256                            #[cfg(feature = "dml-text")]
1257                            r_pr: None,
1258                            #[cfg(feature = "dml-text")]
1259                            t: "Sales Report".to_string(),
1260                            #[cfg(feature = "extra-children")]
1261                            extra_children: Vec::new(),
1262                        }))],
1263                        #[cfg(feature = "dml-text")]
1264                        end_para_r_pr: None,
1265                        #[cfg(feature = "extra-children")]
1266                        extra_children: Vec::new(),
1267                    }],
1268                    #[cfg(feature = "extra-children")]
1269                    extra_children: Vec::new(),
1270                })),
1271                #[cfg(feature = "extra-children")]
1272                extra_children: Vec::new(),
1273            })),
1274            #[cfg(feature = "dml-charts")]
1275            layout: None,
1276            #[cfg(feature = "dml-charts")]
1277            overlay: None,
1278            #[cfg(feature = "dml-charts")]
1279            sp_pr: None,
1280            #[cfg(feature = "dml-charts")]
1281            tx_pr: None,
1282            #[cfg(feature = "dml-charts")]
1283            ext_lst: None,
1284            #[cfg(feature = "extra-children")]
1285            extra_children: Vec::new(),
1286        };
1287
1288        assert_eq!(title.title_text(), Some("Sales Report".to_string()));
1289    }
1290
1291    // -------------------------------------------------------------------------
1292    // DataModelExt tests
1293    // -------------------------------------------------------------------------
1294
1295    #[cfg(feature = "dml-diagrams")]
1296    fn make_data_model(points: Vec<crate::types::DiagramPoint>) -> crate::types::DataModel {
1297        use crate::types::*;
1298        DataModel {
1299            #[cfg(feature = "dml-diagrams")]
1300            pt_lst: Box::new(DiagramPointList {
1301                pt: points,
1302                #[cfg(feature = "extra-children")]
1303                extra_children: Vec::new(),
1304            }),
1305            #[cfg(feature = "dml-diagrams")]
1306            cxn_lst: None,
1307            #[cfg(feature = "dml-diagrams")]
1308            bg: None,
1309            #[cfg(feature = "dml-diagrams")]
1310            whole: None,
1311            #[cfg(feature = "dml-diagrams")]
1312            ext_lst: None,
1313            #[cfg(feature = "extra-children")]
1314            extra_children: Vec::new(),
1315        }
1316    }
1317
1318    #[cfg(feature = "dml-diagrams")]
1319    fn make_diagram_point(
1320        id: &str,
1321        pt_type: Option<crate::types::STPtType>,
1322    ) -> crate::types::DiagramPoint {
1323        use crate::types::*;
1324        DiagramPoint {
1325            #[cfg(feature = "dml-diagrams")]
1326            model_id: id.to_string(),
1327            #[cfg(feature = "dml-diagrams")]
1328            r#type: pt_type,
1329            #[cfg(feature = "dml-diagrams")]
1330            cxn_id: None,
1331            #[cfg(feature = "dml-diagrams")]
1332            pr_set: None,
1333            #[cfg(feature = "dml-diagrams")]
1334            sp_pr: None,
1335            #[cfg(feature = "dml-diagrams")]
1336            t: None,
1337            #[cfg(feature = "dml-diagrams")]
1338            ext_lst: None,
1339            #[cfg(feature = "extra-attrs")]
1340            extra_attrs: Default::default(),
1341            #[cfg(feature = "extra-children")]
1342            extra_children: Vec::new(),
1343        }
1344    }
1345
1346    #[cfg(feature = "dml-diagrams")]
1347    #[test]
1348    fn test_data_model_content_points() {
1349        use crate::ext::DataModelExt;
1350        use crate::types::STPtType;
1351
1352        let points = vec![
1353            make_diagram_point("1", None),                     // node (default)
1354            make_diagram_point("2", Some(STPtType::Node)),     // explicit node
1355            make_diagram_point("3", Some(STPtType::Asst)),     // assistant
1356            make_diagram_point("4", Some(STPtType::ParTrans)), // connector — excluded
1357            make_diagram_point("5", Some(STPtType::SibTrans)), // connector — excluded
1358            make_diagram_point("6", Some(STPtType::Pres)),     // presentation — excluded
1359        ];
1360
1361        let model = make_data_model(points);
1362        let content = model.content_points();
1363
1364        // Only node/asst/doc types are returned
1365        assert_eq!(content.len(), 3);
1366        assert_eq!(content[0].model_id, "1");
1367        assert_eq!(content[1].model_id, "2");
1368        assert_eq!(content[2].model_id, "3");
1369    }
1370
1371    #[cfg(feature = "dml-diagrams")]
1372    #[test]
1373    fn test_data_model_connections_empty() {
1374        use crate::ext::DataModelExt;
1375
1376        let model = make_data_model(vec![]);
1377        assert!(model.connections().is_empty());
1378    }
1379
1380    #[cfg(all(feature = "dml-diagrams", feature = "dml-text"))]
1381    #[test]
1382    fn test_data_model_text() {
1383        use crate::ext::DataModelExt;
1384        use crate::types::*;
1385
1386        let mut pt = make_diagram_point("1", None);
1387        pt.t = Some(Box::new(TextBody {
1388            body_pr: Box::new(CTTextBodyProperties::default()),
1389            lst_style: None,
1390            p: vec![TextParagraph {
1391                #[cfg(feature = "dml-text")]
1392                p_pr: None,
1393                text_run: vec![EGTextRun::R(Box::new(TextRun {
1394                    #[cfg(feature = "dml-text")]
1395                    r_pr: None,
1396                    #[cfg(feature = "dml-text")]
1397                    t: "SmartArt Node".to_string(),
1398                    #[cfg(feature = "extra-children")]
1399                    extra_children: Vec::new(),
1400                }))],
1401                #[cfg(feature = "dml-text")]
1402                end_para_r_pr: None,
1403                #[cfg(feature = "extra-children")]
1404                extra_children: Vec::new(),
1405            }],
1406            #[cfg(feature = "extra-children")]
1407            extra_children: Vec::new(),
1408        }));
1409
1410        let model = make_data_model(vec![pt]);
1411        let texts = model.text();
1412
1413        assert_eq!(texts.len(), 1);
1414        assert_eq!(texts[0], "SmartArt Node");
1415    }
1416
1417    // -------------------------------------------------------------------------
1418    // TextCharacterPropertiesExt tests
1419    // -------------------------------------------------------------------------
1420
1421    #[cfg(feature = "dml-text")]
1422    #[test]
1423    fn test_text_char_props_language_and_size() {
1424        let props = TextCharacterProperties {
1425            lang: Some("en-US".to_string()),
1426            sz: Some(2400), // 24pt
1427            b: Some(true),
1428            i: Some(false),
1429            cap: Some(STTextCapsType::All),
1430            ..Default::default()
1431        };
1432
1433        use crate::ext::TextCharacterPropertiesExt;
1434        assert_eq!(props.language(), Some("en-US"));
1435        assert_eq!(props.font_size_pt(), Some(24.0));
1436        assert!(props.is_bold());
1437        assert!(!props.is_italic());
1438        assert!(props.is_all_caps());
1439        assert!(!props.is_small_caps());
1440    }
1441
1442    #[cfg(feature = "dml-text")]
1443    #[test]
1444    fn test_text_char_props_defaults() {
1445        let props = TextCharacterProperties::default();
1446        use crate::ext::TextCharacterPropertiesExt;
1447        assert!(props.language().is_none());
1448        assert!(props.font_size_pt().is_none());
1449        assert!(!props.is_bold());
1450        assert!(!props.is_italic());
1451        assert!(!props.is_all_caps());
1452        assert!(!props.is_small_caps());
1453        assert!(props.underline_style().is_none());
1454        assert!(props.strike_type().is_none());
1455        assert!(props.kerning_pt().is_none());
1456        assert!(props.baseline_shift_pct().is_none());
1457        assert!(props.character_spacing_pt().is_none());
1458    }
1459
1460    #[cfg(feature = "dml-text")]
1461    #[test]
1462    fn test_text_run_caps_and_language() {
1463        let run = TextRun {
1464            r_pr: Some(Box::new(TextCharacterProperties {
1465                cap: Some(STTextCapsType::Small),
1466                lang: Some("fr-FR".to_string()),
1467                ..Default::default()
1468            })),
1469            t: "test".to_string(),
1470            #[cfg(feature = "extra-children")]
1471            extra_children: Vec::new(),
1472        };
1473
1474        assert!(!run.is_all_caps());
1475        assert!(run.is_small_caps());
1476        assert_eq!(run.language(), Some("fr-FR"));
1477        assert!(run.baseline_shift_pct().is_none());
1478    }
1479
1480    // -------------------------------------------------------------------------
1481    // TextParagraphPropertiesExt tests
1482    // -------------------------------------------------------------------------
1483
1484    #[cfg(feature = "dml-text")]
1485    #[test]
1486    fn test_text_paragraph_properties_ext() {
1487        let props = TextParagraphProperties {
1488            mar_l: Some(457200), // 0.5 inch
1489            mar_r: Some(0),
1490            lvl: Some(2),
1491            algn: Some(STTextAlignType::Ctr),
1492            text_bullet: Some(Box::new(EGTextBullet::BuChar(Box::new(CTTextCharBullet {
1493                char: "•".to_string(),
1494                #[cfg(feature = "extra-attrs")]
1495                extra_attrs: Default::default(),
1496            })))),
1497            ..Default::default()
1498        };
1499
1500        use crate::ext::TextParagraphPropertiesExt;
1501        assert_eq!(props.margin_left_emu(), Some(457200));
1502        assert_eq!(props.margin_right_emu(), Some(0));
1503        assert_eq!(props.paragraph_level(), 2);
1504        assert_eq!(props.text_alignment(), Some(STTextAlignType::Ctr));
1505        assert_eq!(props.bullet_char(), Some("•"));
1506        assert!(props.line_spacing().is_none());
1507        assert!(props.space_before().is_none());
1508    }
1509
1510    #[cfg(feature = "dml-text")]
1511    #[test]
1512    fn test_text_paragraph_properties_defaults() {
1513        let props = TextParagraphProperties::default();
1514        use crate::ext::TextParagraphPropertiesExt;
1515        assert!(props.margin_left_emu().is_none());
1516        assert!(props.margin_right_emu().is_none());
1517        assert!(props.indent_emu().is_none());
1518        assert_eq!(props.paragraph_level(), 0);
1519        assert!(props.text_alignment().is_none());
1520        assert!(props.bullet_char().is_none());
1521    }
1522
1523    // -------------------------------------------------------------------------
1524    // ShapePropertiesExt tests
1525    // -------------------------------------------------------------------------
1526
1527    #[test]
1528    fn test_shape_properties_no_transform() {
1529        let sp = CTShapeProperties::default();
1530        use crate::ext::ShapePropertiesExt;
1531        assert!(sp.offset_emu().is_none());
1532        assert!(sp.extent_emu().is_none());
1533        assert!(sp.rotation_angle_deg().is_none());
1534        assert!(!sp.is_flip_h());
1535        assert!(!sp.is_flip_v());
1536        assert!(!sp.has_line());
1537    }
1538
1539    #[test]
1540    fn test_shape_properties_with_transform() {
1541        use crate::types::{Point2D, PositiveSize2D, Transform2D};
1542        let sp = CTShapeProperties {
1543            transform: Some(Box::new(Transform2D {
1544                rot: Some(1800000), // 30 degrees
1545                flip_h: Some(true),
1546                flip_v: None,
1547                offset: Some(Box::new(Point2D {
1548                    x: "914400".to_string(), // 1 inch
1549                    y: "457200".to_string(), // 0.5 inch
1550                    #[cfg(feature = "extra-attrs")]
1551                    extra_attrs: Default::default(),
1552                })),
1553                extents: Some(Box::new(PositiveSize2D {
1554                    cx: 2743200, // 3 inches
1555                    cy: 1828800, // 2 inches
1556                    #[cfg(feature = "extra-attrs")]
1557                    extra_attrs: Default::default(),
1558                })),
1559                ..Default::default()
1560            })),
1561            ..Default::default()
1562        };
1563
1564        use crate::ext::ShapePropertiesExt;
1565        assert_eq!(sp.offset_emu(), Some((914400, 457200)));
1566        assert_eq!(sp.extent_emu(), Some((2743200, 1828800)));
1567        assert!((sp.rotation_angle_deg().unwrap() - 30.0).abs() < 0.001);
1568        assert!(sp.is_flip_h());
1569        assert!(!sp.is_flip_v());
1570    }
1571
1572    #[cfg(feature = "dml-charts")]
1573    #[test]
1574    fn test_chart_title_none_when_no_tx() {
1575        use crate::ext::ChartTitleExt;
1576        use crate::types::ChartTitle;
1577        let title = ChartTitle {
1578            #[cfg(feature = "dml-charts")]
1579            tx: None,
1580            #[cfg(feature = "dml-charts")]
1581            layout: None,
1582            #[cfg(feature = "dml-charts")]
1583            overlay: None,
1584            #[cfg(feature = "dml-charts")]
1585            sp_pr: None,
1586            #[cfg(feature = "dml-charts")]
1587            tx_pr: None,
1588            #[cfg(feature = "dml-charts")]
1589            ext_lst: None,
1590            #[cfg(feature = "extra-children")]
1591            extra_children: Vec::new(),
1592        };
1593        assert!(title.title_text().is_none());
1594    }
1595}