Skip to main content

pmcp_workbook_runtime/render/
mod.rs

1//! The serve-side, WRITER-ONLY render module (Phase 12).
2//!
3//! Plan 01 landed the shared, versioned [`LayoutDescriptor`] serde shape
4//! ([`layout`]) — the umya-free, zip-free single definition the offline emitter
5//! and the serve-time writer share. Plan 02 (this code) adds the writer itself:
6//! [`render_xlsx`] replays a [`LayoutDescriptor`] and injects the executor's
7//! already-computed values into a "copy of the workbook, filled in," producing
8//! valid, DETERMINISTIC `.xlsx` bytes IN MEMORY (no filesystem — Lambda-safe,
9//! RESEARCH Pitfall 6).
10//!
11//! The writer links `rust_xlsxwriter` (a WRITER; it pulls the `zip` deflate
12//! container but NO workbook reader — D-01, the single deliberate cross-phase
13//! purity-gate relaxation). It is reader-free: `just purity-check` proves
14//! `umya`/`quick-xml` stay absent from the served tree while asserting the
15//! writer is present.
16//!
17//! Determinism (review item 8, T-12-15) is a FIRST-CLASS invariant: the writer
18//! pins the workbook's document properties to a FIXED creation datetime + empty
19//! author/metadata so two renders of the same `(layout, run)` are byte-identical.
20//! Plan 03's regenerate-on-read returns fresh bytes every read and relies on
21//! this byte-stability; the invariant is proven HERE (a determinism test) where
22//! it is introduced, not deferred.
23
24use std::collections::HashMap;
25
26use rust_xlsxwriter::{Color, DocProperties, ExcelDateTime, Format, Formula, Workbook};
27
28use crate::cell_key;
29use crate::resolve::{a1_to_zero_indexed_row_col, parse_a1};
30use crate::sheet_ir::value::CellValue;
31use crate::sheet_ir::RunResult;
32
33/// Map a `rust_xlsxwriter` error into [`RenderError::Writer`]. One shared
34/// converter so every writer call site uses `.map_err(writer_err)` instead of
35/// re-spelling the closure (simplify pass).
36fn writer_err(e: rust_xlsxwriter::XlsxError) -> RenderError {
37    RenderError::Writer(e.to_string())
38}
39
40/// The shared, versioned `LayoutDescriptor`/`SheetLayout`/`CellLayout` serde
41/// shapes (D-05) — the FULL workbook-layout descriptor the bundle's `layout.json`
42/// member serializes and the writer replays.
43pub mod layout;
44
45pub use layout::*;
46
47/// A fallible render failure (review item 8 — the writer value path is
48/// panic-free; a malformed coordinate / non-finite value / writer error surfaces
49/// as an `Err`, NEVER a panic and NEVER a bogus cell). Owned `String` detail to
50/// match the crate's `LintFinding` error style (no borrow across the API).
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
52pub enum RenderError {
53    /// A cell's A1 address in the descriptor did not parse to a `(row, col)`
54    /// coordinate (T-12 panic-freedom — a malformed addr is an `Err`).
55    #[error("malformed cell address {addr} on sheet {sheet}")]
56    MalformedAddr {
57        /// The owning sheet name.
58        sheet: String,
59        /// The A1 address that failed to parse.
60        addr: String,
61    },
62    /// A merge range in the descriptor did not parse into two valid endpoints
63    /// (or is degenerate / out of order).
64    #[error("malformed merge range {range} on sheet {sheet}")]
65    MalformedMerge {
66        /// The owning sheet name.
67        sheet: String,
68        /// The merge range that failed to parse.
69        range: String,
70    },
71    /// A computed value for a cell was a non-finite `f64` (NaN/Inf), which Excel
72    /// cannot represent (handler.rs WR-06 reuse, T-12-05) — never written as a
73    /// bogus number.
74    #[error("non-finite computed value at {cell}")]
75    NonFiniteValue {
76        /// The `cell_key` (`sheet!addr`) whose computed value was non-finite.
77        cell: String,
78    },
79    /// The underlying `rust_xlsxwriter` writer returned an error (e.g. a name or
80    /// dimension limit). Carried as an owned `String` (the crate's error is not
81    /// `Clone`/`Eq`).
82    #[error("xlsx writer error: {0}")]
83    Writer(String),
84}
85
86/// A fixed creation datetime for byte-stable output (review item 8, T-12-15):
87/// the UFH milestone epoch `2024-01-01T00:00:00`. ANY constant works — what
88/// matters is that it does NOT vary per render. Building it is fallible only on
89/// an out-of-range constant, which this one is not.
90fn fixed_creation_datetime() -> Result<ExcelDateTime, RenderError> {
91    ExcelDateTime::from_ymd(2024, 1, 1)
92        .and_then(|d| d.and_hms(0, 0, 0))
93        .map_err(writer_err)
94}
95
96/// Normalize a captured formula for the `rust_xlsxwriter` writer (review item 4).
97///
98/// `rust_xlsxwriter` expects a formula string WITH a single leading `=`. The
99/// descriptor MAY carry a formula already prefixed (`=SUM(A1:A2)`) or bare
100/// (`SUM(A1:A2)`). This returns the formula with EXACTLY one leading `=`: a bare
101/// formula is prefixed, an already-prefixed formula is returned UNCHANGED (never
102/// double-prefixed into `==`). Whitespace before a leading `=` is tolerated.
103#[must_use]
104pub fn normalize_formula_for_writer(f: &str) -> String {
105    if f.trim_start().starts_with('=') {
106        f.to_string()
107    } else {
108        format!("={f}")
109    }
110}
111
112/// Build a `Color` from a captured 8-hex ARGB string (`FFE2EFDA`) or a 6-hex RGB
113/// string (`E2EFDA`). `rust_xlsxwriter`'s `Color::RGB` is a 24-bit value, so the
114/// leading alpha byte (when present) is dropped. Returns `None` for an
115/// unparseable string (the caller treats colour as best-effort).
116fn argb_to_color(argb: &str) -> Option<Color> {
117    let hex = argb.trim();
118    let rgb_hex = match hex.len() {
119        // ARGB -> drop the alpha byte. `get` (not a byte-indexed slice) so an
120        // 8-BYTE string whose byte 2 is not a char boundary (multibyte UTF-8)
121        // is `None`, never a slice panic (CR-01 — `layout.json` is untrusted
122        // bundle input; the writer value path must stay panic-free).
123        8 => hex.get(2..)?,
124        6 => hex, // already RGB
125        _ => return None,
126    };
127    let rgb = u32::from_str_radix(rgb_hex, 16).ok()?;
128    Some(Color::RGB(rgb))
129}
130
131/// Build an optional `Format` from a cell's number-format + fill + font ARGBs.
132/// Returns `None` when the cell carries no styling so unstyled cells skip the
133/// format allocation. Colour/format application is BEST-EFFORT (full visual
134/// fidelity is explicitly NOT the bar — RESEARCH anti-pattern); an unparseable
135/// ARGB is silently skipped, never an error.
136fn cell_format(cell: &CellLayout) -> Option<Format> {
137    if cell.number_format.is_none() && cell.fill_argb.is_none() && cell.font_argb.is_none() {
138        return None;
139    }
140    let mut fmt = Format::new();
141    if let Some(nf) = &cell.number_format {
142        fmt = fmt.set_num_format(nf.clone());
143    }
144    if let Some(fill) = cell.fill_argb.as_deref().and_then(argb_to_color) {
145        fmt = fmt.set_background_color(fill);
146    }
147    if let Some(font) = cell.font_argb.as_deref().and_then(argb_to_color) {
148        fmt = fmt.set_font_color(font);
149    }
150    Some(fmt)
151}
152
153/// The set of `(row, col)` coordinates that are INTERIOR to (but not the
154/// top-left of) a merged range — written by `merge_range`, NEVER again by the
155/// per-cell loop (review item 8: writing the interior of a merge is an
156/// overwrite error in Excel).
157type MergeInterior = std::collections::HashSet<(u32, u16)>;
158
159/// Replay every merge range on a sheet, writing the value/format ONLY to the
160/// top-left cell (review item 8). Returns the interior coordinates the per-cell
161/// loop must SKIP. Merges are processed in the descriptor's stored order
162/// (deterministic). A degenerate / malformed / single-cell merge is a
163/// `RenderError`, never a panic.
164fn replay_merges(
165    ws: &mut rust_xlsxwriter::Worksheet,
166    sheet: &SheetLayout,
167    top_left_text: &HashMap<(u32, u16), String>,
168) -> Result<MergeInterior, RenderError> {
169    let mut interior = MergeInterior::new();
170    let blank = Format::new();
171    for range in &sheet.merges {
172        let (start, end) = range
173            .split_once(':')
174            .ok_or_else(|| RenderError::MalformedMerge {
175                sheet: sheet.name.clone(),
176                range: range.clone(),
177            })?;
178        let malformed = || RenderError::MalformedMerge {
179            sheet: sheet.name.clone(),
180            range: range.clone(),
181        };
182        let (r0, c0) = a1_to_zero_indexed_row_col(start.trim()).ok_or_else(malformed)?;
183        let (r1, c1) = a1_to_zero_indexed_row_col(end.trim()).ok_or_else(malformed)?;
184        let (row_lo, row_hi) = (r0.min(r1), r0.max(r1));
185        let (col_lo, col_hi) = (c0.min(c1), c0.max(c1));
186        // merge_range rejects a single cell; a 1x1 "merge" is malformed input.
187        if row_lo == row_hi && col_lo == col_hi {
188            return Err(malformed());
189        }
190        // Write the top-left cell text via merge_range (it owns the interior).
191        let text = top_left_text
192            .get(&(row_lo, col_lo))
193            .cloned()
194            .unwrap_or_default();
195        ws.merge_range(row_lo, col_lo, row_hi, col_hi, &text, &blank)
196            .map_err(writer_err)?;
197        // Record every interior coordinate (including the top-left, which
198        // merge_range already wrote) so the per-cell loop skips them all.
199        for r in row_lo..=row_hi {
200            for c in col_lo..=col_hi {
201                interior.insert((r, c));
202            }
203        }
204    }
205    Ok(interior)
206}
207
208/// Render a [`LayoutDescriptor`] + the executor's [`RunResult`] into valid,
209/// DETERMINISTIC `.xlsx` bytes IN MEMORY (review item 8, D-01).
210///
211/// The writer replays the descriptor's sheets/cells/merges and INJECTS each
212/// computed value from `run.computed` (keyed `sheet!addr`). Default lean (D-05):
213/// a cell with a formula + a FINITE numeric result is written as a
214/// formula-with-cached-result (`write_formula` + `Formula::set_result`); a
215/// cell with no formula is written as a plain number/string. Every numeric value
216/// is finiteness-guarded before write (handler.rs WR-06 reuse, T-12-05) — a
217/// non-finite value is a [`RenderError::NonFiniteValue`], never a bogus NaN/Inf
218/// cell. The value path is panic-free (`deny(unwrap/expect/panic)`): a malformed
219/// addr/merge surfaces as an `Err`.
220///
221/// Determinism: the workbook's document properties are pinned to a FIXED
222/// creation datetime + empty author/metadata so repeated renders are
223/// byte-identical (Plan 03 regenerate-on-read relies on this; T-12-15).
224///
225/// Output is via `save_to_buffer()` ONLY — never a file path (Lambda-safe,
226/// RESEARCH Pitfall 6).
227pub fn render_xlsx(layout: &LayoutDescriptor, run: &RunResult) -> Result<Vec<u8>, RenderError> {
228    let mut wb = init_workbook()?;
229    for sheet in &layout.sheets {
230        let ws = wb.add_worksheet();
231        render_sheet(ws, sheet, run)?;
232    }
233    wb.save_to_buffer().map_err(writer_err)
234}
235
236/// Build the workbook with its determinism-pinned document properties (review
237/// item 8, T-12-15): a FIXED creation datetime + empty author so two renders of
238/// the same `(layout, run)` are byte-identical.
239fn init_workbook() -> Result<Workbook, RenderError> {
240    let mut wb = Workbook::new();
241    let props = DocProperties::new()
242        .set_author("")
243        .set_creation_datetime(&fixed_creation_datetime()?);
244    wb.set_properties(&props);
245    Ok(wb)
246}
247
248/// Render a single sheet: scaffold (name/hidden/columns) → top-left text map →
249/// merge replay → per-cell value injection. A thin per-sheet orchestrator over
250/// the three phase helpers; the per-cell write order is preserved exactly so
251/// output stays byte-deterministic.
252fn render_sheet(
253    ws: &mut rust_xlsxwriter::Worksheet,
254    sheet: &SheetLayout,
255    run: &RunResult,
256) -> Result<(), RenderError> {
257    apply_sheet_scaffold(ws, sheet)?;
258    // PASS 1: resolve each cell's merge-top-left display TEXT so a merge can
259    // fetch it without re-deriving (also validates each addr panic-free).
260    let top_left_text = build_top_left_text(sheet, run)?;
261    // Replay merges first (top-left only); collect interior coords to skip.
262    let interior = replay_merges(ws, sheet, &top_left_text)?;
263    // PASS 2: write every non-merge-interior cell, injecting computed values.
264    for cell in &sheet.cells {
265        write_cell(ws, sheet, run, cell, &interior)?;
266    }
267    Ok(())
268}
269
270/// Apply the sheet-level scaffold: name, hidden flag, per-column widths and
271/// hidden columns (best-effort, deterministic descriptor order).
272fn apply_sheet_scaffold(
273    ws: &mut rust_xlsxwriter::Worksheet,
274    sheet: &SheetLayout,
275) -> Result<(), RenderError> {
276    ws.set_name(&sheet.name).map_err(writer_err)?;
277    if sheet.hidden {
278        ws.set_hidden(true);
279    }
280    for (col_1based, width) in &sheet.col_widths {
281        if let Some(col) = col_1based.checked_sub(1) {
282            ws.set_column_width(col, *width).map_err(writer_err)?;
283        }
284    }
285    for col_1based in &sheet.hidden_cols {
286        if let Some(col) = col_1based.checked_sub(1) {
287            ws.set_column_hidden(col).map_err(writer_err)?;
288        }
289    }
290    Ok(())
291}
292
293/// PASS 1: resolve each cell to `(row, col)` + the text it would carry, so a
294/// merge can fetch its top-left text without re-deriving it. Validates each addr
295/// up front (panic-free — a bad addr is an `Err`) and rejects a non-finite
296/// computed number (T-12-05) before it can leak into a merged cell.
297fn build_top_left_text(
298    sheet: &SheetLayout,
299    run: &RunResult,
300) -> Result<HashMap<(u32, u16), String>, RenderError> {
301    let mut top_left_text: HashMap<(u32, u16), String> = HashMap::new();
302    for cell in &sheet.cells {
303        // Validate the addr up front (panic-free): a bad addr is an Err.
304        if a1_to_zero_indexed_row_col(&cell.addr).is_none() {
305            // Distinguish a genuinely malformed addr from one parse_a1 rejects
306            // only because it overflows u16: parse_a1 succeeding but the
307            // conversion failing is still malformed-for-the-writer.
308            let _ = parse_a1(&cell.addr); // documents the reuse; result unused
309            return Err(RenderError::MalformedAddr {
310                sheet: sheet.name.clone(),
311                addr: cell.addr.clone(),
312            });
313        }
314        let key = cell_key(&sheet.name, &cell.addr);
315        let display = display_text(run, &key, cell)?;
316        if let (Some((r, c)), Some(text)) = (a1_to_zero_indexed_row_col(&cell.addr), display) {
317            top_left_text.insert((r, c), text);
318        }
319    }
320    Ok(top_left_text)
321}
322
323/// The text a merged top-left should display: prefer the computed value, else
324/// the descriptor's captured value text. A non-finite computed number is an
325/// `Err` (T-12-05), never a bogus merged cell.
326fn display_text(
327    run: &RunResult,
328    key: &str,
329    cell: &CellLayout,
330) -> Result<Option<String>, RenderError> {
331    let display = match run.computed.get(key) {
332        Some(CellValue::Number(n)) if n.is_finite() => Some(format_number(*n)),
333        Some(CellValue::Number(_)) => {
334            return Err(RenderError::NonFiniteValue {
335                cell: key.to_string(),
336            })
337        },
338        Some(CellValue::Text(s)) => Some(s.clone()),
339        Some(CellValue::Bool(b)) => Some(b.to_string()),
340        _ => cell.value.clone(),
341    };
342    Ok(display)
343}
344
345/// PASS 2: write a single non-merge-interior cell, injecting its computed value.
346/// A coordinate owned by a merge range is skipped (merge_range already wrote it).
347fn write_cell(
348    ws: &mut rust_xlsxwriter::Worksheet,
349    sheet: &SheetLayout,
350    run: &RunResult,
351    cell: &CellLayout,
352    interior: &MergeInterior,
353) -> Result<(), RenderError> {
354    let (row, col) =
355        a1_to_zero_indexed_row_col(&cell.addr).ok_or_else(|| RenderError::MalformedAddr {
356            sheet: sheet.name.clone(),
357            addr: cell.addr.clone(),
358        })?;
359    if interior.contains(&(row, col)) {
360        return Ok(()); // merge_range already owns this coordinate
361    }
362    let key = cell_key(&sheet.name, &cell.addr);
363    let computed = run.computed.get(&key);
364    let fmt = cell_format(cell);
365    write_computed_value(ws, row, col, cell, computed, key, fmt.as_ref())
366}
367
368/// Dispatch a cell's computed value to the right writer (flat match): a finite
369/// number → number/formula cell; text/bool → string cell; error/empty/not-computed
370/// → fall back to the captured literal. A non-finite number is an `Err` (T-12-05).
371fn write_computed_value(
372    ws: &mut rust_xlsxwriter::Worksheet,
373    row: u32,
374    col: u16,
375    cell: &CellLayout,
376    computed: Option<&CellValue>,
377    key: String,
378    fmt: Option<&Format>,
379) -> Result<(), RenderError> {
380    match computed {
381        Some(CellValue::Number(n)) => {
382            // WR-06 / T-12-05: a non-finite computed number is never written as
383            // a bogus cell — fail loud.
384            if !n.is_finite() {
385                return Err(RenderError::NonFiniteValue { cell: key });
386            }
387            write_number_cell(ws, row, col, cell, *n, fmt)?;
388        },
389        Some(CellValue::Text(s)) => write_string_cell(ws, row, col, s, fmt)?,
390        Some(CellValue::Bool(b)) => write_string_cell(ws, row, col, &b.to_string(), fmt)?,
391        // Error / Empty / not-computed: fall back to the captured value text (the
392        // descriptor's "copy of the workbook" content) so a non-output cell still
393        // renders its original literal.
394        _ => {
395            if let Some(v) = &cell.value {
396                write_string_cell(ws, row, col, v, fmt)?;
397            }
398        },
399    }
400    Ok(())
401}
402
403/// Format a finite f64 for a fallback text cell deterministically. Full-precision
404/// numbers go through Rust's shortest-round-trip `{}`; this is only used for the
405/// merged-top-left TEXT path (numbers in normal cells are written as numbers).
406fn format_number(n: f64) -> String {
407    // {} on f64 is the shortest round-trip representation — deterministic.
408    format!("{n}")
409}
410
411/// Write a numeric cell. Default lean (D-05): a cell that HAS a formula and a
412/// finite numeric result is written as a formula-with-cached-result
413/// (`Formula::set_result`); otherwise a plain number. Format applied when present.
414fn write_number_cell(
415    ws: &mut rust_xlsxwriter::Worksheet,
416    row: u32,
417    col: u16,
418    cell: &CellLayout,
419    n: f64,
420    fmt: Option<&Format>,
421) -> Result<(), RenderError> {
422    match (&cell.formula, fmt) {
423        (Some(f), Some(fmt)) => {
424            let formula =
425                Formula::new(normalize_formula_for_writer(f)).set_result(format_number(n));
426            ws.write_formula_with_format(row, col, formula, fmt)
427                .map_err(writer_err)?;
428        },
429        (Some(f), None) => {
430            let formula =
431                Formula::new(normalize_formula_for_writer(f)).set_result(format_number(n));
432            ws.write_formula(row, col, formula).map_err(writer_err)?;
433        },
434        (None, Some(fmt)) => {
435            ws.write_number_with_format(row, col, n, fmt)
436                .map_err(writer_err)?;
437        },
438        (None, None) => {
439            ws.write_number(row, col, n).map_err(writer_err)?;
440        },
441    }
442    Ok(())
443}
444
445/// Write a string cell (format applied when present).
446fn write_string_cell(
447    ws: &mut rust_xlsxwriter::Worksheet,
448    row: u32,
449    col: u16,
450    s: &str,
451    fmt: Option<&Format>,
452) -> Result<(), RenderError> {
453    match fmt {
454        Some(fmt) => ws
455            .write_string_with_format(row, col, s, fmt)
456            .map_err(writer_err)?,
457        None => ws.write_string(row, col, s).map_err(writer_err)?,
458    };
459    Ok(())
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::excel_error::ExcelError;
466    use std::collections::HashMap;
467
468    /// The ZIP local-file-header magic an `.xlsx` (a ZIP container) leads with.
469    const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
470
471    fn run_with(pairs: &[(&str, CellValue)]) -> RunResult {
472        let mut computed = HashMap::new();
473        for (k, v) in pairs {
474            computed.insert((*k).to_string(), v.clone());
475        }
476        RunResult {
477            computed,
478            traces: HashMap::new(),
479        }
480    }
481
482    fn cell(addr: &str, formula: Option<&str>, value: Option<&str>) -> CellLayout {
483        CellLayout {
484            addr: addr.to_string(),
485            formula: formula.map(str::to_string),
486            value: value.map(str::to_string),
487            number_format: None,
488            fill_argb: None,
489            font_argb: None,
490        }
491    }
492
493    fn one_sheet(name: &str, cells: Vec<CellLayout>, merges: Vec<String>) -> LayoutDescriptor {
494        LayoutDescriptor {
495            descriptor_version: LAYOUT_DESCRIPTOR_VERSION,
496            source_workbook_hash: None,
497            sheets: vec![SheetLayout {
498                name: name.to_string(),
499                hidden: false,
500                cells,
501                merges,
502                col_widths: vec![],
503                hidden_cols: vec![],
504            }],
505        }
506    }
507
508    #[test]
509    fn render_xlsx_produces_valid_zip_container() {
510        let layout = one_sheet("7_Quote", vec![cell("C11", None, Some("0"))], vec![]);
511        let run = run_with(&[("7_Quote!C11", CellValue::Number(1594.93))]);
512        let bytes = render_xlsx(&layout, &run).expect("render");
513        assert!(!bytes.is_empty(), "non-empty output");
514        assert_eq!(
515            &bytes[..4],
516            ZIP_MAGIC,
517            "leads with the ZIP magic PK\\x03\\x04"
518        );
519    }
520
521    #[test]
522    fn render_xlsx_is_deterministic_byte_identical() {
523        // review item 8 / T-12-15: two renders of the SAME (layout, run) are
524        // byte-identical (creation datetime + metadata suppressed).
525        let layout = one_sheet(
526            "7_Quote",
527            vec![cell("C11", Some("SUM(C9:C10)"), Some("0"))],
528            vec![],
529        );
530        let run = run_with(&[("7_Quote!C11", CellValue::Number(1594.93))]);
531        let a = render_xlsx(&layout, &run).expect("render a");
532        let b = render_xlsx(&layout, &run).expect("render b");
533        assert_eq!(a, b, "two renders of the same input are byte-identical");
534    }
535
536    #[test]
537    fn normalize_formula_for_writer_never_double_prefixes() {
538        // review item 4: a bare formula gains one '='; an already-prefixed one is
539        // unchanged (never '==').
540        assert_eq!(normalize_formula_for_writer("SUM(A1:A2)"), "=SUM(A1:A2)");
541        assert_eq!(normalize_formula_for_writer("=SUM(A1:A2)"), "=SUM(A1:A2)");
542        // Both forms round-trip to a single leading '='.
543        for f in ["SUM(A1:A2)", "=SUM(A1:A2)"] {
544            let out = normalize_formula_for_writer(f);
545            assert!(out.starts_with('='), "has a leading '='");
546            assert!(!out.starts_with("=="), "never double-prefixed");
547        }
548    }
549
550    #[test]
551    fn render_xlsx_rejects_non_finite_computed_value() {
552        // WR-06 / T-12-05: a NaN/Inf computed value is a RenderError, never a cell.
553        let layout = one_sheet("7_Quote", vec![cell("C11", None, None)], vec![]);
554        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
555            let run = run_with(&[("7_Quote!C11", CellValue::Number(bad))]);
556            let err = render_xlsx(&layout, &run).expect_err("non-finite must be Err");
557            assert!(
558                matches!(err, RenderError::NonFiniteValue { .. }),
559                "got {err:?}"
560            );
561        }
562    }
563
564    #[test]
565    fn render_xlsx_surfaces_malformed_addr_as_error_not_panic() {
566        // A malformed descriptor addr is a RenderError (the value path is panic-free).
567        let layout = one_sheet("7_Quote", vec![cell("1A", None, Some("x"))], vec![]);
568        let run = run_with(&[]);
569        let err = render_xlsx(&layout, &run).expect_err("malformed addr must be Err");
570        assert!(
571            matches!(err, RenderError::MalformedAddr { .. }),
572            "got {err:?}"
573        );
574    }
575
576    #[test]
577    fn render_xlsx_writes_formula_with_finite_cached_result() {
578        // A formula cell + a finite result writes the (normalized, single '=')
579        // formula with its cached result; render succeeds and bytes are produced.
580        let layout = one_sheet(
581            "7_Quote",
582            vec![cell("C11", Some("=SUM(C9:C10)"), None)],
583            vec![],
584        );
585        let run = run_with(&[("7_Quote!C11", CellValue::Number(1594.93))]);
586        let bytes = render_xlsx(&layout, &run).expect("render");
587        assert_eq!(&bytes[..4], ZIP_MAGIC);
588    }
589
590    #[test]
591    fn render_xlsx_replays_merge_top_left_only() {
592        // review item 8: a merge A1:B2 with a value at the top-left A1 produces a
593        // valid xlsx. The interior cells (A2/B1/B2) being ALSO present in the
594        // descriptor must NOT cause a double-write error — they are skipped.
595        let layout = one_sheet(
596            "7_Quote",
597            vec![
598                cell("A1", None, Some("merged")),
599                cell("A2", None, Some("interior")),
600                cell("B1", None, Some("interior")),
601                cell("B2", None, Some("interior")),
602            ],
603            vec!["A1:B2".to_string()],
604        );
605        let run = run_with(&[("7_Quote!A1", CellValue::Text("merged".to_string()))]);
606        let bytes = render_xlsx(&layout, &run).expect("render with merge");
607        assert_eq!(
608            &bytes[..4],
609            ZIP_MAGIC,
610            "merge replay still yields a valid xlsx"
611        );
612    }
613
614    #[test]
615    fn render_xlsx_rejects_single_cell_merge() {
616        // A degenerate 1x1 merge is malformed input (Excel rejects single-cell
617        // merges) — surfaced as MalformedMerge, never a panic.
618        let layout = one_sheet(
619            "7_Quote",
620            vec![cell("A1", None, Some("x"))],
621            vec!["A1:A1".to_string()],
622        );
623        let run = run_with(&[]);
624        let err = render_xlsx(&layout, &run).expect_err("single-cell merge must be Err");
625        assert!(
626            matches!(err, RenderError::MalformedMerge { .. }),
627            "got {err:?}"
628        );
629    }
630
631    #[test]
632    fn render_xlsx_writes_text_and_bool_and_falls_back_on_error_value() {
633        // Text/Bool computed values write; an Error value falls back to the
634        // captured descriptor text (no panic, no NaN).
635        let layout = one_sheet(
636            "7_Quote",
637            vec![
638                cell("A1", None, None),
639                cell("A2", None, None),
640                cell("A3", None, Some("orig")),
641            ],
642            vec![],
643        );
644        let run = run_with(&[
645            ("7_Quote!A1", CellValue::Text("hi".to_string())),
646            ("7_Quote!A2", CellValue::Bool(true)),
647            ("7_Quote!A3", CellValue::Error(ExcelError::DivZero)),
648        ]);
649        let bytes = render_xlsx(&layout, &run).expect("render");
650        assert_eq!(&bytes[..4], ZIP_MAGIC);
651    }
652
653    #[test]
654    fn argb_to_color_non_ascii_eight_byte_input_is_none_not_a_panic() {
655        // CR-01 regression: "€abcde" is 8 BYTES (3 + 5) but byte index 2 falls
656        // inside the multibyte '€' — the old `&hex[2..]` slice panicked. The
657        // fix returns None (unparseable ARGB is silently skipped, per the
658        // documented contract).
659        assert_eq!("€abcde".len(), 8, "the reproducer is byte-length 8");
660        assert_eq!(argb_to_color("€abcde"), None);
661        // Valid forms still parse.
662        assert!(argb_to_color("FFE2EFDA").is_some());
663        assert!(argb_to_color("E2EFDA").is_some());
664    }
665
666    #[test]
667    fn render_xlsx_with_non_ascii_argb_renders_without_panic() {
668        // CR-01 end-to-end: a corrupt/attacker-influenced bundle ARGB reaching
669        // cell_format via CellLayout must render (colour skipped), never panic.
670        let mut bad = cell("A1", None, Some("x"));
671        bad.fill_argb = Some("€abcde".to_string());
672        bad.font_argb = Some("€abcde".to_string());
673        let layout = one_sheet("7_Quote", vec![bad], vec![]);
674        let bytes = render_xlsx(&layout, &run_with(&[])).expect("render");
675        assert_eq!(&bytes[..4], ZIP_MAGIC);
676    }
677}