Skip to main content

elixcee_wasm/
lib.rs

1//! wasm-bindgen bridge for `@elixcee/xlsx`'s `XLSX.read(bytes)` — a thin JSON-shaping
2//! layer over `elixcee::reader::read_workbook_from_bytes`. See
3//! `docs/xlsx-architecture.md`'s "Phase 2B-0: sync WASM/read() bridge feasibility" and
4//! "Target workspace shape" sections.
5//!
6//! Ships from two separate `wasm-pack build` invocations (see `packages/xlsx`'s build
7//! tooling): `--target nodejs` for the Node entry point (fully synchronous glue — no
8//! `await init()`, confirmed in the Phase 2B-0 spike) and `--target web` for the browser
9//! entry point, where the caller inlines the compiled `.wasm` bytes into the shipped JS
10//! and calls wasm-bindgen's `initSync` itself rather than depending on a bundler resolving
11//! a bare `.wasm` import (bundlers don't do that by default — also confirmed in 2B-0). The
12//! sync-vs-async difference lives entirely in how each target's glue loads the module, not
13//! in this crate's Rust code — both entry points call the exact same export below.
14
15use elixcee::diagnostics::json_string;
16use elixcee::reader::{BufferSheet, BufferWorkbook, FilterCriteria, SheetCell};
17use elixcee::types::{ArrayShape, CellContent, ExcelError, Variant};
18use std::collections::{HashMap, HashSet};
19use wasm_bindgen::prelude::*;
20
21const MAX_EDITOR_HISTORY: usize = 128;
22const MAX_WORKSHEET_ROW: u32 = 1_048_576;
23const MAX_WORKSHEET_COLUMN: u32 = 16_384;
24
25#[derive(Clone)]
26struct EditorState {
27    sheets: HashMap<String, HashMap<(u32, u32), CellContent>>,
28}
29
30#[derive(Clone)]
31struct EditorTransaction {
32    state: EditorState,
33    undo_len: usize,
34    redo: Vec<EditorState>,
35}
36
37/// Stateful JS/WASM workbook editor for calculation-oriented workflows.
38/// OOXML writing remains the responsibility of the package's existing JS writer.
39#[wasm_bindgen]
40pub struct WorkbookEditor {
41    workbook: BufferWorkbook,
42    sheets: HashMap<String, HashMap<(u32, u32), CellContent>>,
43    undo: Vec<EditorState>,
44    redo: Vec<EditorState>,
45    transaction: Option<EditorTransaction>,
46    transaction_dirty: bool,
47}
48
49#[wasm_bindgen]
50impl WorkbookEditor {
51    #[wasm_bindgen(constructor)]
52    pub fn new(bytes: &[u8]) -> Result<WorkbookEditor, JsValue> {
53        let workbook =
54            elixcee::reader::read_workbook_from_bytes(bytes).map_err(|e| JsValue::from_str(&e))?;
55        let sheets = calculation_sheets(&workbook);
56        Ok(Self {
57            workbook,
58            sheets,
59            undo: Vec::new(),
60            redo: Vec::new(),
61            transaction: None,
62            transaction_dirty: false,
63        })
64    }
65
66    #[wasm_bindgen(js_name = setNumber)]
67    pub fn set_number(
68        &mut self,
69        sheet: &str,
70        row: u32,
71        col: u32,
72        value: f64,
73    ) -> Result<(), JsValue> {
74        let key = self.validate_cell_target(sheet, row, col)?;
75        if !value.is_finite() {
76            return Err(JsValue::from_str("cell value must be finite"));
77        }
78        self.record_edit();
79        let value = if value.fract() == 0.0 {
80            Variant::Integer(value as i64)
81        } else {
82            Variant::Float(value)
83        };
84        self.sheets.get_mut(&key).expect("checked above").insert(
85            (row, col),
86            CellContent {
87                formula: None,
88                value,
89            },
90        );
91        Ok(())
92    }
93
94    #[wasm_bindgen(js_name = setString)]
95    pub fn set_string(
96        &mut self,
97        sheet: &str,
98        row: u32,
99        col: u32,
100        value: &str,
101    ) -> Result<(), JsValue> {
102        let key = self.validate_cell_target(sheet, row, col)?;
103        self.record_edit();
104        self.sheets.get_mut(&key).expect("checked above").insert(
105            (row, col),
106            CellContent {
107                formula: None,
108                value: Variant::Str(value.to_string()),
109            },
110        );
111        Ok(())
112    }
113
114    #[wasm_bindgen(js_name = setBoolean)]
115    pub fn set_boolean(
116        &mut self,
117        sheet: &str,
118        row: u32,
119        col: u32,
120        value: bool,
121    ) -> Result<(), JsValue> {
122        let key = self.validate_cell_target(sheet, row, col)?;
123        self.record_edit();
124        self.sheets.get_mut(&key).expect("checked above").insert(
125            (row, col),
126            CellContent {
127                formula: None,
128                value: Variant::Boolean(value),
129            },
130        );
131        Ok(())
132    }
133
134    pub fn recalculate(&mut self) -> Result<String, JsValue> {
135        elixcee::formula::calculate_workbook(&mut self.sheets, &HashMap::new())
136            .map_err(|e| JsValue::from_str(&e))?;
137        Ok(self.json_snapshot())
138    }
139
140    pub fn snapshot(&mut self) -> String {
141        self.json_snapshot()
142    }
143
144    pub fn undo(&mut self) -> bool {
145        let Some(previous) = self.undo.pop() else {
146            return false;
147        };
148        self.redo.push(self.capture_state());
149        self.sheets = previous.sheets;
150        true
151    }
152
153    pub fn redo(&mut self) -> bool {
154        let Some(next) = self.redo.pop() else {
155            return false;
156        };
157        self.undo.push(self.capture_state());
158        self.sheets = next.sheets;
159        true
160    }
161
162    #[wasm_bindgen(js_name = beginTransaction)]
163    pub fn begin_transaction(&mut self) -> Result<(), JsValue> {
164        if self.transaction.is_some() {
165            return Err(JsValue::from_str("an edit transaction is already active"));
166        }
167        self.transaction = Some(EditorTransaction {
168            state: self.capture_state(),
169            undo_len: self.undo.len(),
170            redo: self.redo.clone(),
171        });
172        self.transaction_dirty = false;
173        Ok(())
174    }
175
176    #[wasm_bindgen(js_name = commitTransaction)]
177    pub fn commit_transaction(&mut self) -> bool {
178        let Some(transaction) = self.transaction.take() else {
179            return false;
180        };
181        if self.transaction_dirty {
182            self.undo.push(transaction.state);
183            self.undo.truncate(MAX_EDITOR_HISTORY);
184            self.redo.clear();
185        }
186        self.transaction_dirty = false;
187        true
188    }
189
190    #[wasm_bindgen(js_name = abortTransaction)]
191    pub fn abort_transaction(&mut self) -> bool {
192        let Some(transaction) = self.transaction.take() else {
193            return false;
194        };
195        self.sheets = transaction.state.sheets;
196        self.undo.truncate(transaction.undo_len);
197        self.redo = transaction.redo;
198        self.transaction_dirty = false;
199        true
200    }
201
202    #[wasm_bindgen(js_name = canUndo)]
203    pub fn can_undo(&self) -> bool {
204        !self.undo.is_empty()
205    }
206    #[wasm_bindgen(js_name = canRedo)]
207    pub fn can_redo(&self) -> bool {
208        !self.redo.is_empty()
209    }
210
211    fn capture_state(&self) -> EditorState {
212        EditorState {
213            sheets: self.sheets.clone(),
214        }
215    }
216
217    fn validate_cell_target(&self, sheet: &str, row: u32, col: u32) -> Result<String, JsValue> {
218        let key = sheet.to_ascii_lowercase();
219        if !self.sheets.contains_key(&key) {
220            return Err(JsValue::from_str("unknown worksheet"));
221        }
222        if row == 0 || row > MAX_WORKSHEET_ROW || col == 0 || col > MAX_WORKSHEET_COLUMN {
223            return Err(JsValue::from_str(
224                "cell coordinates are outside the worksheet bounds",
225            ));
226        }
227        Ok(key)
228    }
229
230    fn record_edit(&mut self) {
231        if self.transaction.is_some() {
232            self.transaction_dirty = true;
233            return;
234        }
235        self.undo.push(self.capture_state());
236        self.undo.truncate(MAX_EDITOR_HISTORY);
237        self.redo.clear();
238    }
239
240    fn json_snapshot(&mut self) -> String {
241        sync_workbook_values(&mut self.workbook, &self.sheets);
242        workbook_json(&self.workbook)
243    }
244}
245
246/// Read an in-memory XLSX/XLSM buffer, returning a JSON string shaped like xlsx@0.18.5's
247/// `WorkBook` (`{SheetNames, Sheets}`; each `WorkSheet` a sparse `{"A1": {t,v,f,fmtId}, ...,
248/// "!ref": "A1:C3", "!merges": [...], "!hiddenRows": [...], "!hiddenCols": [...] }` object,
249/// plus workbook-level `"!numFmts"`/`"!date1904"` and, when present, `Workbook.Names` — see
250/// `packages/xlsx/src/index.d.ts`'s `WorkBook`/`WorkSheet` types). The JS side
251/// (`packages/xlsx/src/index.cjs`'s `read()`) does `JSON.parse` on the result — no
252/// `serde`/`serde_json` dependency needed for a shape this small; reuses
253/// `elixcee::diagnostics::json_string`'s existing hand-rolled escaper (src/diagnostics.rs)
254/// rather than duplicating a JSON writer or adding a dependency.
255///
256/// `!hiddenRows`/`!hiddenCols`/per-cell `fmtId`/`!numFmts`/`!date1904`/`!dataValidations` are NOT the oracle's
257/// own `read()` shapes — they're `reader.rs`'s raw parsed data (1-based `[start,end]`
258/// intervals; a numFmtId integer; the workbook's custom numFmt table; a bool), passed
259/// through as-is. The JS layer resolves all of this into the oracle's real shapes —
260/// `!rows`/`!cols` (0-based sparse `{hidden:true}` arrays, gated behind `opts.cellStyles` —
261/// confirmed live the oracle never emits them without it), `.w`/`.z` (via the real `ssf`
262/// engine, `.z` gated behind `opts.cellNF`/`opts.cellStyles` and always a resolved format
263/// STRING, never the raw `fmtId` integer), and `t:'d'`-typed cells (gated behind
264/// `opts.cellDates`) — see `packages/xlsx/src/internal/read-shape.cjs`. Keeping that
265/// SheetJS-shape-specific (0-based/sparse/option-gated/SSF-backed) work in JS
266/// matches how every other xlsx-shape decision already lives in `index.cjs`, not here —
267/// and avoids porting SSF's own format-code-to-date heuristic into Rust as a second,
268/// unverified implementation of logic already proven correct across 1831 cases
269/// (compat/differential/ssf-format.test.mjs).
270#[wasm_bindgen(js_name = readWorkbook)]
271pub fn read_workbook(bytes: &[u8]) -> Result<String, JsValue> {
272    let wb = elixcee::reader::read_workbook_from_bytes(bytes).map_err(|e| JsValue::from_str(&e))?;
273    Ok(workbook_json(&wb))
274}
275
276/// Recalculate formula cells in an in-memory XLSX/XLSM buffer using the same
277/// Rust workbook runtime as the VM, then return the normal raw workbook JSON.
278///
279/// This deliberately keeps writing in JavaScript: the export is a calculation
280/// bridge, not an OOXML writer. Files without qualified formulas are also
281/// accepted and returned unchanged because the current shared workbook path is
282/// only needed when at least one formula requires workbook context.
283#[wasm_bindgen(js_name = calculateWorkbook)]
284pub fn calculate_workbook(bytes: &[u8]) -> Result<String, JsValue> {
285    let mut wb =
286        elixcee::reader::read_workbook_from_bytes(bytes).map_err(|e| JsValue::from_str(&e))?;
287    let mut sheets = HashMap::new();
288    for bs in &wb.sheets {
289        let mut cells = HashMap::with_capacity(bs.sheet.cells.len().max(bs.formulas.len()));
290        for (&position, cell) in &bs.sheet.cells {
291            cells.insert(
292                position,
293                CellContent {
294                    formula: bs.formulas.get(&position).map(|f| format!("={f}")),
295                    value: sheet_cell_to_variant(cell),
296                },
297            );
298        }
299        for (&position, formula) in &bs.formulas {
300            cells.entry(position).or_insert_with(|| CellContent {
301                formula: Some(format!("={formula}")),
302                value: Variant::Empty,
303            });
304        }
305        sheets.insert(bs.sheet.name.to_ascii_lowercase(), cells);
306    }
307    // Feed defined names into the shared workbook evaluator, preserving local
308    // worksheet scope instead of silently treating local names as global.
309    let named_ranges = wb
310        .defined_names
311        .iter()
312        .filter(|defined_name| defined_name.local_sheet_id.is_none())
313        .map(|defined_name| {
314            (
315                defined_name.name.to_ascii_lowercase(),
316                normalize_defined_name_ref(&defined_name.raw_text),
317            )
318        })
319        .collect::<HashMap<_, _>>();
320    let mut scoped_named_ranges = HashMap::<String, HashMap<String, String>>::new();
321    for defined_name in wb.defined_names.iter().filter_map(|defined_name| {
322        defined_name
323            .local_sheet_id
324            .map(|index| (index, defined_name))
325    }) {
326        let Some(sheet) = wb
327            .sheets
328            .get(defined_name.0)
329            .map(|buffer| buffer.sheet.name.clone())
330        else {
331            continue;
332        };
333        scoped_named_ranges
334            .entry(sheet.to_ascii_lowercase())
335            .or_default()
336            .insert(
337                defined_name.1.name.to_ascii_lowercase(),
338                normalize_defined_name_ref(&defined_name.1.raw_text),
339            );
340    }
341    // The workbook evaluator's structured-reference map is global, while a table
342    // reference is resolved relative to the formula's host sheet. Rewrite the bounded
343    // `Table[Column]` form to an A1 range per host before entering the shared evaluator;
344    // this also keeps same-sheet references on the fast, unqualified path.
345    rewrite_table_structured_formulas(&mut sheets, &wb);
346    elixcee::formula::calculate_workbook_with_context(
347        &mut sheets,
348        &named_ranges,
349        &scoped_named_ranges,
350        &HashMap::new(),
351    )
352    .map_err(|e| JsValue::from_str(&e))?;
353    materialize_formula_array_spills(&mut sheets).map_err(|e| JsValue::from_str(&e))?;
354    for bs in &mut wb.sheets {
355        let Some(cells) = sheets.get(&bs.sheet.name.to_ascii_lowercase()) else {
356            continue;
357        };
358        // Project every calculated cell, not only the original formula cells.
359        // Dynamic-array evaluation can materialize new spill cells after the
360        // OOXML reader has built its initial sparse sheet model.
361        for (&position, cell) in cells {
362            bs.sheet
363                .cells
364                .insert(position, variant_to_sheet_cell(&cell.value));
365        }
366    }
367    Ok(workbook_json(&wb))
368}
369
370/// Materialize the shared evaluator's flat formula arrays as bounded worksheet spills.
371/// Shape-aware functions recover a rectangular footprint from literal arguments; unknown
372/// flat arrays retain the legacy one-row fallback. Never overwrite an occupied cell.
373fn materialize_formula_array_spills(
374    sheets: &mut HashMap<String, HashMap<(u32, u32), CellContent>>,
375) -> Result<(), String> {
376    let mut pending = Vec::new();
377    let mut spill_errors = Vec::new();
378    let mut planned = HashSet::new();
379    for (sheet, cells) in sheets.iter() {
380        for (&(row, col), cell) in cells {
381            let Variant::Array(values) = &cell.value else {
382                continue;
383            };
384            let shape = formula_array_shape(cell.formula.as_deref(), values.len());
385            if shape.is_empty() {
386                continue;
387            }
388            let mut collision = false;
389            let mut targets = Vec::new();
390            for offset in 1..shape.cell_count() {
391                let value = values
392                    .get(offset)
393                    .cloned()
394                    .unwrap_or(Variant::Error(ExcelError::NA));
395                let row_offset = offset / shape.cols;
396                let col_offset = offset % shape.cols;
397                let target_row = row.checked_add(row_offset as u32).ok_or_else(|| {
398                    format!("dynamic array spill exceeds worksheet bounds on {sheet}")
399                })?;
400                let target_col = col.checked_add(col_offset as u32).ok_or_else(|| {
401                    format!("dynamic array spill exceeds worksheet bounds on {sheet}")
402                })?;
403                let key = (sheet.clone(), (target_row, target_col));
404                if cells.contains_key(&(target_row, target_col)) || planned.contains(&key) {
405                    collision = true;
406                    break;
407                }
408                targets.push(((target_row, target_col), value));
409            }
410            if collision {
411                spill_errors.push((sheet.clone(), (row, col)));
412            } else {
413                planned.extend(
414                    targets
415                        .iter()
416                        .map(|(position, _)| (sheet.clone(), *position)),
417                );
418                pending.extend(
419                    targets
420                        .into_iter()
421                        .map(|(position, value)| (sheet.clone(), position, value)),
422                );
423            }
424        }
425    }
426    for (sheet, position, value) in pending {
427        sheets.entry(sheet).or_default().insert(
428            position,
429            CellContent {
430                formula: None,
431                value,
432            },
433        );
434    }
435    for (sheet, position) in spill_errors {
436        if let Some(cell) = sheets
437            .get_mut(&sheet)
438            .and_then(|cells| cells.get_mut(&position))
439        {
440            cell.value = Variant::Str("#SPILL!".to_string());
441        }
442    }
443    Ok(())
444}
445
446fn formula_array_shape(formula: Option<&str>, len: usize) -> ArrayShape {
447    let Some(formula) = formula else {
448        return ArrayShape::new(1, len);
449    };
450    let expression = formula
451        .trim()
452        .trim_start_matches('=')
453        .trim()
454        .to_ascii_uppercase();
455    let Some(open) = expression.find('(') else {
456        return ArrayShape::new(1, len);
457    };
458    if !expression.ends_with(')') {
459        return ArrayShape::new(1, len);
460    }
461    let name = &expression[..open];
462    let arguments = &expression[open + 1..expression.len() - 1];
463    let values = arguments.split(',').map(str::trim).collect::<Vec<_>>();
464    let literal = |index: usize, fallback: usize| {
465        values
466            .get(index)
467            .and_then(|value| value.parse::<usize>().ok())
468            .unwrap_or(fallback)
469    };
470    let shape = match name {
471        "SEQUENCE" | "RANDARRAY" => ArrayShape::new(literal(0, 0), literal(1, 1)),
472        "WRAPROWS" => {
473            let cols = literal(1, 0);
474            ArrayShape::new(
475                if cols == 0 { 0 } else { len.div_ceil(cols) },
476                cols.min(len),
477            )
478        }
479        "WRAPCOLS" => {
480            let rows = literal(1, 0);
481            ArrayShape::new(
482                rows.min(len),
483                if rows == 0 { 0 } else { len.div_ceil(rows) },
484            )
485        }
486        _ => ArrayShape::new(1, len),
487    };
488    if shape.cell_count() >= len && !shape.is_empty() {
489        shape
490    } else {
491        ArrayShape::new(1, len)
492    }
493}
494
495fn normalize_defined_name_ref(raw: &str) -> String {
496    let Some(bang) = raw.rfind('!') else {
497        // The workbook formula parser accepts absolute markers on qualified
498        // references, while the local-range expansion path consumes the
499        // compact A1 address directly. Normalize only this unqualified form.
500        return raw.replace('$', "");
501    };
502    let qualifier = &raw[..bang];
503    if qualifier.starts_with('\'') && qualifier.ends_with('\'') && qualifier.len() >= 2 {
504        return format!(
505            "{}!{}",
506            qualifier[1..qualifier.len() - 1].replace("''", "'"),
507            &raw[bang + 1..]
508        );
509    }
510    raw.to_string()
511}
512
513fn wasm_column_label(mut column: u32) -> String {
514    let mut label = String::new();
515    loop {
516        label.insert(0, (b'A' + (column % 26) as u8) as char);
517        if column < 26 {
518            break;
519        }
520        column = column / 26 - 1;
521    }
522    label
523}
524
525fn replace_table_ref_case_insensitive(source: &str, needle: &str, replacement: &str) -> String {
526    let source_lower = source.to_ascii_lowercase();
527    let needle_lower = needle.to_ascii_lowercase();
528    let mut out = String::with_capacity(source.len());
529    let mut cursor = 0usize;
530    while let Some(relative) = source_lower[cursor..].find(&needle_lower) {
531        let start = cursor + relative;
532        let end = start + needle.len();
533        let inside_string = {
534            let mut in_string = false;
535            let mut chars = source[..start].chars().peekable();
536            while let Some(ch) = chars.next() {
537                if ch != '"' {
538                    continue;
539                }
540                if chars.peek() == Some(&'"') {
541                    chars.next();
542                } else {
543                    in_string = !in_string;
544                }
545            }
546            in_string
547        };
548        let preceded_by_identifier = source[..start]
549            .chars()
550            .next_back()
551            .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
552        let followed_by_identifier = source[end..]
553            .chars()
554            .next()
555            .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
556        out.push_str(&source[cursor..start]);
557        if inside_string || preceded_by_identifier || followed_by_identifier {
558            out.push_str(&source[start..end]);
559        } else {
560            out.push_str(replacement);
561        }
562        cursor = end;
563    }
564    out.push_str(&source[cursor..]);
565    out
566}
567
568fn rewrite_table_structured_formulas(
569    sheets: &mut HashMap<String, HashMap<(u32, u32), CellContent>>,
570    wb: &BufferWorkbook,
571) {
572    let mut patterns = Vec::<(String, String, String, bool)>::new();
573    for buffer in &wb.sheets {
574        let table_sheet = &buffer.sheet.name;
575        for table in &buffer.sheet.tables {
576            let (table_start, table_left) = table.ref_range.0;
577            let (table_end, table_right) = table.ref_range.1;
578            let data_start = table.ref_range.0.0 + table.header_row_count;
579            let data_end = table.ref_range.1.0.saturating_sub(table.totals_row_count);
580            let headers_end = table_start + table.header_row_count - 1;
581            let table_address = |start: u32, end: u32, left: u32, right: u32| {
582                format!(
583                    "{}!{}{}:{}{}",
584                    table_sheet,
585                    wasm_column_label(left.saturating_sub(1)),
586                    start,
587                    wasm_column_label(right.saturating_sub(1)),
588                    end
589                )
590            };
591            for table_name in [&table.name, &table.display_name] {
592                patterns.push((
593                    format!("{}[#Headers]", table_name),
594                    table_sheet.clone(),
595                    table_address(table_start, headers_end, table_left, table_right),
596                    false,
597                ));
598                patterns.push((
599                    format!("{}[#All]", table_name),
600                    table_sheet.clone(),
601                    table_address(table_start, table_end, table_left, table_right),
602                    false,
603                ));
604                if data_start <= data_end {
605                    patterns.push((
606                        format!("{}[#Data]", table_name),
607                        table_sheet.clone(),
608                        table_address(data_start, data_end, table_left, table_right),
609                        false,
610                    ));
611                }
612            }
613            if data_start > data_end {
614                continue;
615            }
616            for (index, column) in table.columns.iter().enumerate() {
617                let col = table.ref_range.0.1 + index as u32;
618                let qualified = format!(
619                    "{}!{}{}:{}{}",
620                    table_sheet,
621                    wasm_column_label(col.saturating_sub(1)),
622                    data_start,
623                    wasm_column_label(col.saturating_sub(1)),
624                    data_end
625                );
626                for table_name in [&table.name, &table.display_name] {
627                    let column_pattern = format!("{}[{}]", table_name, column.name);
628                    patterns.push((
629                        column_pattern.clone(),
630                        table_sheet.clone(),
631                        qualified.clone(),
632                        false,
633                    ));
634                    patterns.push((
635                        format!("{}[[#Data],[{}]]", table_name, column.name),
636                        table_sheet.clone(),
637                        qualified.clone(),
638                        false,
639                    ));
640                    patterns.push((
641                        format!("{}[[#Headers],[{}]]", table_name, column.name),
642                        table_sheet.clone(),
643                        table_address(table_start, headers_end, col, col),
644                        false,
645                    ));
646                    patterns.push((
647                        format!("{}[[#All],[{}]]", table_name, column.name),
648                        table_sheet.clone(),
649                        table_address(table_start, table_end, col, col),
650                        false,
651                    ));
652                    patterns.push((
653                        format!("{}[@{}]", table_name, column.name),
654                        table_sheet.clone(),
655                        qualified.clone(),
656                        true,
657                    ));
658                    patterns.push((
659                        format!("{}[[#This Row],[{}]]", table_name, column.name),
660                        table_sheet.clone(),
661                        qualified.clone(),
662                        true,
663                    ));
664                }
665            }
666        }
667    }
668    patterns.sort_by_key(|(pattern, _, _, _)| std::cmp::Reverse(pattern.len()));
669    for (host, cells) in sheets.iter_mut() {
670        for (&(row, _), cell) in cells.iter_mut() {
671            let Some(formula) = cell.formula.as_mut() else {
672                continue;
673            };
674            for (pattern, table_sheet, qualified, this_row) in &patterns {
675                if *this_row && !host.eq_ignore_ascii_case(table_sheet) {
676                    continue;
677                }
678                let replacement = if *this_row {
679                    let Some((_, data_range)) = qualified.split_once('!') else {
680                        continue;
681                    };
682                    let Some((start, end)) = data_range.split_once(':') else {
683                        continue;
684                    };
685                    let col = start.trim_end_matches(|ch: char| ch.is_ascii_digit());
686                    let start_row = start
687                        .trim_start_matches(|ch: char| ch.is_ascii_alphabetic())
688                        .parse::<u32>()
689                        .ok();
690                    let end_col = end.trim_end_matches(|ch: char| ch.is_ascii_digit());
691                    if start_row.is_none() || end_col != col {
692                        continue;
693                    }
694                    format!("{}{}", col, row)
695                } else if host.eq_ignore_ascii_case(table_sheet) {
696                    qualified
697                        .split_once('!')
698                        .map_or(qualified.as_str(), |(_, address)| address)
699                        .to_string()
700                } else {
701                    qualified.clone()
702                };
703                let rewritten = replace_table_ref_case_insensitive(formula, pattern, &replacement);
704                if rewritten != *formula {
705                    *formula = rewritten;
706                }
707            }
708        }
709    }
710}
711
712/// Return a small, deterministic diagnostic summary without materializing a
713/// calculated workbook. This gives Node/browser callers a structured preflight
714/// signal while keeping read/calculation failures as ordinary `Result` errors.
715#[wasm_bindgen(js_name = diagnoseWorkbook)]
716pub fn diagnose_workbook(bytes: &[u8]) -> String {
717    let wb = match elixcee::reader::read_workbook_from_bytes(bytes) {
718        Ok(wb) => wb,
719        Err(error) => {
720            return format!("{{\"ok\":false,\"error\":{}}}", json_string(&error));
721        }
722    };
723    let mut formula_count = 0usize;
724    let mut qualified_formula_count = 0usize;
725    let mut formula_parse_errors = 0usize;
726    let calculation_sheets = calculation_sheets(&wb);
727    for bs in &wb.sheets {
728        for formula in bs.formulas.values() {
729            formula_count += 1;
730            match elixcee::formula::parse(formula) {
731                Ok(expr) => {
732                    if contains_qualified_reference(&expr) {
733                        qualified_formula_count += 1;
734                    }
735                }
736                Err(_) => formula_parse_errors += 1,
737            }
738        }
739    }
740    format!(
741        "{{\"ok\":true,\"sheetCount\":{},\"formulaCount\":{},\"qualifiedFormulaCount\":{},\"formulaParseErrors\":{},\"hasFormulaCycle\":{}}}",
742        wb.sheets.len(),
743        formula_count,
744        qualified_formula_count,
745        formula_parse_errors,
746        if elixcee::formula::workbook_has_formula_cycle(&calculation_sheets) {
747            "true"
748        } else {
749            "false"
750        }
751    )
752}
753
754fn contains_qualified_reference(expr: &elixcee::formula::FormulaExpr) -> bool {
755    use elixcee::formula::FormulaExpr;
756    match expr {
757        FormulaExpr::CellRef { sheet, .. } | FormulaExpr::Range { sheet, .. } => sheet.is_some(),
758        FormulaExpr::BinOp { lhs, rhs, .. } => {
759            contains_qualified_reference(lhs) || contains_qualified_reference(rhs)
760        }
761        FormulaExpr::UnaryMinus(inner) => contains_qualified_reference(inner),
762        FormulaExpr::FuncCall { args, .. } => args.iter().any(contains_qualified_reference),
763        FormulaExpr::Call { callee, args } => {
764            contains_qualified_reference(callee) || args.iter().any(contains_qualified_reference)
765        }
766        FormulaExpr::Number(_)
767        | FormulaExpr::Str(_)
768        | FormulaExpr::Bool(_)
769        | FormulaExpr::Omitted => false,
770    }
771}
772
773fn sheet_cell_to_variant(cell: &SheetCell) -> Variant {
774    match cell {
775        SheetCell::Integer(value) => Variant::Integer(*value),
776        SheetCell::Float(value) => Variant::Float(*value),
777        SheetCell::Str(value) => Variant::Str(value.clone()),
778        SheetCell::Bool(value) => Variant::Boolean(*value),
779        SheetCell::Error(value) => Variant::Error(value.clone()),
780    }
781}
782
783fn calculation_sheets(
784    workbook: &BufferWorkbook,
785) -> HashMap<String, HashMap<(u32, u32), CellContent>> {
786    let mut sheets = HashMap::new();
787    for bs in &workbook.sheets {
788        let mut cells = HashMap::with_capacity(bs.sheet.cells.len().max(bs.formulas.len()));
789        for (&position, cell) in &bs.sheet.cells {
790            cells.insert(
791                position,
792                CellContent {
793                    formula: bs.formulas.get(&position).map(|f| format!("={f}")),
794                    value: sheet_cell_to_variant(cell),
795                },
796            );
797        }
798        for (&position, formula) in &bs.formulas {
799            cells.entry(position).or_insert_with(|| CellContent {
800                formula: Some(format!("={formula}")),
801                value: Variant::Empty,
802            });
803        }
804        sheets.insert(bs.sheet.name.to_ascii_lowercase(), cells);
805    }
806    sheets
807}
808
809fn sync_workbook_values(
810    workbook: &mut BufferWorkbook,
811    sheets: &HashMap<String, HashMap<(u32, u32), CellContent>>,
812) {
813    for bs in &mut workbook.sheets {
814        let Some(cells) = sheets.get(&bs.sheet.name.to_ascii_lowercase()) else {
815            continue;
816        };
817        for &position in bs.formulas.keys() {
818            if let Some(cell) = cells.get(&position) {
819                bs.sheet
820                    .cells
821                    .insert(position, variant_to_sheet_cell(&cell.value));
822            }
823        }
824        for (&position, cell) in cells {
825            if cell.formula.is_none() {
826                bs.sheet
827                    .cells
828                    .insert(position, variant_to_sheet_cell(&cell.value));
829            }
830        }
831    }
832}
833
834fn variant_to_sheet_cell(value: &Variant) -> SheetCell {
835    match value {
836        Variant::Integer(value) => SheetCell::Integer(*value),
837        Variant::Float(value) => SheetCell::Float(*value),
838        Variant::Str(value) => SheetCell::Str(value.clone()),
839        Variant::Boolean(value) => SheetCell::Bool(*value),
840        Variant::Date(value) => SheetCell::Integer(*value),
841        Variant::Error(value) => SheetCell::Error(value.clone()),
842        Variant::Empty | Variant::Null => SheetCell::Str(String::new()),
843        Variant::Array(values) => values
844            .first()
845            .map(variant_to_sheet_cell)
846            .unwrap_or_else(|| SheetCell::Str(String::new())),
847        Variant::VbaArray(_) | Variant::Record(_) => SheetCell::Str(value.to_string()),
848    }
849}
850
851fn workbook_json(wb: &BufferWorkbook) -> String {
852    let mut names = String::from("[");
853    let mut body = String::from("{");
854    for (i, bs) in wb.sheets.iter().enumerate() {
855        if i > 0 {
856            names.push(',');
857            body.push(',');
858        }
859        names.push_str(&json_string(&bs.sheet.name));
860        body.push_str(&json_string(&bs.sheet.name));
861        body.push(':');
862        body.push_str(&worksheet_json(bs));
863    }
864    names.push(']');
865    body.push('}');
866
867    let mut out = format!("{{\"SheetNames\":{},\"Sheets\":{}", names, body);
868    if !wb.number_formats.is_empty() {
869        // Deterministic key order — same rationale as worksheet_json's cell sort.
870        let mut ids: Vec<_> = wb.number_formats.keys().collect();
871        ids.sort();
872        out.push_str(",\"!numFmts\":{");
873        for (i, id) in ids.iter().enumerate() {
874            if i > 0 {
875                out.push(',');
876            }
877            out.push_str(&json_string(&id.to_string()));
878            out.push(':');
879            out.push_str(&json_string(&wb.number_formats[*id]));
880        }
881        out.push('}');
882    }
883    out.push_str(&format!(",\"!date1904\":{}", wb.date1904));
884    if !wb.defined_names.is_empty() {
885        out.push_str(",\"Workbook\":{\"Names\":[");
886        for (i, defined_name) in wb.defined_names.iter().enumerate() {
887            if i > 0 {
888                out.push(',');
889            }
890            out.push_str("{\"Name\":");
891            out.push_str(&json_string(&defined_name.name));
892            out.push_str(",\"Ref\":");
893            out.push_str(&json_string(&defined_name.raw_text));
894            if let Some(sheet) = defined_name.local_sheet_id {
895                out.push_str(",\"Sheet\":");
896                out.push_str(&sheet.to_string());
897            }
898            out.push('}');
899        }
900        out.push_str("]}");
901    }
902    out.push('}');
903    out
904}
905
906fn table_filter_column_json(column: &elixcee::reader::FilterColumn) -> String {
907    let mut out = format!(
908        "{{\"colId\":{},\"hiddenButton\":{},\"showButton\":{},\"criteria\":",
909        column.col_offset, column.hidden_button, column.show_button
910    );
911    match &column.criteria {
912        FilterCriteria::Values(values) => {
913            out.push_str("{\"kind\":\"values\",\"values\":[");
914            for (index, value) in values.iter().enumerate() {
915                if index > 0 {
916                    out.push(',');
917                }
918                out.push_str(&json_string(value));
919            }
920            out.push_str("]}");
921        }
922        FilterCriteria::Blank => out.push_str("{\"kind\":\"blank\"}"),
923        FilterCriteria::Custom {
924            op1,
925            val1,
926            and,
927            op2,
928            val2,
929        } => {
930            out.push_str(&format!(
931                "{{\"kind\":\"custom\",\"op1\":{},\"val1\":{},\"and\":{}",
932                json_string(op1),
933                json_string(val1),
934                and
935            ));
936            if let (Some(op2), Some(val2)) = (op2, val2) {
937                out.push_str(&format!(
938                    ",\"op2\":{},\"val2\":{}",
939                    json_string(op2),
940                    json_string(val2)
941                ));
942            }
943            out.push('}');
944        }
945        FilterCriteria::Top10 { top, percent, val } => {
946            out.push_str(&format!(
947                "{{\"kind\":\"top10\",\"top\":{},\"percent\":{},\"val\":{}}}",
948                top, percent, val
949            ));
950        }
951        FilterCriteria::DateGroup(_) => out.push_str("{\"kind\":\"dateGroup\"}"),
952    }
953    out.push('}');
954    out
955}
956
957fn worksheet_json(bs: &BufferSheet) -> String {
958    let sheet = &bs.sheet;
959    let mut out = String::from("{");
960    let mut first = true;
961    let (mut min_r, mut min_c, mut max_r, mut max_c) = (u32::MAX, u32::MAX, 0u32, 0u32);
962
963    // Deterministic (row, col) order — a HashMap has no defined iteration order, and a
964    // stable cell order in the emitted JSON matters for anything downstream that diffs or
965    // snapshots the output (e.g. the differential test added alongside this bridge).
966    let mut refs: Vec<_> = sheet.cells.iter().collect();
967    refs.sort_by_key(|((r, c), _)| (*r, *c));
968
969    for (&(row, col), cell) in refs {
970        if !first {
971            out.push(',');
972        }
973        first = false;
974        min_r = min_r.min(row);
975        max_r = max_r.max(row);
976        min_c = min_c.min(col);
977        max_c = max_c.max(col);
978        out.push_str(&json_string(&cell_ref(row, col)));
979        out.push(':');
980        out.push_str(&cell_json(
981            cell,
982            bs.formulas.get(&(row, col)),
983            bs.style_ids.get(&(row, col)),
984            bs.cell_styles.get(&(row, col)),
985        ));
986    }
987
988    // <dimension>, when present and trusted (reader.rs's parse_dimension_ref already
989    // replicates the oracle's own colon-required/non-reversed quirks), wins over the
990    // populated-cell bounding box — matching the oracle's own parse_ws_xml_dim, which
991    // never falls back to a bounding box once a valid <dimension> set !ref. Only fall
992    // back to the bounding box (and only when at least one cell exists) when no
993    // dimension was trusted, same as this bridge's pre-existing behavior.
994    let ref_range = bs
995        .dimension
996        .or_else(|| (!first).then_some(((min_r, min_c), (max_r, max_c))));
997    if let Some(((r1, c1), (r2, c2))) = ref_range {
998        out.push_str(",\"!ref\":");
999        // A single-cell range collapses to just the cell ref, no colon — matching the
1000        // oracle's own encode_range (`start === end ? start : start + ':' + end`),
1001        // ALWAYS used to build !ref regardless of source (bounding box or a trusted
1002        // <dimension>, even one written as "A1:A1" in the XML — confirmed live: the
1003        // oracle's own !ref is never the raw <dimension> text echoed back verbatim, it's
1004        // always re-encoded through encode_range). Found via a real divergence (a
1005        // single-populated-cell sheet reading back as "A1:A1" here vs the oracle's "A1"),
1006        // not assumed.
1007        let start = cell_ref(r1, c1);
1008        if r1 == r2 && c1 == c2 {
1009            out.push_str(&json_string(&start));
1010        } else {
1011            out.push_str(&json_string(&format!("{}:{}", start, cell_ref(r2, c2))));
1012        }
1013    }
1014
1015    if !sheet.merged_ranges.is_empty() {
1016        out.push_str(",\"!merges\":[");
1017        for (i, ((r1, c1), (r2, c2))) in sheet.merged_ranges.iter().enumerate() {
1018            if i > 0 {
1019                out.push(',');
1020            }
1021            // reader.rs's merged_ranges are 1-based inclusive; WorkSheet's !merges uses
1022            // 0-based CellAddress (matching encode_cell/decode_cell's own convention) —
1023            // saturating_sub guards a theoretical 0 from a malformed <mergeCell ref>
1024            // rather than underflowing (see reader.rs's parse_merge_ref, which does no
1025            // bounds validation of its own on the crafted-file input it parses).
1026            out.push_str(&format!(
1027                "{{\"s\":{{\"r\":{},\"c\":{}}},\"e\":{{\"r\":{},\"c\":{}}}}}",
1028                r1.saturating_sub(1),
1029                c1.saturating_sub(1),
1030                r2.saturating_sub(1),
1031                c2.saturating_sub(1)
1032            ));
1033        }
1034        out.push(']');
1035    }
1036
1037    write_hidden_intervals(&mut out, "!hiddenRows", &sheet.hidden_rows);
1038    write_hidden_intervals(&mut out, "!hiddenCols", &sheet.hidden_columns);
1039
1040    if !sheet.data_validations.is_empty() {
1041        out.push_str(",\"!dataValidations\":[");
1042        for (index, validation) in sheet.data_validations.iter().enumerate() {
1043            if index > 0 {
1044                out.push(',');
1045            }
1046            out.push_str("{\"type\":");
1047            out.push_str(&json_string(&validation.validation_type));
1048            out.push_str(",\"sqref\":[");
1049            for (range_index, range) in validation.sqref.iter().enumerate() {
1050                if range_index > 0 {
1051                    out.push(',');
1052                }
1053                out.push_str(&json_string(&format_rect(range)));
1054            }
1055            out.push_str("]}");
1056        }
1057        out.push(']');
1058    }
1059
1060    if !sheet.tables.is_empty() {
1061        out.push_str(",\"!tables\":[");
1062        for (index, table) in sheet.tables.iter().enumerate() {
1063            if index > 0 {
1064                out.push(',');
1065            }
1066            out.push_str("{\"name\":");
1067            out.push_str(&json_string(&table.name));
1068            out.push_str(",\"displayName\":");
1069            out.push_str(&json_string(&table.display_name));
1070            out.push_str(",\"ref\":");
1071            out.push_str(&json_string(&format_rect(&table.ref_range)));
1072            if let Some(auto_filter_ref) = &table.auto_filter_ref {
1073                out.push_str(",\"autoFilterRef\":");
1074                out.push_str(&json_string(&format_rect(auto_filter_ref)));
1075                if !table.autofilter_columns.is_empty() {
1076                    out.push_str(",\"autoFilterColumns\":[");
1077                    for (column_index, column) in table.autofilter_columns.iter().enumerate() {
1078                        if column_index > 0 {
1079                            out.push(',');
1080                        }
1081                        out.push_str(&table_filter_column_json(column));
1082                    }
1083                    out.push(']');
1084                }
1085            }
1086            if !table.columns.is_empty() {
1087                out.push_str(",\"columns\":[");
1088                for (column_index, column) in table.columns.iter().enumerate() {
1089                    if column_index > 0 {
1090                        out.push(',');
1091                    }
1092                    out.push_str("{\"name\":");
1093                    out.push_str(&json_string(&column.name));
1094                    out.push('}');
1095                }
1096                out.push(']');
1097            }
1098            if let Some(style_name) = &table.style_name {
1099                out.push_str(",\"styleName\":");
1100                out.push_str(&json_string(style_name));
1101            }
1102            out.push('}');
1103        }
1104        out.push(']');
1105    }
1106
1107    if !bs.charts.is_empty() {
1108        out.push_str(",\"!charts\":[");
1109        for (index, chart) in bs.charts.iter().enumerate() {
1110            if index > 0 {
1111                out.push(',');
1112            }
1113            out.push_str("{\"ref\":");
1114            out.push_str(&json_string(&format_rect(&chart.ref_range)));
1115            out.push_str(",\"type\":");
1116            out.push_str(&json_string(&chart.chart_type));
1117            out.push_str(",\"title\":");
1118            out.push_str(&json_string(&chart.title));
1119            if let Some(title) = &chart.x_axis_title {
1120                out.push_str(",\"xAxisTitle\":");
1121                out.push_str(&json_string(title));
1122            }
1123            if let Some(title) = &chart.y_axis_title {
1124                out.push_str(",\"yAxisTitle\":");
1125                out.push_str(&json_string(title));
1126            }
1127            out.push_str(",\"legend\":");
1128            out.push_str(if chart.legend { "true" } else { "false" });
1129            if !chart.series_colors.is_empty() {
1130                out.push_str(",\"colors\":[");
1131                for (color_index, color) in chart.series_colors.iter().enumerate() {
1132                    if color_index > 0 {
1133                        out.push(',');
1134                    }
1135                    out.push_str(&json_string(color));
1136                }
1137                out.push(']');
1138            }
1139            out.push_str(&format!(
1140                ",\"widthCols\":{},\"heightRows\":{}",
1141                chart.width_cols, chart.height_rows
1142            ));
1143            out.push('}');
1144        }
1145        out.push(']');
1146    }
1147
1148    if !bs.comment_notes.is_empty() {
1149        out.push_str(",\"!comments\":[");
1150        for (index, comment) in bs.comment_notes.iter().enumerate() {
1151            if index > 0 {
1152                out.push(',');
1153            }
1154            out.push_str("{\"ref\":");
1155            out.push_str(&json_string(&cell_ref(comment.cell.0, comment.cell.1)));
1156            out.push_str(",\"author\":");
1157            out.push_str(&json_string(&comment.author));
1158            out.push_str(",\"text\":");
1159            out.push_str(&json_string(&comment.text));
1160            out.push('}');
1161        }
1162        out.push(']');
1163    }
1164
1165    if !bs.conditional_formats.is_empty() {
1166        out.push_str(",\"!conditionalFormats\":[");
1167        for (index, rule) in bs.conditional_formats.iter().enumerate() {
1168            if index > 0 {
1169                out.push(',');
1170            }
1171            out.push_str("{\"type\":");
1172            out.push_str(&json_string(&rule.rule_type));
1173            if let Some(operator) = &rule.operator {
1174                out.push_str(",\"operator\":");
1175                out.push_str(&json_string(operator));
1176            }
1177            out.push_str(",\"sqref\":[");
1178            for (range_index, range) in rule.sqref.iter().enumerate() {
1179                if range_index > 0 {
1180                    out.push(',');
1181                }
1182                out.push_str(&json_string(&format_rect(range)));
1183            }
1184            out.push_str("],\"formula\":");
1185            out.push_str(&json_string(&rule.formula1));
1186            if let Some(formula2) = &rule.formula2 {
1187                out.push_str(",\"formula2\":");
1188                out.push_str(&json_string(formula2));
1189            }
1190            if let Some(priority) = rule.priority {
1191                out.push_str(&format!(",\"priority\":{}", priority));
1192            }
1193            if rule.stop_if_true {
1194                out.push_str(",\"stopIfTrue\":true");
1195            }
1196            if let Some(dxf) = &rule.dxf {
1197                out.push_str(",\"dxf\":{");
1198                let mut first = true;
1199                if dxf.bold || dxf.italic || dxf.underline || dxf.font_color.is_some() {
1200                    out.push_str("\"font\":{");
1201                    let mut font_first = true;
1202                    if dxf.bold {
1203                        out.push_str("\"bold\":true");
1204                        font_first = false;
1205                    }
1206                    if dxf.italic {
1207                        if !font_first {
1208                            out.push(',');
1209                        }
1210                        out.push_str("\"italic\":true");
1211                        font_first = false;
1212                    }
1213                    if dxf.underline {
1214                        if !font_first {
1215                            out.push(',');
1216                        }
1217                        out.push_str("\"underline\":true");
1218                        font_first = false;
1219                    }
1220                    if let Some(color) = &dxf.font_color {
1221                        if !font_first {
1222                            out.push(',');
1223                        }
1224                        out.push_str("\"color\":{\"rgb\":");
1225                        out.push_str(&json_string(color));
1226                        out.push('}');
1227                    }
1228                    out.push('}');
1229                    first = false;
1230                }
1231                if let Some(color) = &dxf.fill_color {
1232                    if !first {
1233                        out.push(',');
1234                    }
1235                    out.push_str("\"fill\":{\"fgColor\":{\"rgb\":");
1236                    out.push_str(&json_string(color));
1237                    out.push_str("}}");
1238                }
1239                out.push('}');
1240            }
1241            out.push('}');
1242        }
1243        out.push(']');
1244    }
1245
1246    if let Some(pane) = &bs.freeze_pane {
1247        out.push_str(",\"!freezePane\":{\"rows\":");
1248        out.push_str(&pane.rows.to_string());
1249        out.push_str(",\"cols\":");
1250        out.push_str(&pane.cols.to_string());
1251        out.push('}');
1252    }
1253
1254    out.push('}');
1255    out
1256}
1257
1258/// Serializes `reader.rs`'s native 1-based inclusive `(start, end)` intervals as a raw
1259/// `[[start,end], ...]` JSON array under `key` — see `worksheet_json`'s doc comment for why
1260/// this is an internal wire shape, not the oracle's own `!rows`/`!cols`.
1261fn write_hidden_intervals(out: &mut String, key: &str, intervals: &[(u32, u32)]) {
1262    if intervals.is_empty() {
1263        return;
1264    }
1265    out.push_str(",\"");
1266    out.push_str(key);
1267    out.push_str("\":[");
1268    for (i, (start, end)) in intervals.iter().enumerate() {
1269        if i > 0 {
1270            out.push(',');
1271        }
1272        out.push_str(&format!("[{},{}]", start, end));
1273    }
1274    out.push(']');
1275}
1276
1277fn cell_json(
1278    cell: &SheetCell,
1279    formula: Option<&String>,
1280    fmt_id: Option<&u32>,
1281    style: Option<&elixcee::reader::CellStyleDef>,
1282) -> String {
1283    let mut out = match cell {
1284        SheetCell::Integer(v) => format!("{{\"t\":\"n\",\"v\":{}", v),
1285        SheetCell::Float(v) => format!("{{\"t\":\"n\",\"v\":{}", json_number(*v)),
1286        SheetCell::Str(v) => format!("{{\"t\":\"s\",\"v\":{}", json_string(v)),
1287        SheetCell::Bool(v) => format!("{{\"t\":\"b\",\"v\":{}", v),
1288        // Numeric BIFF error code, matching the real oracle's own {t:"e", v:<code>} shape
1289        // exactly (see ExcelError::biff_code's doc comment) -- shapeCell (read-shape.cjs)
1290        // derives .w from it, the same layering already used for .w on every other type.
1291        SheetCell::Error(e) => format!("{{\"t\":\"e\",\"v\":{}", e.biff_code()),
1292    };
1293    if let Some(f) = formula {
1294        out.push_str(",\"f\":");
1295        out.push_str(&json_string(f));
1296    }
1297    if let Some(id) = fmt_id {
1298        // "fmtId", not the oracle's own "z" — an internal wire key holding a raw
1299        // numFmtId integer, not yet the resolved format-code STRING the oracle's real
1300        // `.z` always is (even "General" is a string, never a number, on the oracle —
1301        // confirmed live). Matches the !hiddenRows/!hiddenCols wire-vs-real-shape
1302        // convention above rather than overloading `.z`'s two different meanings under
1303        // one key name. See read-shape.cjs, which resolves this into the real `.z`/`.w`.
1304        out.push_str(",\"fmtId\":");
1305        out.push_str(&id.to_string());
1306    }
1307    if let Some(style) = style {
1308        let mut parts = Vec::new();
1309        if style.bold || style.italic || style.underline || style.font_color.is_some() {
1310            let mut font = String::new();
1311            if style.bold {
1312                font.push_str("\"bold\":true");
1313            }
1314            if style.italic {
1315                if !font.is_empty() {
1316                    font.push(',');
1317                }
1318                font.push_str("\"italic\":true");
1319            }
1320            if style.underline {
1321                if !font.is_empty() {
1322                    font.push(',');
1323                }
1324                font.push_str("\"underline\":true");
1325            }
1326            if let Some(color) = &style.font_color {
1327                if !font.is_empty() {
1328                    font.push(',');
1329                }
1330                font.push_str("\"color\":{\"rgb\":");
1331                font.push_str(&json_string(color));
1332                font.push('}');
1333            }
1334            parts.push(format!("\"font\":{{{}}}", font));
1335        }
1336        if let Some(color) = &style.fill_color {
1337            parts.push(format!(
1338                "\"fill\":{{\"fgColor\":{{\"rgb\":{}}}}}",
1339                json_string(color)
1340            ));
1341        }
1342        let border_specs = [
1343            (
1344                "bottom",
1345                style.border_bottom.as_ref(),
1346                style.border_bottom_color.as_ref(),
1347            ),
1348            (
1349                "left",
1350                style.border_left.as_ref(),
1351                style.border_left_color.as_ref(),
1352            ),
1353            (
1354                "right",
1355                style.border_right.as_ref(),
1356                style.border_right_color.as_ref(),
1357            ),
1358            (
1359                "top",
1360                style.border_top.as_ref(),
1361                style.border_top_color.as_ref(),
1362            ),
1363        ];
1364        let border_parts: Vec<_> = border_specs
1365            .into_iter()
1366            .filter_map(|(name, border, color)| {
1367                border.map(|value| {
1368                    let color = color
1369                        .map(|v| format!(",\"color\":{{\"rgb\":{}}}", json_string(v)))
1370                        .unwrap_or_default();
1371                    format!(
1372                        "\"{}\":{{\"style\":{}{}{}}}",
1373                        name,
1374                        json_string(value),
1375                        color,
1376                        ""
1377                    )
1378                })
1379            })
1380            .collect();
1381        if !border_parts.is_empty() {
1382            parts.push(format!("\"border\":{{{}}}", border_parts.join(",")));
1383        }
1384        if style.horizontal.is_some() || style.vertical.is_some() || style.wrap_text.is_some() {
1385            let mut alignment = String::new();
1386            if let Some(value) = &style.horizontal {
1387                alignment.push_str("\"horizontal\":");
1388                alignment.push_str(&json_string(value));
1389            }
1390            if let Some(value) = &style.vertical {
1391                if !alignment.is_empty() {
1392                    alignment.push(',');
1393                }
1394                alignment.push_str("\"vertical\":");
1395                alignment.push_str(&json_string(value));
1396            }
1397            if let Some(value) = style.wrap_text {
1398                if !alignment.is_empty() {
1399                    alignment.push(',');
1400                }
1401                alignment.push_str(&format!("\"wrapText\":{}", value));
1402            }
1403            parts.push(format!("\"alignment\":{{{}}}", alignment));
1404        }
1405        if !parts.is_empty() {
1406            out.push_str(",\"s\":{");
1407            out.push_str(&parts.join(","));
1408            out.push('}');
1409        }
1410    }
1411    out.push('}');
1412    out
1413}
1414
1415/// A crafted `<v>NaN</v>`/`<v>inf</v>` on a numeric-typed cell parses fine as an f64 (Rust's
1416/// `FromStr` accepts those literals) but isn't valid JSON — guard at this trust boundary
1417/// (untrusted file bytes) rather than emit syntactically broken output for a caller that
1418/// then fails confusingly at `JSON.parse`.
1419fn json_number(v: f64) -> String {
1420    if v.is_finite() {
1421        format!("{}", v)
1422    } else {
1423        "null".to_string()
1424    }
1425}
1426
1427/// 1-based (row, col) -> an "A1"-style reference. `reader.rs`'s own module-doc comment
1428/// on `MergeRect` establishes this codebase's convention of a small per-module
1429/// col-letter helper rather than a cross-module `utils` dependency — followed here rather
1430/// than introduced fresh.
1431fn col_letters(mut col: u32) -> String {
1432    let mut s = String::new();
1433    while col > 0 {
1434        let rem = (col - 1) % 26;
1435        s.insert(0, (b'A' + rem as u8) as char);
1436        col = (col - 1) / 26;
1437    }
1438    s
1439}
1440
1441fn cell_ref(row: u32, col: u32) -> String {
1442    format!("{}{}", col_letters(col), row)
1443}
1444
1445fn format_rect(rect: &((u32, u32), (u32, u32))) -> String {
1446    let ((r1, c1), (r2, c2)) = *rect;
1447    let start = format!("{}{}", col_letters(c1), r1);
1448    if r1 == r2 && c1 == c2 {
1449        start
1450    } else {
1451        format!("{}:{}{}", start, col_letters(c2), r2)
1452    }
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457    use super::*;
1458    use std::collections::HashMap;
1459
1460    fn sheet(name: &str, cells: Vec<((u32, u32), SheetCell)>) -> BufferSheet {
1461        BufferSheet {
1462            sheet: elixcee::reader::WorkbookSheet {
1463                name: name.to_string(),
1464                cells: cells.into_iter().collect::<HashMap<_, _>>(),
1465                sheet_id: None,
1466                workbook_rel_id: None,
1467                source_part_name: None,
1468                merged_ranges: vec![],
1469                hidden_rows: vec![],
1470                hidden_columns: vec![],
1471                raw_style_indices: HashMap::new(),
1472                formulas: HashMap::new(),
1473                cell_number_formats: HashMap::new(),
1474                sheet_state: None,
1475                row_heights: HashMap::new(),
1476                column_widths: Vec::new(),
1477                row_styles: HashMap::new(),
1478                column_styles: Vec::new(),
1479                tables: Vec::new(),
1480                data_validations: Vec::new(),
1481                conditional_format_ranges: Vec::new(),
1482                comment_cells: Vec::new(),
1483                autofilter: None,
1484            },
1485            formulas: HashMap::new(),
1486            dimension: None,
1487            style_ids: HashMap::new(),
1488            charts: Vec::new(),
1489            comment_notes: Vec::new(),
1490            conditional_formats: Vec::new(),
1491            cell_styles: HashMap::new(),
1492            freeze_pane: None,
1493        }
1494    }
1495
1496    // Wraps a single BufferSheet into the BufferWorkbook workbook_json now takes — every
1497    // test here exercises one sheet at a time, so this is the common case; number_formats/
1498    // date1904-specific tests build a BufferWorkbook directly instead of through this.
1499    fn wb1(s: BufferSheet) -> BufferWorkbook {
1500        BufferWorkbook {
1501            sheets: vec![s],
1502            number_formats: HashMap::new(),
1503            date1904: false,
1504            defined_names: Vec::new(),
1505        }
1506    }
1507
1508    #[test]
1509    fn col_letters_matches_the_usual_a1_z1_aa1_examples() {
1510        assert_eq!(col_letters(1), "A");
1511        assert_eq!(col_letters(26), "Z");
1512        assert_eq!(col_letters(27), "AA");
1513        assert_eq!(col_letters(702), "ZZ");
1514    }
1515
1516    #[test]
1517    fn structured_reference_rewrite_respects_formula_token_boundaries() {
1518        assert_eq!(
1519            replace_table_ref_case_insensitive("=SUM(Sales[Amount])", "Sales[Amount]", "B2:B3"),
1520            "=SUM(B2:B3)"
1521        );
1522        assert_eq!(
1523            replace_table_ref_case_insensitive("=\"Sales[Amount]\"", "Sales[Amount]", "B2:B3"),
1524            "=\"Sales[Amount]\""
1525        );
1526        assert_eq!(
1527            replace_table_ref_case_insensitive("=MySales[Amount]", "Sales[Amount]", "B2:B3"),
1528            "=MySales[Amount]"
1529        );
1530        assert_eq!(
1531            replace_table_ref_case_insensitive(
1532                "=\"Sales[\"\"Amount\"\"]\"",
1533                "Sales[Amount]",
1534                "B2:B3"
1535            ),
1536            "=\"Sales[\"\"Amount\"\"]\""
1537        );
1538    }
1539
1540    #[test]
1541    fn workbook_json_shapes_an_empty_sheet_with_no_ref() {
1542        let json = workbook_json(&wb1(sheet("Sheet1", vec![])));
1543        assert_eq!(
1544            json,
1545            r#"{"SheetNames":["Sheet1"],"Sheets":{"Sheet1":{}},"!date1904":false}"#
1546        );
1547    }
1548
1549    #[test]
1550    fn workbook_json_computes_ref_and_cell_types_from_mixed_cells() {
1551        let json = workbook_json(&wb1(sheet(
1552            "Sheet1",
1553            vec![
1554                ((1, 1), SheetCell::Integer(1)),
1555                ((2, 2), SheetCell::Str("hi".to_string())),
1556                ((3, 1), SheetCell::Bool(true)),
1557            ],
1558        )));
1559        assert!(json.contains(r#""A1":{"t":"n","v":1}"#));
1560        assert!(json.contains(r#""B2":{"t":"s","v":"hi"}"#));
1561        assert!(json.contains(r#""A3":{"t":"b","v":true}"#));
1562        assert!(json.contains(r#""!ref":"A1:B3""#));
1563    }
1564
1565    #[test]
1566    fn workbook_json_includes_merges_as_zero_based_ranges() {
1567        let mut s = sheet("Sheet1", vec![((1, 1), SheetCell::Integer(1))]);
1568        s.sheet.merged_ranges.push(((1, 1), (1, 3)));
1569        let json = workbook_json(&wb1(s));
1570        assert!(json.contains(r#""!merges":[{"s":{"r":0,"c":0},"e":{"r":0,"c":2}}]"#));
1571    }
1572
1573    #[test]
1574    fn json_number_guards_non_finite_floats() {
1575        assert_eq!(json_number(1.5), "1.5");
1576        assert_eq!(json_number(f64::NAN), "null");
1577        assert_eq!(json_number(f64::INFINITY), "null");
1578    }
1579
1580    // ── read() item 2: <dimension> preference ───────────────────────────────
1581
1582    #[test]
1583    fn worksheet_json_prefers_dimension_over_the_populated_bounding_box() {
1584        let mut s = sheet("Sheet1", vec![((1, 1), SheetCell::Integer(1))]);
1585        s.dimension = Some(((1, 1), (10, 5)));
1586        let json = workbook_json(&wb1(s));
1587        assert!(json.contains(r#""!ref":"A1:E10""#));
1588    }
1589
1590    #[test]
1591    fn worksheet_json_uses_dimension_even_when_no_cells_are_populated() {
1592        let mut s = sheet("Sheet1", vec![]);
1593        s.dimension = Some(((1, 1), (3, 3)));
1594        let json = workbook_json(&wb1(s));
1595        assert!(json.contains(r#""!ref":"A1:C3""#));
1596    }
1597
1598    #[test]
1599    fn worksheet_json_falls_back_to_the_bounding_box_when_dimension_is_absent() {
1600        let json = workbook_json(&wb1(sheet(
1601            "Sheet1",
1602            vec![
1603                ((2, 2), SheetCell::Integer(1)),
1604                ((3, 4), SheetCell::Integer(2)),
1605            ],
1606        )));
1607        assert!(json.contains(r#""!ref":"B2:D3""#));
1608    }
1609
1610    // A single populated cell (or a single-cell <dimension>) must collapse !ref to just
1611    // the cell ref, no colon — matching the oracle's own encode_range convention. Found
1612    // via a real divergence (see this section's own commit), not assumed.
1613    #[test]
1614    fn worksheet_json_collapses_a_single_cell_bounding_box_ref_no_colon() {
1615        let json = workbook_json(&wb1(sheet("Sheet1", vec![((2, 2), SheetCell::Integer(1))])));
1616        assert!(json.contains(r#""!ref":"B2""#));
1617        assert!(!json.contains("\"!ref\":\"B2:B2\""));
1618    }
1619
1620    #[test]
1621    fn worksheet_json_collapses_a_single_cell_dimension_ref_no_colon() {
1622        let mut s = sheet("Sheet1", vec![((1, 1), SheetCell::Integer(1))]);
1623        s.dimension = Some(((1, 1), (1, 1)));
1624        let json = workbook_json(&wb1(s));
1625        assert!(json.contains(r#""!ref":"A1""#));
1626        assert!(!json.contains("\"!ref\":\"A1:A1\""));
1627    }
1628
1629    // ── read() item 4: formula (.f) ──────────────────────────────────────────
1630
1631    #[test]
1632    fn cell_json_includes_f_when_a_formula_is_present() {
1633        let mut s = sheet("Sheet1", vec![((1, 1), SheetCell::Integer(3))]);
1634        s.formulas.insert((1, 1), "SUM(B1:B2)".to_string());
1635        let json = workbook_json(&wb1(s));
1636        assert!(json.contains(r#""A1":{"t":"n","v":3,"f":"SUM(B1:B2)"}"#));
1637    }
1638
1639    #[test]
1640    fn cell_json_omits_f_when_no_formula_is_present() {
1641        let json = workbook_json(&wb1(sheet("Sheet1", vec![((1, 1), SheetCell::Integer(3))])));
1642        assert!(json.contains(r#""A1":{"t":"n","v":3}"#));
1643        assert!(!json.contains("\"f\":"));
1644    }
1645
1646    // ── read() item 3: hidden row/col intervals ─────────────────────────────
1647
1648    #[test]
1649    fn worksheet_json_includes_hidden_row_and_col_intervals_when_present() {
1650        let mut s = sheet("Sheet1", vec![((1, 1), SheetCell::Integer(1))]);
1651        s.sheet.hidden_rows.push((11, 14));
1652        s.sheet.hidden_columns.push((2, 2));
1653        let json = workbook_json(&wb1(s));
1654        assert!(json.contains(r#""!hiddenRows":[[11,14]]"#));
1655        assert!(json.contains(r#""!hiddenCols":[[2,2]]"#));
1656    }
1657
1658    #[test]
1659    fn worksheet_json_omits_hidden_interval_keys_when_none_are_hidden() {
1660        let json = workbook_json(&wb1(sheet("Sheet1", vec![((1, 1), SheetCell::Integer(1))])));
1661        assert!(!json.contains("!hiddenRows"));
1662        assert!(!json.contains("!hiddenCols"));
1663    }
1664
1665    #[test]
1666    fn worksheet_json_projects_data_validation_type_and_ranges() {
1667        let mut s = sheet("Sheet1", vec![]);
1668        s.sheet
1669            .data_validations
1670            .push(elixcee::reader::DataValidationRule {
1671                validation_type: "list".to_string(),
1672                operator: None,
1673                formula1: Some("Yes,No".to_string()),
1674                formula2: None,
1675                allow_blank: true,
1676                show_input_message: false,
1677                prompt_title: None,
1678                prompt: None,
1679                show_error_message: true,
1680                error_style: None,
1681                error_title: None,
1682                error: None,
1683                sqref: vec![((1, 5), (1, 5)), ((2, 5), (4, 5))],
1684                dirty: false,
1685                raw_span: String::new(),
1686            });
1687        let json = workbook_json(&wb1(s));
1688        assert!(json.contains(r#""!dataValidations":[{"type":"list","sqref":["E1","E2:E4"]}]"#));
1689    }
1690
1691    // ── read() item 6: per-cell fmtId, workbook !numFmts/!date1904 ──────────
1692
1693    #[test]
1694    fn cell_json_includes_fmt_id_when_a_non_zero_style_id_is_present() {
1695        let mut s = sheet("Sheet1", vec![((1, 1), SheetCell::Integer(3))]);
1696        s.style_ids.insert((1, 1), 14);
1697        let json = workbook_json(&wb1(s));
1698        assert!(json.contains(r#""A1":{"t":"n","v":3,"fmtId":14}"#));
1699    }
1700
1701    #[test]
1702    fn cell_json_omits_fmt_id_when_no_style_id_is_present() {
1703        let json = workbook_json(&wb1(sheet("Sheet1", vec![((1, 1), SheetCell::Integer(3))])));
1704        assert!(!json.contains("\"fmtId\":"));
1705    }
1706
1707    #[test]
1708    fn workbook_json_includes_num_fmts_when_present() {
1709        let mut number_formats = HashMap::new();
1710        number_formats.insert(164u32, "0.00\"kg\"".to_string());
1711        let wb = BufferWorkbook {
1712            sheets: vec![sheet("Sheet1", vec![])],
1713            number_formats,
1714            date1904: false,
1715            defined_names: Vec::new(),
1716        };
1717        let json = workbook_json(&wb);
1718        assert!(json.contains(r#""!numFmts":{"164":"0.00\"kg\""}"#));
1719    }
1720
1721    #[test]
1722    fn workbook_json_omits_num_fmts_when_empty() {
1723        let json = workbook_json(&wb1(sheet("Sheet1", vec![])));
1724        assert!(!json.contains("!numFmts"));
1725    }
1726
1727    #[test]
1728    fn workbook_json_exposes_defined_names_with_scope() {
1729        let mut wb = wb1(sheet("Sheet1", vec![]));
1730        wb.defined_names.push(elixcee::reader::XlsxDefinedName {
1731            name: "SalesRange".to_string(),
1732            local_sheet_id: Some(0),
1733            raw_text: "Sheet1!$A$1:$A$2".to_string(),
1734        });
1735        let json = workbook_json(&wb);
1736        assert!(json.contains(
1737            r#""Workbook":{"Names":[{"Name":"SalesRange","Ref":"Sheet1!$A$1:$A$2","Sheet":0}]}"#
1738        ));
1739    }
1740
1741    #[test]
1742    fn normalize_defined_name_ref_supports_quoted_and_local_absolute_refs() {
1743        assert_eq!(
1744            normalize_defined_name_ref("'Sheet 1'!$A$1:$B$2"),
1745            "Sheet 1!$A$1:$B$2"
1746        );
1747        assert_eq!(normalize_defined_name_ref("$A$1"), "A1");
1748    }
1749
1750    #[test]
1751    fn workbook_json_always_includes_date1904() {
1752        let mut wb = wb1(sheet("Sheet1", vec![]));
1753        wb.date1904 = true;
1754        let json = workbook_json(&wb);
1755        assert!(json.contains(r#""!date1904":true"#));
1756    }
1757
1758    #[test]
1759    fn editor_transaction_coalesces_multiple_typed_writes_into_one_undo() {
1760        let workbook = wb1(sheet("Sheet1", vec![]));
1761        let sheets = calculation_sheets(&workbook);
1762        let mut editor = WorkbookEditor {
1763            workbook,
1764            sheets,
1765            undo: Vec::new(),
1766            redo: Vec::new(),
1767            transaction: None,
1768            transaction_dirty: false,
1769        };
1770
1771        editor.begin_transaction().unwrap();
1772        editor.set_string("Sheet1", 1, 1, "planned").unwrap();
1773        editor.set_boolean("Sheet1", 1, 2, true).unwrap();
1774        assert!(editor.commit_transaction());
1775        assert!(editor.can_undo());
1776        assert!(editor.undo());
1777        let snapshot = editor.snapshot();
1778        assert!(!snapshot.contains("planned"));
1779        assert!(!snapshot.contains("\"B1\""));
1780        assert!(!editor.can_undo());
1781        assert!(editor.redo());
1782        let snapshot = editor.snapshot();
1783        assert!(snapshot.contains("planned"));
1784        assert!(snapshot.contains("\"B1\""));
1785    }
1786
1787    #[test]
1788    fn formula_array_spill_materializes_empty_horizontal_targets_without_overwrite() {
1789        let mut sheets = HashMap::from([(
1790            "sheet1".to_string(),
1791            HashMap::from([
1792                (
1793                    (0, 0),
1794                    CellContent {
1795                        formula: Some("=SEQUENCE(1,3)".to_string()),
1796                        value: Variant::Array(vec![
1797                            Variant::Integer(1),
1798                            Variant::Integer(2),
1799                            Variant::Integer(3),
1800                        ]),
1801                    },
1802                ),
1803                (
1804                    (0, 2),
1805                    CellContent {
1806                        formula: None,
1807                        value: Variant::Str("kept".to_string()),
1808                    },
1809                ),
1810            ]),
1811        )]);
1812        materialize_formula_array_spills(&mut sheets).unwrap();
1813        assert!(!sheets["sheet1"].contains_key(&(0, 1)));
1814        assert_eq!(
1815            sheets["sheet1"][&(0, 0)].value,
1816            Variant::Str("#SPILL!".to_string())
1817        );
1818        assert_eq!(
1819            sheets["sheet1"][&(0, 2)].value,
1820            Variant::Str("kept".to_string())
1821        );
1822    }
1823
1824    #[test]
1825    fn sequence_spill_materializes_vertical_and_rectangular_shapes() {
1826        let mut sheets = HashMap::from([(
1827            "sheet1".to_string(),
1828            HashMap::from([(
1829                (0, 0),
1830                CellContent {
1831                    formula: Some("=SEQUENCE(2,2)".to_string()),
1832                    value: Variant::Array(vec![
1833                        Variant::Integer(1),
1834                        Variant::Integer(2),
1835                        Variant::Integer(3),
1836                        Variant::Integer(4),
1837                    ]),
1838                },
1839            )]),
1840        )]);
1841        materialize_formula_array_spills(&mut sheets).unwrap();
1842        assert_eq!(sheets["sheet1"][&(0, 1)].value, Variant::Integer(2));
1843        assert_eq!(sheets["sheet1"][&(1, 0)].value, Variant::Integer(3));
1844        assert_eq!(sheets["sheet1"][&(1, 1)].value, Variant::Integer(4));
1845    }
1846
1847    #[test]
1848    fn formula_array_shape_recovers_wrapped_vector_layouts() {
1849        assert_eq!(
1850            formula_array_shape(Some("=RANDARRAY(3,2)"), 6),
1851            ArrayShape::new(3, 2)
1852        );
1853        assert_eq!(
1854            formula_array_shape(Some("=WRAPROWS(SEQUENCE(5),2)"), 5),
1855            ArrayShape::new(3, 2)
1856        );
1857        assert_eq!(
1858            formula_array_shape(Some("=WRAPCOLS(SEQUENCE(5),2)"), 5),
1859            ArrayShape::new(2, 3)
1860        );
1861        assert_eq!(
1862            formula_array_shape(Some("=UNIQUE(A1:A5)"), 5),
1863            ArrayShape::new(1, 5)
1864        );
1865    }
1866}