Skip to main content

ravenrs_sheet/xlsx/
xls.rs

1//! Legacy Excel 97-2003 `.xls` (BIFF8) import, implemented from Microsoft's public
2//! specifications [MS-CFB] (the compound file container) and [MS-XLS] (the Workbook stream):
3//! <https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/cd03cb5f-ca02-4934-a391-bb674cb8aa06>.
4//!
5//! Read: worksheets, shared strings, cell values, formulas (text and cached results, including
6//! shared and array formulas), cell formatting (number formats, fonts, fills, borders,
7//! alignment, protection, the colour palette and user-defined cell styles), the 1904 date
8//! system, merged cells, column widths, row heights, defined names, print areas, print titles
9//! and the calculation settings (mode, iteration, precision as displayed). Everything else
10//! (charts, pictures, comments, conditional formats, validation, macros) is skipped with a
11//! warning.
12//! Encrypted workbooks and pre-BIFF8 versions are refused.
13
14mod formula;
15mod ftab;
16mod records;
17mod styles;
18
19use std::collections::{HashMap, HashSet};
20use std::sync::Arc;
21
22use crate::base::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
23use crate::model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, Sheet, StyleId, Visibility, Workbook};
24
25use crate::xlsx::cfb::Compound;
26use crate::xlsx::tables::{col_width_to_px, pt_to_px};
27use crate::xlsx::{IoError, ReadReport};
28use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode};
29use records::{Cursor, Record, Records};
30
31const MAX_CELLS: usize = 4_000_000;
32const MAX_STRINGS: usize = 4_000_000;
33const MAX_TEXT: usize = 256 * 1024 * 1024;
34const MAX_SHEETS: usize = 4096;
35const MAX_NAMES: usize = 65_536;
36const MAX_XFS: usize = 65_536;
37const MAX_MERGES: usize = 100_000;
38/// Records read from the whole Workbook stream at most (a 65,536 × 256 sheet has fewer). One
39/// allowance shared by every substream: many BoundSheet records pointing into one huge
40/// substream must not each get their own.
41const MAX_RECORDS: usize = 40_000_000;
42
43const BOF: u16 = 0x0809;
44const EOF: u16 = 0x000A;
45const BIFF8: u16 = 0x0600;
46/// WsBool (MS-XLS 2.4.351): its first byte's `fDialog` bit marks a dialog sheet, whose BOF says worksheet.
47const WSBOOL: u16 = 0x0081;
48const WSBOOL_DIALOG: u8 = 0x10;
49
50pub(crate) fn invalid(msg: impl Into<String>) -> IoError {
51    IoError::Format(format!("XLS: {}", msg.into()))
52}
53
54/// The message for an encrypted workbook (a FilePass record).
55const ENCRYPTED: &str =
56    "this .xls workbook is password-protected (encrypted). GridCraft can't open encrypted .xls files; remove the password in Excel and try again.";
57
58/// Whether the bytes are a compound file holding an Excel workbook stream (BIFF8 `Workbook`
59/// or BIFF5 `Book`).
60pub(crate) fn is_xls(bytes: &[u8]) -> bool {
61    let Ok(cf) = Compound::open(bytes) else { return false };
62    cf.has_root_stream("Workbook") || cf.has_root_stream("Book")
63}
64
65/// Most shared formulas one sheet expands, and the longest template text one attach may
66/// produce: a template shared by its followers was costing every one of them a full decode and
67/// print however often that could never succeed, so each anchor is tried once and failed ones
68/// are skipped for the rest of the sheet. Beyond the cap the remaining references keep their
69/// cached values.
70const MAX_SHARED_ATTACHES: usize = 262_144;
71const MAX_SHARED_TEMPLATE: usize = 8192;
72
73pub(crate) struct Budget {
74    cells: usize,
75    text: usize,
76    records: usize,
77    max_records: usize,
78}
79
80impl Default for Budget {
81    fn default() -> Self {
82        Budget { cells: 0, text: 0, records: 0, max_records: MAX_RECORDS }
83    }
84}
85
86impl Budget {
87    /// A budget with a smaller record allowance (tests).
88    #[cfg(test)]
89    pub(crate) fn with_max_records(max_records: usize) -> Self {
90        Budget { max_records, ..Budget::default() }
91    }
92    fn record(&mut self) -> Result<(), IoError> {
93        self.records = self.records.saturating_add(1);
94        if self.records > self.max_records {
95            return Err(IoError::TooLarge("XLS: too many records".into()));
96        }
97        Ok(())
98    }
99    fn cell(&mut self) -> Result<(), IoError> {
100        self.cells += 1;
101        if self.cells > MAX_CELLS {
102            return Err(IoError::TooLarge("XLS: more than 4 million cells".into()));
103        }
104        Ok(())
105    }
106    fn text(&mut self, len: usize) -> Result<(), IoError> {
107        self.text = self.text.saturating_add(len);
108        if self.text > MAX_TEXT {
109            return Err(IoError::TooLarge("XLS: more than 256 MB of text".into()));
110        }
111        Ok(())
112    }
113}
114
115struct BoundSheet {
116    pos: usize,
117    name: String,
118    visibility: Visibility,
119    kind: u8,
120}
121
122struct Lbl {
123    name: String,
124    builtin: Option<u8>,
125    hidden: bool,
126    macro_name: bool,
127    itab: u16,
128    rgce: Vec<u8>,
129    rgcb: Vec<u8>,
130}
131
132#[derive(Default)]
133struct Globals {
134    sheets: Vec<BoundSheet>,
135    date1904: bool,
136    formats: HashMap<u16, String>,
137    styles: styles::StyleRecords,
138    sst: Vec<Arc<str>>,
139    links: Links,
140    names: Vec<Lbl>,
141    precision_as_displayed: bool,
142}
143
144/// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped.
145pub fn read_xls(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
146    read_xls_with(bytes, Budget::default())
147}
148
149/// [`read_xls`] with an explicit resource budget.
150pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workbook, ReadReport), IoError> {
151    let cf = Compound::open(bytes).map_err(invalid)?;
152    let stream = match cf.root_stream("Workbook").map_err(invalid)? {
153        Some(s) => s,
154        None => {
155            if cf.has_root_stream("Book") {
156                return Err(invalid("Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again."));
157            }
158            return Err(invalid("the file has no Workbook stream, so it is not an Excel workbook"));
159        }
160    };
161    let mut report = ReadReport::default();
162    let g = globals(&stream, &mut budget, &mut report)?;
163
164    let mut wb = Workbook::new();
165    wb.sheets.clear();
166    wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 };
167    // One model style per XF.
168    let styles = styles::build(&g.styles, &g.formats, &mut wb);
169    if g.styles.short {
170        report.warn("Some cell formatting records were shorter than expected; the formatting present was kept.");
171    }
172
173    // BoundSheet8 index → model sheet index.
174    let mut model_index: Vec<Option<usize>> = Vec::with_capacity(g.sheets.len());
175    for bs in &g.sheets {
176        if bs.kind != 0 {
177            report.warn(match bs.kind {
178                2 => "Chart sheets in .xls files are not imported yet and were skipped.",
179                1 => "Excel 4.0 macro sheets were skipped.",
180                _ => "VBA modules and other non-worksheet sheets were skipped.",
181            });
182            model_index.push(None);
183            continue;
184        }
185        wb.check_sheet_name(&bs.name, None).map_err(invalid)?;
186        let Some((mut sheet, calc)) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else {
187            report.warn("Dialog sheets and unreadable sheets were skipped.");
188            model_index.push(None);
189            continue;
190        };
191        sheet.visibility = bs.visibility;
192        // Every worksheet repeats the workbook's calculation settings; Excel applies the first.
193        if wb.sheets.is_empty() {
194            wb.calc = calc;
195        }
196        model_index.push(Some(wb.sheets.len()));
197        wb.sheets.push(Arc::new(sheet));
198    }
199    if wb.sheets.is_empty() {
200        return Err(invalid("the workbook has no worksheets GridCraft can read"));
201    }
202    wb.calc.precision_as_displayed = g.precision_as_displayed;
203    defined_names(&g, &model_index, &mut wb, &mut report);
204    wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0));
205    // The overview comes first.
206    report.warnings.insert(
207        0,
208        "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights and defined names were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
209    );
210    Ok((wb, report))
211}
212
213fn bof(rec: &Record<'_>) -> Result<(u16, u16), IoError> {
214    if rec.id != BOF {
215        return Err(invalid("a substream does not start with a BOF record"));
216    }
217    let mut c = Cursor::new(&rec.parts);
218    Ok((c.u16()?, c.u16()?))
219}
220
221fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Result<Globals, IoError> {
222    let mut recs = Records::new(stream, 0);
223    let first = recs.next_record()?.ok_or_else(|| invalid("empty Workbook stream"))?;
224    let (vers, dt) = bof(&first)?;
225    if vers != BIFF8 {
226        return Err(invalid(if vers == 0x0500 {
227            "Excel 5.0/95 workbooks (BIFF5) are not supported; save the file as .xlsx in Excel and try again."
228        } else {
229            "unsupported BIFF version (only Excel 97-2003 BIFF8 files are read)"
230        }));
231    }
232    if dt != 0x0005 {
233        return Err(invalid("the Workbook stream does not start with the workbook globals"));
234    }
235    let mut g = Globals::default();
236    let mut offsets = std::collections::HashSet::new();
237    while let Some(rec) = recs.next_record()? {
238        budget.record()?;
239        let mut c = Cursor::new(&rec.parts);
240        match rec.id {
241            EOF => break,
242            0x002F => return Err(invalid(ENCRYPTED)),
243            0x0085 => {
244                if g.sheets.len() >= MAX_SHEETS {
245                    return Err(IoError::TooLarge("XLS: too many sheets".into()));
246                }
247                let pos = c.u32()? as usize;
248                if !offsets.insert(pos) {
249                    return Err(invalid("two sheets point at the same substream"));
250                }
251                let state = c.u8()?;
252                let kind = c.u8()?;
253                let name = c.short_string()?;
254                let visibility = match state & 3 {
255                    0 => Visibility::Visible,
256                    1 => Visibility::Hidden,
257                    _ => Visibility::VeryHidden,
258                };
259                g.links.sheets.push(name.clone());
260                g.sheets.push(BoundSheet { pos, name, visibility, kind });
261            }
262            0x0022 => g.date1904 = c.u16()? == 1,
263            0x000E => g.precision_as_displayed = c.u16()? == 0,
264            0x041E => {
265                let ifmt = c.u16()?;
266                let cch = usize::from(c.u16()?);
267                let high = c.u8()? & 1 != 0;
268                // Some writers count one character more than the record holds; Excel opens
269                // those files, so keep the characters that are there.
270                let fit = c.remaining() / if high { 2 } else { 1 };
271                if cch > fit {
272                    report.warn("Some number formats were shorter than their declared length; the characters present were kept.");
273                }
274                let code = c.chars(cch.min(fit), high)?;
275                budget.text(code.len())?;
276                g.formats.insert(ifmt, code);
277            }
278            0x00E0 => g.styles.xf(&mut c, MAX_XFS),
279            0x0031 => g.styles.font(&mut c),
280            0x0092 => g.styles.palette(&mut c),
281            0x0293 => g.styles.style(&mut c),
282            0x00FC => g.sst = shared_strings(&mut c, budget)?,
283            0x01AE => {
284                let _ctab = c.u16()?;
285                let cch = c.u16()?;
286                g.links.books.push(SupBook { internal: cch == 0x0401, addin: cch == 0x3A01, names: vec![] });
287            }
288            0x0023 => {
289                // ExternName: flags, then an AddinUdf or ExternDocName body; both start with
290                // four bytes before the name.
291                c.u16()?;
292                c.skip(4)?;
293                let name = c.short_string()?;
294                if let Some(book) = g.links.books.last_mut()
295                    && book.names.len() < MAX_NAMES
296                {
297                    book.names.push(name);
298                }
299            }
300            0x0017 => {
301                let n = usize::from(c.u16()?);
302                for _ in 0..n.min(c.remaining() / 6) {
303                    let book = c.u16()?;
304                    let first = c.u16()? as i16;
305                    let last = c.u16()? as i16;
306                    g.links.xti.push(Xti { book, first, last });
307                }
308            }
309            0x0018 => {
310                if g.names.len() >= MAX_NAMES {
311                    continue;
312                }
313                let lbl = label(&mut c)?;
314                g.links.names.push(lbl.name.clone());
315                g.names.push(lbl);
316            }
317            _ => {}
318        }
319    }
320    // The same test `defined_names` applies, before any cell formula is decoded.
321    let skipped = g.names.iter().map(|l| !l.rgce.is_empty() && decode(&l.rgce, &l.rgcb, CellRef::new(0, 0), Mode::Name, &g.links).is_err()).collect();
322    g.links.skipped = skipped;
323    Ok(g)
324}
325
326/// Built-in defined names ([MS-XLS] 2.4.150, Lbl.Name).
327const BUILTIN_NAMES: [&str; 14] = [
328    "Consolidate_Area",
329    "Auto_Open",
330    "Auto_Close",
331    "Extract",
332    "Database",
333    "Criteria",
334    "Print_Area",
335    "Print_Titles",
336    "Recorder",
337    "Data_Form",
338    "Auto_Activate",
339    "Auto_Deactivate",
340    "Sheet_Title",
341    "_FilterDatabase",
342];
343
344fn label(c: &mut Cursor<'_, '_>) -> Result<Lbl, IoError> {
345    let flags = c.u16()?;
346    let _key = c.u8()?;
347    let cch = usize::from(c.u8()?);
348    let cce = usize::from(c.u16()?);
349    c.skip(2)?;
350    let itab = c.u16()?;
351    c.skip(4)?;
352    let high = c.u8()? & 1 != 0;
353    let raw = c.chars(cch, high)?;
354    let builtin = flags & 0x0020 != 0;
355    let (name, builtin) = if builtin {
356        let code = raw.chars().next().map_or(0, |ch| ch as u32);
357        let code = u8::try_from(code).unwrap_or(u8::MAX);
358        (BUILTIN_NAMES.get(usize::from(code)).map_or_else(|| raw.clone(), |n| (*n).to_string()), Some(code))
359    } else {
360        (raw, None)
361    };
362    let rest = c.rest();
363    let rgce = rest.get(..cce).unwrap_or(&rest).to_vec();
364    let rgcb = rest.get(cce..).unwrap_or(&[]).to_vec();
365    Ok(Lbl { name, builtin, hidden: flags & 1 != 0, macro_name: flags & 0x000A != 0, itab, rgce, rgcb })
366}
367
368fn shared_strings(c: &mut Cursor<'_, '_>, budget: &mut Budget) -> Result<Vec<Arc<str>>, IoError> {
369    let _total = c.u32()?;
370    let unique = c.u32()? as usize;
371    if unique > MAX_STRINGS {
372        return Err(IoError::TooLarge("XLS: too many shared strings".into()));
373    }
374    // Each string takes at least three bytes: the declared count can't exceed what's there.
375    let mut out = Vec::with_capacity(unique.min(c.remaining() / 3));
376    for _ in 0..unique {
377        if c.remaining() == 0 {
378            break;
379        }
380        let s = c.rich_string()?;
381        budget.text(s.len())?;
382        out.push(Arc::from(s));
383    }
384    Ok(out)
385}
386
387/// A formula cell waiting for its shared or array formula (which follows it in the file).
388struct Pending {
389    pos: CellRef,
390    anchor: CellRef,
391}
392
393/// A parsed formula: tokens and extra data.
394struct Parsed {
395    rgce: Vec<u8>,
396    rgcb: Vec<u8>,
397}
398
399fn parsed(c: &mut Cursor<'_, '_>) -> Result<Parsed, IoError> {
400    let cce = usize::from(c.u16()?);
401    let rest = c.rest();
402    if cce > rest.len() {
403        return Err(invalid("formula runs past the end of its record"));
404    }
405    Ok(Parsed { rgce: rest.get(..cce).unwrap_or(&[]).to_vec(), rgcb: rest.get(cce..).unwrap_or(&[]).to_vec() })
406}
407
408struct SheetState<'g> {
409    g: &'g Globals,
410    styles: &'g [StyleId],
411    sheet: Sheet,
412    pending: Vec<Pending>,
413    shared: HashMap<CellRef, Parsed>,
414    arrays: HashMap<CellRef, (RangeRef, Parsed)>,
415    last_formula: Option<CellRef>,
416    /// The cell waiting for its String record, with its style: a blank cell isn't stored, so a
417    /// formula kept as its value may have no cell to receive the text.
418    pending_string: Option<(CellRef, StyleId)>,
419    dropped: usize,
420    unsupported: Vec<&'static str>,
421    calc: CalcSettings,
422}
423
424impl SheetState<'_> {
425    /// Records why a formula was kept as its value (each reason once).
426    fn unsupported(&mut self, why: &'static str) {
427        if !self.unsupported.contains(&why) {
428            self.unsupported.push(why);
429        }
430    }
431
432    fn style(&self, ixfe: u16) -> StyleId {
433        self.styles.get(usize::from(ixfe)).copied().unwrap_or(StyleId::DEFAULT)
434    }
435
436    fn pos(&mut self, row: u16, col: u16) -> Option<CellRef> {
437        let (r, c) = (u32::from(row), u32::from(col));
438        if r >= MAX_ROWS || c >= MAX_COLS.min(256) {
439            self.dropped += 1;
440            return None;
441        }
442        Some(CellRef::new(r, c))
443    }
444
445    fn put(&mut self, budget: &mut Budget, row: u16, col: u16, ixfe: u16, value: Value) -> Result<Option<CellRef>, IoError> {
446        let Some(pos) = self.pos(row, col) else { return Ok(None) };
447        let style = self.style(ixfe);
448        if value.is_empty() && style == StyleId::DEFAULT {
449            return Ok(Some(pos));
450        }
451        budget.cell()?;
452        self.sheet.cells.set(pos, Cell { value, formula: None, style, py: None });
453        Ok(Some(pos))
454    }
455
456    /// Decodes a formula and attaches it to the cell at `pos`, or records why it can't be.
457    fn attach(&mut self, pos: CellRef, p: &Parsed, mode: Mode, array: Option<RangeRef>) {
458        match decode(&p.rgce, &p.rgcb, pos, mode, &self.g.links) {
459            Ok(e) => {
460                let text = crate::formula::print(&e);
461                let mut f = Formula::new(&text);
462                if f.expr.is_none() {
463                    self.unsupported("syntax GridCraft can't parse yet");
464                    return;
465                }
466                f.array = array.filter(|r| !r.is_single());
467                if let Some(cell) = self.sheet.cells.get_mut(pos) {
468                    cell.formula = Some(Arc::new(f));
469                } else {
470                    self.sheet.cells.set(pos, Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT, py: None });
471                }
472            }
473            Err(Unsupported(why)) => self.unsupported(why),
474        }
475    }
476}
477
478/// RK number ([MS-XLS] 2.5.217): a 30-bit integer or the top of an IEEE double, maybe ×100.
479fn rk(v: u32) -> Value {
480    let n = if v & 2 != 0 { f64::from((v as i32) >> 2) } else { f64::from_bits(u64::from(v & !3) << 32) };
481    Value::number(if v & 1 != 0 { n / 100.0 } else { n })
482}
483
484/// Attaches every pending formula to its anchor's template, returning how many shared
485/// templates were expanded. An anchor whose template can't be imported is refused once:
486/// its followers re-decode the same template for nothing, so they are skipped instead (they
487/// keep their cached values).
488fn resolve_pending(
489    st: &mut SheetState<'_>,
490    arrays: &HashMap<CellRef, (RangeRef, Parsed)>,
491    shared: &HashMap<CellRef, Parsed>,
492    pending: &[Pending],
493) -> usize {
494    let mut failed: HashSet<CellRef> = HashSet::new();
495    let mut shared_attaches = 0usize;
496    for p in pending {
497        if let Some((range, f)) = arrays.get(&p.anchor) {
498            if p.pos == p.anchor {
499                st.attach(p.pos, f, Mode::Cell, Some(*range));
500            }
501        } else if let Some(f) = shared.get(&p.anchor) {
502            if failed.contains(&p.anchor) || shared_attaches >= MAX_SHARED_ATTACHES {
503                continue;
504            }
505            shared_attaches += 1;
506            st.attach(p.pos, f, Mode::Shared, None);
507            let imported = st.sheet.cell(p.pos).and_then(|c| c.formula.as_ref()).is_some_and(|f| f.text.chars().count() <= MAX_SHARED_TEMPLATE);
508            if !imported {
509                failed.insert(p.anchor);
510            }
511        } else {
512            st.unsupported("a shared formula that is missing from the file");
513        }
514    }
515    if shared_attaches >= MAX_SHARED_ATTACHES {
516        st.unsupported("more shared formulas than the expansion cap; some formulas kept their values");
517    }
518    shared_attaches
519}
520
521/// The other cells of a legacy array formula hold only cached results: dropped, as when an
522/// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself
523/// couldn't be imported, they keep their values.
524fn blank_array_followers(st: &mut SheetState<'_>, pending: &[Pending]) {
525    for p in pending {
526        let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos));
527        if p.pos != p.anchor && filled {
528            let style = st.sheet.style_id(p.pos);
529            st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style, py: None });
530        }
531    }
532}
533
534fn worksheet(
535    stream: &[u8],
536    bs: &BoundSheet,
537    g: &Globals,
538    styles: &[StyleId],
539    budget: &mut Budget,
540    report: &mut ReadReport,
541) -> Result<Option<(Sheet, CalcSettings)>, IoError> {
542    if bs.pos >= stream.len() {
543        return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name)));
544    }
545    let mut recs = Records::new(stream, bs.pos);
546    let first = recs.next_record()?.ok_or_else(|| invalid("missing sheet substream"))?;
547    let (vers, dt) = bof(&first)?;
548    if vers != BIFF8 || dt != 0x0010 {
549        return Ok(None);
550    }
551    let mut st = SheetState {
552        g,
553        styles,
554        sheet: Sheet::new(&bs.name),
555        pending: vec![],
556        shared: HashMap::new(),
557        arrays: HashMap::new(),
558        last_formula: None,
559        pending_string: None,
560        dropped: 0,
561        unsupported: vec![],
562        calc: CalcSettings::default(),
563    };
564    let mut depth = 1usize;
565    while let Some(rec) = recs.next_record()? {
566        budget.record()?;
567        match rec.id {
568            BOF => {
569                // An embedded chart's substream.
570                depth += 1;
571                continue;
572            }
573            EOF => {
574                depth -= 1;
575                if depth == 0 {
576                    break;
577                }
578                continue;
579            }
580            _ if depth > 1 => continue,
581            WSBOOL if rec.parts.first().and_then(|p| p.first()).is_some_and(|b| b & WSBOOL_DIALOG != 0) => return Ok(None),
582            _ => {}
583        }
584        sheet_record(&rec, &mut st, budget, report)?;
585    }
586    // Shared and array formulas: their definitions follow the first cell that uses them.
587    let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared));
588    let pending = std::mem::take(&mut st.pending);
589    resolve_pending(&mut st, &arrays, &shared, &pending);
590    blank_array_followers(&mut st, &pending);
591    if st.dropped > 0 {
592        report.warn(format!("{} cells outside the grid were skipped.", st.dropped));
593    }
594    for why in &st.unsupported {
595        report.warn(format!("Some formulas use {why}; their saved results were kept as values."));
596    }
597    Ok(Some((st.sheet, st.calc)))
598}
599
600/// The shortest WINDOW2 a file holds (the chart-sheet form); a shorter one is truncated.
601const WINDOW2_MIN_LEN: usize = 10;
602
603fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> {
604    let mut c = Cursor::new(&rec.parts);
605    // A formula's string result is the String record right after it (shared/array records may sit between).
606    if !matches!(rec.id, 0x0207 | 0x04BC | 0x0221 | 0x0236) {
607        st.pending_string = None;
608    }
609    match rec.id {
610        0x0203 => {
611            let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
612            let v = Value::number(c.f64()?);
613            st.put(budget, r, col, x, v)?;
614        }
615        0x027E => {
616            let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
617            let v = rk(c.u32()?);
618            st.put(budget, r, col, x, v)?;
619        }
620        0x00BD => {
621            let r = c.u16()?;
622            let first = c.u16()?;
623            let n = c.remaining().saturating_sub(2) / 6;
624            for i in 0..n {
625                let x = c.u16()?;
626                let v = rk(c.u32()?);
627                let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX);
628                st.put(budget, r, col, x, v)?;
629            }
630        }
631        0x00FD => {
632            let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
633            let i = c.u32()? as usize;
634            match st.g.sst.get(i) {
635                Some(s) => {
636                    st.put(budget, r, col, x, Value::Text(s.clone()))?;
637                }
638                None => report.warn("Some cells refer to missing shared strings and were left empty."),
639            }
640        }
641        0x0204 => {
642            let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
643            let s = c.string()?;
644            budget.text(s.len())?;
645            st.put(budget, r, col, x, Value::from(s))?;
646        }
647        0x0205 => {
648            let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
649            let (v, is_err) = (c.u8()?, c.u8()?);
650            let v = if is_err != 0 { Value::Error(cell_error(v)) } else { Value::Bool(v != 0) };
651            st.put(budget, r, col, x, v)?;
652        }
653        0x0201 => {
654            let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
655            st.put(budget, r, col, x, Value::Empty)?;
656        }
657        0x00BE => {
658            let r = c.u16()?;
659            let first = c.u16()?;
660            let n = c.remaining().saturating_sub(2) / 2;
661            for i in 0..n {
662                let x = c.u16()?;
663                let col = u16::try_from(usize::from(first) + i).unwrap_or(u16::MAX);
664                st.put(budget, r, col, x, Value::Empty)?;
665            }
666        }
667        0x0006 => formula_cell(&mut c, st, budget)?,
668        0x0207 => {
669            if let Some((pos, style)) = st.pending_string.take() {
670                let s = c.string()?;
671                budget.text(s.len())?;
672                if let Some(cell) = st.sheet.cells.get_mut(pos) {
673                    cell.value = Value::from(s);
674                } else {
675                    st.sheet.cells.set(pos, Cell { value: Value::from(s), formula: None, style, py: None });
676                }
677            }
678        }
679        0x04BC => {
680            // RefU (the cells that may use it), reserved, cUse.
681            c.skip(8)?;
682            let f = parsed(&mut c)?;
683            if let Some(at) = st.last_formula {
684                st.shared.insert(at, f);
685            }
686        }
687        0x0221 => {
688            let (r0, r1) = (c.u16()?, c.u16()?);
689            let (c0, c1) = (c.u8()?, c.u8()?);
690            c.skip(6)?;
691            let f = parsed(&mut c)?;
692            let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1.max(r0)), u32::from(c1.max(c0))));
693            if let Some(at) = st.last_formula {
694                st.arrays.insert(at, (range, f));
695            }
696        }
697        0x000D => {
698            st.calc.mode = match c.u16()? as i16 {
699                0 => CalcMode::Manual,
700                -1 => CalcMode::AutomaticExceptTables,
701                _ => CalcMode::Automatic,
702            }
703        }
704        0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)),
705        0x0011 => st.calc.iterative = c.u16()? == 1,
706        0x0010 => st.calc.set_max_change(c.f64()?),
707        0x0236 => report.warn("What-if data tables are not supported; their values were kept."),
708        0x00E5 => {
709            let n = usize::from(c.u16()?);
710            for _ in 0..n.min(c.remaining() / 8) {
711                let (r0, r1, c0, c1) = (c.u16()?, c.u16()?, c.u16()?, c.u16()?);
712                if r1 < r0 || c1 < c0 || u32::from(c1) >= 256 {
713                    continue;
714                }
715                if st.sheet.merges.len() >= MAX_MERGES {
716                    return Err(IoError::TooLarge("XLS: too many merged ranges".into()));
717                }
718                let range = RangeRef::new(CellRef::new(u32::from(r0), u32::from(c0)), CellRef::new(u32::from(r1), u32::from(c1)));
719                if !range.is_single() {
720                    st.sheet.merges.push(range);
721                }
722            }
723        }
724        0x007D => {
725            let (first, last) = (c.u16()?, c.u16()?);
726            let width = c.u16()?;
727            let x = c.u16()?;
728            let flags = c.u16()?;
729            let last = last.min(255);
730            if first > last {
731                return Ok(());
732            }
733            let mut info = LineInfo::default();
734            let px = col_width_to_px(f64::from(width) / 256.0);
735            if flags & 0x0002 != 0 || px != st.sheet.default_col_width {
736                info.size = Some(px);
737            }
738            info.hidden = flags & 0x0001 != 0;
739            info.outline = ((flags >> 8) & 7) as u8;
740            info.collapsed = flags & 0x1000 != 0;
741            let style = st.style(x);
742            if style != StyleId::DEFAULT {
743                info.style = Some(style);
744            }
745            if info != LineInfo::default() {
746                for col in first..=last {
747                    st.sheet.cols.insert(u32::from(col), info);
748                }
749            }
750        }
751        0x0208 => {
752            let r = c.u16()?;
753            c.skip(4)?;
754            let height = c.u16()?;
755            c.skip(4)?;
756            let flags = c.u32()?;
757            let mut info = LineInfo { custom: flags & 0x40 != 0, hidden: flags & 0x20 != 0, ..LineInfo::default() };
758            let px = pt_to_px(f64::from(height & 0x7FFF) / 20.0);
759            if info.custom || (px - st.sheet.default_row_height).abs() > 0.5 {
760                info.size = Some(px);
761            }
762            info.outline = (flags & 7) as u8;
763            info.collapsed = flags & 0x10 != 0;
764            if flags & 0x80 != 0 {
765                let style = st.style(((flags >> 16) & 0x0FFF) as u16);
766                if style != StyleId::DEFAULT {
767                    info.style = Some(style);
768                }
769            }
770            if info != LineInfo::default() && u32::from(r) < MAX_ROWS {
771                st.sheet.rows.insert(u32::from(r), info);
772            }
773        }
774        0x0225 => {
775            let flags = c.u16()?;
776            let height = c.u16()?;
777            if flags & 0x0002 == 0 && height > 0 {
778                st.sheet.default_row_height = pt_to_px(f64::from(height) / 20.0);
779            }
780        }
781        0x023E => {
782            let flags = if c.remaining() >= WINDOW2_MIN_LEN { c.u16().ok() } else { None };
783            let Some(flags) = flags else { return Ok(()) };
784            let on = |bit: u16| flags & bit != 0;
785            st.sheet.show_formulas = on(0x0001);
786            st.sheet.show_gridlines = on(0x0002);
787            st.sheet.show_headings = on(0x0004);
788            st.sheet.show_zeros = on(0x0010);
789            st.sheet.right_to_left = on(0x0040);
790        }
791        0x001C => report.warn("Cell comments in .xls files are not imported yet."),
792        0x00EC => report.warn("Pictures, charts and shapes in .xls files are not imported yet."),
793        0x01B0 | 0x0879 => report.warn("Conditional formatting in .xls files is not imported yet."),
794        0x01B2 => report.warn("Data validation in .xls files is not imported yet."),
795        0x01B8 => report.warn("Hyperlinks in .xls files are not imported yet."),
796        0x00B0 => report.warn("PivotTables in .xls files are not imported yet; their values were kept."),
797        _ => {}
798    }
799    Ok(())
800}
801
802fn formula_cell(c: &mut Cursor<'_, '_>, st: &mut SheetState<'_>, budget: &mut Budget) -> Result<(), IoError> {
803    let (r, col, x) = (c.u16()?, c.u16()?, c.u16()?);
804    let mut val = [0u8; 8];
805    for b in val.iter_mut() {
806        *b = c.u8()?;
807    }
808    let _flags = c.u16()?;
809    c.skip(4)?;
810    let f = parsed(c)?;
811    let [b0, _, b2, _, _, _, e0, e1] = val;
812    let mut string_result = false;
813    let value = if [e0, e1] == [0xFF, 0xFF] {
814        match b0 {
815            0x00 => {
816                string_result = true;
817                Value::Empty
818            }
819            0x01 => Value::Bool(b2 != 0),
820            0x02 => Value::Error(cell_error(b2)),
821            0x03 => Value::from(""),
822            _ => Value::Empty,
823        }
824    } else {
825        Value::number(f64::from_le_bytes(val))
826    };
827    let Some(pos) = st.pos(r, col) else { return Ok(()) };
828    budget.cell()?;
829    let style = st.style(x);
830    st.sheet.cells.set(pos, Cell { value, formula: None, style, py: None });
831    st.last_formula = Some(pos);
832    if string_result {
833        st.pending_string = Some((pos, style));
834    }
835    // PtgExp: part of a shared or array formula anchored at another cell.
836    if f.rgce.len() == 5 && f.rgce.first() == Some(&0x01) {
837        let get = |i: usize| f.rgce.get(i).copied().unwrap_or(0);
838        let anchor = CellRef::new(u32::from(u16::from_le_bytes([get(1), get(2)])), u32::from(u16::from_le_bytes([get(3), get(4)])));
839        st.pending.push(Pending { pos, anchor });
840        return Ok(());
841    }
842    st.attach(pos, &f, Mode::Cell, None);
843    Ok(())
844}
845
846fn defined_names(g: &Globals, model_index: &[Option<usize>], wb: &mut Workbook, report: &mut ReadReport) {
847    let scope_of = |itab: u16| -> Option<Option<usize>> {
848        if itab == 0 {
849            return Some(None);
850        }
851        model_index.get(usize::from(itab) - 1).copied().flatten().map(Some)
852    };
853    for lbl in &g.names {
854        let Some(scope) = scope_of(lbl.itab) else { continue };
855        let text = match decode(&lbl.rgce, &lbl.rgcb, CellRef::new(0, 0), Mode::Name, &g.links) {
856            Ok(e) => crate::formula::print(&e),
857            Err(_) if lbl.rgce.is_empty() => continue,
858            Err(Unsupported(why)) => {
859                report.warn(format!("Some defined names use {why} and were skipped."));
860                continue;
861            }
862        };
863        match lbl.builtin {
864            Some(6 | 7) => {
865                let Some(si) = scope else { continue };
866                let Some(sheet) = wb.sheet_mut(si) else { continue };
867                if lbl.builtin == Some(6) {
868                    sheet.print.print_area = crate::xlsx::read::parse_print_area(&text);
869                } else {
870                    let (rows, cols) = crate::xlsx::read::parse_print_titles(&text);
871                    sheet.print.title_rows = rows;
872                    sheet.print.title_cols = cols;
873                }
874            }
875            Some(_) => {}
876            None if lbl.macro_name => report.warn("Macro names were skipped."),
877            None => wb.names.push(DefinedName { name: lbl.name.clone(), scope, formula: text, comment: String::new(), hidden: lbl.hidden }),
878        }
879    }
880}