Skip to main content

ironcalc_base/
merged_cells.rs

1use serde::{Deserialize, Serialize};
2use std::borrow::Cow;
3
4use crate::constants::{LAST_COLUMN, LAST_ROW};
5use crate::expressions::types::Area;
6use crate::model::{CellStructure, Model};
7use crate::types::{Alignment, Cell, HorizontalAlignment, MergedCell};
8
9// Splits `range` into the one-row-tall sub-ranges that "merge across" merges
10// separately.
11pub(crate) fn merge_across_ranges(range: &Area) -> Result<Vec<Area>, String> {
12    let Area {
13        sheet,
14        row,
15        column,
16        width,
17        height,
18    } = *range;
19    if row < 1 || column < 1 || width < 1 || height < 1 {
20        return Err("Invalid range".to_string());
21    }
22    Ok((row..row + height)
23        .map(|r| Area {
24            sheet,
25            row: r,
26            column,
27            width,
28            height: 1,
29        })
30        .collect())
31}
32
33// Splits `range` into the one-column-wide sub-ranges that "merge down" merges
34// separately.
35pub(crate) fn merge_down_ranges(range: &Area) -> Result<Vec<Area>, String> {
36    let Area {
37        sheet,
38        row,
39        column,
40        width,
41        height,
42    } = *range;
43    if row < 1 || column < 1 || width < 1 || height < 1 {
44        return Err("Invalid range".to_string());
45    }
46    Ok((column..column + width)
47        .map(|c| Area {
48            sheet,
49            row,
50            column: c,
51            width: 1,
52            height,
53        })
54        .collect())
55}
56
57/// Position of a cell relative to the merged cells of a worksheet
58#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
59pub enum MergeStructure {
60    /// The cell is not part of any merged range
61    None,
62    /// The cell is the anchor (top-left cell) of a merged range
63    Anchor {
64        /// Number of columns of the merged range
65        width: i32,
66        /// Number of rows of the merged range
67        height: i32,
68    },
69    /// The cell is inside a merged range but is not its anchor
70    Covered {
71        /// Row of the anchor of the merged range
72        anchor_row: i32,
73        /// Column of the anchor of the merged range
74        anchor_column: i32,
75    },
76}
77
78impl<'a> Model<'a> {
79    /// Merges the cells in `range` into a single merged cell anchored at its
80    /// top-left corner:
81    /// * at most one cell of the range may have content; if more than one does
82    ///   the merge fails and nothing changes
83    /// * the content, link and style of that single cell move to the anchor
84    ///   and its style is copied to every cell of the range, so the old styles
85    ///   of the other cells are forgotten (after unmerging the whole range
86    ///   shows that cell's formatting). With no content anywhere the anchor
87    ///   plays that role
88    /// * borders included: the content cell's borders outline the merged cell
89    ///   as a whole, so each cell of the range keeps only the border sides
90    ///   that lie on the perimeter (interior edges have no border)
91    ///
92    /// Fails if:
93    /// * the range is invalid, out of bounds or a single cell
94    /// * the range intersects an existing merged cell
95    /// * the range intersects an array formula
96    /// * more than one cell of the range has content
97    pub fn merge_cells(&mut self, range: &Area) -> Result<(), String> {
98        let Area {
99            sheet,
100            row,
101            column,
102            width,
103            height,
104        } = *range;
105        self.check_merge_range(range)?;
106        let content_cell = self.merge_range_content_cell(range)?;
107        // The merged cell takes the content and style of the single cell with
108        // content; of the anchor if the whole range is empty.
109        let (source_row, source_column) = content_cell.unwrap_or((row, column));
110        let merged_style = self.get_style_for_cell(sheet, source_row, source_column)?;
111        if (source_row, source_column) != (row, column) {
112            // Move the content and link to the anchor before the covered
113            // cells (the source among them) are cleared. The anchor is known
114            // to be empty: otherwise there would be two cells with content.
115            let source_cell = self
116                .workbook
117                .worksheet(sheet)?
118                .cell(source_row, source_column)
119                .map(Cow::into_owned);
120            match source_cell {
121                Some(Cell::CellFormula { .. }) | Some(Cell::ArrayFormula { .. }) => {
122                    // Formulas are stored relative to their cell: re-enter the
123                    // text at the anchor so the formula stays as written.
124                    let content =
125                        self.get_localized_cell_content(sheet, source_row, source_column)?;
126                    self.set_user_input(sheet, row, column, content)?;
127                }
128                Some(cell) => {
129                    self.workbook
130                        .worksheet_mut(sheet)?
131                        .update_cell(row, column, cell)?;
132                }
133                None => {}
134            }
135            let worksheet = self.workbook.worksheet_mut(sheet)?;
136            if let Some(link) = worksheet.links.remove(&(source_row, source_column)) {
137                worksheet.links.insert((row, column), link);
138            }
139        }
140        self.merge_cells_keep_styles(range)?;
141        for r in row..row + height {
142            for c in column..column + width {
143                let mut style = merged_style.clone();
144                if r != row {
145                    style.border.top = None;
146                }
147                if r != row + height - 1 {
148                    style.border.bottom = None;
149                }
150                if c != column {
151                    style.border.left = None;
152                }
153                if c != column + width - 1 {
154                    style.border.right = None;
155                }
156                if self.get_style_for_cell(sheet, r, c)? != style {
157                    self.set_cell_style(sheet, r, c, &style)?;
158                }
159            }
160        }
161        Ok(())
162    }
163
164    /// Same as [Model::merge_cells] but the merged cell is also centered
165    /// horizontally ("merge & center").
166    pub fn merge_cells_center(&mut self, range: &Area) -> Result<(), String> {
167        self.merge_cells(range)?;
168        self.center_merged_range(range)
169    }
170
171    /// Merges each row of `range` separately into a one-row-tall merged cell
172    /// ("merge across"), following the rules of [Model::merge_cells] row by
173    /// row. Every row is validated up front, so the operation is
174    /// all-or-nothing: if any row cannot be merged nothing changes.
175    pub fn merge_cells_across(&mut self, range: &Area) -> Result<(), String> {
176        let ranges = merge_across_ranges(range)?;
177        for range in &ranges {
178            self.check_merge(range)?;
179        }
180        for range in &ranges {
181            self.merge_cells(range)?;
182        }
183        Ok(())
184    }
185
186    /// Merges each column of `range` separately into a one-column-wide merged
187    /// cell ("merge down"), following the rules of [Model::merge_cells]
188    /// column by column. Every column is validated up front, so the operation
189    /// is all-or-nothing: if any column cannot be merged nothing changes.
190    pub fn merge_cells_down(&mut self, range: &Area) -> Result<(), String> {
191        let ranges = merge_down_ranges(range)?;
192        for range in &ranges {
193            self.check_merge(range)?;
194        }
195        for range in &ranges {
196            self.merge_cells(range)?;
197        }
198        Ok(())
199    }
200
201    // Full validation of merging `range` without touching the model: the
202    // shape checks of `check_merge_range` plus the single-content-cell rule.
203    // Lets a multi-range merge ("across", "down") be all-or-nothing.
204    pub(crate) fn check_merge(&self, range: &Area) -> Result<(), String> {
205        self.check_merge_range(range)?;
206        self.merge_range_content_cell(range)?;
207        Ok(())
208    }
209
210    // Finds the single cell of `range` with content, if any; merging is not
211    // allowed when more than one cell has content. Spill cells don't count:
212    // they hold values computed by an anchor outside the range, not content
213    // of their own (the spill is blocked and re-evaluated).
214    fn merge_range_content_cell(&self, range: &Area) -> Result<Option<(i32, i32)>, String> {
215        let Area {
216            sheet,
217            row,
218            column,
219            width,
220            height,
221        } = *range;
222        let worksheet = self.workbook.worksheet(sheet)?;
223        let mut content_cell = None;
224        for r in row..row + height {
225            for c in column..column + width {
226                if matches!(
227                    worksheet.cell(r, c).as_deref(),
228                    None | Some(Cell::EmptyCell { .. }) | Some(Cell::SpillCell { .. })
229                ) {
230                    continue;
231                }
232                if content_cell.is_some() {
233                    return Err("Cannot merge cells: more than one cell has content".to_string());
234                }
235                content_cell = Some((r, c));
236            }
237        }
238        Ok(content_cell)
239    }
240
241    // Sets horizontal center alignment on every cell of `range` (the style of
242    // a merged cell lives on all its cells alike). Used by "merge & center"
243    // right after the merge itself.
244    pub(crate) fn center_merged_range(&mut self, range: &Area) -> Result<(), String> {
245        let Area {
246            sheet,
247            row,
248            column,
249            width,
250            height,
251        } = *range;
252        for r in row..row + height {
253            for c in column..column + width {
254                let mut style = self.get_style_for_cell(sheet, r, c)?;
255                let alignment = style.alignment.get_or_insert_with(Alignment::default);
256                if alignment.horizontal != HorizontalAlignment::Center {
257                    alignment.horizontal = HorizontalAlignment::Center;
258                    self.set_cell_style(sheet, r, c, &style)?;
259                }
260            }
261        }
262        Ok(())
263    }
264
265    // Checks that `range` is a valid merge target: in bounds, more than one
266    // cell and intersecting no existing merged cell or array formula.
267    fn check_merge_range(&self, range: &Area) -> Result<(), String> {
268        let Area {
269            sheet,
270            row,
271            column,
272            width,
273            height,
274        } = *range;
275        if row < 1 || column < 1 || width < 1 || height < 1 {
276            return Err("Invalid range".to_string());
277        }
278        if row + height - 1 > LAST_ROW || column + width - 1 > LAST_COLUMN {
279            return Err("Range is out of bounds".to_string());
280        }
281        if width == 1 && height == 1 {
282            return Err("Cannot merge a single cell".to_string());
283        }
284        let worksheet = self.workbook.worksheet(sheet)?;
285        if let Some(m) = worksheet
286            .merged_cells
287            .iter()
288            .find(|m| m.intersects(row, column, width, height))
289        {
290            return Err(format!(
291                "Range intersects the merged cell at row {}, column {}",
292                m.row, m.column
293            ));
294        }
295        for r in row..row + height {
296            for c in column..column + width {
297                match worksheet.get_cell_structure(r, c)? {
298                    CellStructure::ArrayFormula {
299                        range: (array_width, array_height),
300                    } => {
301                        if array_width > 1 || array_height > 1 {
302                            return Err(
303                                "Cannot merge cells that intersect an array formula".to_string()
304                            );
305                        }
306                    }
307                    CellStructure::SpillArray { .. } => {
308                        return Err(
309                            "Cannot merge cells that intersect an array formula".to_string()
310                        );
311                    }
312                    _ => {}
313                }
314            }
315        }
316        Ok(())
317    }
318
319    // Registers the merged cell and clears the content and links of the
320    // covered cells, but leaves every cell's style untouched. Used by the
321    // clipboard when recreating a pasted merge: the pasted cells already carry
322    // the merged style pattern of the source, and stamping the anchor's style
323    // would drop the perimeter borders that live on non-anchor cells.
324    pub(crate) fn merge_cells_keep_styles(&mut self, range: &Area) -> Result<(), String> {
325        let Area {
326            sheet,
327            row,
328            column,
329            width,
330            height,
331        } = *range;
332        self.check_merge_range(range)?;
333        // Clear the content and links of the covered cells. Going through
334        // prepare_cell_for_user_input keeps dynamic array formulas consistent:
335        // a covered spill anchor loses its spill and an outside anchor spilling
336        // into the range is reset, so it re-evaluates (to #SPILL!) afterwards.
337        for r in row..row + height {
338            for c in column..column + width {
339                if r == row && c == column {
340                    continue;
341                }
342                self.prepare_cell_for_user_input(sheet, r, c)?;
343                let worksheet = self.workbook.worksheet_mut(sheet)?;
344                if worksheet.cell(r, c).is_some() {
345                    worksheet.cell_clear_contents(r, c)?;
346                }
347                worksheet.links.remove(&(r, c));
348            }
349        }
350        self.workbook
351            .worksheet_mut(sheet)?
352            .merged_cells
353            .push(MergedCell {
354                row,
355                column,
356                width,
357                height,
358            });
359        Ok(())
360    }
361
362    /// Removes every merged cell that intersects `range`. The content of the
363    /// anchors is kept. Removing no merged cells at all is not an error.
364    pub fn unmerge_cells(&mut self, range: &Area) -> Result<(), String> {
365        let Area {
366            sheet,
367            row,
368            column,
369            width,
370            height,
371        } = *range;
372        if row < 1 || column < 1 || width < 1 || height < 1 {
373            return Err("Invalid range".to_string());
374        }
375        let worksheet = self.workbook.worksheet_mut(sheet)?;
376        worksheet
377            .merged_cells
378            .retain(|m| !m.intersects(row, column, width, height));
379        Ok(())
380    }
381
382    /// Returns the list of merged cells of the worksheet
383    pub fn get_merged_cells(&self, sheet: u32) -> Result<&[MergedCell], String> {
384        Ok(&self.workbook.worksheet(sheet)?.merged_cells)
385    }
386
387    /// Returns the position of (row, column) relative to the merged cells of
388    /// the worksheet: not merged, the anchor of a merged range or covered by one.
389    pub fn get_merge_structure(
390        &self,
391        sheet: u32,
392        row: i32,
393        column: i32,
394    ) -> Result<MergeStructure, String> {
395        let worksheet = self.workbook.worksheet(sheet)?;
396        Ok(match worksheet.merged_cell_containing(row, column) {
397            Some(m) if m.row == row && m.column == column => MergeStructure::Anchor {
398                width: m.width,
399                height: m.height,
400            },
401            Some(m) => MergeStructure::Covered {
402                anchor_row: m.row,
403                anchor_column: m.column,
404            },
405            None => MergeStructure::None,
406        })
407    }
408}