Skip to main content

ravenrs_sheet/engine/cmd/format/
sizes.rs

1use super::{
2    CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
3};
4
5// ---------------------------------------------------------------- sizes
6
7/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
8/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
9/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
10/// sheet, so another sheet needs explicit lines.
11fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
12    let (sheet, lines) = resolve_lines(s, p, rows)?;
13    // Sizes and visibility are formatting of the line, which a protected sheet must allow.
14    if rows {
15        crate::engine::cmd::refuse_unless_allowed(s, sheet, "Formatting rows", |p| p.format_rows)?;
16    } else {
17        crate::engine::cmd::refuse_unless_allowed(s, sheet, "Formatting columns", |p| p.format_columns)?;
18    }
19    Ok((sheet, lines))
20}
21
22fn resolve_lines(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
23    let key = if rows { "rows" } else { "cols" };
24    let mut sheet = target_sheet(s, p)?;
25    if let Some(t) = str_param(p, key) {
26        let (prefix, body) = split_sheet(t);
27        if let Some(name) = prefix {
28            let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
29            if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
30                return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
31            }
32            sheet = named;
33        }
34        // A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
35        let lines = body
36            .split(',')
37            .map(|part| {
38                let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
39                Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
40            })
41            .collect::<Result<Vec<_>>>()?;
42        return Ok((sheet, lines));
43    }
44    let d = s.doc()?;
45    if sheet != d.wb.active_sheet() {
46        return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
47    }
48    Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
49}
50
51/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
52/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
53/// and of the file (`ht` is in points).
54pub(super) fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
55    let h = match (f64_param(p, "height"), f64_param(p, "points")) {
56        (Some(h), _) => {
57            if !(0.0..=546.0).contains(&h) {
58                return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
59            }
60            h
61        }
62        (None, Some(pt)) => {
63            if !(0.0..=409.5).contains(&pt) {
64                return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
65            }
66            pt * 4.0 / 3.0
67        }
68        _ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
69    };
70    let (si, lines) = line_targets(s, p, true)?;
71    edit(s, |cx| {
72        let sh = cx.sheet_mut(si)?;
73        for (a, b) in lines {
74            for r in a..=b.min(a.saturating_add(1_048_575)) {
75                let e = sh.rows.entry(r).or_default();
76                e.size = Some(h as f32);
77                e.hidden = h == 0.0;
78                e.custom = true;
79            }
80        }
81        cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
82        Ok(Json::Null)
83    })
84}
85
86pub(super) fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
87    let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
88        (Some(w), _) => {
89            if !(0.0..=1800.0).contains(&w) {
90                return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
91            }
92            w
93        }
94        (None, Some(c)) => {
95            if !(0.0..=255.0).contains(&c) {
96                return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
97            }
98            chars_to_points(c)
99        }
100        _ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
101    };
102    let (si, lines) = line_targets(s, p, false)?;
103    edit(s, |cx| {
104        let sh = cx.sheet_mut(si)?;
105        for (a, b) in lines {
106            for c in a..=b {
107                let e = sh.cols.entry(c).or_default();
108                e.size = Some(w as f32);
109                e.hidden = w == 0.0;
110            }
111        }
112        Ok(Json::Null)
113    })
114}
115
116/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
117pub fn chars_to_points(c: f64) -> f64 {
118    if c <= 0.0 {
119        0.0
120    } else if c < 1.0 {
121        (c * 12.0).round()
122    } else {
123        (c * 7.0 + 5.0).round()
124    }
125}
126
127/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
128/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
129pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
130    let mut w = 0.0;
131    for c in text.chars() {
132        w += match c {
133            'i' | 'l' | 'j' | '\'' | '.' | ',' | ':' | ';' | '!' | '|' => 0.28,
134            'f' | 't' | 'r' | 'I' | ' ' | '(' | ')' | '-' => 0.38,
135            'm' | 'w' | 'M' | 'W' => 0.85,
136            c if c.is_ascii_uppercase() => 0.65,
137            c if c.is_ascii_digit() => 0.56,
138            c if c.is_ascii() => 0.52,
139            _ => 1.0,
140        };
141    }
142    w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
143}
144
145/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
146/// quick measure so a huge column doesn't lay out every value.
147const EXACT_TEXT_WIDTHS: usize = 2_000;
148
149pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
150    let (si, lines) = line_targets(s, p, false)?;
151    let measure = s.text_width.clone();
152    edit(s, |cx| {
153        let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
154        let mut widths = Vec::new();
155        let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
156        let mut distinct = 0usize;
157        let used = sh.used_range();
158        for (a, b) in lines {
159            for c in a..=b.min(a + 16383) {
160                let Some(u) = used else { continue };
161                let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
162                let mut max: f32 = 0.0;
163                for (cell, _) in sh.cells.iter_range(col) {
164                    if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
165                        continue;
166                    }
167                    let id = sh.style_id(cell);
168                    let st = cx.wb.styles.get(id);
169                    let text = crate::engine::display::cell_text(&cx.wb, sh, cell);
170                    for line in text.lines() {
171                        let w = match &measure {
172                            Some(m) => {
173                                let known = measured.entry(id).or_default();
174                                match known.get(line) {
175                                    Some(w) => *w,
176                                    None => {
177                                        let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
178                                        known.insert(line.to_owned(), w);
179                                        distinct += 1;
180                                        w
181                                    }
182                                }
183                            }
184                            None => approx_text_width(line, st.font.size, st.font.bold),
185                        };
186                        max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
187                    }
188                }
189                if max > 0.0 {
190                    widths.push((c, max.clamp(8.0, 1785.0)));
191                }
192            }
193        }
194        let sh = cx.sheet_mut(si)?;
195        for (c, w) in widths {
196            let e = sh.cols.entry(c).or_default();
197            e.size = Some(w.ceil());
198            e.hidden = false;
199        }
200        Ok(Json::Null)
201    })
202}
203
204pub(super) fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
205    let (si, lines) = line_targets(s, p, true)?;
206    edit(s, |cx| {
207        for (a, b) in lines {
208            let b = b.min(a + 100_000);
209            if let Some(sh) = cx.wb.sheet_mut(si) {
210                for r in a..=b {
211                    if let Some(e) = sh.rows.get_mut(&r) {
212                        e.custom = false;
213                        e.hidden = false;
214                    }
215                }
216            }
217            cx.fit_rows.extend((a..=b).map(|r| (si, r)));
218        }
219        cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
220        Ok(Json::Null)
221    })
222}
223
224pub(super) fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
225    let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
226    let si = target_sheet(s, p)?;
227    edit(s, |cx| {
228        cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
229        Ok(Json::Null)
230    })
231}
232
233pub(super) fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
234    let (si, lines) = line_targets(s, p, rows)?;
235    edit(s, |cx| {
236        let sh = cx.sheet_mut(si)?;
237        for (a, b) in lines {
238            // Unhiding a single selected line also reveals hidden neighbours (Excel needs the
239            // lines on both sides selected; we're forgiving).
240            let (a, b) = if !on && a == b { (a.saturating_sub(1), b + 1) } else { (a, b) };
241            for i in a..=b.min(a.saturating_add(1_048_575)) {
242                let map = if rows { &mut sh.rows } else { &mut sh.cols };
243                if on {
244                    map.entry(i).or_default().hidden = true;
245                } else if let Some(e) = map.get_mut(&i) {
246                    e.hidden = false;
247                    if e.size == Some(0.0) {
248                        e.size = None;
249                    }
250                }
251            }
252        }
253        cx.structural = true; // SUBTOTAL(10x) depends on hidden rows
254        Ok(Json::Null)
255    })
256}