From 32f53d355aebcaec413fde746eb01bbfb46243cd Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 23:53:56 +0000 Subject: [PATCH 1/5] fix(spreadsheet): single-row sheets, workbook titles, XLSB detection - Single-row sheets render the row as data under Excel column letters - Extraction.title from XLSX core.xml, ODS meta.xml, XLS SummaryInformation - XLSB content type / extension / container detected as Format::Xlsb, not Xlsx - XLSX without _rels/.rels gets the standard package relationship - Release profile uses panic = "unwind" so catch_unwind guards work - Document the trailing newline as intended Part of #11 --- CHANGELOG.md | 9 + Cargo.toml | 5 +- crates/markkit/src/extract.rs | 14 +- crates/markkit/src/format.rs | 21 ++ crates/markkit/src/lib.rs | 3 +- crates/markkit/src/spreadsheet.rs | 273 +++++++++++++++++- crates/markkit/tests/common/mod.rs | 61 +++- crates/markkit/tests/document_security.rs | 20 +- .../markkit/tests/spreadsheet_integration.rs | 118 +++++++- knowledge/formats/format-support.md | 29 +- knowledge/log.md | 2 + knowledge/security/threat-model.md | 8 +- 12 files changed, 532 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b78190..fc29176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- Spreadsheets: a sheet with a single row now renders that row as data under Excel column letters + (`| A | B |`) instead of as a header with no rows. +- Spreadsheets: `Extraction.title` is filled from document properties (XLSX `docProps/core.xml`, + ODS `meta.xml`, XLS `SummaryInformation`). +- `Format::from_content_type` detects XLSB (`application/vnd.ms-excel.sheet.binary.macroEnabled.12`) + as the new `Format::Xlsb` instead of `Xlsx`; `.xlsb` files and `xl/workbook.bin` containers too. +- XLSX packages missing `_rels/.rels` no longer fail inside calamine. +- Release builds use `panic = "unwind"`, so a parser panic becomes an error instead of aborting the + CLI or the host Python/Node process. - Indented HTML (most real web pages) now keeps blank lines between blocks; previously headings, paragraphs, lists and tables after the first ran together with a leading space (`# A\n B` instead of `# A\n\nB`), which also broke headings and tables as markdown. diff --git a/Cargo.toml b/Cargo.toml index a67f998..b16e1da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,5 +68,8 @@ proptest = "1" [profile.release] lto = "thin" codegen-units = 1 -panic = "abort" +# Unwind (not abort): `extract_document` wraps third-party parsers in +# `catch_unwind`, and PyO3/NAPI turn panics into exceptions. Abort would let one +# malformed document kill the CLI or the host Python/Node process. +panic = "unwind" strip = true diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index 06ce32b..b627752 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -8,9 +8,11 @@ use crate::format::{DocumentHint, Format}; #[derive(Debug, Clone, PartialEq, Serialize)] #[non_exhaustive] pub struct Extraction { - /// Rendered Markdown. + /// Rendered Markdown. Non-empty output always ends with exactly one `\n` + /// (POSIX text convention, safe to concatenate); empty documents render `""`. pub markdown: String, - /// Document title when known (DOCX core properties or `Title` style). + /// Document title when known: DOCX core properties or `Title` style, + /// XLSX `docProps/core.xml`, ODS `meta.xml`, XLS `SummaryInformation`. pub title: Option, /// Format the document was parsed as. pub format: Format, @@ -50,7 +52,8 @@ impl Extraction { } /// Run a parser, converting a panic into an error (defence in depth for -/// third-party parsers; only effective when built with `panic = "unwind"`). +/// third-party parsers; only effective when built with `panic = "unwind"`, which +/// markkit's own release profile uses). #[cfg(any(feature = "docx", feature = "spreadsheet"))] fn guarded( what: &str, @@ -181,7 +184,9 @@ impl Markkit { } Format::Pdf => self.extract_pdf_sync(bytes), Format::Docx => self.extract_docx(bytes), - Format::Xlsx | Format::Xls | Format::Ods => self.extract_spreadsheet(bytes), + Format::Xlsx | Format::Xlsb | Format::Xls | Format::Ods => { + self.extract_spreadsheet(bytes) + } Format::Doc => Err(Error::UnsupportedFormat( "legacy Word 97-2003 (.doc) documents are not supported".into(), )), @@ -222,6 +227,7 @@ impl Markkit { })?; let mut renderer = crate::render::MarkdownRenderer::new(); let mut e = Extraction::new(renderer.render(&out.doc), out.format); + e.title = out.title; e.warnings = out.warnings; e.stats.sheets = Some(out.sheets); e.stats.rows = Some(out.rows); diff --git a/crates/markkit/src/format.rs b/crates/markkit/src/format.rs index cf632a7..509604c 100644 --- a/crates/markkit/src/format.rs +++ b/crates/markkit/src/format.rs @@ -22,6 +22,8 @@ pub enum Format { Doc, /// Office Open XML spreadsheet (`.xlsx`, `.xlsm`, `.xltx`). Xlsx, + /// Excel binary workbook (`.xlsb`). + Xlsb, /// Legacy Excel 97-2003 binary (`.xls`). Xls, /// OpenDocument spreadsheet (`.ods`). @@ -76,6 +78,7 @@ impl Format { Format::Docx => "docx", Format::Doc => "doc", Format::Xlsx => "xlsx", + Format::Xlsb => "xlsb", Format::Xls => "xls", Format::Ods => "ods", Format::Pptx => "pptx", @@ -98,6 +101,8 @@ impl Format { Format::Docx } else if ct.contains("msword") { Format::Doc + } else if ct.contains("ms-excel.sheet.binary") { + Format::Xlsb } else if ct.contains("spreadsheetml") || ct.contains("ms-excel.sheet") { Format::Xlsx } else if ct.contains("ms-excel") { @@ -125,6 +130,7 @@ impl Format { "docx" | "docm" | "dotx" | "dotm" => Format::Docx, "doc" | "dot" => Format::Doc, "xlsx" | "xlsm" | "xltx" | "xltm" => Format::Xlsx, + "xlsb" => Format::Xlsb, "xls" | "xlt" => Format::Xls, "ods" => Format::Ods, "pptx" | "pptm" | "ppt" => Format::Pptx, @@ -198,6 +204,8 @@ fn sniff_zip(bytes: &[u8]) -> Format { Format::Ods } else if contains(bytes, b"word/document") { Format::Docx + } else if contains(bytes, b"xl/workbook.bin") { + Format::Xlsb } else if contains(bytes, b"xl/workbook.") { Format::Xlsx } else if contains(bytes, b"ppt/presentation.") { @@ -245,6 +253,18 @@ mod tests { ); assert_eq!(Format::from_content_type("application/pdf"), Format::Pdf); assert_eq!(Format::from_content_type("text/plain"), Format::PlainText); + assert_eq!( + Format::from_content_type("application/vnd.ms-excel.sheet.binary.macroEnabled.12"), + Format::Xlsb + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-excel.sheet.macroEnabled.12"), + Format::Xlsx + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-excel"), + Format::Xls + ); } #[test] @@ -252,6 +272,7 @@ mod tests { assert_eq!(Format::from_extension("html"), Format::Html); assert_eq!(Format::from_extension("PDF"), Format::Pdf); assert_eq!(Format::from_extension("docx"), Format::Docx); + assert_eq!(Format::from_extension("XLSB"), Format::Xlsb); } #[test] diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index d6453e2..1ed8349 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -56,7 +56,8 @@ pub mod ir; pub mod render; #[cfg(feature = "spreadsheet")] mod spreadsheet; -#[cfg(feature = "docx")] +#[cfg(any(feature = "docx", feature = "spreadsheet"))] +#[cfg_attr(not(feature = "docx"), allow(dead_code))] // spreadsheet uses only `find`/`text` mod xml; pub use config::MarkkitBuilder; diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs index 2ca280c..9afba58 100644 --- a/crates/markkit/src/spreadsheet.rs +++ b/crates/markkit/src/spreadsheet.rs @@ -34,6 +34,7 @@ use crate::render::escape_table_cell; pub(crate) struct SheetOutput { pub doc: Document, pub format: Format, + pub title: Option, pub warnings: Vec, pub sheets: usize, pub rows: usize, @@ -63,7 +64,8 @@ enum Container { } pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result { - let container = if bytes.starts_with(b"PK") { + let mut patched = None; + let (container, title) = if bytes.starts_with(b"PK") { let mut zip = ZipGuard::open(bytes, config).map_err(archive_err)?; let has = |zip: &ZipGuard<'_>, name: &str| zip.names().any(|n| n.eq_ignore_ascii_case(name)); @@ -79,20 +81,29 @@ pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result Result out.warn(format!("sheet_error: sheet '{name}': {e}")), } } - out.finish(Format::Xlsx) + out.finish(Format::Xlsx, title) } Container::Ods => { let mut wb: Ods<_> = Ods::new(Cursor::new(bytes)).map_err(err)?; let sel = out.select(wb.sheets_metadata()); read_ranges(&mut wb, sel, &mut out, config); - out.finish(Format::Ods) + out.finish(Format::Ods, title) } Container::Xls => { let mut wb: Xls<_> = Xls::new(Cursor::new(bytes)).map_err(err)?; let sel = out.select(wb.sheets_metadata()); read_ranges(&mut wb, sel, &mut out, config); - out.finish(Format::Xls) + out.finish(Format::Xls, title) } } } @@ -217,6 +228,19 @@ fn fmt_ref(d: &DataRef<'_>) -> String { // ─── Grid collection (bounded) ────────────────────────────────────── +/// Excel column letters for a 0-based index (`0` → `A`, `26` → `AA`). +fn col_name(c: u32) -> String { + let mut n = u64::from(c) + 1; + let mut out = Vec::new(); + while n > 0 { + n -= 1; + out.push(b'A' + (n % 26) as u8); + n /= 26; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + struct SheetTable { header: Vec, rows: Vec>, @@ -298,10 +322,19 @@ impl Grid { } row }); - let header = rows.next()?; + let first = rows.next()?; + let rest: Vec> = rows.collect(); + // GFM tables need a header row. A sheet with a single row is data, not a + // header: label its columns A, B, … so the row renders as a data row. + let (header, rows) = if rest.is_empty() { + let letters = (min_col..min_col + width as u32).map(col_name).collect(); + (letters, vec![first]) + } else { + (first, rest) + }; Some(SheetTable { header, - rows: rows.collect(), + rows, truncated_rows: self.truncated_rows, truncated_cols: self.truncated_cols, }) @@ -395,10 +428,11 @@ impl<'c> Collector<'c> { }); } - fn finish(self, format: Format) -> Result { + fn finish(self, format: Format, title: Option) -> Result { Ok(SheetOutput { doc: Document::new(self.nodes), format, + title, warnings: self.warnings, sheets: self.sheets, rows: self.rows, @@ -406,6 +440,128 @@ impl<'c> Collector<'c> { } } +// ─── Package fixes and document title ─────────────────────────────── + +/// Longest title kept (characters); longer titles are cut. +const MAX_TITLE_CHARS: usize = 1024; + +/// calamine locates the workbook through `_rels/.rels` and fails without it. +/// Rebuild the (already validated) archive with the standard relationship to +/// `xl/workbook.xml`; entries are copied raw, without recompression. +fn with_package_rels(zip: &mut ZipGuard<'_>) -> Result, Error> { + const RELS: &str = r#""#; + let zerr = |e: zip::result::ZipError| err(format!("cannot rebuild XLSX package: {e}")); + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..zip.archive.len() { + let entry = zip.archive.by_index_raw(i).map_err(zerr)?; + w.raw_copy_file(entry).map_err(zerr)?; + } + let opts = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("_rels/.rels", opts).map_err(zerr)?; + std::io::Write::write_all(&mut w, RELS.as_bytes()) + .map_err(|e| err(format!("cannot rebuild XLSX package: {e}")))?; + Ok(w.finish().map_err(zerr)?.into_inner()) +} + +fn clean_title(raw: &str) -> Option { + let t: String = raw + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(MAX_TITLE_CHARS) + .collect(); + (!t.is_empty()).then_some(t) +} + +/// `dc:title` from OOXML `docProps/core.xml` or ODF `meta.xml`. Best effort: +/// a missing or malformed part yields `None`; only archive limits are errors. +fn read_dc_title(zip: &mut ZipGuard<'_>, part: &str) -> Result, Error> { + let Some(xml) = zip.read(part).map_err(archive_err)? else { + return Ok(None); + }; + Ok(dc_title(&xml)) +} + +fn dc_title(xml: &[u8]) -> Option { + let root = crate::xml::parse(xml).ok()?; + clean_title(&root.find("title")?.text()) +} + +/// Title (PIDSI_TITLE) from the XLS `\u{5}SummaryInformation` property set +/// ([MS-OLEPS]). Best effort: anything malformed yields `None`. +fn xls_title(comp: &mut cfb::CompoundFile) -> Option { + const CAP: u64 = 1024 * 1024; + let mut data = Vec::new(); + comp.open_stream("/\u{5}SummaryInformation") + .ok()? + .take(CAP) + .read_to_end(&mut data) + .ok()?; + summary_title(&data) +} + +fn summary_title(d: &[u8]) -> Option { + const PID_CODEPAGE: u32 = 1; + const PID_TITLE: u32 = 2; + const VT_I2: u32 = 0x0002; + const VT_LPSTR: u32 = 0x001E; + const VT_LPWSTR: u32 = 0x001F; + if read_u16(d, 0)? != 0xFFFE || read_u32(d, 24)? == 0 { + return None; + } + let set = read_u32(d, 44)? as usize; + let count = read_u32(d, set.checked_add(4)?)? as usize; + let mut codepage = None; + let mut title_at = None; + for i in 0..count.min(1024) { + let entry = set.checked_add(8 + i * 8)?; + let (pid, off) = (read_u32(d, entry)?, read_u32(d, entry + 4)? as usize); + let at = set.checked_add(off)?; + match pid { + PID_CODEPAGE if read_u32(d, at)? & 0xFFFF == VT_I2 => { + codepage = Some(read_u16(d, at + 4)?); + } + PID_TITLE => title_at = Some(at), + _ => {} + } + } + let at = title_at?; + let typ = read_u32(d, at)? & 0xFFFF; + let n = read_u32(d, at + 4)? as usize; + let start = at + 8; + let raw = match typ { + VT_LPWSTR => { + let bytes = d.get(start..start.checked_add(n.checked_mul(2)?)?)?; + let units: Vec = bytes + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect(); + String::from_utf16_lossy(&units) + } + VT_LPSTR => { + let bytes = d.get(start..start.checked_add(n)?)?; + match codepage { + Some(1200) => { + let units: Vec = bytes + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect(); + String::from_utf16_lossy(&units) + } + // UTF-8 when valid, otherwise Latin-1 (close to Windows-1252). + _ => match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), + }, + } + } + _ => return None, + }; + clean_title(raw.trim_end_matches('\0')) +} + // ─── XLS pre-scan (BIFF records) ──────────────────────────────────── fn read_u16(d: &[u8], at: usize) -> Option { @@ -429,9 +585,10 @@ fn records(stream: &[u8]) -> impl Iterator { }) } -fn prescan_xls(bytes: &[u8], config: &MarkkitBuilder) -> Result<(), Error> { - let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)) - .map_err(|e| err(format!("invalid XLS container: {e}")))?; +fn prescan_xls( + comp: &mut cfb::CompoundFile, + config: &MarkkitBuilder, +) -> Result<(), Error> { let name = ["/Workbook", "/Book", "/WORKBOOK", "/BOOK"] .into_iter() .find(|n| comp.is_stream(n)) @@ -705,6 +862,92 @@ mod tests { assert!(t.truncated_rows); } + #[test] + fn column_names() { + assert_eq!(col_name(0), "A"); + assert_eq!(col_name(25), "Z"); + assert_eq!(col_name(26), "AA"); + assert_eq!(col_name(16_383), "XFD"); + assert_eq!(col_name(701), "ZZ"); + assert_eq!(col_name(702), "AAA"); + assert!(!col_name(u32::MAX).is_empty()); + } + + #[test] + fn single_row_becomes_data_under_column_letters() { + let cfg = MarkkitBuilder::default(); + let mut g = Grid::new(&cfg); + g.add(4, 2, "x".into()); + g.add(4, 3, "y".into()); + let t = g.into_table().unwrap(); + assert_eq!(t.header, vec!["C", "D"]); + assert_eq!(t.rows, vec![vec!["x".to_string(), "y".to_string()]]); + } + + #[test] + fn dc_title_reads_and_normalizes() { + let core = br#" Q3 + Budget & Plan "#; + assert_eq!(dc_title(core).as_deref(), Some("Q3 Budget & Plan")); + assert_eq!(dc_title(b" "), None); + assert_eq!(dc_title(b""), None); + assert_eq!(dc_title(b"\xff\xfe<<"), None); + let long = format!("{}", "x".repeat(5000)); + assert_eq!(dc_title(long.as_bytes()).unwrap().len(), MAX_TITLE_CHARS); + } + + fn summary_info(codepage: u16, typ: u32, value: &[u8], count: u32) -> Vec { + let mut d = vec![0u8; 48]; + d[0..2].copy_from_slice(&0xFFFEu16.to_le_bytes()); + d[24..28].copy_from_slice(&1u32.to_le_bytes()); + d[44..48].copy_from_slice(&48u32.to_le_bytes()); + // Property set: size, count, 2 × (pid, offset), codepage, title. + let mut set = Vec::new(); + set.extend_from_slice(&0u32.to_le_bytes()); + set.extend_from_slice(&2u32.to_le_bytes()); + set.extend_from_slice(&1u32.to_le_bytes()); + set.extend_from_slice(&24u32.to_le_bytes()); + set.extend_from_slice(&2u32.to_le_bytes()); + set.extend_from_slice(&32u32.to_le_bytes()); + set.extend_from_slice(&2u32.to_le_bytes()); + set.extend_from_slice(&u32::from(codepage).to_le_bytes()); + set.extend_from_slice(&typ.to_le_bytes()); + set.extend_from_slice(&count.to_le_bytes()); + set.extend_from_slice(value); + d.extend_from_slice(&set); + d + } + + #[test] + fn summary_title_decodes_codepages() { + let d = summary_info(1252, 0x1E, b"Sales\0", 6); + assert_eq!(summary_title(&d).as_deref(), Some("Sales")); + let d = summary_info(1252, 0x1E, b"Caf\xe9\0", 5); + assert_eq!(summary_title(&d).as_deref(), Some("Café")); + let d = summary_info(65001, 0x1E, "Звіт\0".as_bytes(), 9); + assert_eq!(summary_title(&d).as_deref(), Some("Звіт")); + let wide: Vec = "Hi\0".encode_utf16().flat_map(u16::to_le_bytes).collect(); + let d = summary_info(1200, 0x1F, &wide, 3); + assert_eq!(summary_title(&d).as_deref(), Some("Hi")); + } + + #[test] + fn summary_title_rejects_malformed() { + assert_eq!(summary_title(b""), None); + assert_eq!(summary_title(&[0xFE, 0xFF, 0, 0]), None); + // Length pointing past the stream. + let d = summary_info(1252, 0x1E, b"abc", u32::MAX); + assert_eq!(summary_title(&d), None); + // Property offset pointing past the stream. + let mut d = summary_info(1252, 0x1E, b"abc\0", 4); + d[48 + 20..48 + 24].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(summary_title(&d), None); + // Section offset pointing past the stream. + let mut d = summary_info(1252, 0x1E, b"abc\0", 4); + d[44..48].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(summary_title(&d), None); + } + #[test] fn biff_scan_rejects_inverted_mulrk() { let mut s = Vec::new(); diff --git a/crates/markkit/tests/common/mod.rs b/crates/markkit/tests/common/mod.rs index 47bbd2b..268d897 100644 --- a/crates/markkit/tests/common/mod.rs +++ b/crates/markkit/tests/common/mod.rs @@ -416,15 +416,70 @@ pub fn biff8_workbook(sheets: &[XlsSheet]) -> Vec { /// Wrap a workbook stream into an OLE2 compound file. pub fn xls_from_stream(stream: &[u8]) -> Vec { + ole2(&[("/Workbook", stream)]) +} + +/// Build an OLE2 compound file from `(path, data)` streams. +pub fn ole2(streams: &[(&str, &[u8])]) -> Vec { let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap(); - { - let mut s = comp.create_stream("/Workbook").unwrap(); - s.write_all(stream).unwrap(); + for (path, data) in streams { + let mut s = comp.create_stream(path).unwrap(); + s.write_all(data).unwrap(); } comp.flush().unwrap(); comp.into_inner().into_inner() } +/// `\u{5}SummaryInformation` property set with a code page and a VT_LPSTR title. +pub fn summary_information(codepage: u16, title: &[u8]) -> Vec { + let mut value = title.to_vec(); + value.push(0); + let mut set = Vec::new(); + for v in [ + 0u32, + 2, + 1, + 24, + 2, + 32, + 2, + u32::from(codepage), + 0x1E, + value.len() as u32, + ] { + set.extend_from_slice(&v.to_le_bytes()); + } + set.extend_from_slice(&value); + let mut d = vec![0u8; 48]; + d[0..2].copy_from_slice(&0xFFFEu16.to_le_bytes()); + d[24..28].copy_from_slice(&1u32.to_le_bytes()); + d[44..48].copy_from_slice(&48u32.to_le_bytes()); + d.extend_from_slice(&set); + d +} + +/// Rebuild a ZIP without the `drop` entries and with `add` appended. +pub fn rezip(bytes: &[u8], drop: &[&str], add: &[(&str, &[u8])]) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut entries = Vec::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + let name = f.name().unwrap().to_string(); + if drop.contains(&name.as_str()) { + continue; + } + let mut data = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut data).unwrap(); + entries.push((name, data)); + } + let mut refs: Vec<(&str, &[u8])> = entries + .iter() + .map(|(n, d)| (n.as_str(), d.as_slice())) + .collect(); + refs.extend_from_slice(add); + zip_bytes(&refs) +} + pub fn xls(sheets: &[XlsSheet]) -> Vec { xls_from_stream(&biff8_workbook(sheets)) } diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index 693ea25..ce64334 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -319,6 +319,24 @@ fn valid_fixtures() -> Vec> { dims: Some((0, 1, 0, 2)), cells: vec![XlsCell::Label(0, 0, "a"), XlsCell::Num(0, 1, 2.0)], }])); + // Title sources: XLS SummaryInformation, XLSX core.xml + no `_rels/.rels`. + let stream = biff8_workbook(&[XlsSheet { + name: "S", + dims: None, + cells: vec![XlsCell::Label(0, 0, "a")], + }]); + let summary = summary_information(1252, b"Title"); + v.push(ole2(&[ + ("/Workbook", &stream), + ("/\u{5}SummaryInformation", &summary), + ])); + let core = b"T"; + let book = xlsx_with( + &[Sheet::new("S", vec![vec![Cell::S("a")]])], + &[], + &[("docProps/core.xml", core)], + ); + v.push(rezip(&book, &["_rels/.rels"], &[])); } v } @@ -379,7 +397,7 @@ proptest! { /// Byte flips in valid documents never panic. #[test] fn mutated_fixtures_never_panic( - idx in 0usize..4, + idx in 0usize..6, flips in proptest::collection::vec((any::(), any::()), 1..16), ) { let fixtures = valid_fixtures(); diff --git a/crates/markkit/tests/spreadsheet_integration.rs b/crates/markkit/tests/spreadsheet_integration.rs index d976897..111e8c7 100644 --- a/crates/markkit/tests/spreadsheet_integration.rs +++ b/crates/markkit/tests/spreadsheet_integration.rs @@ -50,9 +50,10 @@ fn xlsx_multiple_sheets_and_empty_sheet_skipped() { let e = extract(&bytes); assert_eq!( e.markdown, - "## A\n\n| h |\n| --- |\n| 1 |\n\n## B\n\n| x | y |\n| --- | --- |\n" + "## A\n\n| h |\n| --- |\n| 1 |\n\n## B\n\n| A | B |\n| --- | --- |\n| x | y |\n" ); assert_eq!(e.stats.sheets, Some(2)); + assert_eq!(e.stats.rows, Some(2)); } #[test] @@ -275,3 +276,118 @@ fn xls_by_content_type() { .unwrap(); assert!(e.markdown.contains("| x |"), "{}", e.markdown); } + +// ─── Single-row sheets, titles, package quirks ────────────────────── + +#[test] +fn single_row_sheet_renders_as_data_under_column_letters() { + // One row starting at column C: header is the real column letters. + let data = r#"only7"#; + let bytes = xlsx_with( + &[Sheet::new("One", vec![])], + &[Some(worksheet_xml(data))], + &[], + ); + let e = extract(&bytes); + assert_eq!( + e.markdown, + "## One\n\n| C | D |\n| --- | --- |\n| only | 7 |\n" + ); + assert_eq!(e.stats.rows, Some(1)); + + // Two rows: the first is still the header. + let bytes = xlsx(&[Sheet::new("Two", vec![vec![S("h")], vec![S("v")]])]); + assert_eq!( + extract(&bytes).markdown, + "## Two\n\n| h |\n| --- |\n| v |\n" + ); +} + +#[test] +fn xlsx_title_from_core_properties() { + let core = br#"Quarterly & Annual"#; + let bytes = xlsx_with( + &[Sheet::new("S", vec![vec![S("h")], vec![S("v")]])], + &[], + &[("docProps/core.xml", core)], + ); + assert_eq!(extract(&bytes).title.as_deref(), Some("Quarterly & Annual")); + // No core properties: no title. + let bytes = xlsx(&[Sheet::new("S", vec![vec![S("v")]])]); + assert_eq!(extract(&bytes).title, None); +} + +#[test] +fn xlsx_malformed_core_properties_do_not_fail_extraction() { + let bytes = xlsx_with( + &[Sheet::new("S", vec![vec![S("h")], vec![S("v")]])], + &[], + &[( + "docProps/core.xml", + b"x".repeat(10_000), "t"); + let bytes = xlsx_with( + &[Sheet::new("S", vec![vec![S("v")]])], + &[], + &[("docProps/core.xml", deep.as_bytes())], + ); + assert_eq!(extract(&bytes).title, None); +} + +#[test] +fn ods_title_from_meta() { + let content = format!( + r#"{}"#, + ods_string_cell("v") + ); + let meta = br#"ODS Report"#; + let bytes = rezip(&ods(&content), &[], &[("meta.xml", meta)]); + let e = extract(&bytes); + assert_eq!(e.format, Format::Ods); + assert_eq!(e.title.as_deref(), Some("ODS Report")); +} + +#[test] +fn xls_title_from_summary_information() { + let stream = biff8_workbook(&[XlsSheet { + name: "S", + dims: None, + cells: vec![XlsCell::Label(0, 0, "h"), XlsCell::Label(1, 0, "v")], + }]); + let summary = summary_information(1252, b"Legacy Budget"); + let bytes = ole2(&[ + ("/Workbook", &stream), + ("/\u{5}SummaryInformation", &summary), + ]); + let e = extract(&bytes); + assert_eq!(e.format, Format::Xls); + assert_eq!(e.title.as_deref(), Some("Legacy Budget")); + + // Garbage property set: no title, extraction still succeeds. + let bytes = ole2(&[ + ("/Workbook", &stream), + ("/\u{5}SummaryInformation", b"\xFE\xFFjunk"), + ]); + let e = extract(&bytes); + assert_eq!(e.title, None); + assert!(e.markdown.contains("| v |")); +} + +#[test] +fn xlsx_without_package_relationships_still_parses() { + let bytes = xlsx(&[Sheet::new("S", vec![vec![S("h")], vec![S("v")]])]); + let bytes = rezip(&bytes, &["_rels/.rels"], &[]); + let e = extract(&bytes); + assert_eq!(e.markdown, "## S\n\n| h |\n| --- |\n| v |\n"); +} + +#[test] +fn xlsb_content_type_is_not_xlsx() { + let ct = "application/vnd.ms-excel.sheet.binary.macroEnabled.12"; + assert_eq!(Format::from_content_type(ct), Format::Xlsb); + assert_eq!(Format::from_file_name("book.xlsb"), Format::Xlsb); +} diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 7e3a825..cc7c292 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -44,13 +44,28 @@ impl Markkit { pub struct Extraction { // #[non_exhaustive] pub markdown: String, - pub title: Option, // DOCX core.xml dc:title, else first Title-style paragraph + pub title: Option, // see "Document titles" below pub format: Format, pub warnings: Vec, // ": " pub stats: ExtractionStats, // pages / sheets / rows / paragraphs / tables (Option each) } ``` +Non-empty `markdown` always ends with exactly one `\n` (POSIX text convention; outputs +concatenate cleanly); an empty document renders `""`. This is intended and stable. + +### Document titles + +| Format | Source | +|---|---| +| DOCX | `docProps/core.xml` `dc:title` (via `_rels/.rels`), else the first `Title`-style paragraph | +| XLSX | `docProps/core.xml` `dc:title` | +| ODS | `meta.xml` `dc:title` | +| XLS | `\u{5}SummaryInformation` property set, `PIDSI_TITLE` (code page aware: 1200 UTF-16, UTF-8, else Latin-1) | + +Whitespace is collapsed, titles are capped at 1024 characters, and a missing or +malformed property part yields `None` without failing the extraction. + `extract_bytes(bytes, content_type)` delegates to `extract_document_sync` and returns only the Markdown. Parsing is CPU-bound; async callers should use the sync variant in `spawn_blocking`. @@ -60,12 +75,14 @@ Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`. ## Format Detection Priority order (`Format::detect`): -1. Content type (`DocumentHint::content_type`; parameters ignored). Generic types such as +1. Content type (`DocumentHint::content_type`; parameters ignored). XLSB + (`application/vnd.ms-excel.sheet.binary.macroEnabled.12`) is matched before the + generic `ms-excel.sheet` → XLSX rule. Generic types such as `application/octet-stream` map to `Unknown` and fall through. 2. File extension (`DocumentHint::file_name`; URL query/fragment ignored). 3. Magic bytes (`Format::sniff`): - `PK\x03\x04` ZIP: ODF `mimetype` entry → ODS; `word/document*` → DOCX; - `xl/workbook.*` → XLSX; `ppt/presentation.*` → PPTX (part names are stored + `xl/workbook.bin` → XLSB; `xl/workbook.*` → XLSX; `ppt/presentation.*` → PPTX (part names are stored uncompressed, so no archive parsing is needed for detection). - OLE2 (`D0 CF 11 E0`): UTF-16 `WordDocument` stream → DOC; `Workbook`/`Book` → XLS. - `%PDF-` within the first 1 KiB → PDF. @@ -95,7 +112,11 @@ numbering through the main part's relationships. - One `## ` section per non-empty worksheet, in workbook order, followed by a GFM table. The first non-empty row is the header; fully empty rows and leading empty - columns are dropped. + columns are dropped. A sheet with a single non-empty row has no header: its columns + are labelled with their Excel letters (`| C | D |`) and the row renders as data + (counted in `stats.rows`). +- XLSX packages without `_rels/.rels` (hand-built files) get the standard + officeDocument relationship to `xl/workbook.xml` before calamine opens them. - Cells: numbers without trailing `.0` for integers; booleans `true`/`false`; dates as `YYYY-MM-DD[ HH:MM:SS]`, durations `H:MM:SS`; errors as Excel shows them (`#DIV/0!`). Pipes are escaped (`\|`), line breaks flattened to spaces. diff --git a/knowledge/log.md b/knowledge/log.md index 1b2ff07..d6797ed 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,8 @@ ## 2026-10-26 +* **Spreadsheet follow-ups (#11)**: single-row sheets render as data under column letters; XLSX/ODS/XLS titles come from document properties; XLSB content type no longer maps to XLSX; XLSX without `_rels/.rels` parses; trailing-newline output documented as intended; release profile switched to `panic = "unwind"` so `catch_unwind` is effective in markkit's own binaries. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. + * **URL fetch hardening**: T3 SSRF is mitigated. `extract_url` blocks non-public addresses by default (URL, resolver and redirect layers), enforces scheme, allowlist, redirect, body and time limits; new invariant S12 and `url_security` tests. Proxy-resolved names are the documented residual. See [Threat Model](security/threat-model.md) and [Security Model](security/security-model.md). * **Dependency trim**: unused dependencies removed (lockfile shrinks by ~460 lines; the `instant`/`paste` unmaintained advisories and their `deny.toml` ignores are gone with the stub bench deps). quick-xml moves to 0.42, one version shared with calamine; markkit's XML walkers now read `&str` names and values. See [Threat Model](security/threat-model.md) T6d and [Maintenance](operations/maintenance.md). * **Inter-block whitespace**: indented HTML lost blank lines between blocks and leaked a leading space into every following line, because whitespace-only text between blocks stayed in the block list as `Text(" ")`. The parser now normalizes block-context lists; golden fixtures, which had recorded the broken output, are regenerated. See [HTML Extraction](formats/html-extraction.md) and [IR Specification](foundations/ir.md). diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 49d78f1..d694522 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -187,7 +187,11 @@ process, or smuggling content past a human reviewer. DIMENSIONS, inverted MULRK column ranges, out-of-range sheet offsets). - XML: depth capped at 256, tree built iteratively, unclosed elements tolerated. - Parsers run under `catch_unwind` (defence in depth; only effective with - `panic = "unwind"` -- the workspace release profile uses `abort`). + `panic = "unwind"`, which the workspace release profile uses so the CLI and the + Python/Node bindings survive a parser panic; embedders building with `abort` lose + this layer). +- XLS `SummaryInformation` title parsing is bounds-checked (1 MiB stream cap, checked + offsets, 1024-property cap); failures yield no title, never an error. #### T6d: XML entity attacks (billion laughs, XXE) - quick-xml never expands DTD-declared or external entities; such references are dropped. @@ -213,6 +217,8 @@ process, or smuggling content past a human reviewer. `spreadsheet_integration::xlsx_sparse_far_cell_does_not_allocate_grid` - `docx_billion_laughs_not_expanded`, `docx_external_entity_not_resolved`, `docx_deep_nesting_does_not_overflow_stack` +- `spreadsheet::tests::summary_title_rejects_malformed`, + `spreadsheet_integration::xlsx_malformed_core_properties_do_not_fail_extraction` - `truncated_fixtures_never_panic`, `random_bytes_never_panic`, `random_container_bytes_never_panic`, `mutated_fixtures_never_panic` - `spreadsheet_integration::xlsx_hidden_sheet_skipped_by_default`, From c913ef07e209dbf33bbefb2d512de25e2f28fd86 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 23:53:56 +0000 Subject: [PATCH 2/5] fix(spreadsheet): single-row sheets, workbook titles, XLSB detection - Single-row sheets render the row as data under Excel column letters - Extraction.title from XLSX core.xml, ODS meta.xml, XLS SummaryInformation - XLSB content type / extension / container detected as Format::Xlsb, not Xlsx - XLSX without _rels/.rels gets the standard package relationship - Release profile uses panic = "unwind" so catch_unwind guards work - Document the trailing newline as intended Part of #11 --- CHANGELOG.md | 9 + Cargo.toml | 5 +- crates/markkit/src/extract.rs | 16 +- crates/markkit/src/format.rs | 21 ++ crates/markkit/src/lib.rs | 3 +- crates/markkit/src/spreadsheet.rs | 273 +++++++++++++++++- crates/markkit/tests/common/mod.rs | 61 +++- crates/markkit/tests/document_security.rs | 20 +- .../markkit/tests/spreadsheet_integration.rs | 118 +++++++- knowledge/formats/format-support.md | 30 +- knowledge/log.md | 1 + knowledge/security/threat-model.md | 8 +- 12 files changed, 533 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb6e175..408ca37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- Spreadsheets: a sheet with a single row now renders that row as data under Excel column letters + (`| A | B |`) instead of as a header with no rows. +- Spreadsheets: `Extraction.title` is filled from document properties (XLSX `docProps/core.xml`, + ODS `meta.xml`, XLS `SummaryInformation`). +- `Format::from_content_type` detects XLSB (`application/vnd.ms-excel.sheet.binary.macroEnabled.12`) + as the new `Format::Xlsb` instead of `Xlsx`; `.xlsb` files and `xl/workbook.bin` containers too. +- XLSX packages missing `_rels/.rels` no longer fail inside calamine. +- Release builds use `panic = "unwind"`, so a parser panic becomes an error instead of aborting the + CLI or the host Python/Node process. - Indented HTML (most real web pages) now keeps blank lines between blocks; previously headings, paragraphs, lists and tables after the first ran together with a leading space (`# A\n B` instead of `# A\n\nB`), which also broke headings and tables as markdown. diff --git a/Cargo.toml b/Cargo.toml index ab34c1a..4f0e07c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,5 +73,8 @@ proptest = "1" [profile.release] lto = "thin" codegen-units = 1 -panic = "abort" +# Unwind (not abort): `extract_document` wraps third-party parsers in +# `catch_unwind`, and PyO3/NAPI turn panics into exceptions. Abort would let one +# malformed document kill the CLI or the host Python/Node process. +panic = "unwind" strip = true diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index 7ce0a14..2c3c9c7 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -8,10 +8,12 @@ use crate::format::{DocumentHint, Format}; #[derive(Debug, Clone, PartialEq, Serialize)] #[non_exhaustive] pub struct Extraction { - /// Rendered Markdown. + /// Rendered Markdown. Non-empty output always ends with exactly one `\n` + /// (POSIX text convention, safe to concatenate); empty documents render `""`. pub markdown: String, - /// Document title when known (HTML `og:title` / JSON-LD / ``; - /// DOCX core properties or `Title` style). + /// Document title when known: HTML `og:title` / JSON-LD / `<title>`; DOCX + /// core properties or `Title` style; XLSX `docProps/core.xml`, ODS + /// `meta.xml`, XLS `SummaryInformation`. pub title: Option<String>, /// Format the document was parsed as. pub format: Format, @@ -81,7 +83,8 @@ impl Extraction { } /// Run a parser, converting a panic into an error (defence in depth for -/// third-party parsers; only effective when built with `panic = "unwind"`). +/// third-party parsers; only effective when built with `panic = "unwind"`, which +/// markkit's own release profile uses). #[cfg(any(feature = "docx", feature = "spreadsheet"))] fn guarded<T>( what: &str, @@ -208,7 +211,9 @@ impl Markkit { Format::Html => self.extract_html_document(&String::from_utf8_lossy(bytes)), Format::Pdf => self.extract_pdf_sync(bytes), Format::Docx => self.extract_docx(bytes), - Format::Xlsx | Format::Xls | Format::Ods => self.extract_spreadsheet(bytes), + Format::Xlsx | Format::Xlsb | Format::Xls | Format::Ods => { + self.extract_spreadsheet(bytes) + } Format::Doc => Err(Error::UnsupportedFormat( "legacy Word 97-2003 (.doc) documents are not supported".into(), )), @@ -266,6 +271,7 @@ impl Markkit { })?; let mut renderer = crate::render::MarkdownRenderer::new(); let mut e = Extraction::new(renderer.render(&out.doc), out.format); + e.title = out.title; e.warnings = out.warnings; e.stats.sheets = Some(out.sheets); e.stats.rows = Some(out.rows); diff --git a/crates/markkit/src/format.rs b/crates/markkit/src/format.rs index cf632a7..509604c 100644 --- a/crates/markkit/src/format.rs +++ b/crates/markkit/src/format.rs @@ -22,6 +22,8 @@ pub enum Format { Doc, /// Office Open XML spreadsheet (`.xlsx`, `.xlsm`, `.xltx`). Xlsx, + /// Excel binary workbook (`.xlsb`). + Xlsb, /// Legacy Excel 97-2003 binary (`.xls`). Xls, /// OpenDocument spreadsheet (`.ods`). @@ -76,6 +78,7 @@ impl Format { Format::Docx => "docx", Format::Doc => "doc", Format::Xlsx => "xlsx", + Format::Xlsb => "xlsb", Format::Xls => "xls", Format::Ods => "ods", Format::Pptx => "pptx", @@ -98,6 +101,8 @@ impl Format { Format::Docx } else if ct.contains("msword") { Format::Doc + } else if ct.contains("ms-excel.sheet.binary") { + Format::Xlsb } else if ct.contains("spreadsheetml") || ct.contains("ms-excel.sheet") { Format::Xlsx } else if ct.contains("ms-excel") { @@ -125,6 +130,7 @@ impl Format { "docx" | "docm" | "dotx" | "dotm" => Format::Docx, "doc" | "dot" => Format::Doc, "xlsx" | "xlsm" | "xltx" | "xltm" => Format::Xlsx, + "xlsb" => Format::Xlsb, "xls" | "xlt" => Format::Xls, "ods" => Format::Ods, "pptx" | "pptm" | "ppt" => Format::Pptx, @@ -198,6 +204,8 @@ fn sniff_zip(bytes: &[u8]) -> Format { Format::Ods } else if contains(bytes, b"word/document") { Format::Docx + } else if contains(bytes, b"xl/workbook.bin") { + Format::Xlsb } else if contains(bytes, b"xl/workbook.") { Format::Xlsx } else if contains(bytes, b"ppt/presentation.") { @@ -245,6 +253,18 @@ mod tests { ); assert_eq!(Format::from_content_type("application/pdf"), Format::Pdf); assert_eq!(Format::from_content_type("text/plain"), Format::PlainText); + assert_eq!( + Format::from_content_type("application/vnd.ms-excel.sheet.binary.macroEnabled.12"), + Format::Xlsb + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-excel.sheet.macroEnabled.12"), + Format::Xlsx + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-excel"), + Format::Xls + ); } #[test] @@ -252,6 +272,7 @@ mod tests { assert_eq!(Format::from_extension("html"), Format::Html); assert_eq!(Format::from_extension("PDF"), Format::Pdf); assert_eq!(Format::from_extension("docx"), Format::Docx); + assert_eq!(Format::from_extension("XLSB"), Format::Xlsb); } #[test] diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index e68e111..ff97b56 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -60,7 +60,8 @@ pub mod render; mod spreadsheet; #[cfg(feature = "tool")] pub mod tool; -#[cfg(feature = "docx")] +#[cfg(any(feature = "docx", feature = "spreadsheet"))] +#[cfg_attr(not(feature = "docx"), allow(dead_code))] // spreadsheet uses only `find`/`text` mod xml; pub use config::MarkkitBuilder; diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs index 2b04212..cca4777 100644 --- a/crates/markkit/src/spreadsheet.rs +++ b/crates/markkit/src/spreadsheet.rs @@ -34,6 +34,7 @@ use crate::render::escape_table_cell; pub(crate) struct SheetOutput { pub doc: Document, pub format: Format, + pub title: Option<String>, pub warnings: Vec<String>, pub sheets: usize, pub rows: usize, @@ -63,7 +64,8 @@ enum Container { } pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result<SheetOutput, Error> { - let container = if bytes.starts_with(b"PK") { + let mut patched = None; + let (container, title) = if bytes.starts_with(b"PK") { let mut zip = ZipGuard::open(bytes, config).map_err(archive_err)?; let has = |zip: &ZipGuard<'_>, name: &str| zip.names().any(|n| n.eq_ignore_ascii_case(name)); @@ -79,20 +81,29 @@ pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result<SheetOutput return Err(err("ZIP container is not an XLSX or ODS workbook")); }; zip.validate_all().map_err(archive_err)?; - if let Container::Ods = container { + let title = if let Container::Ods = container { let content = zip .read("content.xml") .map_err(archive_err)? .ok_or_else(|| err("ODS is missing content.xml"))?; prescan_ods(&content, config.max_spreadsheet_cells)?; - } - container + read_dc_title(&mut zip, "meta.xml")? + } else { + if !has(&zip, "_rels/.rels") { + patched = Some(with_package_rels(&mut zip)?); + } + read_dc_title(&mut zip, "docProps/core.xml")? + }; + (container, title) } else if bytes.starts_with(OLE2_MAGIC) { - prescan_xls(bytes, config)?; - Container::Xls + let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)) + .map_err(|e| err(format!("invalid XLS container: {e}")))?; + prescan_xls(&mut comp, config)?; + (Container::Xls, xls_title(&mut comp)) } else { return Err(err("not a spreadsheet (expected XLSX, XLS or ODS)")); }; + let bytes = patched.as_deref().unwrap_or(bytes); let mut out = Collector::new(config); match container { @@ -116,19 +127,19 @@ pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result<SheetOutput Err(e) => out.warn(format!("sheet_error: sheet '{name}': {e}")), } } - out.finish(Format::Xlsx) + out.finish(Format::Xlsx, title) } Container::Ods => { let mut wb: Ods<_> = Ods::new(Cursor::new(bytes)).map_err(err)?; let sel = out.select(wb.sheets_metadata()); read_ranges(&mut wb, sel, &mut out, config); - out.finish(Format::Ods) + out.finish(Format::Ods, title) } Container::Xls => { let mut wb: Xls<_> = Xls::new(Cursor::new(bytes)).map_err(err)?; let sel = out.select(wb.sheets_metadata()); read_ranges(&mut wb, sel, &mut out, config); - out.finish(Format::Xls) + out.finish(Format::Xls, title) } } } @@ -217,6 +228,19 @@ fn fmt_ref(d: &DataRef<'_>) -> String { // ─── Grid collection (bounded) ────────────────────────────────────── +/// Excel column letters for a 0-based index (`0` → `A`, `26` → `AA`). +fn col_name(c: u32) -> String { + let mut n = u64::from(c) + 1; + let mut out = Vec::new(); + while n > 0 { + n -= 1; + out.push(b'A' + (n % 26) as u8); + n /= 26; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + struct SheetTable { header: Vec<String>, rows: Vec<Vec<String>>, @@ -298,10 +322,19 @@ impl Grid { } row }); - let header = rows.next()?; + let first = rows.next()?; + let rest: Vec<Vec<String>> = rows.collect(); + // GFM tables need a header row. A sheet with a single row is data, not a + // header: label its columns A, B, … so the row renders as a data row. + let (header, rows) = if rest.is_empty() { + let letters = (min_col..min_col + width as u32).map(col_name).collect(); + (letters, vec![first]) + } else { + (first, rest) + }; Some(SheetTable { header, - rows: rows.collect(), + rows, truncated_rows: self.truncated_rows, truncated_cols: self.truncated_cols, }) @@ -396,10 +429,11 @@ impl<'c> Collector<'c> { }); } - fn finish(self, format: Format) -> Result<SheetOutput, Error> { + fn finish(self, format: Format, title: Option<String>) -> Result<SheetOutput, Error> { Ok(SheetOutput { doc: Document::new(self.nodes), format, + title, warnings: self.warnings, sheets: self.sheets, rows: self.rows, @@ -407,6 +441,128 @@ impl<'c> Collector<'c> { } } +// ─── Package fixes and document title ─────────────────────────────── + +/// Longest title kept (characters); longer titles are cut. +const MAX_TITLE_CHARS: usize = 1024; + +/// calamine locates the workbook through `_rels/.rels` and fails without it. +/// Rebuild the (already validated) archive with the standard relationship to +/// `xl/workbook.xml`; entries are copied raw, without recompression. +fn with_package_rels(zip: &mut ZipGuard<'_>) -> Result<Vec<u8>, Error> { + const RELS: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#; + let zerr = |e: zip::result::ZipError| err(format!("cannot rebuild XLSX package: {e}")); + let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..zip.archive.len() { + let entry = zip.archive.by_index_raw(i).map_err(zerr)?; + w.raw_copy_file(entry).map_err(zerr)?; + } + let opts = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("_rels/.rels", opts).map_err(zerr)?; + std::io::Write::write_all(&mut w, RELS.as_bytes()) + .map_err(|e| err(format!("cannot rebuild XLSX package: {e}")))?; + Ok(w.finish().map_err(zerr)?.into_inner()) +} + +fn clean_title(raw: &str) -> Option<String> { + let t: String = raw + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") + .chars() + .take(MAX_TITLE_CHARS) + .collect(); + (!t.is_empty()).then_some(t) +} + +/// `dc:title` from OOXML `docProps/core.xml` or ODF `meta.xml`. Best effort: +/// a missing or malformed part yields `None`; only archive limits are errors. +fn read_dc_title(zip: &mut ZipGuard<'_>, part: &str) -> Result<Option<String>, Error> { + let Some(xml) = zip.read(part).map_err(archive_err)? else { + return Ok(None); + }; + Ok(dc_title(&xml)) +} + +fn dc_title(xml: &[u8]) -> Option<String> { + let root = crate::xml::parse(xml).ok()?; + clean_title(&root.find("title")?.text()) +} + +/// Title (PIDSI_TITLE) from the XLS `\u{5}SummaryInformation` property set +/// ([MS-OLEPS]). Best effort: anything malformed yields `None`. +fn xls_title<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<String> { + const CAP: u64 = 1024 * 1024; + let mut data = Vec::new(); + comp.open_stream("/\u{5}SummaryInformation") + .ok()? + .take(CAP) + .read_to_end(&mut data) + .ok()?; + summary_title(&data) +} + +fn summary_title(d: &[u8]) -> Option<String> { + const PID_CODEPAGE: u32 = 1; + const PID_TITLE: u32 = 2; + const VT_I2: u32 = 0x0002; + const VT_LPSTR: u32 = 0x001E; + const VT_LPWSTR: u32 = 0x001F; + if read_u16(d, 0)? != 0xFFFE || read_u32(d, 24)? == 0 { + return None; + } + let set = read_u32(d, 44)? as usize; + let count = read_u32(d, set.checked_add(4)?)? as usize; + let mut codepage = None; + let mut title_at = None; + for i in 0..count.min(1024) { + let entry = set.checked_add(8 + i * 8)?; + let (pid, off) = (read_u32(d, entry)?, read_u32(d, entry + 4)? as usize); + let at = set.checked_add(off)?; + match pid { + PID_CODEPAGE if read_u32(d, at)? & 0xFFFF == VT_I2 => { + codepage = Some(read_u16(d, at + 4)?); + } + PID_TITLE => title_at = Some(at), + _ => {} + } + } + let at = title_at?; + let typ = read_u32(d, at)? & 0xFFFF; + let n = read_u32(d, at + 4)? as usize; + let start = at + 8; + let raw = match typ { + VT_LPWSTR => { + let bytes = d.get(start..start.checked_add(n.checked_mul(2)?)?)?; + let units: Vec<u16> = bytes + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect(); + String::from_utf16_lossy(&units) + } + VT_LPSTR => { + let bytes = d.get(start..start.checked_add(n)?)?; + match codepage { + Some(1200) => { + let units: Vec<u16> = bytes + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect(); + String::from_utf16_lossy(&units) + } + // UTF-8 when valid, otherwise Latin-1 (close to Windows-1252). + _ => match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => bytes.iter().map(|&b| char::from(b)).collect(), + }, + } + } + _ => return None, + }; + clean_title(raw.trim_end_matches('\0')) +} + // ─── XLS pre-scan (BIFF records) ──────────────────────────────────── fn read_u16(d: &[u8], at: usize) -> Option<u16> { @@ -430,9 +586,10 @@ fn records(stream: &[u8]) -> impl Iterator<Item = (u16, &[u8])> { }) } -fn prescan_xls(bytes: &[u8], config: &MarkkitBuilder) -> Result<(), Error> { - let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)) - .map_err(|e| err(format!("invalid XLS container: {e}")))?; +fn prescan_xls<F: Read + Seek>( + comp: &mut cfb::CompoundFile<F>, + config: &MarkkitBuilder, +) -> Result<(), Error> { let name = ["/Workbook", "/Book", "/WORKBOOK", "/BOOK"] .into_iter() .find(|n| comp.is_stream(n)) @@ -706,6 +863,92 @@ mod tests { assert!(t.truncated_rows); } + #[test] + fn column_names() { + assert_eq!(col_name(0), "A"); + assert_eq!(col_name(25), "Z"); + assert_eq!(col_name(26), "AA"); + assert_eq!(col_name(16_383), "XFD"); + assert_eq!(col_name(701), "ZZ"); + assert_eq!(col_name(702), "AAA"); + assert!(!col_name(u32::MAX).is_empty()); + } + + #[test] + fn single_row_becomes_data_under_column_letters() { + let cfg = MarkkitBuilder::default(); + let mut g = Grid::new(&cfg); + g.add(4, 2, "x".into()); + g.add(4, 3, "y".into()); + let t = g.into_table().unwrap(); + assert_eq!(t.header, vec!["C", "D"]); + assert_eq!(t.rows, vec![vec!["x".to_string(), "y".to_string()]]); + } + + #[test] + fn dc_title_reads_and_normalizes() { + let core = br#"<cp:coreProperties xmlns:cp="c" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title> Q3 + Budget & Plan </dc:title></cp:coreProperties>"#; + assert_eq!(dc_title(core).as_deref(), Some("Q3 Budget & Plan")); + assert_eq!(dc_title(b"<a><dc:title> </dc:title></a>"), None); + assert_eq!(dc_title(b"<a></a>"), None); + assert_eq!(dc_title(b"\xff\xfe<<"), None); + let long = format!("<title>{}", "x".repeat(5000)); + assert_eq!(dc_title(long.as_bytes()).unwrap().len(), MAX_TITLE_CHARS); + } + + fn summary_info(codepage: u16, typ: u32, value: &[u8], count: u32) -> Vec { + let mut d = vec![0u8; 48]; + d[0..2].copy_from_slice(&0xFFFEu16.to_le_bytes()); + d[24..28].copy_from_slice(&1u32.to_le_bytes()); + d[44..48].copy_from_slice(&48u32.to_le_bytes()); + // Property set: size, count, 2 × (pid, offset), codepage, title. + let mut set = Vec::new(); + set.extend_from_slice(&0u32.to_le_bytes()); + set.extend_from_slice(&2u32.to_le_bytes()); + set.extend_from_slice(&1u32.to_le_bytes()); + set.extend_from_slice(&24u32.to_le_bytes()); + set.extend_from_slice(&2u32.to_le_bytes()); + set.extend_from_slice(&32u32.to_le_bytes()); + set.extend_from_slice(&2u32.to_le_bytes()); + set.extend_from_slice(&u32::from(codepage).to_le_bytes()); + set.extend_from_slice(&typ.to_le_bytes()); + set.extend_from_slice(&count.to_le_bytes()); + set.extend_from_slice(value); + d.extend_from_slice(&set); + d + } + + #[test] + fn summary_title_decodes_codepages() { + let d = summary_info(1252, 0x1E, b"Sales\0", 6); + assert_eq!(summary_title(&d).as_deref(), Some("Sales")); + let d = summary_info(1252, 0x1E, b"Caf\xe9\0", 5); + assert_eq!(summary_title(&d).as_deref(), Some("Café")); + let d = summary_info(65001, 0x1E, "Звіт\0".as_bytes(), 9); + assert_eq!(summary_title(&d).as_deref(), Some("Звіт")); + let wide: Vec = "Hi\0".encode_utf16().flat_map(u16::to_le_bytes).collect(); + let d = summary_info(1200, 0x1F, &wide, 3); + assert_eq!(summary_title(&d).as_deref(), Some("Hi")); + } + + #[test] + fn summary_title_rejects_malformed() { + assert_eq!(summary_title(b""), None); + assert_eq!(summary_title(&[0xFE, 0xFF, 0, 0]), None); + // Length pointing past the stream. + let d = summary_info(1252, 0x1E, b"abc", u32::MAX); + assert_eq!(summary_title(&d), None); + // Property offset pointing past the stream. + let mut d = summary_info(1252, 0x1E, b"abc\0", 4); + d[48 + 20..48 + 24].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(summary_title(&d), None); + // Section offset pointing past the stream. + let mut d = summary_info(1252, 0x1E, b"abc\0", 4); + d[44..48].copy_from_slice(&u32::MAX.to_le_bytes()); + assert_eq!(summary_title(&d), None); + } + #[test] fn biff_scan_rejects_inverted_mulrk() { let mut s = Vec::new(); diff --git a/crates/markkit/tests/common/mod.rs b/crates/markkit/tests/common/mod.rs index 47bbd2b..268d897 100644 --- a/crates/markkit/tests/common/mod.rs +++ b/crates/markkit/tests/common/mod.rs @@ -416,15 +416,70 @@ pub fn biff8_workbook(sheets: &[XlsSheet]) -> Vec { /// Wrap a workbook stream into an OLE2 compound file. pub fn xls_from_stream(stream: &[u8]) -> Vec { + ole2(&[("/Workbook", stream)]) +} + +/// Build an OLE2 compound file from `(path, data)` streams. +pub fn ole2(streams: &[(&str, &[u8])]) -> Vec { let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap(); - { - let mut s = comp.create_stream("/Workbook").unwrap(); - s.write_all(stream).unwrap(); + for (path, data) in streams { + let mut s = comp.create_stream(path).unwrap(); + s.write_all(data).unwrap(); } comp.flush().unwrap(); comp.into_inner().into_inner() } +/// `\u{5}SummaryInformation` property set with a code page and a VT_LPSTR title. +pub fn summary_information(codepage: u16, title: &[u8]) -> Vec { + let mut value = title.to_vec(); + value.push(0); + let mut set = Vec::new(); + for v in [ + 0u32, + 2, + 1, + 24, + 2, + 32, + 2, + u32::from(codepage), + 0x1E, + value.len() as u32, + ] { + set.extend_from_slice(&v.to_le_bytes()); + } + set.extend_from_slice(&value); + let mut d = vec![0u8; 48]; + d[0..2].copy_from_slice(&0xFFFEu16.to_le_bytes()); + d[24..28].copy_from_slice(&1u32.to_le_bytes()); + d[44..48].copy_from_slice(&48u32.to_le_bytes()); + d.extend_from_slice(&set); + d +} + +/// Rebuild a ZIP without the `drop` entries and with `add` appended. +pub fn rezip(bytes: &[u8], drop: &[&str], add: &[(&str, &[u8])]) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut entries = Vec::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + let name = f.name().unwrap().to_string(); + if drop.contains(&name.as_str()) { + continue; + } + let mut data = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut data).unwrap(); + entries.push((name, data)); + } + let mut refs: Vec<(&str, &[u8])> = entries + .iter() + .map(|(n, d)| (n.as_str(), d.as_slice())) + .collect(); + refs.extend_from_slice(add); + zip_bytes(&refs) +} + pub fn xls(sheets: &[XlsSheet]) -> Vec { xls_from_stream(&biff8_workbook(sheets)) } diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index 693ea25..ce64334 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -319,6 +319,24 @@ fn valid_fixtures() -> Vec> { dims: Some((0, 1, 0, 2)), cells: vec![XlsCell::Label(0, 0, "a"), XlsCell::Num(0, 1, 2.0)], }])); + // Title sources: XLS SummaryInformation, XLSX core.xml + no `_rels/.rels`. + let stream = biff8_workbook(&[XlsSheet { + name: "S", + dims: None, + cells: vec![XlsCell::Label(0, 0, "a")], + }]); + let summary = summary_information(1252, b"Title"); + v.push(ole2(&[ + ("/Workbook", &stream), + ("/\u{5}SummaryInformation", &summary), + ])); + let core = b"T"; + let book = xlsx_with( + &[Sheet::new("S", vec![vec![Cell::S("a")]])], + &[], + &[("docProps/core.xml", core)], + ); + v.push(rezip(&book, &["_rels/.rels"], &[])); } v } @@ -379,7 +397,7 @@ proptest! { /// Byte flips in valid documents never panic. #[test] fn mutated_fixtures_never_panic( - idx in 0usize..4, + idx in 0usize..6, flips in proptest::collection::vec((any::(), any::()), 1..16), ) { let fixtures = valid_fixtures(); diff --git a/crates/markkit/tests/spreadsheet_integration.rs b/crates/markkit/tests/spreadsheet_integration.rs index d976897..111e8c7 100644 --- a/crates/markkit/tests/spreadsheet_integration.rs +++ b/crates/markkit/tests/spreadsheet_integration.rs @@ -50,9 +50,10 @@ fn xlsx_multiple_sheets_and_empty_sheet_skipped() { let e = extract(&bytes); assert_eq!( e.markdown, - "## A\n\n| h |\n| --- |\n| 1 |\n\n## B\n\n| x | y |\n| --- | --- |\n" + "## A\n\n| h |\n| --- |\n| 1 |\n\n## B\n\n| A | B |\n| --- | --- |\n| x | y |\n" ); assert_eq!(e.stats.sheets, Some(2)); + assert_eq!(e.stats.rows, Some(2)); } #[test] @@ -275,3 +276,118 @@ fn xls_by_content_type() { .unwrap(); assert!(e.markdown.contains("| x |"), "{}", e.markdown); } + +// ─── Single-row sheets, titles, package quirks ────────────────────── + +#[test] +fn single_row_sheet_renders_as_data_under_column_letters() { + // One row starting at column C: header is the real column letters. + let data = r#"only7"#; + let bytes = xlsx_with( + &[Sheet::new("One", vec![])], + &[Some(worksheet_xml(data))], + &[], + ); + let e = extract(&bytes); + assert_eq!( + e.markdown, + "## One\n\n| C | D |\n| --- | --- |\n| only | 7 |\n" + ); + assert_eq!(e.stats.rows, Some(1)); + + // Two rows: the first is still the header. + let bytes = xlsx(&[Sheet::new("Two", vec![vec![S("h")], vec![S("v")]])]); + assert_eq!( + extract(&bytes).markdown, + "## Two\n\n| h |\n| --- |\n| v |\n" + ); +} + +#[test] +fn xlsx_title_from_core_properties() { + let core = br#"Quarterly & Annual"#; + let bytes = xlsx_with( + &[Sheet::new("S", vec![vec![S("h")], vec![S("v")]])], + &[], + &[("docProps/core.xml", core)], + ); + assert_eq!(extract(&bytes).title.as_deref(), Some("Quarterly & Annual")); + // No core properties: no title. + let bytes = xlsx(&[Sheet::new("S", vec![vec![S("v")]])]); + assert_eq!(extract(&bytes).title, None); +} + +#[test] +fn xlsx_malformed_core_properties_do_not_fail_extraction() { + let bytes = xlsx_with( + &[Sheet::new("S", vec![vec![S("h")], vec![S("v")]])], + &[], + &[( + "docProps/core.xml", + b"x".repeat(10_000), "t"); + let bytes = xlsx_with( + &[Sheet::new("S", vec![vec![S("v")]])], + &[], + &[("docProps/core.xml", deep.as_bytes())], + ); + assert_eq!(extract(&bytes).title, None); +} + +#[test] +fn ods_title_from_meta() { + let content = format!( + r#"{}"#, + ods_string_cell("v") + ); + let meta = br#"ODS Report"#; + let bytes = rezip(&ods(&content), &[], &[("meta.xml", meta)]); + let e = extract(&bytes); + assert_eq!(e.format, Format::Ods); + assert_eq!(e.title.as_deref(), Some("ODS Report")); +} + +#[test] +fn xls_title_from_summary_information() { + let stream = biff8_workbook(&[XlsSheet { + name: "S", + dims: None, + cells: vec![XlsCell::Label(0, 0, "h"), XlsCell::Label(1, 0, "v")], + }]); + let summary = summary_information(1252, b"Legacy Budget"); + let bytes = ole2(&[ + ("/Workbook", &stream), + ("/\u{5}SummaryInformation", &summary), + ]); + let e = extract(&bytes); + assert_eq!(e.format, Format::Xls); + assert_eq!(e.title.as_deref(), Some("Legacy Budget")); + + // Garbage property set: no title, extraction still succeeds. + let bytes = ole2(&[ + ("/Workbook", &stream), + ("/\u{5}SummaryInformation", b"\xFE\xFFjunk"), + ]); + let e = extract(&bytes); + assert_eq!(e.title, None); + assert!(e.markdown.contains("| v |")); +} + +#[test] +fn xlsx_without_package_relationships_still_parses() { + let bytes = xlsx(&[Sheet::new("S", vec![vec![S("h")], vec![S("v")]])]); + let bytes = rezip(&bytes, &["_rels/.rels"], &[]); + let e = extract(&bytes); + assert_eq!(e.markdown, "## S\n\n| h |\n| --- |\n| v |\n"); +} + +#[test] +fn xlsb_content_type_is_not_xlsx() { + let ct = "application/vnd.ms-excel.sheet.binary.macroEnabled.12"; + assert_eq!(Format::from_content_type(ct), Format::Xlsb); + assert_eq!(Format::from_file_name("book.xlsb"), Format::Xlsb); +} diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index f1f915b..0ae6deb 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -44,7 +44,7 @@ impl Markkit { pub struct Extraction { // #[non_exhaustive] pub markdown: String, - pub title: Option, // HTML og:title / JSON-LD / ; DOCX core.xml dc:title, else Title style + pub title: Option<String>, // see "Document titles" below pub format: Format, pub warnings: Vec<String>, // "<code>: <message>" pub stats: ExtractionStats, // pages / sheets / rows / paragraphs / tables (Option each) @@ -52,6 +52,22 @@ pub struct Extraction { // #[non_exhaustive] } ``` +Non-empty `markdown` always ends with exactly one `\n` (POSIX text convention; outputs +concatenate cleanly); an empty document renders `""`. This is intended and stable. + +### Document titles + +| Format | Source | +|---|---| +| HTML | `og:title` / JSON-LD / `<title>` (see [HTML Extraction](html-extraction.md)) | +| DOCX | `docProps/core.xml` `dc:title` (via `_rels/.rels`), else the first `Title`-style paragraph | +| XLSX | `docProps/core.xml` `dc:title` | +| ODS | `meta.xml` `dc:title` | +| XLS | `\u{5}SummaryInformation` property set, `PIDSI_TITLE` (code page aware: 1200 UTF-16, UTF-8, else Latin-1) | + +Whitespace is collapsed, titles are capped at 1024 characters, and a missing or +malformed property part yields `None` without failing the extraction. + `extract_bytes(bytes, content_type)` delegates to `extract_document_sync` and returns only the Markdown. Parsing is CPU-bound; async callers should use the sync variant in `spawn_blocking`. @@ -61,12 +77,14 @@ Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`. ## Format Detection Priority order (`Format::detect`): -1. Content type (`DocumentHint::content_type`; parameters ignored). Generic types such as +1. Content type (`DocumentHint::content_type`; parameters ignored). XLSB + (`application/vnd.ms-excel.sheet.binary.macroEnabled.12`) is matched before the + generic `ms-excel.sheet` → XLSX rule. Generic types such as `application/octet-stream` map to `Unknown` and fall through. 2. File extension (`DocumentHint::file_name`; URL query/fragment ignored). 3. Magic bytes (`Format::sniff`): - `PK\x03\x04` ZIP: ODF `mimetype` entry → ODS; `word/document*` → DOCX; - `xl/workbook.*` → XLSX; `ppt/presentation.*` → PPTX (part names are stored + `xl/workbook.bin` → XLSB; `xl/workbook.*` → XLSX; `ppt/presentation.*` → PPTX (part names are stored uncompressed, so no archive parsing is needed for detection). - OLE2 (`D0 CF 11 E0`): UTF-16 `WordDocument` stream → DOC; `Workbook`/`Book` → XLS. - `%PDF-` within the first 1 KiB → PDF. @@ -96,7 +114,11 @@ numbering through the main part's relationships. - One `## <Sheet name>` section per non-empty worksheet, in workbook order, followed by a GFM table. The first non-empty row is the header; fully empty rows and leading empty - columns are dropped. + columns are dropped. A sheet with a single non-empty row has no header: its columns + are labelled with their Excel letters (`| C | D |`) and the row renders as data + (counted in `stats.rows`). +- XLSX packages without `_rels/.rels` (hand-built files) get the standard + officeDocument relationship to `xl/workbook.xml` before calamine opens them. - Cells: numbers without trailing `.0` for integers; booleans `true`/`false`; dates as `YYYY-MM-DD[ HH:MM:SS]`, durations `H:MM:SS`; errors as Excel shows them (`#DIV/0!`). Pipes are escaped (`\|`), line breaks flattened to spaces. diff --git a/knowledge/log.md b/knowledge/log.md index 38c2dc7..f639756 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-26 +* **Spreadsheet follow-ups (#11)**: single-row sheets render as data under column letters; XLSX/ODS/XLS titles come from document properties; XLSB content type no longer maps to XLSX; XLSX without `_rels/.rels` parses; trailing-newline output documented as intended; release profile switched to `panic = "unwind"` so `catch_unwind` is effective in markkit's own binaries. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. * **Page metadata**: HTML `extract_document` now fills `title` and a new `Extraction::metadata` (description, author, published, language, canonical URL, site name, image) from `<meta>`, Open Graph, `<link rel=canonical>` and bounded JSON-LD. Precedence table in [HTML Extraction](formats/html-extraction.md); scheme filter and JSON-LD bounds in [Threat Model](security/threat-model.md) T1 / T2f. * **IR v2**: footnotes, definition lists, task lists, figures, table alignment and spans; `Document.ir_version` (`IR_VERSION = 2`), `#[non_exhaustive]` IR types, `Node::is_block`/`is_inline`. HTML maps GitHub/Pandoc footnotes, `<dl>`, checkbox items, captioned figures, `colspan`/`rowspan`/alignment and row-header `<th>`. New threat T2e and invariant S13 (span grid budget, footnote label sanitizing). See [IR Specification](foundations/ir.md), [HTML Extraction](formats/html-extraction.md), [Threat Model](security/threat-model.md). * **Agent tool**: new [Tool Contract](integrations/tool-contract.md) concept in a new `integrations/` domain. `MarkkitTool` (opt-in `tool` feature) follows the Everruns toolkit-library contract; tool arguments are a new untrusted surface in the [Threat Model](security/threat-model.md). diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 66496af..80d3ffb 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -205,7 +205,11 @@ process, or smuggling content past a human reviewer. DIMENSIONS, inverted MULRK column ranges, out-of-range sheet offsets). - XML: depth capped at 256, tree built iteratively, unclosed elements tolerated. - Parsers run under `catch_unwind` (defence in depth; only effective with - `panic = "unwind"` -- the workspace release profile uses `abort`). + `panic = "unwind"`, which the workspace release profile uses so the CLI and the + Python/Node bindings survive a parser panic; embedders building with `abort` lose + this layer). +- XLS `SummaryInformation` title parsing is bounds-checked (1 MiB stream cap, checked + offsets, 1024-property cap); failures yield no title, never an error. #### T6d: XML entity attacks (billion laughs, XXE) - quick-xml never expands DTD-declared or external entities; such references are dropped. @@ -231,6 +235,8 @@ process, or smuggling content past a human reviewer. `spreadsheet_integration::xlsx_sparse_far_cell_does_not_allocate_grid` - `docx_billion_laughs_not_expanded`, `docx_external_entity_not_resolved`, `docx_deep_nesting_does_not_overflow_stack` +- `spreadsheet::tests::summary_title_rejects_malformed`, + `spreadsheet_integration::xlsx_malformed_core_properties_do_not_fail_extraction` - `truncated_fixtures_never_panic`, `random_bytes_never_panic`, `random_container_bytes_never_panic`, `mutated_fixtures_never_panic` - `spreadsheet_integration::xlsx_hidden_sheet_skipped_by_default`, From 7322499159afd6df97e4d0a1fb312358e4368686 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 00:03:23 +0000 Subject: [PATCH 3/5] fix(spreadsheet): satisfy clippy chunks_exact_to_as_chunks --- crates/markkit/src/spreadsheet.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs index cca4777..8cb5787 100644 --- a/crates/markkit/src/spreadsheet.rs +++ b/crates/markkit/src/spreadsheet.rs @@ -503,6 +503,13 @@ fn xls_title<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<String> summary_title(&data) } +/// Decode UTF-16LE (lossy; a trailing odd byte is ignored). +fn utf16le(bytes: &[u8]) -> String { + let (pairs, _) = bytes.as_chunks::<2>(); + let units: Vec<u16> = pairs.iter().map(|b| u16::from_le_bytes(*b)).collect(); + String::from_utf16_lossy(&units) +} + fn summary_title(d: &[u8]) -> Option<String> { const PID_CODEPAGE: u32 = 1; const PID_TITLE: u32 = 2; @@ -534,23 +541,12 @@ fn summary_title(d: &[u8]) -> Option<String> { let start = at + 8; let raw = match typ { VT_LPWSTR => { - let bytes = d.get(start..start.checked_add(n.checked_mul(2)?)?)?; - let units: Vec<u16> = bytes - .chunks_exact(2) - .map(|b| u16::from_le_bytes([b[0], b[1]])) - .collect(); - String::from_utf16_lossy(&units) + utf16le(d.get(start..start.checked_add(n.checked_mul(2)?)?)?) } VT_LPSTR => { let bytes = d.get(start..start.checked_add(n)?)?; match codepage { - Some(1200) => { - let units: Vec<u16> = bytes - .chunks_exact(2) - .map(|b| u16::from_le_bytes([b[0], b[1]])) - .collect(); - String::from_utf16_lossy(&units) - } + Some(1200) => utf16le(bytes), // UTF-8 when valid, otherwise Latin-1 (close to Windows-1252). _ => match std::str::from_utf8(bytes) { Ok(s) => s.to_string(), From 86b7104c3bd9c9ee7367f95fef285bb6a4cc3865 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 00:32:25 +0000 Subject: [PATCH 4/5] chore(knowledge): tidy log after merge --- knowledge/log.md | 1 - 1 file changed, 1 deletion(-) diff --git a/knowledge/log.md b/knowledge/log.md index 71fdff0..440e1e5 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -4,7 +4,6 @@ * **Spreadsheet follow-ups (#11)**: single-row sheets render as data under column letters; XLSX/ODS/XLS titles come from document properties; XLSB content type no longer maps to XLSX; XLSX without `_rels/.rels` parses; trailing-newline output documented as intended; release profile switched to `panic = "unwind"` so `catch_unwind` is effective in markkit's own binaries. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. * **Node options parity**: [Node.js Package](runtimes/node-package.md) functions take a trailing options object (all builder fields plus URL policy) and gain `extractDocument`/`extractDocumentSync`; the parity table now covers Python, Node and WASM. - * **WASM package**: new [WASM Package](runtimes/wasm-package.md) concept for `@everruns/markkit-wasm` (wasm-bindgen, single-threaded, no URL fetching), CI job `wasm` gating `Check`, and `publish-wasm.yml`. [Release Process](operations/release-process.md) bumps both npm `package.json` versions. * **Ship contract**: "ship" now ends with the merge commit's `main` CI green, not at merge; the `ship` skill gains existing-PR, auto-merge, and post-merge verification steps aligned with bashkit. See [Maintenance](operations/maintenance.md). * **IR provenance**: optional per-block `Source` (HTML XPath, DOCX body index, sheet + A1 range, page) behind `track_sources`; `parse_document` / `ParsedDocument` expose the IR for every format. XPath sibling indexes are cached per parent (`html_security::source_tracking_many_siblings_is_linear`). See [IR Specification](foundations/ir.md) and [Markkit Architecture](foundations/architecture.md). From a6a7d7d868fd936e0ceeff341b2f19700aae1a76 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 00:01:41 +0000 Subject: [PATCH 5/5] feat(spreadsheet): read XLSB workbooks with a bounds-checked BIFF12 reader --- CHANGELOG.md | 3 + README.md | 6 +- crates/markkit/src/extract.rs | 2 +- crates/markkit/src/lib.rs | 2 +- crates/markkit/src/spreadsheet.rs | 88 ++- crates/markkit/src/spreadsheet/xlsb.rs | 635 ++++++++++++++++++ crates/markkit/tests/common/mod.rs | 111 +++ crates/markkit/tests/document_security.rs | 67 +- crates/markkit/tests/fixtures/xlsb/README.md | 8 + crates/markkit/tests/fixtures/xlsb/date.xlsb | Bin 0 -> 7711 bytes .../markkit/tests/fixtures/xlsb/issues.xlsb | Bin 0 -> 19226 bytes .../markkit/tests/spreadsheet_integration.rs | 141 +++- knowledge/formats/format-support.md | 11 +- knowledge/log.md | 1 + knowledge/operations/testing.md | 2 +- knowledge/security/threat-model.md | 12 +- 16 files changed, 1071 insertions(+), 18 deletions(-) create mode 100644 crates/markkit/src/spreadsheet/xlsb.rs create mode 100644 crates/markkit/tests/fixtures/xlsb/README.md create mode 100644 crates/markkit/tests/fixtures/xlsb/date.xlsb create mode 100644 crates/markkit/tests/fixtures/xlsb/issues.xlsb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afb309..0975f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- XLSB (Excel binary workbook) support in the `spreadsheet` feature, via a new bounds-checked + reader: values, shared strings, dates and durations, hidden-sheet skipping, row/column caps and + the workbook title. Previously XLSB returned `UnsupportedFormat`. - HTML page metadata: `extract_document` fills `Extraction::title` (Open Graph, JSON-LD or `<title>`) and the new `Extraction::metadata` (description, author, published date, language, canonical URL, site name, lead image) from `<meta>`, Open Graph, `<link rel=canonical>` and diff --git a/README.md b/README.md index b39c035..aa1ed76 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ package for browsers and edge runtimes. - **HTML** – Web pages and HTML documents; nav, header, footer, scripts, styles and forms stripped by default - **DOCX** – Headings, lists, tables, links, emphasis -- **Spreadsheets** – XLSX/XLSM, XLS and ODS workbooks to markdown tables, one section per sheet +- **Spreadsheets** – XLSX/XLSM, XLSB, XLS and ODS workbooks to markdown tables, one section per sheet - **Format detection** – Content type, then file extension, then magic bytes - **URL fetching** – Fetch and extract in one call (`http_client` feature) - **Configurable** – CSS include/exclude selectors, link/image preservation, resource limits @@ -31,9 +31,9 @@ package for browsers and edge runtimes. |---|---|---| | HTML / XHTML | Supported | `html` (default) | | DOCX | Supported | `docx` | -| XLSX, XLSM, XLS, ODS | Supported | `spreadsheet` | +| XLSX, XLSM, XLSB, XLS, ODS | Supported | `spreadsheet` | | PDF | Planned | `pdf` | -| DOC, PPTX, XLSB | Detected, rejected explicitly | – | +| DOC, PPTX | Detected, rejected explicitly | – | ## Quick Start diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index 3a89da4..1bcf082 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -214,7 +214,7 @@ impl Markkit { .map(|e| e.markdown) } - /// Convert a document (HTML, DOCX, XLSX/XLS/ODS) to Markdown with metadata. + /// Convert a document (HTML, DOCX, XLSX/XLSB/XLS/ODS) to Markdown with metadata. /// /// The format is detected from `hint.content_type`, then the extension of /// `hint.file_name`, then magic bytes. Parsing is CPU-bound and runs inline; diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index 66f11a1..dba904b 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -33,7 +33,7 @@ //! //! - **HTML** (`html`, default): Convert HTML to markdown with configurable rules //! - **DOCX** (`docx`): Convert Word documents -//! - **Spreadsheet** (`spreadsheet`): Convert XLSX/XLSM, XLS and ODS workbooks to tables +//! - **Spreadsheet** (`spreadsheet`): Convert XLSX/XLSM, XLSB, XLS and ODS workbooks to tables //! - **PDF** (`pdf`): Planned; currently returns [`Error::UnsupportedFormat`] //! - **URL fetching** (`http_client`, default): Fetch and extract from URLs via reqwest //! - **Agent tool** (`tool`): `markkit::tool::MarkkitTool`, an LLM tool following the diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs index 1df6fbe..f3bf055 100644 --- a/crates/markkit/src/spreadsheet.rs +++ b/crates/markkit/src/spreadsheet.rs @@ -16,7 +16,9 @@ //! executed or rendered; dialog/macro/chart sheets are skipped. //! - Hidden sheets are skipped by default (content invisible to a human reviewer //! is a prompt-injection vector); opt in with `include_hidden_sheets`. -//! - XLSB is detected but not supported (no grid pre-scan implemented). +//! - XLSB is read by our own bounds-checked BIFF12 reader ([`xlsb`]), streaming +//! cells into the same capped grid; calamine's XLSB reader panics on +//! malformed records. use std::collections::BTreeMap; use std::io::{Cursor, Read, Seek}; @@ -31,6 +33,8 @@ use crate::format::Format; use crate::ir::{Document, Node, Source, TableCell}; use crate::render::escape_table_cell; +mod xlsb; + pub(crate) struct SheetOutput { pub doc: Document, pub format: Format, @@ -59,6 +63,7 @@ const ODS_MAX_COLS: u64 = 16_384; #[derive(Clone, Copy)] enum Container { Xlsx, + Xlsb, Ods, Xls, } @@ -72,15 +77,17 @@ pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result<SheetOutput let container = if has(&zip, "xl/workbook.xml") { Container::Xlsx } else if has(&zip, "xl/workbook.bin") { - return Err(Error::UnsupportedFormat( - "XLSB (binary Excel workbook) is not supported".into(), - )); + Container::Xlsb } else if has(&zip, "mimetype") || has(&zip, "content.xml") { Container::Ods } else { - return Err(err("ZIP container is not an XLSX or ODS workbook")); + return Err(err("ZIP container is not an XLSX, XLSB or ODS workbook")); }; zip.validate_all().map_err(archive_err)?; + if let Container::Xlsb = container { + let title = read_dc_title(&mut zip, "docProps/core.xml")?; + return parse_xlsb(&mut zip, config, title); + } let title = if let Container::Ods = container { let content = zip .read("content.xml") @@ -101,7 +108,7 @@ pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result<SheetOutput prescan_xls(&mut comp, config)?; (Container::Xls, xls_title(&mut comp)) } else { - return Err(err("not a spreadsheet (expected XLSX, XLS or ODS)")); + return Err(err("not a spreadsheet (expected XLSX, XLSB, XLS or ODS)")); }; let bytes = patched.as_deref().unwrap_or(bytes); @@ -141,9 +148,58 @@ pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result<SheetOutput read_ranges(&mut wb, sel, &mut out, config); out.finish(Format::Xls, title) } + Container::Xlsb => unreachable!("XLSB returns early"), } } +fn parse_xlsb( + zip: &mut ZipGuard<'_>, + config: &MarkkitBuilder, + title: Option<String>, +) -> Result<SheetOutput, Error> { + let wb = xlsb::open(zip)?; + let meta: Vec<calamine::Sheet> = wb + .sheets + .iter() + .map(|s| calamine::Sheet { + name: s.name.clone(), + // Chart / dialog / macro sheets and dangling relationships have no + // worksheet part and are skipped like other non-worksheets. + typ: if s.part.is_some() { + SheetType::WorkSheet + } else { + SheetType::ChartSheet + }, + visible: match s.visibility { + xlsb::Visibility::Visible => SheetVisible::Visible, + xlsb::Visibility::Hidden => SheetVisible::Hidden, + }, + }) + .collect(); + let mut out = Collector::new(config); + let selected = out.select(&meta); + for ((name, include), sheet) in selected.into_iter().zip(&wb.sheets) { + let Some(part) = sheet.part.as_deref().filter(|_| include) else { + continue; + }; + let Some(data) = zip.read(part).map_err(archive_err)? else { + out.warn(format!("sheet_error: sheet '{name}': missing part {part}")); + continue; + }; + let mut grid = Grid::new(config); + let res = wb.cells(&data).try_for_each(|cell| { + let (r, c, v) = cell?; + grid.add(r, c, fmt_xlsb(&v, wb.is_1904)); + Ok::<_, Error>(()) + }); + match res { + Ok(()) => out.push_sheet(&name, grid), + Err(e) => out.warn(format!("sheet_error: sheet '{name}': {e}")), + } + } + out.finish(Format::Xlsb, title) +} + fn read_ranges<RS, R>( wb: &mut R, sel: Vec<(String, bool)>, @@ -219,6 +275,26 @@ fn fmt_data(d: &Data) -> String { } } +fn fmt_xlsb(v: &xlsb::Value, is_1904: bool) -> String { + use calamine::{ExcelDateTime, ExcelDateTimeType}; + match v { + xlsb::Value::Number(f) => fmt_float(*f), + xlsb::Value::DateTime(f) => fmt_datetime(&ExcelDateTime::new( + *f, + ExcelDateTimeType::DateTime, + is_1904, + )), + xlsb::Value::Duration(f) => fmt_datetime(&ExcelDateTime::new( + *f, + ExcelDateTimeType::TimeDelta, + is_1904, + )), + xlsb::Value::Text(s) => s.clone(), + xlsb::Value::Bool(b) => b.to_string(), + xlsb::Value::Error(e) => (*e).to_string(), + } +} + fn fmt_ref(d: &DataRef<'_>) -> String { match d { DataRef::SharedString(s) => (*s).to_string(), diff --git a/crates/markkit/src/spreadsheet/xlsb.rs b/crates/markkit/src/spreadsheet/xlsb.rs new file mode 100644 index 0000000..746bafb --- /dev/null +++ b/crates/markkit/src/spreadsheet/xlsb.rs @@ -0,0 +1,635 @@ +//! XLSB (Excel binary workbook, BIFF12 records in an OPC ZIP) reader. +//! +//! Decisions: +//! - Own reader, not calamine's: calamine's XLSB path indexes record buffers +//! and shared-string tables without bounds checks, so malformed records panic +//! (fatal under `panic = "abort"`). Every read here is checked; malformed +//! records are skipped or fail the part with an error, never a panic. +//! - Parts are read through [`ZipGuard`] (bounded decompression) after the +//! whole archive is validated, so no part can exceed archive limits. +//! - Like the XLSX path, cells stream into the capped [`Grid`](super) — no dense +//! grid, whatever `BrtWsDim` claims. +//! - Only cached cell values are read: formulas are never evaluated, VBA +//! (`vbaProject.bin`) is never read. +//! - Dates: a numeric cell is a date when its XF's number format is a built-in +//! date/time id or a custom code with date/time tokens ([MS-XLSB] 2.4.353). + +use crate::archive::ZipGuard; +use crate::error::Error; + +fn err(msg: impl std::fmt::Display) -> Error { + Error::SpreadsheetExtract(format!("XLSB: {msg}")) +} + +// ─── Records ──────────────────────────────────────────────────────── + +/// Iterate BIFF12 records `(type, payload)`. Type: 1–2 bytes, 7 bits each; +/// size: 1–4 bytes, 7 bits each ([MS-XLSB] 2.1.4). Stops with an error on a +/// truncated record. +pub(crate) fn records(data: &[u8]) -> impl Iterator<Item = Result<(u16, &[u8]), Error>> { + let mut pos = 0usize; + let mut failed = false; + std::iter::from_fn(move || { + if failed || pos >= data.len() { + return None; + } + let mut next = || { + let b = *data.get(pos)?; + pos += 1; + Some(b) + }; + let rec = (|| { + let b0 = next()?; + let mut typ = u16::from(b0 & 0x7F); + if b0 & 0x80 != 0 { + typ |= u16::from(next()? & 0x7F) << 7; + } + let mut len = 0usize; + for i in 0..4 { + let b = next()?; + len |= usize::from(b & 0x7F) << (7 * i); + if b & 0x80 == 0 { + break; + } + } + Some((typ, len)) + })(); + let Some((typ, len)) = rec else { + failed = true; + return Some(Err(err("truncated record header"))); + }; + let Some(payload) = pos.checked_add(len).and_then(|end| data.get(pos..end)) else { + failed = true; + return Some(Err(err(format!("record 0x{typ:04X} overruns its part")))); + }; + pos += len; + Some(Ok((typ, payload))) + }) +} + +fn u16_at(d: &[u8], at: usize) -> Option<u16> { + Some(u16::from_le_bytes( + d.get(at..at.checked_add(2)?)?.try_into().ok()?, + )) +} + +fn u32_at(d: &[u8], at: usize) -> Option<u32> { + Some(u32::from_le_bytes( + d.get(at..at.checked_add(4)?)?.try_into().ok()?, + )) +} + +fn f64_at(d: &[u8], at: usize) -> Option<f64> { + Some(f64::from_le_bytes( + d.get(at..at.checked_add(8)?)?.try_into().ok()?, + )) +} + +/// `XLWideString` at `at`: u32 character count + UTF-16LE. Returns the string +/// and the offset just past it. `0xFFFFFFFF` (nullable form) reads as empty. +fn wide_str(d: &[u8], at: usize) -> Option<(String, usize)> { + let n = u32_at(d, at)?; + if n == u32::MAX { + return Some((String::new(), at + 4)); + } + let start = at + 4; + let end = start.checked_add(usize::try_from(n).ok()?.checked_mul(2)?)?; + Some((super::utf16le(d.get(start..end)?), end)) +} + +// ─── Workbook ─────────────────────────────────────────────────────── + +/// Sheet visibility from `BrtBundleSh.hsState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Visibility { + Visible, + Hidden, +} + +#[derive(Debug)] +pub(crate) struct SheetRef { + pub name: String, + /// Archive path of the sheet part; `None` for non-worksheet sheets + /// (chart, dialog, macro) or a dangling relationship. + pub part: Option<String>, + pub visibility: Visibility, +} + +#[derive(Debug, Default)] +pub(crate) struct Workbook { + pub sheets: Vec<SheetRef>, + pub is_1904: bool, + strings: Vec<String>, + /// Per cell XF: number format kind. + xf_kinds: Vec<NumKind>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NumKind { + Number, + DateTime, + Duration, +} + +const WORKSHEET_REL: &str = "/relationships/worksheet"; + +pub(crate) fn open(zip: &mut ZipGuard<'_>) -> Result<Workbook, Error> { + let a = super::archive_err; + let workbook = zip + .read("xl/workbook.bin") + .map_err(a)? + .ok_or_else(|| err("missing xl/workbook.bin"))?; + let rels = zip + .read("xl/_rels/workbook.bin.rels") + .map_err(a)? + .map(|x| parse_rels(&x)) + .unwrap_or_default(); + let mut wb = Workbook::default(); + read_workbook(&workbook, &rels, &mut wb)?; + if let Some(sst) = zip.read("xl/sharedStrings.bin").map_err(a)? { + wb.strings = read_shared_strings(&sst)?; + } + if let Some(styles) = zip.read("xl/styles.bin").map_err(a)? { + wb.xf_kinds = read_styles(&styles); + } + Ok(wb) +} + +/// `(Id, Type, Target)` from `xl/_rels/workbook.bin.rels`; malformed XML → empty. +fn parse_rels(xml: &[u8]) -> Vec<(String, String, String)> { + let Ok(root) = crate::xml::parse(xml) else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut stack = vec![&root]; + while let Some(el) = stack.pop() { + for c in el.elements() { + if c.name == "Relationship" { + if let (Some(id), Some(t), Some(target)) = + (c.attr("Id"), c.attr("Type"), c.attr("Target")) + { + out.push((id.to_string(), t.to_string(), target.to_string())); + } + } else { + stack.push(c); + } + } + } + out +} + +/// Resolve a relationship target relative to `xl/`. +fn resolve_target(target: &str) -> String { + match target.strip_prefix('/') { + Some(abs) => abs.to_string(), + None => format!("xl/{target}"), + } +} + +fn read_workbook( + data: &[u8], + rels: &[(String, String, String)], + wb: &mut Workbook, +) -> Result<(), Error> { + for rec in records(data) { + let (typ, d) = rec?; + match typ { + // BrtWbProp: flags, bit 0 = f1904. + 0x0099 => wb.is_1904 = u32_at(d, 0).is_some_and(|f| f & 1 != 0), + // BrtBundleSh: hsState, iTabID, strRelID, strName. + 0x009C => { + let malformed = || err("malformed sheet entry in workbook.bin"); + let state = u32_at(d, 0).ok_or_else(malformed)?; + let (rel_id, next) = wide_str(d, 8).ok_or_else(malformed)?; + let (name, _) = wide_str(d, next).ok_or_else(malformed)?; + let part = rels + .iter() + .find(|(id, t, _)| *id == rel_id && t.ends_with(WORKSHEET_REL)) + .map(|(_, _, target)| resolve_target(target)); + wb.sheets.push(SheetRef { + name, + part, + visibility: if state == 0 { + Visibility::Visible + } else { + Visibility::Hidden + }, + }); + } + // BrtEndBundleShs + 0x0090 => break, + _ => {} + } + } + Ok(()) +} + +fn read_shared_strings(data: &[u8]) -> Result<Vec<String>, Error> { + let mut out = Vec::new(); + for rec in records(data) { + let (typ, d) = rec?; + // BrtSSTItem: RichStr = flags u8 + XLWideString (+ optional runs). + if typ == 0x0013 { + out.push(wide_str(d, 1).map(|(s, _)| s).unwrap_or_default()); + } + } + Ok(out) +} + +/// Number format kind per cell XF. Best effort: malformed styles → no dates. +fn read_styles(data: &[u8]) -> Vec<NumKind> { + let mut custom = std::collections::HashMap::new(); + let mut kinds = Vec::new(); + let mut in_cell_xfs = false; + for rec in records(data) { + let Ok((typ, d)) = rec else { break }; + match typ { + // BrtFmt: ifmt u16 + stFmtCode. + 0x002C => { + if let (Some(id), Some((code, _))) = (u16_at(d, 0), wide_str(d, 2)) { + custom.insert(id, custom_kind(&code)); + } + } + 0x0269 => in_cell_xfs = true, + 0x026A => in_cell_xfs = false, + // BrtXF: ixfeParent u16, iFmt u16. + 0x002F if in_cell_xfs => { + let id = u16_at(d, 2).unwrap_or(0); + kinds.push(custom.get(&id).copied().unwrap_or_else(|| builtin_kind(id))); + } + _ => {} + } + } + kinds +} + +fn builtin_kind(id: u16) -> NumKind { + match id { + 14..=22 | 27..=36 | 45 | 47 | 50..=58 => NumKind::DateTime, + 46 => NumKind::Duration, + _ => NumKind::Number, + } +} + +/// Classify a custom number format code: date/time tokens outside quoted +/// literals, escapes and `[...]` sections (except elapsed `[h]`, `[m]`, `[s]`). +fn custom_kind(code: &str) -> NumKind { + // Only the first section (positive numbers) matters. + let mut chars = code.chars().peekable(); + let (mut date, mut elapsed) = (false, false); + while let Some(c) = chars.next() { + match c { + ';' => break, + '"' => { + for q in chars.by_ref() { + if q == '"' { + break; + } + } + } + '\\' | '_' | '*' => { + chars.next(); + } + '[' => { + let mut inner = String::new(); + for b in chars.by_ref() { + if b == ']' { + break; + } + inner.push(b); + } + let lower = inner.to_ascii_lowercase(); + if !lower.is_empty() && lower.chars().all(|c| matches!(c, 'h' | 'm' | 's')) { + elapsed = true; + } + } + 'y' | 'Y' | 'd' | 'D' | 'h' | 'H' | 's' | 'S' | 'm' | 'M' => date = true, + _ => {} + } + } + if elapsed { + NumKind::Duration + } else if date && !code.eq_ignore_ascii_case("general") { + NumKind::DateTime + } else { + NumKind::Number + } +} + +// ─── Cells ────────────────────────────────────────────────────────── + +/// A cell value as read from a BIFF12 cell record. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Value { + Number(f64), + DateTime(f64), + Duration(f64), + Text(String), + Bool(bool), + Error(&'static str), +} + +fn error_text(code: u8) -> &'static str { + match code { + 0x00 => "#NULL!", + 0x07 => "#DIV/0!", + 0x0F => "#VALUE!", + 0x17 => "#REF!", + 0x1D => "#NAME?", + 0x24 => "#NUM!", + 0x2A => "#N/A", + 0x2B => "#GETTING_DATA", + _ => "#ERROR!", + } +} + +/// `RkNumber` ([MS-XLSB] 2.5.122). +fn rk(v: u32) -> f64 { + let n = if v & 2 != 0 { + f64::from((v as i32) >> 2) + } else { + f64::from_bits(u64::from(v & 0xFFFF_FFFC) << 32) + }; + if v & 1 != 0 { n / 100.0 } else { n } +} + +impl Workbook { + /// Stream a worksheet's cells as `(row, col, value)`. Records outside + /// `BrtBeginSheetData`…`BrtEndSheetData` are ignored; malformed cell records + /// are skipped; a truncated part ends the stream with an error. + pub(crate) fn cells<'d>( + &'d self, + data: &'d [u8], + ) -> impl Iterator<Item = Result<(u32, u32, Value), Error>> + 'd { + let mut recs = records(data); + let mut in_data = false; + let mut row = 0u32; + std::iter::from_fn(move || { + loop { + let (typ, d) = match recs.next()? { + Ok(r) => r, + Err(e) => return Some(Err(e)), + }; + match typ { + 0x0091 => in_data = true, + 0x0092 => return None, + 0x0000 if in_data => row = u32_at(d, 0).unwrap_or(row), + 0x0002..=0x000B if in_data => { + if let Some(cell) = self.cell(typ, d) { + return Some(Ok((row, cell.0, cell.1))); + } + } + _ => {} + } + } + }) + } + + fn cell(&self, typ: u16, d: &[u8]) -> Option<(u32, Value)> { + // Cell: column u32, iStyleRef 24 bits, flags 8 bits; value at offset 8. + let col = u32_at(d, 0)?; + let style = (u32_at(d, 4)? & 0x00FF_FFFF) as usize; + let num = |n: f64| match self.xf_kinds.get(style) { + Some(NumKind::DateTime) => Value::DateTime(n), + Some(NumKind::Duration) => Value::Duration(n), + _ => Value::Number(n), + }; + let v = match typ { + 0x0002 => num(rk(u32_at(d, 8)?)), + 0x0003 | 0x000B => Value::Error(error_text(*d.get(8)?)), + 0x0004 | 0x000A => Value::Bool(*d.get(8)? != 0), + 0x0005 | 0x0009 => num(f64_at(d, 8)?), + 0x0006 | 0x0008 => Value::Text(wide_str(d, 8)?.0), + 0x0007 => Value::Text(self.strings.get(u32_at(d, 8)? as usize)?.clone()), + _ => return None, + }; + Some((col, v)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(out: &mut Vec<u8>, typ: u16, data: &[u8]) { + if typ < 0x80 { + out.push(typ as u8); + } else { + out.push((typ & 0x7F) as u8 | 0x80); + out.push((typ >> 7) as u8); + } + let mut n = data.len(); + loop { + let b = (n & 0x7F) as u8; + n >>= 7; + if n == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } + out.extend_from_slice(data); + } + + fn ws(s: &str) -> Vec<u8> { + let mut v = (s.encode_utf16().count() as u32).to_le_bytes().to_vec(); + v.extend(s.encode_utf16().flat_map(u16::to_le_bytes)); + v + } + + #[test] + fn record_varints() { + let mut d = Vec::new(); + rec(&mut d, 0x0269, &[1, 2, 3]); + rec(&mut d, 0x0013, &vec![7; 300]); + let got: Vec<_> = records(&d).map(|r| r.unwrap()).collect(); + assert_eq!(got[0], (0x0269, &[1u8, 2, 3][..])); + assert_eq!(got[1].0, 0x0013); + assert_eq!(got[1].1.len(), 300); + } + + #[test] + fn truncated_records_error_once() { + let mut d = Vec::new(); + rec(&mut d, 0x0013, &[1, 2, 3, 4]); + d.truncate(d.len() - 1); + let got: Vec<_> = records(&d).collect(); + assert_eq!(got.len(), 1); + assert!(got[0].is_err()); + // Header cut mid-varint. + assert!(records(&[0x80]).next().unwrap().is_err()); + assert!( + records(&[0x13, 0xFF, 0xFF, 0xFF, 0xFF]) + .next() + .unwrap() + .is_err() + ); + } + + #[test] + fn wide_strings_are_bounds_checked() { + assert_eq!(wide_str(&ws("hé"), 0), Some(("hé".to_string(), 8))); + assert_eq!(wide_str(&u32::MAX.to_le_bytes(), 0).unwrap().0, ""); + let mut long = 1_000_000u32.to_le_bytes().to_vec(); + long.extend_from_slice(b"ab"); + assert_eq!(wide_str(&long, 0), None); + assert_eq!(wide_str(&[1, 0], 0), None); + assert_eq!(wide_str(&ws("x"), usize::MAX - 1), None); + } + + #[test] + fn rk_numbers() { + assert_eq!(rk((42 << 2) | 2), 42.0); + assert_eq!(rk(((-7i32 << 2) as u32) | 2), -7.0); + assert_eq!(rk((1234 << 2) | 3), 12.34); + let bits = (1.5f64.to_bits() >> 32) as u32; + assert_eq!(rk(bits), 1.5); + } + + #[test] + fn number_format_classification() { + assert_eq!(custom_kind("yyyy-mm-dd"), NumKind::DateTime); + assert_eq!(custom_kind("h:mm AM/PM"), NumKind::DateTime); + assert_eq!(custom_kind("[h]:mm:ss"), NumKind::Duration); + assert_eq!(custom_kind("0.00"), NumKind::Number); + assert_eq!(custom_kind("#,##0 \"days\""), NumKind::Number); + assert_eq!(custom_kind("[Red]0.0"), NumKind::Number); + assert_eq!(custom_kind("[$-409]d-mmm"), NumKind::DateTime); + assert_eq!(custom_kind("General"), NumKind::Number); + assert_eq!(custom_kind("0;\"d\"m"), NumKind::Number); + assert_eq!(builtin_kind(14), NumKind::DateTime); + assert_eq!(builtin_kind(46), NumKind::Duration); + assert_eq!(builtin_kind(2), NumKind::Number); + } + + fn cell_rec(col: u32, style: u32, value: &[u8]) -> Vec<u8> { + let mut d = col.to_le_bytes().to_vec(); + d.extend_from_slice(&style.to_le_bytes()); + d.extend_from_slice(value); + d + } + + #[test] + fn cells_stream_with_malformed_records_skipped() { + let wb = Workbook { + strings: vec!["shared".into()], + xf_kinds: vec![NumKind::Number, NumKind::DateTime], + ..Default::default() + }; + let mut s = Vec::new(); + rec(&mut s, 0x0005, &cell_rec(0, 0, &1.0f64.to_le_bytes())); // before sheet data: ignored + rec(&mut s, 0x0091, &[]); + rec(&mut s, 0x0000, &3u32.to_le_bytes()); + rec(&mut s, 0x0005, &cell_rec(1, 1, &45000.0f64.to_le_bytes())); + rec(&mut s, 0x0007, &cell_rec(2, 0, &0u32.to_le_bytes())); + rec(&mut s, 0x0007, &cell_rec(3, 0, &99u32.to_le_bytes())); // bad isst: skipped + rec(&mut s, 0x0006, &cell_rec(4, 0, &ws("inline"))); + rec(&mut s, 0x0006, &cell_rec(5, 0, &[0xFF, 0xFF, 0xFF, 0x7F])); // overlong: skipped + rec(&mut s, 0x0004, &[1, 2]); // short: skipped + rec(&mut s, 0x0003, &cell_rec(6, 0, &[0x07])); + rec(&mut s, 0x0004, &cell_rec(7, 0, &[1])); + rec( + &mut s, + 0x0002, + &cell_rec(8, 7, &((5u32 << 2) | 2).to_le_bytes()), + ); // style out of range + rec(&mut s, 0x0092, &[]); + rec(&mut s, 0x0005, &cell_rec(0, 0, &2.0f64.to_le_bytes())); // after: ignored + let got: Vec<_> = wb.cells(&s).map(|c| c.unwrap()).collect(); + assert_eq!( + got, + vec![ + (3, 1, Value::DateTime(45000.0)), + (3, 2, Value::Text("shared".into())), + (3, 4, Value::Text("inline".into())), + (3, 6, Value::Error("#DIV/0!")), + (3, 7, Value::Bool(true)), + (3, 8, Value::Number(5.0)), + ] + ); + } + + #[test] + fn workbook_sheets_resolve_relationships() { + let rels = vec![ + ( + "rId1".into(), + format!("http://x{WORKSHEET_REL}"), + "worksheets/sheet1.bin".into(), + ), + ( + "rId2".into(), + "http://x/relationships/chartsheet".into(), + "chartsheets/c.bin".into(), + ), + ( + "rId3".into(), + format!("http://x{WORKSHEET_REL}"), + "/xl/worksheets/s3.bin".into(), + ), + ]; + let sheet = |state: u32, rel: &str, name: &str| { + let mut d = state.to_le_bytes().to_vec(); + d.extend_from_slice(&0u32.to_le_bytes()); + d.extend(ws(rel)); + d.extend(ws(name)); + d + }; + let mut d = Vec::new(); + rec(&mut d, 0x0099, &1u32.to_le_bytes()); + rec(&mut d, 0x009C, &sheet(0, "rId1", "Data")); + rec(&mut d, 0x009C, &sheet(1, "rId2", "Chart")); + rec(&mut d, 0x009C, &sheet(2, "rId3", "Secret")); + rec(&mut d, 0x009C, &sheet(0, "rId9", "Dangling")); + rec(&mut d, 0x0090, &[]); + let mut wb = Workbook::default(); + read_workbook(&d, &rels, &mut wb).unwrap(); + assert!(wb.is_1904); + let parts: Vec<_> = wb.sheets.iter().map(|s| s.part.as_deref()).collect(); + assert_eq!( + parts, + vec![ + Some("xl/worksheets/sheet1.bin"), + None, + Some("xl/worksheets/s3.bin"), + None + ] + ); + assert_eq!(wb.sheets[2].visibility, Visibility::Hidden); + + let mut bad = Vec::new(); + rec(&mut bad, 0x009C, &[0, 0, 0, 0]); + assert!(read_workbook(&bad, &rels, &mut Workbook::default()).is_err()); + } + + // These call the reader directly, outside the `catch_unwind` guard, so a + // panic fails the test. + proptest::proptest! { + #![proptest_config(proptest::prelude::ProptestConfig::with_cases(512))] + + #[test] + fn arbitrary_parts_never_panic(data in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..512)) { + let _ = records(&data).count(); + let _ = read_workbook(&data, &[], &mut Workbook::default()); + let _ = read_shared_strings(&data); + let _ = read_styles(&data); + let wb = Workbook { strings: vec!["s".into()], xf_kinds: vec![NumKind::DateTime], ..Default::default() }; + let mut sheet = Vec::new(); + rec(&mut sheet, 0x0091, &[]); + sheet.extend_from_slice(&data); + let _ = wb.cells(&sheet).count(); + } + + #[test] + fn arbitrary_cell_payloads_never_panic( + typ in 0u16..0x000C, + payload in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..40), + ) { + let wb = Workbook { strings: vec!["s".into()], xf_kinds: vec![NumKind::Duration], ..Default::default() }; + let mut sheet = Vec::new(); + rec(&mut sheet, 0x0091, &[]); + rec(&mut sheet, typ, &payload); + let _ = wb.cells(&sheet).count(); + } + } +} diff --git a/crates/markkit/tests/common/mod.rs b/crates/markkit/tests/common/mod.rs index 268d897..08239cb 100644 --- a/crates/markkit/tests/common/mod.rs +++ b/crates/markkit/tests/common/mod.rs @@ -483,3 +483,114 @@ pub fn rezip(bytes: &[u8], drop: &[&str], add: &[(&str, &[u8])]) -> Vec<u8> { pub fn xls(sheets: &[XlsSheet]) -> Vec<u8> { xls_from_stream(&biff8_workbook(sheets)) } + +// ─── XLSB (BIFF12 records in an OPC ZIP) ──────────────────────────── + +/// Append a BIFF12 record (1–2 byte type, 7-bit varint size). +pub fn biff12(out: &mut Vec<u8>, typ: u16, data: &[u8]) { + if typ < 0x80 { + out.push(typ as u8); + } else { + out.push((typ & 0x7F) as u8 | 0x80); + out.push((typ >> 7) as u8); + } + let mut n = data.len(); + loop { + let b = (n & 0x7F) as u8; + n >>= 7; + if n == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } + out.extend_from_slice(data); +} + +/// `XLWideString`. +pub fn wide(s: &str) -> Vec<u8> { + let units: Vec<u16> = s.encode_utf16().collect(); + let mut v = (units.len() as u32).to_le_bytes().to_vec(); + v.extend(units.iter().flat_map(|u| u.to_le_bytes())); + v +} + +/// Worksheet part with inline strings (`BrtCellSt`) and numbers (`BrtCellReal`). +pub fn xlsb_sheet(rows: &[Vec<Cell>]) -> Vec<u8> { + let mut d = Vec::new(); + biff12(&mut d, 0x0081, &[]); // BrtBeginSheet + biff12(&mut d, 0x0094, &[0; 16]); // BrtWsDim + biff12(&mut d, 0x0091, &[]); // BrtBeginSheetData + for (r, row) in rows.iter().enumerate() { + biff12(&mut d, 0x0000, &(r as u32).to_le_bytes()); + for (c, cell) in row.iter().enumerate() { + let mut rec = (c as u32).to_le_bytes().to_vec(); + rec.extend_from_slice(&0u32.to_le_bytes()); + match cell { + Cell::S(s) => { + rec.extend(wide(s)); + biff12(&mut d, 0x0006, &rec); + } + Cell::Str(s) => { + rec.extend(wide(s)); + biff12(&mut d, 0x0006, &rec); + } + Cell::N(n) => { + rec.extend_from_slice(&n.to_le_bytes()); + biff12(&mut d, 0x0005, &rec); + } + Cell::B(b) => { + rec.push(u8::from(*b)); + biff12(&mut d, 0x0004, &rec); + } + Cell::Empty => {} + } + } + } + biff12(&mut d, 0x0092, &[]); // BrtEndSheetData + biff12(&mut d, 0x0082, &[]); // BrtEndSheet + d +} + +/// Build an XLSB from sheets (hidden sheets honoured), plus extra raw entries. +pub fn xlsb_with(sheets: &[Sheet], extra: &[(&str, &[u8])]) -> Vec<u8> { + let mut wb = Vec::new(); + biff12(&mut wb, 0x0083, &[]); // BrtBeginBook + biff12(&mut wb, 0x008F, &[]); // BrtBeginBundleShs + let mut rels = String::new(); + let mut entries: Vec<(String, Vec<u8>)> = Vec::new(); + for (i, s) in sheets.iter().enumerate() { + let n = i + 1; + let mut rec = u32::from(s.hidden).to_le_bytes().to_vec(); + rec.extend_from_slice(&(n as u32).to_le_bytes()); + rec.extend(wide(&format!("rId{n}"))); + rec.extend(wide(&s.name)); + biff12(&mut wb, 0x009C, &rec); + rels.push_str(&format!(r#"<Relationship Id="rId{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{n}.bin"/>"#)); + entries.push((format!("xl/worksheets/sheet{n}.bin"), xlsb_sheet(&s.rows))); + } + biff12(&mut wb, 0x0090, &[]); // BrtEndBundleShs + biff12(&mut wb, 0x0084, &[]); // BrtEndBook + let rels = format!( + r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"# + ); + let root_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.bin"/></Relationships>"#; + let mut all: Vec<(String, Vec<u8>)> = vec![ + ("_rels/.rels".into(), root_rels.as_bytes().to_vec()), + ("xl/workbook.bin".into(), wb), + ("xl/_rels/workbook.bin.rels".into(), rels.into_bytes()), + ]; + all.extend(entries); + for (n, d) in extra { + all.push((n.to_string(), d.to_vec())); + } + let refs: Vec<(&str, &[u8])> = all + .iter() + .map(|(n, d)| (n.as_str(), d.as_slice())) + .collect(); + zip_bytes(&refs) +} + +pub fn xlsb(sheets: &[Sheet]) -> Vec<u8> { + xlsb_with(sheets, &[]) +} diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index ce64334..9e594db 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -24,6 +24,7 @@ fn hints() -> Vec<DocumentHint<'static>> { DocumentHint::content_type(XLSX_CT), DocumentHint::content_type("application/vnd.ms-excel"), DocumentHint::content_type("application/vnd.oasis.opendocument.spreadsheet"), + DocumentHint::content_type("application/vnd.ms-excel.sheet.binary.macroEnabled.12"), ] } @@ -106,6 +107,57 @@ fn xlsx_zip_bomb_sheet_rejected() { assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); } +#[cfg(feature = "spreadsheet")] +#[test] +fn xlsb_zip_bomb_sheet_rejected() { + let mut rows = vec![vec![Cell::S("x")]]; + rows.extend((0..1).map(|_| vec![Cell::Str("y".repeat(4 * 1024 * 1024))])); + let bytes = xlsb(&[Sheet::new("S", rows)]); + assert!(bytes.len() < 256 * 1024); + let m = Markkit::builder() + .max_archive_entry_size(1024 * 1024) + .build(); + let err = m + .extract_document_sync(&bytes, DocumentHint::default()) + .unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + +#[cfg(feature = "spreadsheet")] +#[test] +fn xlsb_huge_declared_dimensions_do_not_allocate() { + // BrtWsDim claims A1:XFD1048576; cells are streamed, never a dense grid. + let mut sheet = Vec::new(); + biff12(&mut sheet, 0x0081, &[]); + let mut dim = Vec::new(); + for v in [0u32, 1_048_575, 0, 16_383] { + dim.extend_from_slice(&v.to_le_bytes()); + } + biff12(&mut sheet, 0x0094, &dim); + biff12(&mut sheet, 0x0091, &[]); + for (row, col) in [(0u32, 0u32), (1_048_575, 16_383)] { + biff12(&mut sheet, 0x0000, &row.to_le_bytes()); + let mut c = col.to_le_bytes().to_vec(); + c.extend_from_slice(&0u32.to_le_bytes()); + c.extend(wide("far")); + biff12(&mut sheet, 0x0006, &c); + } + biff12(&mut sheet, 0x0092, &[]); + let base = xlsb(&[Sheet::new("S", vec![])]); + let bytes = rezip( + &base, + &["xl/worksheets/sheet1.bin"], + &[("xl/worksheets/sheet1.bin", &sheet)], + ); + let e = Markkit::builder() + .build() + .extract_document_sync(&bytes, DocumentHint::default()) + .unwrap(); + assert!(e.markdown.contains("far")); + assert!(e.markdown.len() < 64 * 1024); + assert!(e.warnings.iter().any(|w| w.starts_with("truncated_sheet:"))); +} + #[cfg(feature = "spreadsheet")] #[test] fn xlsx_bomb_in_unreferenced_entry_rejected() { @@ -337,6 +389,19 @@ fn valid_fixtures() -> Vec<Vec<u8>> { &[("docProps/core.xml", core)], ); v.push(rezip(&book, &["_rels/.rels"], &[])); + v.push(xlsb(&[Sheet::new( + "S", + vec![vec![Cell::S("a"), Cell::N(1.0)], vec![Cell::B(true)]], + )])); + for name in ["date", "issues"] { + v.push( + std::fs::read(format!( + "{}/tests/fixtures/xlsb/{name}.xlsb", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(), + ); + } } v } @@ -397,7 +462,7 @@ proptest! { /// Byte flips in valid documents never panic. #[test] fn mutated_fixtures_never_panic( - idx in 0usize..6, + idx in 0usize..9, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..16), ) { let fixtures = valid_fixtures(); diff --git a/crates/markkit/tests/fixtures/xlsb/README.md b/crates/markkit/tests/fixtures/xlsb/README.md new file mode 100644 index 0000000..e4cc917 --- /dev/null +++ b/crates/markkit/tests/fixtures/xlsb/README.md @@ -0,0 +1,8 @@ +# XLSB fixtures + +Real Excel binary workbooks, copied from the calamine test suite +(<https://github.com/tafia/calamine/tree/master/tests>, MIT license, +Copyright 2016-2026 Johann Tuffe). + +- `date.xlsb`: dates and a `[h]:mm:ss` duration +- `issues.xlsb`: mixed types, shared strings, formulas, special characters, a VBA project diff --git a/crates/markkit/tests/fixtures/xlsb/date.xlsb b/crates/markkit/tests/fixtures/xlsb/date.xlsb new file mode 100644 index 0000000000000000000000000000000000000000..4a206a2633ea32109d584363e2d34d27b00870b7 GIT binary patch literal 7711 zcmeHMg<Dix`yEoGb3ld~LSiI_QW_i@gdwFt7`jtXL}?I^5JVd34hd-)%0Z<?8tHBc zk@}4{KE2%g{r-XPp65B|%z4gv*O`6xT6@3m+He&tY)Sww01p5FfB}Y90)#FY001U7 z06+o2!!(3BJ9$_-d6;VZxLCRy^LRTtFlAw5a%2K9(a-<8{SSYEcr6L%Ha^Np$bpos zN%3g^t0FE;m`W}WH<pg|hz}U}%<=eI^65!S5Xn_pX?;48!>MMmH1?_n{%#<qowb1Q z63dGxIy@s@36<N655IpTj~rYYyhU)$9x&$c;PFj;VFo^Mb!TJ(^gA^eo&Z-`=nc$$ z-WIGZKOT~>qglF$-!X5q&l+Dv=Adfv0h#ywCdWco2f}1%dy4h#`0EAF`{fDmU+z!a zu(1$v)e0|D<-s9*bSh6!SD9e#kiviz!(;m=Ws2rx)W=vfO<<Qb<|(n6ppB4L&4<2B zDH8abN^b)twj#1fL_-l^*l9e~F=Y+eW1bQ*Lym^Nx|;jJo2=0+b@Usxtvq!kDKH7e z5EuHDE9EP)T-aew^k3J*s~LKk@5{ZST2OQ3Dk9jnAr3#HX&<F&1-ovNK4MVxB}-LU zb9gdKOq^>wP{_`Vr_o+1oTy4iRk+iR)5jcmD7P46+7Fx}NI%B{04^@D0Pw#E6&5hx zd4y)4Dq4pGXrY?ASvt7$^87gdE71R8RsPcS$hbbWc0R(cZN(<o!pWMGBHpW-My8x8 zQZAAiHYZR`R5q*h(IJApK(LnnZ2N0pM$=T!P(jNrSx8xcr$&Hzey84u&dxwnS-Nzm zY;QluGsrEsAmc}m-#l85)ns_ue4t@TMGaZe+UpC`I1<;m(NXmwLAOnoGxQZq{Xyn4 zSFIP(H0O5G&HdUBwPZ$9uE2W+mU~8Oave*Ts0T7A8a=FV_EJZ$2}2n94Hbn=vuW|^ z^YJ5B-lRTLoTk18+7TaE^2)j>?+mB7rW|B|X}TohX@qI;h1g>%%ko{h*w?Tx<RO75 zI9-WYQeXsQ!DDpO|E?2U0VbVA^c`-rPG|vm7~T%Nzf0x5vzxuSv$H*qxvkTmvcW*l zYv{@Ee|sy5McTFVk;-ldp9hXUZ1%#>jYri7-7Z6(0d!0zm}^<9Cxp*W@DqiJa`xjQ z%&KjsH*A_k)UqGsckp-<>1YHnJHys}%V%?aS{`C|n)}*!%_z025~h4Vv}(>DQ1t5v zj3{{>VNDdM=zAL>){m&ei}g+S0N;^+ttugJL6XOAl(2}E_k{tT;ZtN>4Bi}n2=Ad2 zIDWj{@7X&jJo+ZQ$w->%gZL7TwuPp8)~pJ3c`uepS9w`@q7gybN<_zE$<xUp(`!Zc zR^RYeTd`tO-A;|5JkQq}NySeVzEk`YNeo{Juymt){S3X6pm#J$ex-+<6~e|$&|Dfz zRoV{+MvTQ0BODl4B3%m(yh=(7rQ!RCSkM3{mpB19EzRJT76`K?YnzumW~{^uq5^u* z4!$6MN`V}HN{ntATnwN+Pzz`X#Ck!4OPy&0g$cv1!X#lBaJ8scX|eYhvTuyCl9F|{ zR>*|=&k~>>a1^YVAy%vrFWU<+$pK0g09Ud=b{KxYj)Sxp9}KsDQaiISgph*f+7iDq z1`d)->&bpb0weG7b)3;a4=d<LD+I#k9^wa~|1OQUL^2gtXu1!hr9t$2tKB_(94y^` zNFqn`4lhiDRAPGy8G$#_mKWhPZqQ$(%RC@Ec>T_e7{Nk%U_pP7ViBv?DHh~hy76e2 z{q%}gOAUnlwqn`|12>t>WOrJUnETRXeNv;vyk-Y3>0x5OeB<tQtuV7BbBAZ-hY17k zPg#MSLyC1{*t(o|1GLB@#->}z4r*N;33TH=mt5bX#79}+s^AG3R!mgZFJ^+Q11!n| z700D@25mlRKYjW^;!H+vFirL%aZ%P~tGU4WiHB~p{}&(X0K;<eA}L83`x!MCZe~Q} z-Y325*37vN6xaKyRr{mjrS3UlQ*GD!Whhm=Gax86HRtDI;a8Pl)j?+SkIX5}>RU#R zbW0$UKDu#zMP;F8LC&dS?`%t5QLfYpv$pgQSwXl(B=?R0C0RR@2_<G*W!Bux^r}yO zUmRbw2syBY^N|bOlN3?21~?QX_L$VZU*EGh7GS%{{sI>D=*|mcQ29T0$SaNOFOldw zbm&fD`MpDa(9qq+($d5Ik74V#3+R6aEr-4zVkN|#Imz5M^Ds@_=j#g78od`|zsew8 zmCzyhBFa*E@9@BJb96WHo7AiG>R_EIj)-W7OnU=(hp=d0mH+mKk##W+P89J73Sq|X zKjh-Q+E;B}$foa0u0sXHQsG>M*;f+a#9zNY*N|jRtY`PJO3kMH)^o|)&6M6~Ttf9c z+BARh2I5ba>IZv_V%{m)9fg9zxb*qm6BpXer5WkaZ5FZdqF|pq^O@!Qqe4PY7#^JM z54l|VCrW-HcBC;ww|)~%Nt(ZXfuC*9(UefZk|BLm)fgKVN+)biVa``bu2fAPpPE-e z${O}qIxH;MKyNf1g9RVoP8Nh5%m90x2U6~4E8Sy1$4IMh9*zl%a{t&gk>umNeCU<o zB9k3050*w`RCZ@6wLFPymEX7$#G+i|86O5~)tgty8Wp;u*xaa4&|GhEJ1~Zxso-8v zqEdo1G5uy5F@_Rra6m}cMp#pr68QZbe10R8)_|+a0aTUy!OK89J)YHlVgvjA^P49+ z&;3C&hMi;=@cL~H&cot5*t(X|w<aGC8WP5Pn|X0j%dt1$_`O%}S|Q62lsBLCoH9Io zh&anhh_2;PQ#jQ1^47E&CMZ364NJWLt;9W0HPJN5n%*^b{gBj6$z-42hi2J;nCg&F zw=$f&#Dd!PhQJoI%q-O1epH@;Q;T24YT%=5D)ib?JYT14`q^79ooS1}Bh@Nhq2#_P z1oCc_=q$lM^$I0QJa7>g0C-FLqbU4sc=WKbbhPCC?Fju*6mIBG*?|-&TX^^BUo_v{ ze^bmzA)*N3bh2OvYihcQ#L16`Ti@^OMLomwpD{Yf%Z+(4O*uZjpA}D=JP4=#j^8Ul zLq5I_eqiNQlN{R><*2Hp;!trndpq@bDYfcL(p0_gqMvlB1f2kRFj1p-p_$<=I(Zgy zHQK{XzngcfxVtoN-4?`mZBMtbG9B~1X#QXmcyg;1I2!7JCFGFtbyZ%nFN800G}q=S z?3)u^C&t_kG<`t<I)3FXMo%Jsu(G5yYjr6p&dlM;M0Q5BpO|R1Q{+2D*k(`g1#z&_ z?cR>om@bJ~dfvzuA6FL-@BP!2)q&Xv1(!3r^T%9{3XO92kV_x4D{De++CvHz2K*Sm zEZ54B&F{#y^Pm{T-uj3{3v05xSFh07XxL+~Q6Z9VWD~I#zlL}&`fOc0IqC7$Oketu zLNBf~<t25@eiuB5dR>9!ly38|msSa;G7nM9#IRR=!7SCqqEPRhyQg;ESCvo2?7umU z1akcNy|YC-?OMdvH?Tt<KLfG<X7_5$tBBSlz*o6#O2`9Q8sFS$CxE(@Whn1G<=HT- zx#k-lV23KYqY8a?+i!fFpud@8E5M&GC+vCu+*eM9Y%pa*D95#qj`8ahjQFlk+rp48 zue4Wy?|1H=itZ`P<2jn;=n<s1MwSv?^#g*s5br^Up)<p}OuQKqVv^nue0M(V&cAzi zMJmr-<!<s65TCthYJFSsWW8A7bB5HTv+v!@-JgoR^X5#?c6N(RA78K^Rm@p(nE1JV zTU(v|z$<k;f5MKt-9tm9?VW-BQh#K(#S?;W=8t>sO`6e@wat>H&&22pGP++^j#1u6 zCNyiHM>3m6qx}xMNwhKFb@O;PlA`_N$f-2emMod0TqP7R(qW_=bW-JFo-UqVSZ9d0 znY45WFJP+^NC`M4f3DK^j<zR-GMMg;OISTyON19xxMYh5Y+!UB#rI54aOtzLc?~Hv zBu5?B0L&d#s|fH(O2NBbR5Q9US!qBfRN4@A<iR=#45LLIL5JJCsd{eVtMZ(1WZJd9 zAv?Om`tYlF!JKF$ERX%AysmZ7HG#3ouzStQgXR8hFNa!S%l^+IMaG1VC*?JAm?s9x zmb1<5+71<0ibqLCdEz-<JIPOCe+l0gll2V|&)n(o_yX2bIZ6qh5e_R>w$*2vOVM{g zE$YzO9!#VRLEAJfGpTLj7-Dv!zPDs;Y`x9i2=O$I%uAuS*B8l7zG-3JlwYeWc*;8Y z7+ybN1Dx(~sVcc&cE9aRt#0$8*(Jk+*R;uqmx{K-a6|ujbK_+9mHcT`^wnGm$$amW z?q=~oNujm2%pH~tch|$rn+LO+lVg0ZLQ*;u$Msx0q?jkSk8SWl=ULpTs(Nb=+DdFY zW<;ckL5~Q;({RKFIDFE%ft^+M%-0Eb7rl`^U9)3>>?_Ki$B{4U6CK$1#vaH>kERC) z@h7QeIWTD$6Fz0?X7|BM_75@|3$7>t>3s@1kt_PF2dKFLsfc}Tb~0RHd_}btkD-#Z zlO;BvbvR<<J;DT$r;eYei&0Umr`^gw<cU1!SPUO#%k_GyYR?+)Hl&0?q0+m21>0;r zpAHQ5-?A@T{}6AtFUzsu3pY@q9UhmeyyaYmbkYIY6ma1Xm7awdb2h^HVS<jM7TzR) zL?oqMXgCpH7KQg0;RRQ>_6!C6Jm|b*$0_TKrU$tY9^Si3Ii>!J&b=zZez;xOg6<7} zrly*OH71BNNJOzA9U;yz557B878$0<&L}fWQOf5B>FFZ@z8D}JocgM8^7%>coFSg_ z$5NmiKp_aZo@q|rL*{>!mK4Li?wkwFeO=t*5-$`V$s&57F|MHvRiDv(FCe<71;kp^ zYOum9AdjmiBfg%6ERq>+u>CAVPR#B#brTeh;>@bwNJ=gWE3?&wI)=!B^uia9lbhP? z$jti%Yuecdo4M)_f<KpTP`u{B!04N^UTMg;!NadvM9>N$y^TYHt@5)UZ0p_FA^M~? z#7a3_eq*w%gL|E<Yt?MWD2;JLw-^}G74ax9vRWLX(Z`>Klpu{3ZlX%p=tXeNBADeX zz-!$SO3Waah{@&EQjEKwZ(JujkwH3&nsgty1t(8*i&*Bz6xa8-m8~``n!n=)j+YL? zY;H`FJauwY8w#e@@<>nrjM}ZCNHN>p+8pG=--{Iw>sFcD>&Vj^RC9<6q%G#{wQz`d zvLap(-*Dj8tkXUGp~tD$_KjVA!(H3OajNq1ty+mUy4#Y~r4m=Yk}dlRJ?!e02fn(g z<ua{|tkoB3aS5f_Reg3%CK+-hED?{576g)-blZP*M$dVFPoFc?KQgkT??|9`hTmt! zU$QlH3VqMbN686c>HS-_HW+6Hz{FvCfeDRy5<({y^M-<}4Fw2jkT!v8dvH;pJ)VDf zh3+0dI1Zf-?V!^kGIa5ZaJGQEIlH*?S~$B|{^7;`TeG4+oo}2B+Ug*cJ5rnk?@4+N zN(W~NCICF;*m7zyD+LbWgy9xrPAbkwjnRtRlVAn&xoTSosV4C7G$}Djs#o6GlB}dv zPrncx{*dgd^scgE4Ncs!1D#1st7slJgbN4u8bd|?cvY#(^F0LXP2QkcDmI8oX)Jyn z?Bk`DA8&9@$W|rs5zZm0lZPNKw~`T>FL%JGW|YG+2(Snckq4O-h%t{b<7~$~u*D}K zO}&l5#$jzc8L=uk;?KF6fny?qx{*6!lWIG$1vcE#-FW?_WP^NU-P1Ykn{dU&%V~9| zJikxyqAmzAYah0Hng`CpwJFA3L}D~=t1)Pchr<Qyqt5m6FYD#stIo@$d>45p0XTY2 zIbL<ewdWj9K%6f<DU7C{W4qRi+&<Gyp`z96wmLZ0mnSLzc5_>If+`HKtZw+m>%Fm? zrv&JE6XU@=Uz;;uMBmW4Trwd;a&Pn+NNQZV-f{b}Il27uJ@Ovr4)A_V9hP?lK^{=W zS5c(EMe-oT6D&2b3+nl27pDDw=gBKHks)ZFlm1HNdoC{j@)%9vKekj&q|+xp(q@)j zlGtryYl5Qw_pZSPa1tin@#_2Ys={bfeQd}k5oC0X?HAOya!xiM<W-fGKW()nA@SD7 z!nJ<g5h|_P)%cW~nhnxw`-SFB>8Rt`?gl-CoNI(*onG64B;5G@NO_0ocK{htP5`S> zkzOc`ZYukfO!;7dnS$cFMS0A4-C~PlRET}~_Cps`--o?`c4DMZe`+whj4)>zu5Y2u zyys!!*4iVccFj-ySBgn<X5PI%EO2mq5W^OgLAxu7ZC`!+sjQ!{==TM~8<HD>ddTI> zOda9O?QG<u&oAGYXD0+h+`xvG6nK7gVIa83Yz;qD2|8m3h>`?$`{qaX>F?^Wp{60i z1vc@paqv){Rf(e_&0_m@s8lFpXhx`M0L4ApXN##~<voV=bXT=g$*WosK~J<IYGepj zRT_24GF+Qd#TX=1{n*7)hynRGm(RNrzRIZ8NL@SPo(dT^U4D4>VatO<IPGaad@}l* zE0YsPtxXk5Cw~%{tk8QQ-LgNmf3&z~uF=A|8djAw^Ujp#0zCr%J_TT4as&R`dfA`d z_~-c_R?pxne+BrfkN;=jk8>efE5Eq<mjnN5Z2lTJfG*YkyUlqS=d$<p6X_7WBzVaQ zyBvJkLi!oJarL+0pN7)q(934W&(KHcBJ_{<{(siTWx&hc!B4=)gqHz-ItiCiF4ww0 zQK*Q2L%Ce}UIw_F7W@Q&P+k5Tmy?9cSbv@We<A?@&(K(Z%LXoo|8>;;HT*8xT=`G< ZA7e0F1qV%>AFD|OfC)6+c|bp|{ttK)&{hBd literal 0 HcmV?d00001 diff --git a/crates/markkit/tests/fixtures/xlsb/issues.xlsb b/crates/markkit/tests/fixtures/xlsb/issues.xlsb new file mode 100644 index 0000000000000000000000000000000000000000..56332013f1d1617c0cfec168813749283bed937a GIT binary patch literal 19226 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 Fe*ms;{@DNk literal 0 HcmV?d00001 diff --git a/crates/markkit/tests/spreadsheet_integration.rs b/crates/markkit/tests/spreadsheet_integration.rs index bc9eb4b..457e593 100644 --- a/crates/markkit/tests/spreadsheet_integration.rs +++ b/crates/markkit/tests/spreadsheet_integration.rs @@ -385,13 +385,150 @@ fn xlsx_without_package_relationships_still_parses() { assert_eq!(e.markdown, "## S\n\n| h |\n| --- |\n| v |\n"); } +// ─── XLSB ─────────────────────────────────────────────────────────── + +const XLSB_CT: &str = "application/vnd.ms-excel.sheet.binary.macroEnabled.12"; + +fn fixture(name: &str) -> Vec<u8> { + std::fs::read(format!( + "{}/tests/fixtures/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + #[test] fn xlsb_content_type_is_not_xlsx() { - let ct = "application/vnd.ms-excel.sheet.binary.macroEnabled.12"; - assert_eq!(Format::from_content_type(ct), Format::Xlsb); + assert_eq!(Format::from_content_type(XLSB_CT), Format::Xlsb); assert_eq!(Format::from_file_name("book.xlsb"), Format::Xlsb); } +#[test] +fn xlsb_generated_workbook() { + let bytes = xlsb(&[ + Sheet::new( + "People", + vec![ + vec![S("Name"), S("Age"), S("Member")], + vec![S("Ann"), N(30.0), B(true)], + vec![S("Bob|Jr"), N(41.5), B(false)], + ], + ), + Sheet::new("Empty", vec![]), + ]); + for hint in [ + DocumentHint::default(), + DocumentHint::content_type(XLSB_CT), + DocumentHint::file_name("book.xlsb"), + // Mislabelled: the container is sniffed. + DocumentHint::content_type(XLSX_CT), + ] { + let e = Markkit::builder() + .build() + .extract_document_sync(&bytes, hint) + .unwrap(); + assert_eq!(e.format, Format::Xlsb, "{hint:?}"); + assert_eq!( + e.markdown, + "## People\n\n| Name | Age | Member |\n| --- | --- | --- |\n| Ann | 30 | true |\n| Bob\\|Jr | 41.5 | false |\n" + ); + assert_eq!(e.stats.sheets, Some(1)); + assert_eq!(e.stats.rows, Some(2)); + } +} + +#[test] +fn xlsb_real_workbook_with_dates_and_duration() { + let e = extract(&fixture("xlsb/date.xlsb")); + assert_eq!(e.format, Format::Xlsb); + assert_eq!( + e.markdown, + "## Sheet1\n\n| 2021-01-01 | 15 |\n| --- | --- |\n| 2021-01-02 | 16 |\n| 255:10:10 | 17 |\n" + ); +} + +#[test] +fn xlsb_real_workbook_mixed_types_and_shared_strings() { + let e = extract(&fixture("xlsb/issues.xlsb")); + assert!(e.warnings.is_empty(), "{:?}", e.warnings); + assert!(e.markdown.starts_with( + "## datatypes\n\n| 1 |\n| --- |\n| 1.5 |\n| ab |\n| false |\n| test |\n| 2016-10-20 |\n" + )); + assert!( + e.markdown + .contains("## issue2\n\n| 1 | a |\n| --- | --- |\n| 2 | b |\n| 3 | c |\n") + ); + assert!(e.markdown.contains("| ☺ |")); + assert!(e.markdown.contains("| àâéêèçöïî«» |")); + // VBA project is present in the file but never rendered. + assert!(!e.markdown.to_lowercase().contains("vba")); +} + +#[test] +fn xlsb_hidden_sheet_skipped_by_default() { + let mut hidden = Sheet::new("Secret", vec![vec![S("h")], vec![S("pw")]]); + hidden.hidden = true; + let bytes = xlsb(&[Sheet::new("Open", vec![vec![S("h")], vec![S("v")]]), hidden]); + let e = extract(&bytes); + assert!(!e.markdown.contains("Secret"), "{}", e.markdown); + assert!( + e.warnings + .iter() + .any(|w| w.starts_with("hidden_sheet_skipped:")) + ); + let m = Markkit::builder().include_hidden_sheets(true).build(); + assert!(extract_with(&m, &bytes).markdown.contains("## Secret")); +} + +#[test] +fn xlsb_caps_and_title() { + let rows: Vec<Vec<Cell>> = (0..20).map(|i| vec![N(f64::from(i)); 10]).collect(); + let core = b"<cp:coreProperties><dc:title>Binary Book</dc:title></cp:coreProperties>"; + let bytes = xlsb_with(&[Sheet::new("Big", rows)], &[("docProps/core.xml", core)]); + let m = Markkit::builder() + .max_sheet_rows(3) + .max_sheet_cols(2) + .build(); + let e = extract_with(&m, &bytes); + assert_eq!(e.title.as_deref(), Some("Binary Book")); + assert_eq!(e.stats.rows, Some(3)); + assert_eq!( + e.warnings + .iter() + .filter(|w| w.starts_with("truncated_sheet:")) + .count(), + 2, + "{:?}", + e.warnings + ); +} + +#[test] +fn xlsb_missing_and_truncated_sheet_parts_warn() { + let bytes = xlsb(&[Sheet::new("S", vec![vec![S("h")], vec![S("v")]])]); + // Sheet part removed. + let gone = rezip(&bytes, &["xl/worksheets/sheet1.bin"], &[]); + let e = extract(&gone); + assert!( + e.warnings.iter().any(|w| w.starts_with("sheet_error:")), + "{:?}", + e.warnings + ); + // Sheet part cut mid-record (the end records take 6 bytes). + let sheet = xlsb_sheet(&[vec![S("h")], vec![S("value")]]); + let cut = rezip( + &bytes, + &["xl/worksheets/sheet1.bin"], + &[("xl/worksheets/sheet1.bin", &sheet[..sheet.len() - 8])], + ); + let e = extract(&cut); + assert!( + e.warnings.iter().any(|w| w.starts_with("sheet_error:")), + "{:?}", + e.warnings + ); +} + // ─── Provenance ────────────────────────────────────────────────────── #[test] diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 0ae6deb..df029ae 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -21,10 +21,12 @@ tags: ### Tier 2 (Feature-gated) - **DOCX** (`docx`): Word documents via a direct `zip` + `quick-xml` reader (no docx-rs) - **XLSX/XLSM, XLS, ODS** (`spreadsheet`): Workbooks to Markdown tables via calamine +- **XLSB** (`spreadsheet`): Excel binary workbooks via markkit's own bounds-checked BIFF12 + reader (calamine's XLSB reader panics on malformed records) - **PDF** (`pdf`): Planned. Returns `Error::UnsupportedFormat` (never an empty success) ### Detected, not supported -- **DOC** (Word 97-2003 binary), **PPTX**, **XLSB**: explicit `UnsupportedFormat` errors +- **DOC** (Word 97-2003 binary), **PPTX**: explicit `UnsupportedFormat` errors ### Tier 3 (Planned) - **EPUB**: E-book extraction @@ -61,7 +63,7 @@ concatenate cleanly); an empty document renders `""`. This is intended and stabl |---|---| | HTML | `og:title` / JSON-LD / `<title>` (see [HTML Extraction](html-extraction.md)) | | DOCX | `docProps/core.xml` `dc:title` (via `_rels/.rels`), else the first `Title`-style paragraph | -| XLSX | `docProps/core.xml` `dc:title` | +| XLSX, XLSB | `docProps/core.xml` `dc:title` | | ODS | `meta.xml` `dc:title` | | XLS | `\u{5}SummaryInformation` property set, `PIDSI_TITLE` (code page aware: 1200 UTF-16, UTF-8, else Latin-1) | @@ -126,6 +128,11 @@ numbering through the main part's relationships. emits a `truncated_sheet` warning. - Hidden / very hidden sheets are skipped with `hidden_sheet_skipped` unless `include_hidden_sheets(true)`. Chart, dialog and macro sheets are skipped. +- XLSB: sheets from `xl/workbook.bin` (`BrtBundleSh`) resolved through + `xl/_rels/workbook.bin.rels`; shared strings from `xl/sharedStrings.bin`; dates and + durations from the cell XF's number format in `xl/styles.bin` (built-in date ids or custom + codes with date/time tokens; `[h]`-style elapsed codes are durations). A missing or + truncated sheet part emits `sheet_error` and the other sheets still render. - Only cached cell values are read: formulas are not evaluated, VBA/XLM macros are never executed or rendered. diff --git a/knowledge/log.md b/knowledge/log.md index ffa79fe..ed46106 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-27 +* **XLSB support (#11)**: Excel binary workbooks are read by a new bounds-checked BIFF12 reader (`spreadsheet/xlsb.rs`) instead of being rejected; calamine's XLSB reader was not used because it panics on malformed records. Same caps, hidden-sheet and macro rules as XLSX. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. * **Spreadsheet follow-ups (#11)**: single-row sheets render as data under column letters; XLSX/ODS/XLS titles come from document properties; XLSB content type no longer maps to XLSX; XLSX without `_rels/.rels` parses; trailing-newline output documented as intended; release profile switched to `panic = "unwind"` so `catch_unwind` is effective in markkit's own binaries. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. ## 2026-10-26 diff --git a/knowledge/operations/testing.md b/knowledge/operations/testing.md index c59bcb1..874e4a3 100644 --- a/knowledge/operations/testing.md +++ b/knowledge/operations/testing.md @@ -21,7 +21,7 @@ tags: | `crates/markkit/tests/docx_integration.rs`, `spreadsheet_integration.rs` | Office mapping | | `crates/markkit/tests/document_detection.rs` | Format detection order and explicit unsupported errors | | `crates/markkit/tests/document_security.rs` | Zip bombs, grid amplification, XML entities, malformed containers | -| `crates/markkit/tests/common/` | In-memory DOCX/XLSX/XLS/ODS fixture builders | +| `crates/markkit/tests/common/` | In-memory DOCX/XLSX/XLSB/XLS/ODS fixture builders; `fixtures/xlsb/` holds two real XLSB files from calamine (MIT) | | `crates/markkit-cli/tests/cli_integration.rs` | CLI flags and output | | `crates/markkit/benches/extraction.rs` | Criterion micro-benchmarks | | `crates/markkit-bench/` | Benchmark harness; `cargo test` runs every case once | diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 47c0ea1..a37554f 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -172,7 +172,7 @@ the proxy, or use `allow_host`. - `html_security::very_long_text_node` (100KB text node) - `html_proptest::no_panic_on_arbitrary_input` -### T6: Malicious Office Documents (DOCX / XLSX / XLS / ODS) +### T6: Malicious Office Documents (DOCX / XLSX / XLSB / XLS / ODS) **Description**: Crafted containers aimed at exhausting memory/CPU, crashing the process, or smuggling content past a human reviewer. @@ -211,6 +211,12 @@ process, or smuggling content past a human reviewer. `panic = "unwind"`, which the workspace release profile uses so the CLI and the Python/Node bindings survive a parser panic; embedders building with `abort` lose this layer). +- XLSB is parsed by markkit's own BIFF12 reader, not calamine's (which indexes record + buffers and the shared-string table unchecked). Every field read is bounds-checked: + record sizes against the part, wide-string lengths with checked arithmetic, shared-string + and XF indexes by lookup. Malformed cell records are skipped; a truncated part fails that + sheet with `sheet_error`. Cells stream into the capped grid, so `BrtWsDim` claims never + allocate. Proptests drive the reader directly, outside `catch_unwind`. - XLS `SummaryInformation` title parsing is bounds-checked (1 MiB stream cap, checked offsets, 1024-property cap); failures yield no title, never an error. @@ -238,6 +244,10 @@ process, or smuggling content past a human reviewer. `spreadsheet_integration::xlsx_sparse_far_cell_does_not_allocate_grid` - `docx_billion_laughs_not_expanded`, `docx_external_entity_not_resolved`, `docx_deep_nesting_does_not_overflow_stack` +- `xlsb_zip_bomb_sheet_rejected`, `xlsb_huge_declared_dimensions_do_not_allocate`, + `spreadsheet::xlsb::tests::arbitrary_parts_never_panic`, + `spreadsheet::xlsb::tests::arbitrary_cell_payloads_never_panic`, + `spreadsheet_integration::xlsb_hidden_sheet_skipped_by_default` - `spreadsheet::tests::summary_title_rejects_malformed`, `spreadsheet_integration::xlsx_malformed_core_properties_do_not_fail_extraction` - `truncated_fixtures_never_panic`, `random_bytes_never_panic`,