Skip to main content

ppt_rs/parts/
table.rs

1//! Table part
2//!
3//! Represents table data embedded in slides with advanced formatting.
4//!
5//! # Features
6//! - Cell merging (row span, column span)
7//! - Text formatting (bold, italic, underline, strikethrough)
8//! - Cell alignment (horizontal and vertical)
9//! - Borders (all sides, individual sides)
10//! - Background colors and gradients
11//! - Font customization (size, color, family)
12//! - Table styles
13
14use super::base::{ContentType, Part, PartType};
15use crate::core::{escape_xml, ToXml};
16use crate::exc::PptxError;
17
18/// Horizontal alignment
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum HorizontalAlign {
21    #[default]
22    Left,
23    Center,
24    Right,
25    Justify,
26}
27
28impl HorizontalAlign {
29    pub fn as_str(&self) -> &'static str {
30        match self {
31            HorizontalAlign::Left => "l",
32            HorizontalAlign::Center => "ctr",
33            HorizontalAlign::Right => "r",
34            HorizontalAlign::Justify => "just",
35        }
36    }
37}
38
39/// Vertical alignment
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum VerticalAlign {
42    Top,
43    #[default]
44    Middle,
45    Bottom,
46}
47
48impl VerticalAlign {
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            VerticalAlign::Top => "t",
52            VerticalAlign::Middle => "ctr",
53            VerticalAlign::Bottom => "b",
54        }
55    }
56}
57
58/// Border style
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum BorderStyle {
61    #[default]
62    Solid,
63    Dashed,
64    Dotted,
65    Double,
66    None,
67}
68
69impl BorderStyle {
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            BorderStyle::Solid => "solid",
73            BorderStyle::Dashed => "dash",
74            BorderStyle::Dotted => "dot",
75            BorderStyle::Double => "dbl",
76            BorderStyle::None => "none",
77        }
78    }
79}
80
81/// Cell border
82#[derive(Debug, Clone, Default)]
83pub struct CellBorder {
84    pub width: i32, // in EMU (12700 = 1pt)
85    pub color: String,
86    pub style: BorderStyle,
87}
88
89impl CellBorder {
90    pub fn new(width_pt: f32, color: impl Into<String>) -> Self {
91        CellBorder {
92            width: (width_pt * 12700.0) as i32,
93            color: color.into(),
94            style: BorderStyle::Solid,
95        }
96    }
97
98    pub fn style(mut self, style: BorderStyle) -> Self {
99        self.style = style;
100        self
101    }
102
103    pub fn to_xml(&self, tag: &str) -> String {
104        if self.style == BorderStyle::None {
105            return format!("<a:{}/>\n", tag);
106        }
107        format!(
108            r#"<a:{} w="{}" cap="flat" cmpd="sng" algn="ctr">
109              <a:solidFill><a:srgbClr val="{}"/></a:solidFill>
110              <a:prstDash val="{}"/>
111            </a:{}>"#,
112            tag,
113            self.width,
114            self.color.trim_start_matches('#'),
115            self.style.as_str(),
116            tag
117        )
118    }
119}
120
121/// Cell borders (all four sides)
122#[derive(Debug, Clone, Default)]
123pub struct CellBorders {
124    pub left: Option<CellBorder>,
125    pub right: Option<CellBorder>,
126    pub top: Option<CellBorder>,
127    pub bottom: Option<CellBorder>,
128}
129
130impl CellBorders {
131    pub fn all(border: CellBorder) -> Self {
132        CellBorders {
133            left: Some(border.clone()),
134            right: Some(border.clone()),
135            top: Some(border.clone()),
136            bottom: Some(border),
137        }
138    }
139
140    pub fn none() -> Self {
141        let no_border = CellBorder {
142            width: 0,
143            color: String::new(),
144            style: BorderStyle::None,
145        };
146        CellBorders {
147            left: Some(no_border.clone()),
148            right: Some(no_border.clone()),
149            top: Some(no_border.clone()),
150            bottom: Some(no_border),
151        }
152    }
153
154    pub fn to_xml(&self) -> String {
155        let mut xml = String::new();
156        if let Some(ref b) = self.left {
157            xml.push_str(&b.to_xml("lnL"));
158        }
159        if let Some(ref b) = self.right {
160            xml.push_str(&b.to_xml("lnR"));
161        }
162        if let Some(ref b) = self.top {
163            xml.push_str(&b.to_xml("lnT"));
164        }
165        if let Some(ref b) = self.bottom {
166            xml.push_str(&b.to_xml("lnB"));
167        }
168        xml
169    }
170}
171
172impl ToXml for CellBorders {
173    fn to_xml(&self) -> String {
174        CellBorders::to_xml(self)
175    }
176}
177
178/// Cell margins
179#[derive(Debug, Clone)]
180pub struct CellMargins {
181    pub left: i32, // in EMU
182    pub right: i32,
183    pub top: i32,
184    pub bottom: i32,
185}
186
187impl Default for CellMargins {
188    fn default() -> Self {
189        CellMargins {
190            left: 91440, // 0.1 inch
191            right: 91440,
192            top: 45720, // 0.05 inch
193            bottom: 45720,
194        }
195    }
196}
197
198impl CellMargins {
199    pub fn uniform(margin: i32) -> Self {
200        CellMargins {
201            left: margin,
202            right: margin,
203            top: margin,
204            bottom: margin,
205        }
206    }
207}
208
209/// Table cell with advanced formatting
210#[derive(Debug, Clone)]
211pub struct TableCellPart {
212    pub text: String,
213    pub row_span: u32,
214    pub col_span: u32,
215    pub bold: bool,
216    pub italic: bool,
217    pub underline: bool,
218    pub strikethrough: bool,
219    pub background_color: Option<String>,
220    pub text_color: Option<String>,
221    pub font_size: Option<u32>,
222    pub font_family: Option<String>,
223    pub h_align: HorizontalAlign,
224    pub v_align: VerticalAlign,
225    pub borders: Option<CellBorders>,
226    pub margins: Option<CellMargins>,
227    pub is_merged: bool, // For cells that are part of a merge (not the anchor)
228}
229
230impl TableCellPart {
231    /// Create a new table cell
232    pub fn new(text: impl Into<String>) -> Self {
233        TableCellPart {
234            text: text.into(),
235            row_span: 1,
236            col_span: 1,
237            bold: false,
238            italic: false,
239            underline: false,
240            strikethrough: false,
241            background_color: None,
242            text_color: None,
243            font_size: None,
244            font_family: None,
245            h_align: HorizontalAlign::default(),
246            v_align: VerticalAlign::default(),
247            borders: None,
248            margins: None,
249            is_merged: false,
250        }
251    }
252
253    /// Create a merged placeholder cell (for cells covered by a span)
254    pub fn merged() -> Self {
255        let mut cell = Self::new("");
256        cell.is_merged = true;
257        cell
258    }
259
260    /// Set bold
261    pub fn bold(mut self) -> Self {
262        self.bold = true;
263        self
264    }
265
266    /// Set italic
267    pub fn italic(mut self) -> Self {
268        self.italic = true;
269        self
270    }
271
272    /// Set underline
273    pub fn underline(mut self) -> Self {
274        self.underline = true;
275        self
276    }
277
278    /// Set strikethrough
279    pub fn strikethrough(mut self) -> Self {
280        self.strikethrough = true;
281        self
282    }
283
284    /// Set background color
285    pub fn background(mut self, color: impl Into<String>) -> Self {
286        self.background_color = Some(color.into());
287        self
288    }
289
290    /// Set text color
291    pub fn color(mut self, color: impl Into<String>) -> Self {
292        self.text_color = Some(color.into());
293        self
294    }
295
296    /// Set font size (in points)
297    pub fn font_size(mut self, size: u32) -> Self {
298        self.font_size = Some(size);
299        self
300    }
301
302    /// Set font family
303    pub fn font(mut self, family: impl Into<String>) -> Self {
304        self.font_family = Some(family.into());
305        self
306    }
307
308    /// Set horizontal alignment
309    pub fn align(mut self, align: HorizontalAlign) -> Self {
310        self.h_align = align;
311        self
312    }
313
314    /// Set vertical alignment
315    pub fn valign(mut self, align: VerticalAlign) -> Self {
316        self.v_align = align;
317        self
318    }
319
320    /// Center text (horizontal and vertical)
321    pub fn center(mut self) -> Self {
322        self.h_align = HorizontalAlign::Center;
323        self.v_align = VerticalAlign::Middle;
324        self
325    }
326
327    /// Set row span
328    pub fn row_span(mut self, span: u32) -> Self {
329        self.row_span = span;
330        self
331    }
332
333    /// Set column span
334    pub fn col_span(mut self, span: u32) -> Self {
335        self.col_span = span;
336        self
337    }
338
339    /// Set all borders
340    pub fn borders(mut self, borders: CellBorders) -> Self {
341        self.borders = Some(borders);
342        self
343    }
344
345    /// Set uniform border on all sides
346    pub fn border(mut self, width_pt: f32, color: impl Into<String>) -> Self {
347        self.borders = Some(CellBorders::all(CellBorder::new(width_pt, color)));
348        self
349    }
350
351    /// Set cell margins
352    pub fn margins(mut self, margins: CellMargins) -> Self {
353        self.margins = Some(margins);
354        self
355    }
356
357    /// Generate XML for this cell
358    pub fn to_xml(&self) -> String {
359        // Handle merged cells (placeholders)
360        if self.is_merged {
361            return r#"<a:tc hMerge="1"><a:txBody><a:bodyPr/><a:lstStyle/><a:p><a:endParaRPr/></a:p></a:txBody><a:tcPr/></a:tc>"#.to_string();
362        }
363
364        let mut attrs = String::new();
365        if self.row_span > 1 {
366            attrs.push_str(&format!(r#" rowSpan="{}""#, self.row_span));
367        }
368        if self.col_span > 1 {
369            attrs.push_str(&format!(r#" gridSpan="{}""#, self.col_span));
370        }
371
372        // Background fill
373        let bg_xml = self
374            .background_color
375            .as_ref()
376            .map(|c| {
377                format!(
378                    r#"<a:solidFill><a:srgbClr val="{}"/></a:solidFill>"#,
379                    c.trim_start_matches('#')
380                )
381            })
382            .unwrap_or_default();
383
384        // Text run properties
385        let mut rpr_attrs = String::new();
386        if self.bold {
387            rpr_attrs.push_str(r#" b="1""#);
388        }
389        if self.italic {
390            rpr_attrs.push_str(r#" i="1""#);
391        }
392        if self.underline {
393            rpr_attrs.push_str(r#" u="sng""#);
394        }
395        if self.strikethrough {
396            rpr_attrs.push_str(r#" strike="sngStrike""#);
397        }
398        if let Some(size) = self.font_size {
399            rpr_attrs.push_str(&format!(r#" sz="{}""#, size * 100));
400        }
401
402        // Text color
403        let color_xml = self
404            .text_color
405            .as_ref()
406            .map(|c| {
407                format!(
408                    r#"<a:solidFill><a:srgbClr val="{}"/></a:solidFill>"#,
409                    c.trim_start_matches('#')
410                )
411            })
412            .unwrap_or_default();
413
414        // Font family
415        let font_xml = self
416            .font_family
417            .as_ref()
418            .map(|f| format!(r#"<a:latin typeface="{}"/>"#, f))
419            .unwrap_or_default();
420
421        // Paragraph alignment
422        let p_pr = if self.h_align == HorizontalAlign::Center {
423            String::new()
424        } else {
425            format!(r#"<a:pPr algn="{}"/>"#, self.h_align.as_str())
426        };
427
428        // Cell properties
429        let mut tcpr_attrs = format!(r#" anchor="{}""#, self.v_align.as_str());
430        if let Some(ref m) = self.margins {
431            tcpr_attrs.push_str(&format!(
432                r#" marL="{}" marR="{}" marT="{}" marB="{}""#,
433                m.left, m.right, m.top, m.bottom
434            ));
435        }
436
437        // Borders
438        let borders_xml = self
439            .borders
440            .as_ref()
441            .map(|b| b.to_xml())
442            .unwrap_or_default();
443
444        format!(
445            r#"<a:tc{}>
446          <a:txBody>
447            <a:bodyPr/>
448            <a:lstStyle/>
449            <a:p>
450              {}
451              <a:r>
452                <a:rPr lang="en-US"{}>{}{}</a:rPr>
453                <a:t>{}</a:t>
454              </a:r>
455            </a:p>
456          </a:txBody>
457          <a:tcPr{}>{}{}</a:tcPr>
458        </a:tc>"#,
459            attrs,
460            p_pr,
461            rpr_attrs,
462            color_xml,
463            font_xml,
464            escape_xml(&self.text),
465            tcpr_attrs,
466            borders_xml,
467            bg_xml
468        )
469    }
470}
471
472impl ToXml for TableCellPart {
473    fn to_xml(&self) -> String {
474        TableCellPart::to_xml(self)
475    }
476}
477
478/// Table row
479#[derive(Debug, Clone)]
480pub struct TableRowPart {
481    pub cells: Vec<TableCellPart>,
482    pub height: Option<i64>, // in EMU
483}
484
485impl TableRowPart {
486    /// Create a new table row
487    pub fn new(cells: Vec<TableCellPart>) -> Self {
488        TableRowPart {
489            cells,
490            height: None,
491        }
492    }
493
494    /// Set row height in EMU
495    pub fn height(mut self, height: i64) -> Self {
496        self.height = Some(height);
497        self
498    }
499
500    /// Generate XML for this row
501    pub fn to_xml(&self) -> String {
502        let height_attr = self
503            .height
504            .map(|h| format!(r#" h="{}""#, h))
505            .unwrap_or_default();
506
507        let cells_xml: String = self
508            .cells
509            .iter()
510            .map(|c| c.to_xml())
511            .collect::<Vec<_>>()
512            .join("\n        ");
513
514        format!(
515            r#"<a:tr{}>
516        {}
517      </a:tr>"#,
518            height_attr, cells_xml
519        )
520    }
521}
522
523impl ToXml for TableRowPart {
524    fn to_xml(&self) -> String {
525        TableRowPart::to_xml(self)
526    }
527}
528
529/// Table part for embedding in slides
530#[derive(Debug, Clone)]
531pub struct TablePart {
532    pub rows: Vec<TableRowPart>,
533    pub col_widths: Vec<i64>, // in EMU
534    pub x: i64,
535    pub y: i64,
536    pub width: i64,
537    pub height: i64,
538}
539
540impl TablePart {
541    /// Create a new table part
542    pub fn new() -> Self {
543        TablePart {
544            rows: vec![],
545            col_widths: vec![],
546            x: 914400,       // 1 inch
547            y: 1828800,      // 2 inches
548            width: 7315200,  // 8 inches
549            height: 1828800, // 2 inches
550        }
551    }
552
553    /// Add a row
554    pub fn add_row(mut self, row: TableRowPart) -> Self {
555        // Auto-calculate column widths if not set
556        if self.col_widths.is_empty() && !row.cells.is_empty() {
557            let col_count = row.cells.len();
558            let col_width = self.width / col_count as i64;
559            self.col_widths = vec![col_width; col_count];
560        }
561        self.rows.push(row);
562        self
563    }
564
565    /// Set position
566    pub fn position(mut self, x: i64, y: i64) -> Self {
567        self.x = x;
568        self.y = y;
569        self
570    }
571
572    /// Set size
573    pub fn size(mut self, width: i64, height: i64) -> Self {
574        self.width = width;
575        self.height = height;
576        self
577    }
578
579    /// Set column widths
580    pub fn col_widths(mut self, widths: Vec<i64>) -> Self {
581        self.col_widths = widths;
582        self
583    }
584
585    /// Generate table XML for embedding in a slide
586    pub fn to_slide_xml(&self, shape_id: usize) -> String {
587        let grid_cols: String = self
588            .col_widths
589            .iter()
590            .map(|w| format!(r#"<a:gridCol w="{}"/>"#, w))
591            .collect::<Vec<_>>()
592            .join("\n        ");
593
594        let rows_xml: String = self
595            .rows
596            .iter()
597            .map(|r| r.to_xml())
598            .collect::<Vec<_>>()
599            .join("\n      ");
600
601        format!(
602            r#"<p:graphicFrame>
603  <p:nvGraphicFramePr>
604    <p:cNvPr id="{}" name="Table {}"/>
605    <p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>
606    <p:nvPr/>
607  </p:nvGraphicFramePr>
608  <p:xfrm>
609    <a:off x="{}" y="{}"/>
610    <a:ext cx="{}" cy="{}"/>
611  </p:xfrm>
612  <a:graphic>
613    <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table">
614      <a:tbl>
615        <a:tblPr firstRow="1" bandRow="1">
616          <a:tableStyleId>{{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}}</a:tableStyleId>
617        </a:tblPr>
618        <a:tblGrid>
619        {}
620        </a:tblGrid>
621      {}
622      </a:tbl>
623    </a:graphicData>
624  </a:graphic>
625</p:graphicFrame>"#,
626            shape_id, shape_id, self.x, self.y, self.width, self.height, grid_cols, rows_xml
627        )
628    }
629}
630
631impl Default for TablePart {
632    fn default() -> Self {
633        Self::new()
634    }
635}
636
637impl Part for TablePart {
638    fn path(&self) -> &str {
639        "" // Tables are embedded in slides, not separate parts
640    }
641
642    fn part_type(&self) -> PartType {
643        PartType::Slide // Tables are part of slides
644    }
645
646    fn content_type(&self) -> ContentType {
647        ContentType::Xml
648    }
649
650    fn to_xml(&self) -> Result<String, PptxError> {
651        Ok(self.to_slide_xml(2))
652    }
653
654    fn from_xml(_xml: &str) -> Result<Self, PptxError> {
655        Ok(TablePart::new())
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_table_cell_new() {
665        let cell = TableCellPart::new("Test");
666        assert_eq!(cell.text, "Test");
667        assert!(!cell.bold);
668    }
669
670    #[test]
671    fn test_table_cell_formatting() {
672        let cell = TableCellPart::new("Bold")
673            .bold()
674            .color("FF0000")
675            .font_size(14);
676        assert!(cell.bold);
677        assert_eq!(cell.text_color, Some("FF0000".to_string()));
678        assert_eq!(cell.font_size, Some(14));
679    }
680
681    #[test]
682    fn test_table_cell_span() {
683        let cell = TableCellPart::new("Merged").row_span(2).col_span(3);
684        assert_eq!(cell.row_span, 2);
685        assert_eq!(cell.col_span, 3);
686    }
687
688    #[test]
689    fn test_table_row_new() {
690        let row = TableRowPart::new(vec![TableCellPart::new("A"), TableCellPart::new("B")]);
691        assert_eq!(row.cells.len(), 2);
692    }
693
694    #[test]
695    fn test_table_part_new() {
696        let table = TablePart::new()
697            .add_row(TableRowPart::new(vec![
698                TableCellPart::new("Header 1"),
699                TableCellPart::new("Header 2"),
700            ]))
701            .add_row(TableRowPart::new(vec![
702                TableCellPart::new("Data 1"),
703                TableCellPart::new("Data 2"),
704            ]));
705        assert_eq!(table.rows.len(), 2);
706        assert_eq!(table.col_widths.len(), 2);
707    }
708
709    #[test]
710    fn test_table_to_xml() {
711        let table = TablePart::new().add_row(TableRowPart::new(vec![TableCellPart::new("Test")]));
712        let xml = table.to_slide_xml(5);
713        assert!(xml.contains("p:graphicFrame"));
714        assert!(xml.contains("a:tbl"));
715        assert!(xml.contains("Test"));
716    }
717}