diff --git a/ROADMAP.md b/ROADMAP.md index 1faf680ca..4c22fe077 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | | 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) | | 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | +| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles | | 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | | 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values | @@ -230,6 +231,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook | | 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) | | 2026-10-16 | minor | Progress log: interface fonts held once instead of twice | +| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting | | 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | | 2026-10-14 | minor | Progress log: spill anchor reading its old spill area | | 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict | diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index c8f199a47..754525f14 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -25,6 +25,7 @@ mod shared_media; mod sheet_names; mod smooth_roundtrip; mod trendline_roundtrip; +mod xls_style_tests; mod xls_tests; mod xlsb_tests; mod zip_order; diff --git a/crates/xlsx/src/tests/xls_style_tests.rs b/crates/xlsx/src/tests/xls_style_tests.rs new file mode 100644 index 000000000..d944e8915 --- /dev/null +++ b/crates/xlsx/src/tests/xls_style_tests.rs @@ -0,0 +1,322 @@ +//! Legacy `.xls` (BIFF8) cell formatting: Font, XF, Palette and Style records. Every workbook is +//! assembled here from the [MS-XLS] structures; no Excel-produced file is used. + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::style::Protection; +use gridcraft_model::{BorderLine, BorderStyle, Color, Fill, HAlign, PatternType, Style, StyleId, Underline, VAlign, VertAlign, Workbook}; + +use super::xls_tests::compound; +use crate::tables::INDEXED; +use crate::{read_xls, read_xlsx, write_xlsx}; + +fn rec(id: u16, payload: &[u8]) -> Vec { + let mut v = id.to_le_bytes().to_vec(); + v.extend_from_slice(&(payload.len() as u16).to_le_bytes()); + v.extend_from_slice(payload); + v +} + +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +fn bof(dt: u16) -> Vec { + let mut p = u16s(&[0x0600, dt, 0x0DBB, 0x07CC]); + p.extend_from_slice(&[0; 8]); + rec(0x0809, &p) +} + +fn eof() -> Vec { + rec(0x000A, &[]) +} + +/// Font: height in twips, flags, colour, weight, script, underline, then a UTF-16 name. +fn font(twips: u16, flags: u16, icv: u16, weight: u16, script: u16, underline: u8, name: &str) -> Vec { + let mut p = u16s(&[twips, flags, icv, weight, script]); + p.extend_from_slice(&[underline, 0, 0, 0]); + p.push(name.encode_utf16().count() as u8); + p.push(1); + p.extend(u16s(&name.encode_utf16().collect::>())); + rec(0x0031, &p) +} + +/// The CellXF fields an XF record carries after ifnt, ifmt and the protection flags. +#[derive(Clone, Copy, Default)] +struct XfData { + alc: u8, + wrap: bool, + alc_v: u8, + trot: u8, + indent: u8, + shrink: bool, + /// dgLeft, dgRight, dgTop, dgBottom, dgDiag. + dg: [u32; 5], + /// icvLeft, icvRight, icvTop, icvBottom, icvDiag. + icv: [u32; 5], + diag: u32, + fls: u32, + fore: u16, + back: u16, +} + +fn xf(ifnt: u16, ifmt: u16, flags: u16, d: XfData) -> Vec { + let mut p = u16s(&[ifnt, ifmt, flags]); + p.push(d.alc | (u8::from(d.wrap) << 3) | (d.alc_v << 4)); + p.push(d.trot); + p.push(d.indent | (u8::from(d.shrink) << 4)); + p.push(0xFC); // fAtr* bits: no bearing on the values read + let b1 = d.dg[0] | (d.dg[1] << 4) | (d.dg[2] << 8) | (d.dg[3] << 12) | (d.icv[0] << 16) | (d.icv[1] << 23) | (d.diag << 30); + let b2 = d.icv[2] | (d.icv[3] << 7) | (d.icv[4] << 14) | (d.dg[4] << 21) | (d.fls << 26); + p.extend_from_slice(&b1.to_le_bytes()); + p.extend_from_slice(&b2.to_le_bytes()); + p.extend_from_slice(&(d.fore | (d.back << 7)).to_le_bytes()); + rec(0x00E0, &p) +} + +/// What Excel writes for unformatted cells: bottom aligned, automatic fill colours. +fn plain() -> XfData { + XfData { alc_v: 2, fore: 0x40, back: 0x41, ..XfData::default() } +} + +/// Locked, a cell XF inheriting from style XF 0. +const CELL: u16 = 0x0001; +/// Locked, a style XF with no parent. +const STYLE: u16 = 0xFFF5; + +fn number(r: u16, c: u16, ixfe: u16, n: f64) -> Vec { + let mut p = u16s(&[r, c, ixfe]); + p.extend_from_slice(&n.to_le_bytes()); + rec(0x0203, &p) +} + +fn blank(r: u16, c: u16, ixfe: u16) -> Vec { + rec(0x0201, &u16s(&[r, c, ixfe])) +} + +/// A one-sheet workbook: `formatting` records in the globals, `cells` in the sheet. +fn workbook(formatting: &[Vec], cells: &[Vec]) -> Vec { + let boundsheet = |pos: u32| { + let mut p = pos.to_le_bytes().to_vec(); + p.extend_from_slice(&[0, 0, 6, 0]); + p.extend_from_slice(b"Sheet1"); + rec(0x0085, &p) + }; + let globals = |pos: u32| [vec![bof(0x0005)], formatting.to_vec(), vec![boundsheet(pos), eof()]].concat().concat(); + let sheet = [vec![bof(0x0010)], cells.to_vec(), vec![eof()]].concat().concat(); + let len = globals(0).len() as u32; + [globals(len), sheet].concat() +} + +fn style_at<'a>(wb: &'a Workbook, a1: &str) -> &'a Style { + wb.styles.get(wb.sheets[0].style_id(CellRef::parse(a1).unwrap())) +} + +/// Four default fonts (Arial 10 and its bold/italic variants), as Excel writes them. +fn default_fonts() -> Vec> { + vec![ + font(200, 0, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 0, 0x7FFF, 700, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 400, 0, 0, "Arial"), + font(200, 2, 0x7FFF, 700, 0, 0, "Arial"), + ] +} + +/// Fonts, a palette, style XFs, cell XFs and named styles covering every decoded field. +fn formatted() -> Vec { + let mut f = default_fonts(); + // ifnt 5 (index 4 is never written): 14 pt italic, struck, bold, double underline, + // superscript, red (colour 10). + f.push(font(280, 0x000A, 10, 700, 1, 2, "Courier New")); + // ifnt 6: single accounting underline, subscript, a custom palette colour. + f.push(font(220, 0, 9, 400, 2, 0x21, "Times New Roman")); + // Palette: the first two of the 56 custom entries (colour indexes 8 and 9) replaced. + let mut pal = u16s(&[56]); + for (i, c) in INDEXED.iter().enumerate().skip(8) { + let c = match i { + 8 => 0x123456, + 9 => 0xABCDEF, + _ => *c, + }; + pal.extend_from_slice(&[(c >> 16) as u8, (c >> 8) as u8, c as u8, 0]); + } + f.push(rec(0x0092, &pal)); + f.push(rec(0x041E, &[&164u16.to_le_bytes()[..], &[4u8, 0, 0][..], &b"0.0%"[..]].concat())); + // XF 0: the Normal style. + f.push(xf(0, 0, STYLE, plain())); + // XF 1: everything at once. + f.push(xf( + 5, + 164, + 0x0002, // hidden, not locked + XfData { + alc: 2, + wrap: true, + alc_v: 1, + trot: 135, + indent: 3, + shrink: true, + dg: [1, 0, 7, 2, 3], + icv: [9, 0, 8, 0x40, 10], + diag: 3, + fls: 1, + fore: 8, + back: 0x41, + }, + )); + // XF 2: a 12.5% gray pattern on yellow, font index 4 (invalid: the default font). + f.push(xf(4, 0, CELL, XfData { alc: 7, alc_v: 4, trot: 255, fls: 0x11, fore: 10, back: 13, ..plain() })); + // XF 3: a font index past the end, right aligned, diagonal-up only. + f.push(xf(200, 0, CELL, XfData { alc: 3, alc_v: 0, trot: 90, dg: [0, 13, 0, 0, 5], icv: [0, 12, 0, 0, 16], diag: 2, ..plain() })); + // XF 4: an ordinary cell: the default style. + f.push(xf(0, 0, CELL, plain())); + // XF 5: font ifnt 6. + f.push(xf(6, 0, CELL, plain())); + // Styles: built-in Normal, a user style on XF 1 and a duplicate name (ignored), a built-in + // style (Comma) left to GridCraft's own definition. + f.push(rec(0x0293, &[&u16s(&[0x8000])[..], &[0u8, 0xFF][..]].concat())); + f.push(rec(0x0293, &[&u16s(&[1, 5])[..], &[0u8][..], &b"Fancy"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[2, 5])[..], &[0u8][..], &b"FANCY"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[0x8002])[..], &[3u8, 0xFF][..]].concat())); + workbook( + &f, + &[ + number(0, 0, 1, 0.5), + blank(0, 1, 1), + number(1, 0, 2, 2.0), + number(2, 0, 3, 3.0), + number(3, 0, 4, 4.0), + number(4, 0, 999, 5.0), + number(5, 0, 5, 6.0), + blank(6, 0, 4), + ], + ) +} + +#[test] +fn reads_fonts_fills_borders_alignment_and_protection() { + let (wb, report) = read_xls(&compound("Workbook", &formatted())).unwrap(); + assert!(report.warnings[0].contains("cell formatting"), "{:?}", report.warnings); + assert!(!report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + // The Normal style's font is the workbook default. + let normal = wb.styles.get(StyleId::DEFAULT); + assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false)); + assert_eq!(normal.font.color, Color::Auto); + + let s = style_at(&wb, "A1"); + let f = &s.font; + assert_eq!((f.name.as_str(), f.size), ("Courier New", 14.0)); + assert!(f.bold && f.italic && f.strike); + assert_eq!((f.underline, f.vert, f.color), (Underline::Double, VertAlign::Superscript, Color::Rgb(0xFF0000))); + assert_eq!(s.num_fmt.as_str(), "0.0%"); + assert_eq!(s.protection, Protection { locked: false, hidden: true }); + assert_eq!((s.align.h, s.align.v, s.align.wrap, s.align.shrink), (HAlign::Center, VAlign::Center, true, true)); + assert_eq!((s.align.rotation, s.align.indent), (-45, 3)); + // Colour index 8 and 9 come from the Palette record. + assert_eq!(s.fill, Fill::solid(Color::Rgb(0x123456))); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Rgb(0xABCDEF) }); + assert_eq!(s.borders.right, BorderLine::default()); + assert_eq!(s.borders.top, BorderLine { style: BorderStyle::Hair, color: Color::Rgb(0x123456) }); + assert_eq!(s.borders.bottom, BorderLine { style: BorderStyle::Medium, color: Color::Auto }); + let dashed = BorderLine { style: BorderStyle::Dashed, color: Color::Rgb(0xFF0000) }; + assert_eq!((s.borders.diag_down, s.borders.diag_up), (dashed, dashed)); + // A formatted blank cell keeps its formatting. + assert_eq!(wb.sheets[0].style_id(CellRef::parse("B1").unwrap()), wb.sheets[0].style_id(CellRef::parse("A1").unwrap())); + assert_eq!(wb.sheets[0].value(CellRef::parse("B1").unwrap()), Value::Empty); + + let s = style_at(&wb, "A2"); + assert_eq!(s.font, normal.font, "font index 4 falls back to the default font"); + assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) }); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Distributed, VAlign::Distributed, 255)); + + let s = style_at(&wb, "A3"); + assert_eq!(s.font, normal.font, "a font index past the end falls back to the default font"); + assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Right, VAlign::Top, 90)); + assert_eq!(s.borders.right, BorderLine { style: BorderStyle::SlantDashDot, color: Color::Rgb(0x0000FF) }); + assert_eq!(s.borders.diag_up, BorderLine { style: BorderStyle::Thick, color: Color::Rgb(0x800000) }); + assert_eq!(s.borders.diag_down, BorderLine::default()); + + // An ordinary cell XF is the default style; so is an XF index past the end. + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A4").unwrap()), StyleId::DEFAULT); + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A5").unwrap()), StyleId::DEFAULT); + // A blank cell in the default style isn't stored. + assert!(wb.sheets[0].cell(CellRef::parse("A7").unwrap()).is_none()); + + let s = style_at(&wb, "A6"); + assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF))); + assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript)); + + // User-defined styles are kept (first of a name wins); built-in ones are GridCraft's own. + assert_eq!(wb.cell_styles.len(), 1, "{:?}", wb.cell_styles.iter().map(|(n, _)| n).collect::>()); + assert_eq!(wb.cell_styles[0].0, "Fancy"); + assert_eq!(&wb.cell_styles[0].1, style_at(&wb, "A1")); + + // The formatting survives a save as .xlsx. + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + for a1 in ["A1", "A2", "A3", "A6"] { + assert_eq!(style_at(&back, a1), style_at(&wb, a1), "{a1}"); + } + assert_eq!(back.styles.get(StyleId::DEFAULT).font.name, "Arial"); + assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::>(), ["Fancy"]); +} + +#[test] +fn without_a_palette_the_default_colours_apply() { + let mut f = default_fonts(); + f.push(xf(0, 0, STYLE, plain())); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 63, ..plain() })); + // Out-of-range colour indexes are automatic. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 0x7F, dg: [1, 0, 0, 0, 0], icv: [0x50, 0, 0, 0, 0], ..plain() })); + let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]))).unwrap(); + assert_eq!(style_at(&wb, "A1").fill, Fill::solid(Color::Rgb(0x000000))); + assert_eq!(style_at(&wb, "A2").fill, Fill::solid(Color::Rgb(0x333333))); + let s = style_at(&wb, "A3"); + assert_eq!(s.fill, Fill::solid(Color::Auto)); + assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Auto }); +} + +#[test] +fn short_formatting_records_keep_what_is_there() { + let mut f = default_fonts(); + // A truncated font (no name): the default font keeps the numbering of the next one. + f.push(rec(0x0031, &u16s(&[240, 0]))); + f.push(font(300, 0, 0x7FFF, 700, 0, 0, "Georgia")); + // A palette claiming more entries than it holds. + f.push(rec(0x0092, &[&u16s(&[56])[..], &[1u8, 2, 3, 0][..]].concat())); + f.push(xf(0, 0, STYLE, plain())); + // XF 1: only ifnt and ifmt and flags: number format kept, no alignment. + f.push(rec(0x00E0, &u16s(&[6, 10, 0x0001]))); + // XF 2: two bytes. + f.push(rec(0x00E0, &u16s(&[6]))); + // XF 3: palette colour 8 (overridden) as a fill. + f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() })); + // Style records cut short, and a style naming a missing XF. + f.push(rec(0x0293, &u16s(&[0x8000]))); + f.push(rec(0x0293, &[&u16s(&[1, 40])[..], &[0u8][..], &b"Cut"[..]].concat())); + f.push(rec(0x0293, &[&u16s(&[900, 4])[..], &[0u8][..], &b"Gone"[..]].concat())); + let book = workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]); + let (wb, report) = read_xls(&compound("Workbook", &book)).unwrap(); + assert!(report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings); + let s = style_at(&wb, "A1"); + assert_eq!((s.font.name.as_str(), s.font.bold), ("Georgia", true)); + assert_eq!(s.num_fmt.as_str(), "0.00%"); + assert_eq!((s.align.v, s.protection.locked), (VAlign::Bottom, true)); + assert_eq!(wb.sheets[0].style_id(CellRef::parse("A2").unwrap()), StyleId::DEFAULT); + assert_eq!(style_at(&wb, "A3").fill, Fill::solid(Color::Rgb(0x010203))); + assert!(wb.cell_styles.is_empty()); +} + +#[test] +fn hostile_formatting_records_never_panic() { + let stream = formatted(); + for at in 0..stream.len() { + for x in [0x00u8, 0xFF, 0x80] { + let mut s = stream.clone(); + s[at] = x; + let _ = read_xls(&compound("Workbook", &s)); + } + } + for cut in 0..stream.len() { + let _ = read_xls(&compound("Workbook", &stream[..cut])); + } +} diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index ceaf3e26d..7f1df1197 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -3,24 +3,27 @@ //! . //! //! Read: worksheets, shared strings, cell values, formulas (text and cached results, including -//! shared and array formulas), number formats, the 1904 date system, merged cells, column -//! widths, row heights, defined names, print areas, print titles and the calculation settings -//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts, -//! pictures, comments, conditional formats, validation, macros) is skipped with a warning. +//! shared and array formulas), cell formatting (number formats, fonts, fills, borders, +//! alignment, protection, the colour palette and user-defined cell styles), the 1904 date +//! system, merged cells, column widths, row heights, defined names, print areas, print titles +//! and the calculation settings (mode, iteration, precision as displayed). Everything else +//! (charts, pictures, comments, conditional formats, validation, macros) is skipped with a +//! warning. //! Encrypted workbooks and pre-BIFF8 versions are refused. mod formula; mod ftab; mod records; +mod styles; use std::collections::HashMap; use std::sync::Arc; use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value}; -use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook}; +use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, Sheet, StyleId, Visibility, Workbook}; use crate::cfb::Compound; -use crate::tables::{builtin_format, col_width_to_px, pt_to_px}; +use crate::tables::{col_width_to_px, pt_to_px}; use crate::{IoError, ReadReport}; use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode}; use records::{Cursor, Record, Records}; @@ -123,7 +126,7 @@ struct Globals { sheets: Vec, date1904: bool, formats: HashMap, - xf_formats: Vec, + styles: styles::StyleRecords, sst: Vec>, links: Links, names: Vec, @@ -153,19 +156,11 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo let mut wb = Workbook::new(); wb.sheets.clear(); wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 }; - // One model style per XF: only the number format is imported. - let styles: Vec = g - .xf_formats - .iter() - .map(|ifmt| { - let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General"); - if code.eq_ignore_ascii_case("General") { - StyleId::DEFAULT - } else { - wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() }) - } - }) - .collect(); + // One model style per XF. + let styles = styles::build(&g.styles, &g.formats, &mut wb); + if g.styles.short { + report.warn("Some cell formatting records were shorter than expected; the formatting present was kept."); + } // BoundSheet8 index → model sheet index. let mut model_index: Vec> = Vec::with_capacity(g.sheets.len()); @@ -202,7 +197,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo // The overview comes first. report.warnings.insert( 0, - "Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(), + "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(), ); Ok((wb, report)) } @@ -272,10 +267,10 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul budget.text(code.len())?; g.formats.insert(ifmt, code); } - 0x00E0 if g.xf_formats.len() < MAX_XFS => { - c.u16()?; - g.xf_formats.push(c.u16()?); - } + 0x00E0 => g.styles.xf(&mut c, MAX_XFS), + 0x0031 => g.styles.font(&mut c), + 0x0092 => g.styles.palette(&mut c), + 0x0293 => g.styles.style(&mut c), 0x00FC => g.sst = shared_strings(&mut c, budget)?, 0x01AE => { let _ctab = c.u16()?; diff --git a/crates/xlsx/src/xls/styles.rs b/crates/xlsx/src/xls/styles.rs new file mode 100644 index 000000000..6e254c20c --- /dev/null +++ b/crates/xlsx/src/xls/styles.rs @@ -0,0 +1,390 @@ +//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with +//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is +//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether +//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`. + +use std::collections::HashMap; + +use gridcraft_model::style::Protection; +use gridcraft_model::{ + Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Underline, VAlign, + VertAlign, Workbook, +}; + +use super::records::Cursor; +use crate::tables::{INDEXED, builtin_format}; + +/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022). +const MAX_FONTS: usize = 1024; +/// Style records kept at most. +const MAX_STYLES: usize = 65_536; +/// Characters kept from a style name (Excel allows 255). +const MAX_STYLE_NAME: usize = 255; + +/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours. +const ICV_FONT_AUTO: u16 = 0x7FFF; +const ICV_DEFAULT_FG: u16 = 0x40; +const ICV_DEFAULT_BG: u16 = 0x41; + +/// One XF record, decoded as far as its bytes allow. +#[derive(Clone, Debug, Default)] +pub(super) struct Xf { + ifnt: u16, + ifmt: u16, + flags: u16, + /// The CellXF/StyleXF data: 14 bytes when complete. + data: [u8; 14], + /// Whether all 20 bytes were present. + complete: bool, +} + +/// A Style record: the XF it names and its name. +#[derive(Clone, Debug)] +pub(super) struct NamedStyle { + ixf: u16, + /// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style. + builtin: Option, + /// A user style's name (empty for built-in styles). + name: String, +} + +/// Formatting records collected from the globals substream. +#[derive(Debug, Default)] +pub(super) struct StyleRecords { + fonts: Vec, + font_colors: Vec, + xfs: Vec, + /// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present. + palette: Option>, + styles: Vec, + /// Records that were shorter than their structure; the parts present were used. + pub(super) short: bool, +} + +impl StyleRecords { + /// A Font record. The colour is resolved later, once the palette is known. + pub(super) fn font(&mut self, c: &mut Cursor<'_, '_>) { + if self.fonts.len() >= MAX_FONTS { + return; + } + match read_font(c) { + Some((font, icv)) => { + self.fonts.push(font); + self.font_colors.push(icv); + } + None => { + // Keep the numbering of the fonts after it. + self.short = true; + self.fonts.push(Font::default()); + self.font_colors.push(ICV_FONT_AUTO); + } + } + } + + /// An XF record. A short record keeps the fields it has. + pub(super) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) { + if self.xfs.len() >= max { + return; + } + let mut xf = Xf::default(); + let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))(); + match head { + Some((ifnt, ifmt, flags)) => { + xf.ifnt = ifnt; + xf.ifmt = ifmt; + xf.flags = flags; + xf.complete = true; + for b in xf.data.iter_mut() { + match c.u8() { + Ok(v) => *b = v, + Err(_) => { + xf.complete = false; + break; + } + } + } + } + None => { + // Not even the font and format: an unformatted, locked cell. + xf.flags = 1; + } + } + if !xf.complete { + self.short = true; + } + self.xfs.push(xf); + } + + /// A Palette record: colours for indexes 8 onwards. + pub(super) fn palette(&mut self, c: &mut Cursor<'_, '_>) { + let Ok(ccv) = c.u16() else { + self.short = true; + return; + }; + let n = usize::from(ccv).min(56); + if usize::from(ccv) > c.remaining() / 4 { + self.short = true; + } + let mut colors = Vec::with_capacity(n); + for _ in 0..n { + let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break }; + colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)); + } + self.palette = Some(colors); + } + + /// A Style record. + pub(super) fn style(&mut self, c: &mut Cursor<'_, '_>) { + if self.styles.len() >= MAX_STYLES { + return; + } + let Ok(word) = c.u16() else { + self.short = true; + return; + }; + let ixf = word & 0x0FFF; + if word & 0x8000 != 0 { + let Ok(id) = c.u8() else { + self.short = true; + return; + }; + // BuiltInStyle: the style's number (0 is Normal) and outline level. + self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() }); + } else { + match c.string() { + Ok(name) => { + let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect(); + if !name.is_empty() { + self.styles.push(NamedStyle { ixf, builtin: None, name }); + } + } + Err(_) => self.short = true, + } + } + } + + /// The 64-entry colour table: the built-in eight, then the palette (or the default one). + fn color_table(&self) -> [u32; 64] { + let mut table = INDEXED; + if let Some(p) = &self.palette { + for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) { + *slot = *c; + } + } + table + } +} + +/// Font record fields up to the name; `None` if the record is too short for them. +fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> { + let height = c.u16().ok()?; + let flags = c.u16().ok()?; + let icv = c.u16().ok()?; + let weight = c.u16().ok()?; + let script = c.u16().ok()?; + let underline = c.u8().ok()?; + c.skip(3).ok()?; // family, character set, unused + let name = c.short_string().ok()?; + let mut f = Font::default(); + let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect(); + if !name.trim().is_empty() { + f.name = name; + } + if height > 0 { + f.size = (f32::from(height) / 20.0).clamp(1.0, 409.0); + } + f.italic = flags & 0x0002 != 0; + f.strike = flags & 0x0008 != 0; + // Weights run 100-1000; Excel writes 400 for normal and 700 for bold. + f.bold = weight >= 600; + f.vert = match script { + 1 => VertAlign::Superscript, + 2 => VertAlign::Subscript, + _ => VertAlign::Baseline, + }; + f.underline = match underline { + 0x01 => Underline::Single, + 0x02 => Underline::Double, + 0x21 => Underline::SingleAccounting, + 0x22 => Underline::DoubleAccounting, + _ => Underline::None, + }; + Some((f, icv)) +} + +/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system +/// and automatic colours as automatic. +fn color(icv: u16, table: &[u32; 64]) -> Color { + match icv { + ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto, + _ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)), + } +} + +fn border_style(dg: u32) -> BorderStyle { + match dg { + 1 => BorderStyle::Thin, + 2 => BorderStyle::Medium, + 3 => BorderStyle::Dashed, + 4 => BorderStyle::Dotted, + 5 => BorderStyle::Thick, + 6 => BorderStyle::Double, + 7 => BorderStyle::Hair, + 8 => BorderStyle::MediumDashed, + 9 => BorderStyle::DashDot, + 10 => BorderStyle::MediumDashDot, + 11 => BorderStyle::DashDotDot, + 12 => BorderStyle::MediumDashDotDot, + 13 => BorderStyle::SlantDashDot, + _ => BorderStyle::None, + } +} + +fn pattern(fls: u16) -> PatternType { + match fls { + 0x01 => PatternType::Solid, + 0x02 => PatternType::Gray50, + 0x03 => PatternType::Gray75, + 0x04 => PatternType::Gray25, + 0x05 => PatternType::DarkHorizontal, + 0x06 => PatternType::DarkVertical, + 0x07 => PatternType::DarkDown, + 0x08 => PatternType::DarkUp, + 0x09 => PatternType::DarkGrid, + 0x0A => PatternType::DarkTrellis, + 0x0B => PatternType::LightHorizontal, + 0x0C => PatternType::LightVertical, + 0x0D => PatternType::LightDown, + 0x0E => PatternType::LightUp, + 0x0F => PatternType::LightGrid, + 0x10 => PatternType::LightTrellis, + 0x11 => PatternType::Gray125, + 0x12 => PatternType::Gray0625, + _ => PatternType::None, + } +} + +fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine { + let style = border_style(dg); + if style == BorderStyle::None { + return BorderLine::default(); + } + // Colour 0 means "not specified": automatic. + let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) }; + BorderLine { style, color } +} + +/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one). +fn xf_font(ifnt: u16, fonts: &[Font]) -> Font { + let i = match ifnt { + 0..=3 => usize::from(ifnt), + 4 => return fonts.first().cloned().unwrap_or_default(), + _ => usize::from(ifnt) - 1, + }; + fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default() +} + +fn num_fmt(ifmt: u16, formats: &HashMap) -> NumFmt { + let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General"); + if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) } +} + +/// The model style of one XF. +fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap, table: &[u32; 64]) -> Style { + let mut s = Style { + font: xf_font(xf.ifnt, fonts), + num_fmt: num_fmt(xf.ifmt, formats), + protection: Protection { locked: xf.flags & 0x0001 != 0, hidden: xf.flags & 0x0002 != 0 }, + ..Style::default() + }; + if !xf.complete { + return s; + } + let d = &xf.data; + let byte = |i: usize| d.get(i).copied().unwrap_or(0); + let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]); + let (a0, rot, a2) = (byte(0), byte(1), byte(2)); + s.align = Alignment { + h: match a0 & 0x07 { + 1 => HAlign::Left, + 2 => HAlign::Center, + 3 => HAlign::Right, + 4 => HAlign::Fill, + 5 => HAlign::Justify, + 6 => HAlign::CenterAcross, + 7 => HAlign::Distributed, + _ => HAlign::General, + }, + wrap: a0 & 0x08 != 0, + v: match (a0 >> 4) & 0x07 { + 0 => VAlign::Top, + 1 => VAlign::Center, + 3 => VAlign::Justify, + 4 => VAlign::Distributed, + _ => VAlign::Bottom, + }, + rotation: match rot { + 0..=90 => i16::from(rot), + 91..=180 => -(i16::from(rot) - 90), + 255 => 255, + _ => 0, + }, + indent: a2 & 0x0F, + shrink: a2 & 0x10 != 0, + }; + let b1 = word32(4); + let b2 = word32(8); + let mut borders = Borders { + left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table), + right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table), + top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table), + bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table), + ..Borders::default() + }; + let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table); + let which = (b1 >> 30) & 0x03; + if which & 1 != 0 { + borders.diag_down = diag; + } + if which & 2 != 0 { + borders.diag_up = diag; + } + s.borders = borders; + let fill = u16::from_le_bytes([byte(12), byte(13)]); + let fls = ((b2 >> 26) & 0x3F) as u16; + let fg = color(fill & 0x7F, table); + let bg = color((fill >> 7) & 0x7F, table); + s.fill = match pattern(fls) { + PatternType::None => Fill::default(), + PatternType::Solid => Fill::solid(fg), + p => Fill { pattern: p, fg, bg }, + }; + s +} + +/// Imports the formatting: sets the workbook's default (Normal) font and named cell styles, and +/// returns the model style of every XF in file order. +pub(super) fn build(r: &StyleRecords, formats: &HashMap, wb: &mut Workbook) -> Vec { + let table = r.color_table(); + let fonts: Vec = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect(); + // The Normal style's font is what every unformatted cell shows. + let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf)); + let normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned()); + if let Some(font) = normal_font { + wb.styles = StyleTable::with_default(Style { font, ..Style::default() }); + } + let out = r.xfs.iter().map(|xf| wb.styles.intern(xf_style(xf, &fonts, formats, &table))).collect(); + // User-defined named styles. Excel 2007 and later write every built-in style into each .xls + // (about fifty, their colours approximated to the palette); GridCraft defines those itself. + let mut named: Vec<(String, Style)> = Vec::new(); + for ns in &r.styles { + if ns.builtin.is_some() || ns.name.eq_ignore_ascii_case("Normal") || named.iter().any(|(n, _)| n.eq_ignore_ascii_case(&ns.name)) { + continue; + } + if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) { + named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table))); + } + } + wb.cell_styles = named; + out +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cf567f8bd..b1109b678 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (`.xls` import reads cell formatting: fonts, fills, borders, alignment, protection, palette, user cell styles; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -54,7 +54,7 @@ values) is still dropped. | `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) | | `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` | | `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` | -| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` | +| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` | | `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — | | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | @@ -100,7 +100,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 | | Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | -| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | +| Legacy `.xls` (BIFF8): exact theme colours (XFExt), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | | Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | | CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | @@ -132,6 +132,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) | | 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses ` ` (#1488) | | 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) | +| 2026-10-14 | minor | `.xls` import reads cell formatting from the Font, XF, Palette and Style records: fonts, fills, borders, alignment, protection, the custom palette, the Normal font and user cell styles | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-14 | minor | XLSX formulas read with dynamic-array metadata (`cm`) keep it on save, so Excel does not recalculate them with implicit intersection | | 2026-10-14 | minor | PDF export writes clickable annotations for safe cell URLs and destinations on printed pages; CLI `convert` accepts `.pdf` output (#954) | diff --git a/docs/gaps.md b/docs/gaps.md index b07727991..cfee087e3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -72,14 +72,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 7. Legacy `.xls` only partly read; encrypted workbooks can't be saved with a password (FF) - **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number - formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional + formats, merges, sizes, names, and cell formatting: fonts, fills, borders, alignment); exact theme colours (XFExt), charts, pictures, comments, conditional formats, validation and BIFF5 files are not read, and `.xls` is never written. Password-encrypted `.xlsx` workbooks (Agile and Standard encryption) open with their password since #11, but are saved without it: GridCraft can't write an encrypted file yet. Encrypted `.xls` (FILEPASS) and RC4-encrypted files are still refused with a message. - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 14–21 h (`.xls` styles and objects 10–15, encryption 4–6). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 12–18 h (`.xls` objects 8–12, encryption 4–6). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`,