Skip to main content

ravenrs_sheet/base/
addr.rs

1//! Cell and range addresses.
2//!
3//! Internally rows and columns are 0-based (`A1` = row 0, col 0). The sheet is 1,048,576 rows by
4//! 16,384 columns (`XFD1048576`), like Excel.
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10pub const MAX_ROWS: u32 = 1_048_576;
11pub const MAX_COLS: u32 = 16_384;
12
13/// A cell position (0-based).
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default)]
15pub struct CellRef {
16    pub row: u32,
17    pub col: u32,
18}
19
20impl CellRef {
21    pub const fn new(row: u32, col: u32) -> Self {
22        CellRef { row, col }
23    }
24    /// `A1`-style text, e.g. `B7`.
25    pub fn a1(&self) -> String {
26        format!("{}{}", col_to_letters(self.col), self.row as u64 + 1)
27    }
28    pub fn is_valid(&self) -> bool {
29        self.row < MAX_ROWS && self.col < MAX_COLS
30    }
31    /// Moved by `(dr, dc)`, `None` when the result leaves the sheet.
32    pub fn offset(&self, dr: i64, dc: i64) -> Option<CellRef> {
33        let r = (self.row as i64).checked_add(dr)?;
34        let c = (self.col as i64).checked_add(dc)?;
35        if (0..MAX_ROWS as i64).contains(&r) && (0..MAX_COLS as i64).contains(&c) { Some(CellRef::new(r as u32, c as u32)) } else { None }
36    }
37    /// Moved by `(dr, dc)` and clamped to the sheet.
38    pub fn offset_clamped(&self, dr: i64, dc: i64) -> CellRef {
39        let r = (self.row as i64).saturating_add(dr).clamp(0, MAX_ROWS as i64 - 1);
40        let c = (self.col as i64).saturating_add(dc).clamp(0, MAX_COLS as i64 - 1);
41        CellRef::new(r as u32, c as u32)
42    }
43    /// Parses `A1`, `$A$1`, `xfd1048576` (case-insensitive). `None` if malformed or out of range.
44    pub fn parse(s: &str) -> Option<CellRef> {
45        let (c, r, _, _, rest) = parse_a1_prefix(s)?;
46        if !rest.is_empty() {
47            return None;
48        }
49        Some(CellRef::new(r, c))
50    }
51}
52
53impl fmt::Display for CellRef {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(f, "{}{}", col_to_letters(self.col), self.row as u64 + 1)
56    }
57}
58
59/// Parses an A1 reference at the start of `s`: `(col, row, col_abs, row_abs, rest)`.
60pub fn parse_a1_prefix(s: &str) -> Option<(u32, u32, bool, bool, &str)> {
61    let b = s.as_bytes();
62    let mut i = 0;
63    let col_abs = b.first() == Some(&b'$');
64    if col_abs {
65        i += 1;
66    }
67    let cs = i;
68    while i < b.len() && b[i].is_ascii_alphabetic() {
69        i += 1;
70    }
71    if i == cs || i - cs > 3 {
72        return None;
73    }
74    let col = letters_to_col(s.get(cs..i)?)?;
75    let row_abs = b.get(i) == Some(&b'$');
76    if row_abs {
77        i += 1;
78    }
79    let rs = i;
80    while i < b.len() && b[i].is_ascii_digit() {
81        i += 1;
82    }
83    if i == rs || i - rs > 7 {
84        return None;
85    }
86    let row: u32 = s.get(rs..i)?.parse().ok()?;
87    if row == 0 || row > MAX_ROWS {
88        return None;
89    }
90    Some((col, row - 1, col_abs, row_abs, s.get(i..)?))
91}
92
93/// 0 → `A`, 25 → `Z`, 26 → `AA`, 16383 → `XFD`.
94pub fn col_to_letters(col: u32) -> String {
95    let mut n = col as u64 + 1;
96    let mut out = Vec::with_capacity(3);
97    while n > 0 {
98        let rem = ((n - 1) % 26) as u8;
99        out.push(b'A' + rem);
100        n = (n - 1) / 26;
101    }
102    out.reverse();
103    String::from_utf8(out).unwrap_or_default()
104}
105
106/// `A` → 0, `xfd` → 16383. `None` for empty, non-letters or beyond `XFD`.
107pub fn letters_to_col(s: &str) -> Option<u32> {
108    if s.is_empty() || s.len() > 3 {
109        return None;
110    }
111    let mut n: u32 = 0;
112    for ch in s.bytes() {
113        if !ch.is_ascii_alphabetic() {
114            return None;
115        }
116        n = n * 26 + (ch.to_ascii_uppercase() - b'A' + 1) as u32;
117    }
118    if n == 0 || n > MAX_COLS {
119        return None;
120    }
121    Some(n - 1)
122}
123
124/// A rectangular block of cells, inclusive on both ends. Always normalised (`start <= end`).
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
126pub struct RangeRef {
127    pub start: CellRef,
128    pub end: CellRef,
129}
130
131impl RangeRef {
132    pub fn new(a: CellRef, b: CellRef) -> Self {
133        RangeRef { start: CellRef::new(a.row.min(b.row), a.col.min(b.col)), end: CellRef::new(a.row.max(b.row), a.col.max(b.col)) }
134    }
135    pub fn cell(c: CellRef) -> Self {
136        RangeRef { start: c, end: c }
137    }
138    /// Whole rows `r0..=r1`.
139    pub fn rows(r0: u32, r1: u32) -> Self {
140        RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, MAX_COLS - 1))
141    }
142    /// Whole columns `c0..=c1`.
143    pub fn cols(c0: u32, c1: u32) -> Self {
144        RangeRef::new(CellRef::new(0, c0), CellRef::new(MAX_ROWS - 1, c1))
145    }
146    pub fn all() -> Self {
147        RangeRef::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1))
148    }
149    pub fn height(&self) -> u32 {
150        self.end.row - self.start.row + 1
151    }
152    pub fn width(&self) -> u32 {
153        self.end.col - self.start.col + 1
154    }
155    pub fn count(&self) -> u64 {
156        self.height() as u64 * self.width() as u64
157    }
158    pub fn is_single(&self) -> bool {
159        self.start == self.end
160    }
161    pub fn is_full_rows(&self) -> bool {
162        self.start.col == 0 && self.end.col == MAX_COLS - 1
163    }
164    pub fn is_full_cols(&self) -> bool {
165        self.start.row == 0 && self.end.row == MAX_ROWS - 1
166    }
167    pub fn contains(&self, c: CellRef) -> bool {
168        c.row >= self.start.row && c.row <= self.end.row && c.col >= self.start.col && c.col <= self.end.col
169    }
170    pub fn contains_range(&self, o: &RangeRef) -> bool {
171        self.contains(o.start) && self.contains(o.end)
172    }
173    pub fn intersects(&self, o: &RangeRef) -> bool {
174        self.start.row <= o.end.row && o.start.row <= self.end.row && self.start.col <= o.end.col && o.start.col <= self.end.col
175    }
176    pub fn intersection(&self, o: &RangeRef) -> Option<RangeRef> {
177        if !self.intersects(o) {
178            return None;
179        }
180        Some(RangeRef {
181            start: CellRef::new(self.start.row.max(o.start.row), self.start.col.max(o.start.col)),
182            end: CellRef::new(self.end.row.min(o.end.row), self.end.col.min(o.end.col)),
183        })
184    }
185    pub fn union(&self, o: &RangeRef) -> RangeRef {
186        RangeRef {
187            start: CellRef::new(self.start.row.min(o.start.row), self.start.col.min(o.start.col)),
188            end: CellRef::new(self.end.row.max(o.end.row), self.end.col.max(o.end.col)),
189        }
190    }
191    /// The top-left cell of a list of areas: the lowest row and the lowest column across all of
192    /// them, whatever order they are listed in. Excel writes the relative formulas of a
193    /// multi-area validation or conditional format for this cell. `None` for no areas.
194    pub fn top_left(ranges: &[RangeRef]) -> Option<CellRef> {
195        ranges.iter().copied().reduce(|a, b| a.union(&b)).map(|r| r.start)
196    }
197    /// Row-major iterator over the cells. Callers must bound huge ranges themselves.
198    pub fn iter(&self) -> impl Iterator<Item = CellRef> + '_ {
199        let (r0, r1, c0, c1) = (self.start.row, self.end.row, self.start.col, self.end.col);
200        (r0..=r1).flat_map(move |r| (c0..=c1).map(move |c| CellRef::new(r, c)))
201    }
202    /// `A1:B2`, `A1` for single cells, `1:3` for full rows, `A:C` for full columns.
203    pub fn a1(&self) -> String {
204        if self.is_full_rows() && !self.is_full_cols() {
205            return format!("{}:{}", self.start.row + 1, self.end.row + 1);
206        }
207        if self.is_full_cols() && !self.is_full_rows() {
208            return format!("{}:{}", col_to_letters(self.start.col), col_to_letters(self.end.col));
209        }
210        if self.is_single() { self.start.a1() } else { format!("{}:{}", self.start.a1(), self.end.a1()) }
211    }
212    /// Parses `A1`, `A1:B2`, `$A$1:$B$2`, `A:C`, `3:5` (no sheet prefix).
213    pub fn parse(s: &str) -> Option<RangeRef> {
214        let s = s.trim();
215        if let Some((a, b)) = s.split_once(':') {
216            if let (Some(x), Some(y)) = (CellRef::parse(a), CellRef::parse(b)) {
217                return Some(RangeRef::new(x, y));
218            }
219            let strip = |t: &str| t.strip_prefix('$').unwrap_or(t).to_string();
220            if let (Some(x), Some(y)) = (letters_to_col(&strip(a)), letters_to_col(&strip(b))) {
221                return Some(RangeRef::cols(x.min(y), x.max(y)));
222            }
223            let row_num = |t: &str| {
224                let t = t.strip_prefix('$').unwrap_or(t);
225                if t.is_empty() || !t.bytes().all(|c| c.is_ascii_digit()) {
226                    return None;
227                }
228                t.parse::<u32>().ok()
229            };
230            let (pa, pb) = (row_num(a)?, row_num(b)?);
231            if pa == 0 || pb == 0 || pa > MAX_ROWS || pb > MAX_ROWS {
232                return None;
233            }
234            return Some(RangeRef::rows(pa.min(pb) - 1, pa.max(pb) - 1));
235        }
236        CellRef::parse(s).map(RangeRef::cell)
237    }
238}
239
240impl fmt::Display for RangeRef {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        f.write_str(&self.a1())
243    }
244}
245
246/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1`
247/// in any case.
248fn is_r1c1_token(name: &str) -> bool {
249    let mut chars = name.chars().peekable();
250    let mut letters = 0;
251    for letter in ['r', 'c'] {
252        if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
253            chars.next();
254            letters += 1;
255            while chars.peek().is_some_and(char::is_ascii_digit) {
256                chars.next();
257            }
258        }
259    }
260    letters > 0 && chars.next().is_none()
261}
262
263/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or
264/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell within
265/// the sheet or as an R1C1 reference, or is a boolean.
266pub fn sheet_needs_quotes(name: &str) -> bool {
267    let Some(first) = name.chars().next() else {
268        return true;
269    };
270    if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) {
271        return true;
272    }
273    CellRef::parse(name).is_some() || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE")
274}
275
276/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it.
277pub fn quote_sheet(name: &str) -> String {
278    if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() }
279}
280
281/// The inverse of [`quote_sheet`]: takes the quotes off a sheet name and reads `''` as `'`.
282/// Text that isn't quoted is returned as it is.
283pub fn unquote_sheet(text: &str) -> String {
284    match text.trim().strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
285        Some(inner) => inner.replace("''", "'"),
286        None => text.trim().to_string(),
287    }
288}
289
290/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). Splits at the last `!`, so a quoted name may
291/// hold one (`'a!b'!A1`).
292pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
293    match reference.rsplit_once('!') {
294        Some((sheet, body)) => (Some(unquote_sheet(sheet)), body),
295        None => (None, reference),
296    }
297}
298
299/// The areas of a reference list, split at the commas between them. A comma inside a quoted
300/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
301/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
302/// quoted state, so a doubled one toggles it twice.
303pub fn split_areas(reference: &str) -> Vec<&str> {
304    let mut areas = Vec::new();
305    let (mut start, mut quoted) = (0, false);
306    for (i, c) in reference.char_indices() {
307        match c {
308            '\'' => quoted = !quoted,
309            ',' if !quoted => {
310                areas.push(reference.get(start..i).unwrap_or_default());
311                start = i.saturating_add(1);
312            }
313            _ => {}
314        }
315    }
316    areas.push(reference.get(start..).unwrap_or_default());
317    areas
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn areas_split_only_at_commas_outside_quoted_sheet_names() {
326        assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
327        assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
328        assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
329        assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
330        assert_eq!(split_areas(""), [""]);
331    }
332
333    #[test]
334    fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
335        assert!(RangeRef::parse("+1:+3").is_none());
336        assert!(RangeRef::parse("-1:3").is_none());
337        assert!(RangeRef::parse("$$1:3").is_none());
338        assert!(RangeRef::parse(":3").is_none());
339        assert!(RangeRef::parse("$$A:$$C").is_none());
340        assert!(RangeRef::parse("$A:$C").is_some());
341        let r = RangeRef::parse("$1:$3").ok_or_else(|| std::io::Error::other("valid row range"))?;
342        assert_eq!((r.start.row, r.end.row), (0, 2));
343        Ok(())
344    }
345
346    #[test]
347    fn top_left_spans_every_area_in_any_order() {
348        let areas: Vec<RangeRef> = ["H62:H68", "H26:H27", "H93:H95"].iter().filter_map(|a| RangeRef::parse(a)).collect();
349        assert_eq!(RangeRef::top_left(&areas), CellRef::parse("H26"));
350        // Lowest row and lowest column, even when they come from different areas.
351        let areas: Vec<RangeRef> = ["C5:D6", "E2", "B9"].iter().filter_map(|a| RangeRef::parse(a)).collect();
352        assert_eq!(RangeRef::top_left(&areas), CellRef::parse("B2"));
353        assert_eq!(RangeRef::top_left(&[]), None);
354    }
355
356    #[test]
357    fn sheet_quoting_follows_excel() {
358        for quoted in [
359            "A1",
360            "XFD1",
361            "AB1048576",
362            "R1",
363            "C1",
364            "RC1",
365            "R",
366            "C",
367            "RC",
368            "R1C",
369            "R1C1",
370            "r2c3",
371            "CR1",
372            ".x",
373            "TRUE",
374            "false",
375            "1abc",
376            "My Sheet",
377            "Bob's",
378            "a-b",
379            "",
380        ] {
381            assert!(sheet_needs_quotes(quoted), "{quoted:?} should be quoted");
382        }
383        for bare in ["Sheet1", "XFE1", "AB1048577", "Ärger", "日本", "Σ", "Sheet.1", "_x", "A1A", "Data", "RR", "CRX"] {
384            assert!(!sheet_needs_quotes(bare), "{bare:?} should stay bare");
385        }
386    }
387
388    #[test]
389    fn quote_unquote_and_split_sheet() {
390        assert_eq!(quote_sheet("Bob's"), "'Bob''s'");
391        assert_eq!(quote_sheet("Data"), "Data");
392        assert_eq!(unquote_sheet("'Bob''s'"), "Bob's");
393        assert_eq!(unquote_sheet("Data"), "Data");
394        assert_eq!(unquote_sheet("'"), "'");
395        assert_eq!(split_sheet("'a!b'!A1"), (Some("a!b".to_string()), "A1"));
396        assert_eq!(split_sheet("'Bob''s'!$A$1:$B$2"), (Some("Bob's".to_string()), "$A$1:$B$2"));
397        assert_eq!(split_sheet("A1"), (None, "A1"));
398    }
399
400    /// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
401    /// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
402    /// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
403    /// reference.
404    #[test]
405    fn split_sheet_leaves_device_names_inert_in_the_body() {
406        for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
407            let (sheet, rest) = split_sheet(s);
408            assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
409            let _ = sheet;
410        }
411        // A plain single-letter sheet is still split, not mistaken for a body.
412        assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
413    }
414
415    #[test]
416    fn letters_roundtrip() {
417        for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
418            assert_eq!(col_to_letters(c), s);
419            assert_eq!(letters_to_col(s), Some(c));
420        }
421        assert_eq!(letters_to_col("XFE"), None);
422        assert_eq!(letters_to_col(""), None);
423        assert_eq!(letters_to_col("A1"), None);
424    }
425
426    #[test]
427    fn parse_cells() {
428        assert_eq!(CellRef::parse("B7"), Some(CellRef::new(6, 1)));
429        assert_eq!(CellRef::parse("$b$7"), Some(CellRef::new(6, 1)));
430        assert_eq!(CellRef::parse("XFD1048576"), Some(CellRef::new(MAX_ROWS - 1, MAX_COLS - 1)));
431        assert_eq!(CellRef::parse("A0"), None);
432        assert_eq!(CellRef::parse("A1048577"), None);
433        assert_eq!(CellRef::parse("A1x"), None);
434        assert_eq!(CellRef::parse(""), None);
435        assert_eq!(CellRef::parse("$"), None);
436    }
437
438    #[test]
439    fn ranges() -> Result<(), std::io::Error> {
440        let parse = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid range"));
441        let r = parse("C3:A1")?;
442        assert_eq!(r.a1(), "A1:C3");
443        assert_eq!(r.count(), 9);
444        assert_eq!(parse("A:C")?.a1(), "A:C");
445        assert_eq!(parse("3:5")?.a1(), "3:5");
446        assert!(RangeRef::parse("0:5").is_none());
447        let a = parse("A1:C3")?;
448        let b = parse("B2:D4")?;
449        assert_eq!(a.intersection(&b).ok_or_else(|| std::io::Error::other("overlap expected"))?.a1(), "B2:C3");
450        assert_eq!(a.union(&b).a1(), "A1:D4");
451        assert_eq!(a.iter().count(), 9);
452        Ok(())
453    }
454
455    #[test]
456    fn extreme_offsets() {
457        let cell = CellRef::new(1, 1);
458        for delta in [i64::MIN, i64::MAX] {
459            assert_eq!(cell.offset(delta, 0), None);
460            assert_eq!(cell.offset(0, delta), None);
461            assert_eq!(cell.offset(delta, delta), None);
462        }
463        assert_eq!(cell.offset_clamped(i64::MAX, i64::MAX), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1));
464        assert_eq!(cell.offset_clamped(i64::MIN, i64::MIN), CellRef::new(0, 0));
465        assert_eq!(cell.offset_clamped(i64::MAX, i64::MIN), CellRef::new(MAX_ROWS - 1, 0));
466        assert_eq!(cell.offset_clamped(i64::MIN, i64::MAX), CellRef::new(0, MAX_COLS - 1));
467        assert_eq!(cell.offset(2, -1), Some(CellRef::new(3, 0)));
468    }
469
470    #[test]
471    fn offsets() {
472        assert_eq!(CellRef::new(0, 0).offset(-1, 0), None);
473        assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3));
474    }
475}
476
477/// The shape of a chart's category range beside its values: a plain list, or a multi-level
478/// axis (Excel: a category range two or more columns wide beside a column of values is a
479/// two-level axis, year beside month; one two or more rows tall above a row of values likewise).
480#[derive(Clone, Copy, Debug, PartialEq, Eq)]
481pub struct CategoryLevels {
482    /// The levels are columns and the points run down the rows (else the levels are rows and the
483    /// points run along the columns).
484    pub by_columns: bool,
485    /// Outer levels beyond the innermost labels (the column or row nearest the values): 1 for a
486    /// two-level axis. At most 3 are kept.
487    pub outer: u32,
488}
489
490/// How `categories` lays its labels out beside `values` (`None` when the values aren't a
491/// reference: the categories' own shape decides). `None` for a plain list of labels.
492pub fn category_levels(categories: &RangeRef, values: Option<&RangeRef>) -> Option<CategoryLevels> {
493    if categories.is_full_rows() || categories.is_full_cols() {
494        return None;
495    }
496    let (w, h) = (categories.width(), categories.height());
497    let vertical = match values {
498        Some(v) => v.height() >= v.width(),
499        None => h >= w,
500    };
501    let outer = if vertical { w.saturating_sub(1) } else { h.saturating_sub(1) };
502    (outer > 0).then_some(CategoryLevels { by_columns: vertical, outer: outer.min(3) })
503}
504
505#[cfg(test)]
506mod category_level_tests {
507    use super::*;
508
509    #[test]
510    fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() -> Result<(), std::io::Error> {
511        let r = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid test range"));
512        assert_eq!(category_levels(&r("A4:B39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 1 }));
513        assert_eq!(category_levels(&r("A4:D39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 3 }));
514        assert_eq!(category_levels(&r("A4:F39")?, Some(&r("C4:C39")?)).map(|l| l.outer), Some(3), "capped");
515        assert_eq!(category_levels(&r("A4:A39")?, Some(&r("C4:C39")?)), None, "one column: a plain list");
516        // A row of values: levels are rows.
517        assert_eq!(category_levels(&r("B2:M3")?, Some(&r("B4:M4")?)), Some(CategoryLevels { by_columns: false, outer: 1 }));
518        assert_eq!(category_levels(&r("B3:M3")?, Some(&r("B4:M4")?)), None);
519        // Literal values: the categories' own shape decides.
520        assert_eq!(category_levels(&r("A1:B6")?, None), Some(CategoryLevels { by_columns: true, outer: 1 }));
521        assert_eq!(category_levels(&r("A1:F2")?, None), Some(CategoryLevels { by_columns: false, outer: 1 }));
522        assert_eq!(category_levels(&r("A:B")?, Some(&r("C:C")?)), None, "whole columns");
523        Ok(())
524    }
525}