Skip to main content

ppt_rs/generator/slide_content/
table_merge.rs

1//! Advanced table cell merging
2//!
3//! Provides utilities for merging cells across rows and columns in tables,
4//! generating proper `gridSpan`, `rowSpan`, `vMerge`, and `hMerge` attributes.
5
6/// A merge region defined by top-left corner and span
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct MergeRegion {
9    pub row: usize,
10    pub col: usize,
11    pub row_span: usize,
12    pub col_span: usize,
13}
14
15impl MergeRegion {
16    /// Create a merge region starting at (row, col) spanning rows × cols
17    pub fn new(row: usize, col: usize, row_span: usize, col_span: usize) -> Result<Self, String> {
18        if row_span == 0 || col_span == 0 {
19            return Err("Span must be at least 1".to_string());
20        }
21        Ok(Self { row, col, row_span, col_span })
22    }
23
24    /// Last row index (inclusive)
25    pub fn last_row(&self) -> usize {
26        self.row + self.row_span - 1
27    }
28
29    /// Last column index (inclusive)
30    pub fn last_col(&self) -> usize {
31        self.col + self.col_span - 1
32    }
33
34    /// Check if a cell (r, c) is inside this merge region
35    pub fn contains(&self, r: usize, c: usize) -> bool {
36        r >= self.row && r <= self.last_row() && c >= self.col && c <= self.last_col()
37    }
38
39    /// Check if this is the anchor cell (top-left)
40    pub fn is_anchor(&self, r: usize, c: usize) -> bool {
41        r == self.row && c == self.col
42    }
43
44    /// Whether this region overlaps with another
45    pub fn overlaps(&self, other: &MergeRegion) -> bool {
46        self.row <= other.last_row()
47            && other.row <= self.last_row()
48            && self.col <= other.last_col()
49            && other.col <= self.last_col()
50    }
51}
52
53/// Cell merge state for XML generation
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum CellMergeState {
56    /// Normal cell, not merged
57    Normal,
58    /// Anchor cell of a merge region
59    Anchor { row_span: usize, col_span: usize },
60    /// Horizontally merged (covered by col_span of anchor)
61    HMerge,
62    /// Vertically merged (covered by row_span of anchor)
63    VMerge,
64}
65
66impl CellMergeState {
67    /// Generate XML attributes for `<a:tc>` element
68    pub fn to_xml_attrs(&self) -> String {
69        match self {
70            CellMergeState::Normal => String::new(),
71            CellMergeState::Anchor { row_span, col_span } => {
72                let mut attrs = String::new();
73                if *col_span > 1 {
74                    attrs.push_str(&format!(r#" gridSpan="{}""#, col_span));
75                }
76                if *row_span > 1 {
77                    attrs.push_str(&format!(r#" rowSpan="{}""#, row_span));
78                }
79                attrs
80            }
81            CellMergeState::HMerge => r#" hMerge="1""#.to_string(),
82            CellMergeState::VMerge => r#" vMerge="1""#.to_string(),
83        }
84    }
85
86    pub fn is_merged_away(&self) -> bool {
87        matches!(self, CellMergeState::HMerge | CellMergeState::VMerge)
88    }
89}
90
91/// Manages merge regions for a table and computes per-cell merge state
92#[derive(Clone, Debug, Default)]
93pub struct TableMergeMap {
94    regions: Vec<MergeRegion>,
95    rows: usize,
96    cols: usize,
97}
98
99impl TableMergeMap {
100    /// Create a merge map for a table of given dimensions
101    pub fn new(rows: usize, cols: usize) -> Self {
102        Self { regions: Vec::new(), rows, cols }
103    }
104
105    /// Add a merge region. Returns error if out of bounds or overlapping.
106    pub fn add_merge(&mut self, region: MergeRegion) -> Result<(), String> {
107        if region.last_row() >= self.rows {
108            return Err(format!(
109                "Merge region row {}-{} exceeds table rows {}",
110                region.row, region.last_row(), self.rows
111            ));
112        }
113        if region.last_col() >= self.cols {
114            return Err(format!(
115                "Merge region col {}-{} exceeds table cols {}",
116                region.col, region.last_col(), self.cols
117            ));
118        }
119        for existing in &self.regions {
120            if existing.overlaps(&region) {
121                return Err(format!(
122                    "Merge region ({},{}) {}x{} overlaps with ({},{}) {}x{}",
123                    region.row, region.col, region.row_span, region.col_span,
124                    existing.row, existing.col, existing.row_span, existing.col_span,
125                ));
126            }
127        }
128        self.regions.push(region);
129        Ok(())
130    }
131
132    /// Merge a rectangular range (convenience method)
133    pub fn merge_cells(&mut self, row: usize, col: usize, row_span: usize, col_span: usize) -> Result<(), String> {
134        let region = MergeRegion::new(row, col, row_span, col_span)?;
135        self.add_merge(region)
136    }
137
138    /// Get the merge state for a specific cell
139    pub fn cell_state(&self, r: usize, c: usize) -> CellMergeState {
140        for region in &self.regions {
141            if region.is_anchor(r, c) {
142                return CellMergeState::Anchor {
143                    row_span: region.row_span,
144                    col_span: region.col_span,
145                };
146            }
147            if region.contains(r, c) {
148                // Same row as anchor but different col → hMerge
149                if r == region.row {
150                    return CellMergeState::HMerge;
151                }
152                // Different row → vMerge
153                return CellMergeState::VMerge;
154            }
155        }
156        CellMergeState::Normal
157    }
158
159    /// Get all merge regions
160    pub fn regions(&self) -> &[MergeRegion] {
161        &self.regions
162    }
163
164    /// Number of merge regions
165    pub fn len(&self) -> usize {
166        self.regions.len()
167    }
168
169    pub fn is_empty(&self) -> bool {
170        self.regions.is_empty()
171    }
172
173    /// Table dimensions
174    pub fn dimensions(&self) -> (usize, usize) {
175        (self.rows, self.cols)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_merge_region_new() {
185        let r = MergeRegion::new(0, 0, 2, 3).unwrap();
186        assert_eq!(r.row, 0);
187        assert_eq!(r.col, 0);
188        assert_eq!(r.row_span, 2);
189        assert_eq!(r.col_span, 3);
190    }
191
192    #[test]
193    fn test_merge_region_zero_span() {
194        assert!(MergeRegion::new(0, 0, 0, 1).is_err());
195        assert!(MergeRegion::new(0, 0, 1, 0).is_err());
196    }
197
198    #[test]
199    fn test_merge_region_last() {
200        let r = MergeRegion::new(1, 2, 3, 4).unwrap();
201        assert_eq!(r.last_row(), 3);
202        assert_eq!(r.last_col(), 5);
203    }
204
205    #[test]
206    fn test_merge_region_contains() {
207        let r = MergeRegion::new(1, 1, 2, 2).unwrap();
208        assert!(r.contains(1, 1));
209        assert!(r.contains(2, 2));
210        assert!(!r.contains(0, 0));
211        assert!(!r.contains(3, 1));
212    }
213
214    #[test]
215    fn test_merge_region_is_anchor() {
216        let r = MergeRegion::new(1, 2, 2, 3).unwrap();
217        assert!(r.is_anchor(1, 2));
218        assert!(!r.is_anchor(1, 3));
219        assert!(!r.is_anchor(2, 2));
220    }
221
222    #[test]
223    fn test_merge_region_overlaps() {
224        let a = MergeRegion::new(0, 0, 2, 2).unwrap();
225        let b = MergeRegion::new(1, 1, 2, 2).unwrap();
226        assert!(a.overlaps(&b));
227        assert!(b.overlaps(&a));
228
229        let c = MergeRegion::new(2, 2, 1, 1).unwrap();
230        assert!(!a.overlaps(&c));
231    }
232
233    #[test]
234    fn test_merge_region_adjacent_no_overlap() {
235        let a = MergeRegion::new(0, 0, 2, 2).unwrap();
236        let b = MergeRegion::new(0, 2, 2, 2).unwrap();
237        assert!(!a.overlaps(&b));
238    }
239
240    #[test]
241    fn test_cell_merge_state_normal() {
242        let s = CellMergeState::Normal;
243        assert_eq!(s.to_xml_attrs(), "");
244        assert!(!s.is_merged_away());
245    }
246
247    #[test]
248    fn test_cell_merge_state_anchor() {
249        let s = CellMergeState::Anchor { row_span: 2, col_span: 3 };
250        let xml = s.to_xml_attrs();
251        assert!(xml.contains(r#"gridSpan="3""#));
252        assert!(xml.contains(r#"rowSpan="2""#));
253        assert!(!s.is_merged_away());
254    }
255
256    #[test]
257    fn test_cell_merge_state_anchor_col_only() {
258        let s = CellMergeState::Anchor { row_span: 1, col_span: 3 };
259        let xml = s.to_xml_attrs();
260        assert!(xml.contains(r#"gridSpan="3""#));
261        assert!(!xml.contains("rowSpan"));
262    }
263
264    #[test]
265    fn test_cell_merge_state_hmerge() {
266        let s = CellMergeState::HMerge;
267        assert!(s.to_xml_attrs().contains("hMerge"));
268        assert!(s.is_merged_away());
269    }
270
271    #[test]
272    fn test_cell_merge_state_vmerge() {
273        let s = CellMergeState::VMerge;
274        assert!(s.to_xml_attrs().contains("vMerge"));
275        assert!(s.is_merged_away());
276    }
277
278    #[test]
279    fn test_table_merge_map_new() {
280        let m = TableMergeMap::new(5, 4);
281        assert!(m.is_empty());
282        assert_eq!(m.dimensions(), (5, 4));
283    }
284
285    #[test]
286    fn test_table_merge_map_add() {
287        let mut m = TableMergeMap::new(5, 4);
288        assert!(m.merge_cells(0, 0, 2, 2).is_ok());
289        assert_eq!(m.len(), 1);
290    }
291
292    #[test]
293    fn test_table_merge_map_out_of_bounds() {
294        let mut m = TableMergeMap::new(3, 3);
295        assert!(m.merge_cells(2, 2, 2, 1).is_err()); // row overflow
296        assert!(m.merge_cells(0, 2, 1, 2).is_err()); // col overflow
297    }
298
299    #[test]
300    fn test_table_merge_map_overlap_detection() {
301        let mut m = TableMergeMap::new(5, 5);
302        m.merge_cells(0, 0, 2, 2).unwrap();
303        assert!(m.merge_cells(1, 1, 2, 2).is_err());
304    }
305
306    #[test]
307    fn test_table_merge_map_adjacent_ok() {
308        let mut m = TableMergeMap::new(5, 5);
309        m.merge_cells(0, 0, 2, 2).unwrap();
310        assert!(m.merge_cells(0, 2, 2, 2).is_ok());
311        assert_eq!(m.len(), 2);
312    }
313
314    #[test]
315    fn test_table_merge_map_cell_state() {
316        let mut m = TableMergeMap::new(4, 4);
317        m.merge_cells(0, 0, 2, 3).unwrap();
318
319        // Anchor
320        assert_eq!(
321            m.cell_state(0, 0),
322            CellMergeState::Anchor { row_span: 2, col_span: 3 }
323        );
324        // hMerge (same row as anchor, different col)
325        assert_eq!(m.cell_state(0, 1), CellMergeState::HMerge);
326        assert_eq!(m.cell_state(0, 2), CellMergeState::HMerge);
327        // vMerge (different row)
328        assert_eq!(m.cell_state(1, 0), CellMergeState::VMerge);
329        assert_eq!(m.cell_state(1, 1), CellMergeState::VMerge);
330        assert_eq!(m.cell_state(1, 2), CellMergeState::VMerge);
331        // Normal (outside)
332        assert_eq!(m.cell_state(0, 3), CellMergeState::Normal);
333        assert_eq!(m.cell_state(2, 0), CellMergeState::Normal);
334    }
335
336    #[test]
337    fn test_table_merge_map_multiple_regions() {
338        let mut m = TableMergeMap::new(4, 4);
339        m.merge_cells(0, 0, 1, 2).unwrap();
340        m.merge_cells(2, 2, 2, 2).unwrap();
341
342        assert_eq!(
343            m.cell_state(0, 0),
344            CellMergeState::Anchor { row_span: 1, col_span: 2 }
345        );
346        assert_eq!(m.cell_state(0, 1), CellMergeState::HMerge);
347        assert_eq!(
348            m.cell_state(2, 2),
349            CellMergeState::Anchor { row_span: 2, col_span: 2 }
350        );
351        assert_eq!(m.cell_state(3, 3), CellMergeState::VMerge);
352        assert_eq!(m.cell_state(1, 1), CellMergeState::Normal);
353    }
354}