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::{Part, PartType, ContentType};
15use crate::exc::PptxError;
16use crate::core::{escape_xml, ToXml};
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 { width: 0, color: String::new(), style: BorderStyle::None };
142        CellBorders {
143            left: Some(no_border.clone()),
144            right: Some(no_border.clone()),
145            top: Some(no_border.clone()),
146            bottom: Some(no_border),
147        }
148    }
149
150    pub fn to_xml(&self) -> String {
151        let mut xml = String::new();
152        if let Some(ref b) = self.left { xml.push_str(&b.to_xml("lnL")); }
153        if let Some(ref b) = self.right { xml.push_str(&b.to_xml("lnR")); }
154        if let Some(ref b) = self.top { xml.push_str(&b.to_xml("lnT")); }
155        if let Some(ref b) = self.bottom { xml.push_str(&b.to_xml("lnB")); }
156        xml
157    }
158}
159
160impl ToXml for CellBorders {
161    fn to_xml(&self) -> String {
162        CellBorders::to_xml(self)
163    }
164}
165
166/// Cell margins
167#[derive(Debug, Clone)]
168pub struct CellMargins {
169    pub left: i32,   // in EMU
170    pub right: i32,
171    pub top: i32,
172    pub bottom: i32,
173}
174
175impl Default for CellMargins {
176    fn default() -> Self {
177        CellMargins {
178            left: 91440,   // 0.1 inch
179            right: 91440,
180            top: 45720,    // 0.05 inch
181            bottom: 45720,
182        }
183    }
184}
185
186impl CellMargins {
187    pub fn uniform(margin: i32) -> Self {
188        CellMargins { left: margin, right: margin, top: margin, bottom: margin }
189    }
190}
191
192/// Table cell with advanced formatting
193#[derive(Debug, Clone)]
194pub struct TableCellPart {
195    pub text: String,
196    pub row_span: u32,
197    pub col_span: u32,
198    pub bold: bool,
199    pub italic: bool,
200    pub underline: bool,
201    pub strikethrough: bool,
202    pub background_color: Option<String>,
203    pub text_color: Option<String>,
204    pub font_size: Option<u32>,
205    pub font_family: Option<String>,
206    pub h_align: HorizontalAlign,
207    pub v_align: VerticalAlign,
208    pub borders: Option<CellBorders>,
209    pub margins: Option<CellMargins>,
210    pub is_merged: bool,  // For cells that are part of a merge (not the anchor)
211}
212
213impl TableCellPart {
214    /// Create a new table cell
215    pub fn new(text: impl Into<String>) -> Self {
216        TableCellPart {
217            text: text.into(),
218            row_span: 1,
219            col_span: 1,
220            bold: false,
221            italic: false,
222            underline: false,
223            strikethrough: false,
224            background_color: None,
225            text_color: None,
226            font_size: None,
227            font_family: None,
228            h_align: HorizontalAlign::default(),
229            v_align: VerticalAlign::default(),
230            borders: None,
231            margins: None,
232            is_merged: false,
233        }
234    }
235
236    /// Create a merged placeholder cell (for cells covered by a span)
237    pub fn merged() -> Self {
238        let mut cell = Self::new("");
239        cell.is_merged = true;
240        cell
241    }
242
243    /// Set bold
244    pub fn bold(mut self) -> Self {
245        self.bold = true;
246        self
247    }
248
249    /// Set italic
250    pub fn italic(mut self) -> Self {
251        self.italic = true;
252        self
253    }
254
255    /// Set underline
256    pub fn underline(mut self) -> Self {
257        self.underline = true;
258        self
259    }
260
261    /// Set strikethrough
262    pub fn strikethrough(mut self) -> Self {
263        self.strikethrough = true;
264        self
265    }
266
267    /// Set background color
268    pub fn background(mut self, color: impl Into<String>) -> Self {
269        self.background_color = Some(color.into());
270        self
271    }
272
273    /// Set text color
274    pub fn color(mut self, color: impl Into<String>) -> Self {
275        self.text_color = Some(color.into());
276        self
277    }
278
279    /// Set font size (in points)
280    pub fn font_size(mut self, size: u32) -> Self {
281        self.font_size = Some(size);
282        self
283    }
284
285    /// Set font family
286    pub fn font(mut self, family: impl Into<String>) -> Self {
287        self.font_family = Some(family.into());
288        self
289    }
290
291    /// Set horizontal alignment
292    pub fn align(mut self, align: HorizontalAlign) -> Self {
293        self.h_align = align;
294        self
295    }
296
297    /// Set vertical alignment
298    pub fn valign(mut self, align: VerticalAlign) -> Self {
299        self.v_align = align;
300        self
301    }
302
303    /// Center text (horizontal and vertical)
304    pub fn center(mut self) -> Self {
305        self.h_align = HorizontalAlign::Center;
306        self.v_align = VerticalAlign::Middle;
307        self
308    }
309
310    /// Set row span
311    pub fn row_span(mut self, span: u32) -> Self {
312        self.row_span = span;
313        self
314    }
315
316    /// Set column span
317    pub fn col_span(mut self, span: u32) -> Self {
318        self.col_span = span;
319        self
320    }
321
322    /// Set all borders
323    pub fn borders(mut self, borders: CellBorders) -> Self {
324        self.borders = Some(borders);
325        self
326    }
327
328    /// Set uniform border on all sides
329    pub fn border(mut self, width_pt: f32, color: impl Into<String>) -> Self {
330        self.borders = Some(CellBorders::all(CellBorder::new(width_pt, color)));
331        self
332    }
333
334    /// Set cell margins
335    pub fn margins(mut self, margins: CellMargins) -> Self {
336        self.margins = Some(margins);
337        self
338    }
339
340    /// Generate XML for this cell
341    pub fn to_xml(&self) -> String {
342        // Handle merged cells (placeholders)
343        if self.is_merged {
344            return r#"<a:tc hMerge="1"><a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody><a:tcPr/></a:tc>"#.to_string();
345        }
346
347        let mut attrs = String::new();
348        if self.row_span > 1 {
349            attrs.push_str(&format!(r#" rowSpan="{}""#, self.row_span));
350        }
351        if self.col_span > 1 {
352            attrs.push_str(&format!(r#" gridSpan="{}""#, self.col_span));
353        }
354
355        // Background fill
356        let bg_xml = self.background_color.as_ref()
357            .map(|c| format!(r#"<a:solidFill><a:srgbClr val="{}"/></a:solidFill>"#, c.trim_start_matches('#')))
358            .unwrap_or_default();
359
360        // Text run properties
361        let mut rpr_attrs = String::new();
362        if self.bold { rpr_attrs.push_str(r#" b="1""#); }
363        if self.italic { rpr_attrs.push_str(r#" i="1""#); }
364        if self.underline { rpr_attrs.push_str(r#" u="sng""#); }
365        if self.strikethrough { rpr_attrs.push_str(r#" strike="sngStrike""#); }
366        if let Some(size) = self.font_size {
367            rpr_attrs.push_str(&format!(r#" sz="{}""#, size * 100));
368        }
369
370        // Text color
371        let color_xml = self.text_color.as_ref()
372            .map(|c| format!(r#"<a:solidFill><a:srgbClr val="{}"/></a:solidFill>"#, c.trim_start_matches('#')))
373            .unwrap_or_default();
374
375        // Font family
376        let font_xml = self.font_family.as_ref()
377            .map(|f| format!(r#"<a:latin typeface="{}"/>"#, f))
378            .unwrap_or_default();
379
380        // Paragraph alignment
381        let p_align = format!(r#" algn="{}""#, self.h_align.as_str());
382
383        // Cell properties
384        let mut tcpr_attrs = format!(r#" anchor="{}""#, self.v_align.as_str());
385        if let Some(ref m) = self.margins {
386            tcpr_attrs.push_str(&format!(r#" marL="{}" marR="{}" marT="{}" marB="{}""#, 
387                m.left, m.right, m.top, m.bottom));
388        }
389
390        // Borders
391        let borders_xml = self.borders.as_ref()
392            .map(|b| b.to_xml())
393            .unwrap_or_default();
394
395        format!(
396            r#"<a:tc{}>
397          <a:txBody>
398            <a:bodyPr/>
399            <a:lstStyle/>
400            <a:p{}>
401              <a:r>
402                <a:rPr lang="en-US"{}>{}{}</a:rPr>
403                <a:t>{}</a:t>
404              </a:r>
405            </a:p>
406          </a:txBody>
407          <a:tcPr{}>{}{}</a:tcPr>
408        </a:tc>"#,
409            attrs,
410            p_align,
411            rpr_attrs,
412            color_xml,
413            font_xml,
414            escape_xml(&self.text),
415            tcpr_attrs,
416            borders_xml,
417            bg_xml
418        )
419    }
420}
421
422impl ToXml for TableCellPart {
423    fn to_xml(&self) -> String {
424        TableCellPart::to_xml(self)
425    }
426}
427
428/// Table row
429#[derive(Debug, Clone)]
430pub struct TableRowPart {
431    pub cells: Vec<TableCellPart>,
432    pub height: Option<i64>, // in EMU
433}
434
435impl TableRowPart {
436    /// Create a new table row
437    pub fn new(cells: Vec<TableCellPart>) -> Self {
438        TableRowPart {
439            cells,
440            height: None,
441        }
442    }
443
444    /// Set row height in EMU
445    pub fn height(mut self, height: i64) -> Self {
446        self.height = Some(height);
447        self
448    }
449
450    /// Generate XML for this row
451    pub fn to_xml(&self) -> String {
452        let height_attr = self.height
453            .map(|h| format!(r#" h="{}""#, h))
454            .unwrap_or_default();
455
456        let cells_xml: String = self.cells.iter()
457            .map(|c| c.to_xml())
458            .collect::<Vec<_>>()
459            .join("\n        ");
460
461        format!(
462            r#"<a:tr{}>
463        {}
464      </a:tr>"#,
465            height_attr,
466            cells_xml
467        )
468    }
469}
470
471impl ToXml for TableRowPart {
472    fn to_xml(&self) -> String {
473        TableRowPart::to_xml(self)
474    }
475}
476
477/// Table part for embedding in slides
478#[derive(Debug, Clone)]
479pub struct TablePart {
480    pub rows: Vec<TableRowPart>,
481    pub col_widths: Vec<i64>, // in EMU
482    pub x: i64,
483    pub y: i64,
484    pub width: i64,
485    pub height: i64,
486}
487
488impl TablePart {
489    /// Create a new table part
490    pub fn new() -> Self {
491        TablePart {
492            rows: vec![],
493            col_widths: vec![],
494            x: 914400,      // 1 inch
495            y: 1828800,     // 2 inches
496            width: 7315200, // 8 inches
497            height: 1828800, // 2 inches
498        }
499    }
500
501    /// Add a row
502    pub fn add_row(mut self, row: TableRowPart) -> Self {
503        // Auto-calculate column widths if not set
504        if self.col_widths.is_empty() && !row.cells.is_empty() {
505            let col_count = row.cells.len();
506            let col_width = self.width / col_count as i64;
507            self.col_widths = vec![col_width; col_count];
508        }
509        self.rows.push(row);
510        self
511    }
512
513    /// Set position
514    pub fn position(mut self, x: i64, y: i64) -> Self {
515        self.x = x;
516        self.y = y;
517        self
518    }
519
520    /// Set size
521    pub fn size(mut self, width: i64, height: i64) -> Self {
522        self.width = width;
523        self.height = height;
524        self
525    }
526
527    /// Set column widths
528    pub fn col_widths(mut self, widths: Vec<i64>) -> Self {
529        self.col_widths = widths;
530        self
531    }
532
533    /// Generate table XML for embedding in a slide
534    pub fn to_slide_xml(&self, shape_id: usize) -> String {
535        let grid_cols: String = self.col_widths.iter()
536            .map(|w| format!(r#"<a:gridCol w="{}"/>"#, w))
537            .collect::<Vec<_>>()
538            .join("\n        ");
539
540        let rows_xml: String = self.rows.iter()
541            .map(|r| r.to_xml())
542            .collect::<Vec<_>>()
543            .join("\n      ");
544
545        format!(
546            r#"<p:graphicFrame>
547  <p:nvGraphicFramePr>
548    <p:cNvPr id="{}" name="Table {}"/>
549    <p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr>
550    <p:nvPr/>
551  </p:nvGraphicFramePr>
552  <p:xfrm>
553    <a:off x="{}" y="{}"/>
554    <a:ext cx="{}" cy="{}"/>
555  </p:xfrm>
556  <a:graphic>
557    <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table">
558      <a:tbl>
559        <a:tblPr firstRow="1" bandRow="1">
560          <a:tableStyleId>{{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}}</a:tableStyleId>
561        </a:tblPr>
562        <a:tblGrid>
563        {}
564        </a:tblGrid>
565      {}
566      </a:tbl>
567    </a:graphicData>
568  </a:graphic>
569</p:graphicFrame>"#,
570            shape_id,
571            shape_id,
572            self.x,
573            self.y,
574            self.width,
575            self.height,
576            grid_cols,
577            rows_xml
578        )
579    }
580}
581
582impl Default for TablePart {
583    fn default() -> Self {
584        Self::new()
585    }
586}
587
588impl Part for TablePart {
589    fn path(&self) -> &str {
590        "" // Tables are embedded in slides, not separate parts
591    }
592
593    fn part_type(&self) -> PartType {
594        PartType::Slide // Tables are part of slides
595    }
596
597    fn content_type(&self) -> ContentType {
598        ContentType::Xml
599    }
600
601    fn to_xml(&self) -> Result<String, PptxError> {
602        Ok(self.to_slide_xml(2))
603    }
604
605    fn from_xml(_xml: &str) -> Result<Self, PptxError> {
606        Ok(TablePart::new())
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn test_table_cell_new() {
616        let cell = TableCellPart::new("Test");
617        assert_eq!(cell.text, "Test");
618        assert!(!cell.bold);
619    }
620
621    #[test]
622    fn test_table_cell_formatting() {
623        let cell = TableCellPart::new("Bold")
624            .bold()
625            .color("FF0000")
626            .font_size(14);
627        assert!(cell.bold);
628        assert_eq!(cell.text_color, Some("FF0000".to_string()));
629        assert_eq!(cell.font_size, Some(14));
630    }
631
632    #[test]
633    fn test_table_cell_span() {
634        let cell = TableCellPart::new("Merged")
635            .row_span(2)
636            .col_span(3);
637        assert_eq!(cell.row_span, 2);
638        assert_eq!(cell.col_span, 3);
639    }
640
641    #[test]
642    fn test_table_row_new() {
643        let row = TableRowPart::new(vec![
644            TableCellPart::new("A"),
645            TableCellPart::new("B"),
646        ]);
647        assert_eq!(row.cells.len(), 2);
648    }
649
650    #[test]
651    fn test_table_part_new() {
652        let table = TablePart::new()
653            .add_row(TableRowPart::new(vec![
654                TableCellPart::new("Header 1"),
655                TableCellPart::new("Header 2"),
656            ]))
657            .add_row(TableRowPart::new(vec![
658                TableCellPart::new("Data 1"),
659                TableCellPart::new("Data 2"),
660            ]));
661        assert_eq!(table.rows.len(), 2);
662        assert_eq!(table.col_widths.len(), 2);
663    }
664
665    #[test]
666    fn test_table_to_xml() {
667        let table = TablePart::new()
668            .add_row(TableRowPart::new(vec![
669                TableCellPart::new("Test"),
670            ]));
671        let xml = table.to_slide_xml(5);
672        assert!(xml.contains("p:graphicFrame"));
673        assert!(xml.contains("a:tbl"));
674        assert!(xml.contains("Test"));
675    }
676}