Skip to main content

sheetkit_core/
merge.rs

1//! Merge cell operations.
2//!
3//! Provides functions for merging and unmerging ranges of cells in a worksheet.
4
5use crate::error::{Error, Result};
6use crate::utils::cell_ref::cell_name_to_coordinates;
7use sheetkit_xml::worksheet::{MergeCell, MergeCells, WorksheetXml};
8
9/// Parse a range reference like "A1:C3" into ((col1, row1), (col2, row2)) coordinates,
10/// both 1-based. Ensures the returned rectangle is normalized so that
11/// (col1, row1) is the top-left and (col2, row2) is the bottom-right.
12fn parse_range(reference: &str) -> Result<(u32, u32, u32, u32)> {
13    let parts: Vec<&str> = reference.split(':').collect();
14    if parts.len() != 2 {
15        return Err(Error::InvalidCellReference(format!(
16            "expected range like 'A1:C3', got '{reference}'"
17        )));
18    }
19    let (c1, r1) = cell_name_to_coordinates(parts[0])?;
20    let (c2, r2) = cell_name_to_coordinates(parts[1])?;
21    let min_col = c1.min(c2);
22    let max_col = c1.max(c2);
23    let min_row = r1.min(r2);
24    let max_row = r1.max(r2);
25    Ok((min_col, min_row, max_col, max_row))
26}
27
28/// Check whether two rectangular ranges overlap.
29fn ranges_overlap(a: (u32, u32, u32, u32), b: (u32, u32, u32, u32)) -> bool {
30    let (a_min_col, a_min_row, a_max_col, a_max_row) = a;
31    let (b_min_col, b_min_row, b_max_col, b_max_row) = b;
32    a_min_col <= b_max_col
33        && a_max_col >= b_min_col
34        && a_min_row <= b_max_row
35        && a_max_row >= b_min_row
36}
37
38/// Merge a range of cells on the given worksheet.
39///
40/// `top_left` and `bottom_right` are cell references like "A1" and "C3".
41/// Returns an error if the new range overlaps with any existing merge region.
42pub fn merge_cells(ws: &mut WorksheetXml, top_left: &str, bottom_right: &str) -> Result<()> {
43    let (tl_col, tl_row) = cell_name_to_coordinates(top_left)?;
44    let (br_col, br_row) = cell_name_to_coordinates(bottom_right)?;
45
46    let min_col = tl_col.min(br_col);
47    let max_col = tl_col.max(br_col);
48    let min_row = tl_row.min(br_row);
49    let max_row = tl_row.max(br_row);
50    let new_range = (min_col, min_row, max_col, max_row);
51
52    let reference = format!("{top_left}:{bottom_right}");
53
54    // Check for overlaps with existing merge regions.
55    if let Some(ref mc) = ws.merge_cells {
56        for existing in &mc.merge_cells {
57            let existing_range = parse_range(&existing.reference)?;
58            if ranges_overlap(new_range, existing_range) {
59                return Err(Error::MergeCellOverlap {
60                    new: reference,
61                    existing: existing.reference.clone(),
62                });
63            }
64        }
65    }
66
67    // Add the merge cell entry.
68    let merge_cells = ws.merge_cells.get_or_insert_with(|| MergeCells {
69        count: None,
70        merge_cells: Vec::new(),
71    });
72    merge_cells.merge_cells.push(MergeCell { reference });
73    merge_cells.count = Some(merge_cells.merge_cells.len() as u32);
74
75    Ok(())
76}
77
78/// Remove a specific merge cell range from the worksheet.
79///
80/// `reference` is the exact range string like "A1:C3" that was previously merged.
81/// Returns an error if the range is not found.
82pub fn unmerge_cell(ws: &mut WorksheetXml, reference: &str) -> Result<()> {
83    let mc = ws
84        .merge_cells
85        .as_mut()
86        .ok_or_else(|| Error::MergeCellNotFound(reference.to_string()))?;
87
88    let initial_len = mc.merge_cells.len();
89    mc.merge_cells.retain(|m| m.reference != reference);
90
91    if mc.merge_cells.len() == initial_len {
92        return Err(Error::MergeCellNotFound(reference.to_string()));
93    }
94
95    if mc.merge_cells.is_empty() {
96        ws.merge_cells = None;
97    } else {
98        mc.count = Some(mc.merge_cells.len() as u32);
99    }
100
101    Ok(())
102}
103
104/// Get all merge cell references in the worksheet.
105///
106/// Returns a list of range strings like `["A1:B2", "D1:F3"]`.
107pub fn get_merge_cells(ws: &WorksheetXml) -> Vec<String> {
108    ws.merge_cells
109        .as_ref()
110        .map(|mc| mc.merge_cells.iter().map(|m| m.reference.clone()).collect())
111        .unwrap_or_default()
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn new_ws() -> WorksheetXml {
119        WorksheetXml::default()
120    }
121
122    #[test]
123    fn test_merge_cells_basic() {
124        let mut ws = new_ws();
125        merge_cells(&mut ws, "A1", "B2").unwrap();
126        let merged = get_merge_cells(&ws);
127        assert_eq!(merged, vec!["A1:B2"]);
128        assert_eq!(ws.merge_cells.as_ref().unwrap().count, Some(1));
129    }
130
131    #[test]
132    fn test_merge_cells_multiple() {
133        let mut ws = new_ws();
134        merge_cells(&mut ws, "A1", "B2").unwrap();
135        merge_cells(&mut ws, "D1", "F3").unwrap();
136        merge_cells(&mut ws, "A5", "C7").unwrap();
137        let merged = get_merge_cells(&ws);
138        assert_eq!(merged.len(), 3);
139        assert_eq!(merged[0], "A1:B2");
140        assert_eq!(merged[1], "D1:F3");
141        assert_eq!(merged[2], "A5:C7");
142        assert_eq!(ws.merge_cells.as_ref().unwrap().count, Some(3));
143    }
144
145    #[test]
146    fn test_merge_cells_overlap_detection() {
147        let mut ws = new_ws();
148        merge_cells(&mut ws, "A1", "C3").unwrap();
149
150        // Exact overlap.
151        let err = merge_cells(&mut ws, "A1", "C3").unwrap_err();
152        assert!(err.to_string().contains("overlaps"));
153
154        // Partial overlap -- B2:D4 overlaps with A1:C3.
155        let err = merge_cells(&mut ws, "B2", "D4").unwrap_err();
156        assert!(err.to_string().contains("overlaps"));
157
158        // Fully contained -- B2:B2 is inside A1:C3.
159        let err = merge_cells(&mut ws, "B2", "B2").unwrap_err();
160        assert!(err.to_string().contains("overlaps"));
161
162        // Non-overlapping should succeed.
163        merge_cells(&mut ws, "D1", "F3").unwrap();
164    }
165
166    #[test]
167    fn test_merge_cells_overlap_adjacent_no_overlap() {
168        let mut ws = new_ws();
169        merge_cells(&mut ws, "A1", "B2").unwrap();
170        // C1:D2 is adjacent but does not overlap with A1:B2.
171        merge_cells(&mut ws, "C1", "D2").unwrap();
172        // A3:B4 is below and does not overlap.
173        merge_cells(&mut ws, "A3", "B4").unwrap();
174        assert_eq!(get_merge_cells(&ws).len(), 3);
175    }
176
177    #[test]
178    fn test_unmerge_cell() {
179        let mut ws = new_ws();
180        merge_cells(&mut ws, "A1", "B2").unwrap();
181        merge_cells(&mut ws, "D1", "F3").unwrap();
182
183        unmerge_cell(&mut ws, "A1:B2").unwrap();
184        let merged = get_merge_cells(&ws);
185        assert_eq!(merged, vec!["D1:F3"]);
186        assert_eq!(ws.merge_cells.as_ref().unwrap().count, Some(1));
187    }
188
189    #[test]
190    fn test_unmerge_cell_last_removes_element() {
191        let mut ws = new_ws();
192        merge_cells(&mut ws, "A1", "B2").unwrap();
193        unmerge_cell(&mut ws, "A1:B2").unwrap();
194        assert!(ws.merge_cells.is_none());
195        assert!(get_merge_cells(&ws).is_empty());
196    }
197
198    #[test]
199    fn test_unmerge_cell_not_found() {
200        let mut ws = new_ws();
201        let err = unmerge_cell(&mut ws, "A1:B2").unwrap_err();
202        assert!(err.to_string().contains("not found"));
203
204        // Add one range, then try to unmerge a different range.
205        merge_cells(&mut ws, "A1", "B2").unwrap();
206        let err = unmerge_cell(&mut ws, "C1:D2").unwrap_err();
207        assert!(err.to_string().contains("not found"));
208    }
209
210    #[test]
211    fn test_get_merge_cells_empty() {
212        let ws = new_ws();
213        assert!(get_merge_cells(&ws).is_empty());
214    }
215
216    #[test]
217    fn test_merge_cells_invalid_reference() {
218        let mut ws = new_ws();
219        let err = merge_cells(&mut ws, "!!!", "B2").unwrap_err();
220        assert!(err.to_string().contains("invalid cell reference"));
221
222        let err = merge_cells(&mut ws, "A1", "ZZZ").unwrap_err();
223        assert!(err.to_string().contains("no row number"));
224    }
225
226    #[test]
227    fn test_parse_range_valid() {
228        let (c1, r1, c2, r2) = parse_range("A1:C3").unwrap();
229        assert_eq!((c1, r1, c2, r2), (1, 1, 3, 3));
230    }
231
232    #[test]
233    fn test_parse_range_reversed() {
234        // Even if cells are given in reversed order, we normalize.
235        let (c1, r1, c2, r2) = parse_range("C3:A1").unwrap();
236        assert_eq!((c1, r1, c2, r2), (1, 1, 3, 3));
237    }
238
239    #[test]
240    fn test_parse_range_invalid() {
241        assert!(parse_range("A1").is_err());
242        assert!(parse_range("A1:B2:C3").is_err());
243        assert!(parse_range("").is_err());
244    }
245
246    #[test]
247    fn test_ranges_overlap_function() {
248        // Overlapping rectangles.
249        assert!(ranges_overlap((1, 1, 3, 3), (2, 2, 4, 4)));
250        // Identical.
251        assert!(ranges_overlap((1, 1, 3, 3), (1, 1, 3, 3)));
252        // Contained.
253        assert!(ranges_overlap((1, 1, 5, 5), (2, 2, 3, 3)));
254        // Adjacent horizontally -- no overlap.
255        assert!(!ranges_overlap((1, 1, 2, 2), (3, 1, 4, 2)));
256        // Adjacent vertically -- no overlap.
257        assert!(!ranges_overlap((1, 1, 2, 2), (1, 3, 2, 4)));
258        // Completely disjoint.
259        assert!(!ranges_overlap((1, 1, 2, 2), (5, 5, 6, 6)));
260    }
261}