From 32f53d355aebcaec413fde746eb01bbfb46243cd Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 26 Oct 2026 23:53:56 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 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 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`, From 7b9703c0faa232b41fd0d82ab6c9c14b5d836557 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 00:12:34 +0000 Subject: [PATCH 06/10] feat(pptx): convert PowerPoint presentations slide by slide New pptx feature (in all_formats): slides in sldIdLst order as '## Slide N: <title>' sections, bulleted body placeholders, text boxes, tables with spans, hyperlinks, speaker notes. Hidden slides skipped unless include_hidden_content. Shared OOXML helpers move from docx.rs to ooxml.rs. Legacy .ppt becomes Format::Ppt and is rejected explicitly. Part of #11 --- CHANGELOG.md | 5 + README.md | 9 +- crates/markkit-cli/Cargo.toml | 1 + crates/markkit/Cargo.toml | 3 +- crates/markkit/src/docx.rs | 337 +----------- crates/markkit/src/error.rs | 3 + crates/markkit/src/extract.rs | 40 +- crates/markkit/src/format.rs | 29 +- crates/markkit/src/ir.rs | 2 +- crates/markkit/src/lib.rs | 11 +- crates/markkit/src/ooxml.rs | 343 ++++++++++++ crates/markkit/src/pptx.rs | 515 ++++++++++++++++++ crates/markkit/src/xml.rs | 36 +- crates/markkit/tests/common/mod.rs | 112 ++++ crates/markkit/tests/document_detection.rs | 18 +- crates/markkit/tests/document_security.rs | 89 ++- crates/markkit/tests/fixtures/pptx/README.md | 7 + crates/markkit/tests/fixtures/pptx/deck.pptx | Bin 0 -> 35928 bytes .../markkit/tests/fixtures/pptx/make_deck.py | 19 + crates/markkit/tests/pptx_integration.rs | 249 +++++++++ docs/getting-started.md | 4 +- knowledge/formats/format-support.md | 36 +- knowledge/foundations/ir.md | 2 +- knowledge/log.md | 1 + knowledge/security/threat-model.md | 9 +- schema/ir.schema.json | 2 +- 26 files changed, 1510 insertions(+), 372 deletions(-) create mode 100644 crates/markkit/src/ooxml.rs create mode 100644 crates/markkit/src/pptx.rs create mode 100644 crates/markkit/tests/fixtures/pptx/README.md create mode 100644 crates/markkit/tests/fixtures/pptx/deck.pptx create mode 100644 crates/markkit/tests/fixtures/pptx/make_deck.py create mode 100644 crates/markkit/tests/pptx_integration.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cde8de..ee4e4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). pulldown-cmark and rendered back, so raw HTML never passes through. Front matter `title:` or the first `# ` heading becomes `Extraction::title`. Available in the CLI, Python, Node and WASM packages; `format` serializes as `"text"` and `"markdown"`. +- PPTX support behind the new `pptx` feature (included in `all_formats`, so in the CLI and the + Python/Node bindings): one `## Slide N: <title>` section per slide with bullets, text boxes, + tables, hyperlinks and speaker notes. Hidden slides are skipped with a `hidden_slide_skipped` + warning unless `include_hidden_content(true)`. Legacy `.ppt` is detected as the new `Format::Ppt` + and rejected explicitly. `Error::PptxExtract` reports malformed presentations. - 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 d8d31a9..9816e4e 100644 --- a/README.md +++ b/README.md @@ -19,6 +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 +- **PPTX** – One section per slide: titles, bullets, tables, links, speaker notes - **Spreadsheets** – XLSX/XLSM, XLSB, XLS and ODS workbooks to markdown tables, one section per sheet - **Plain text / Markdown** – Text to paragraphs; Markdown normalized through the same IR (raw HTML never passed through) - **Format detection** – Content type, then file extension, then magic bytes @@ -32,11 +33,12 @@ package for browsers and edge runtimes. |---|---|---| | HTML / XHTML | Supported | `html` (default) | | DOCX | Supported | `docx` | +| PPTX, PPTM, PPSX | Supported | `pptx` | | XLSX, XLSM, XLSB, XLS, ODS | Supported | `spreadsheet` | | Plain text | Supported | always on | | Markdown | Supported | `markdown` | | PDF (text layer) | Supported | `pdf` | -| DOC, PPTX | Detected, rejected explicitly | – | +| DOC, PPT | Detected, rejected explicitly | – | ## Quick Start @@ -191,10 +193,11 @@ let markkit = Markkit::builder() | `html` | Yes | HTML parsing and conversion | | `http_client` | No | URL fetching via reqwest (`extract_url`) | | `docx` | No | DOCX conversion (zip + quick-xml) | +| `pptx` | No | PPTX conversion (zip + quick-xml) | | `spreadsheet` | No | XLSX/XLSM, XLS, ODS conversion (calamine) | | `pdf` | No | PDF text-layer extraction (hayro-syntax, pure Rust) | | `markdown` | No | Markdown normalization (pulldown-cmark) | -| `all_formats` | No | `html`, `pdf`, `docx`, `spreadsheet`, `markdown` | +| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown` | | `tool` | No | `markkit::tool::MarkkitTool` LLM agent tool (Everruns toolkit contract) | | `logging` | No | Reserved: pulls in `tracing`, emits no events yet | @@ -205,7 +208,7 @@ Enable it to get `extract_url` (the CLI and the Python/Node bindings ship with i [dependencies] markkit = { version = "0.1", features = ["http_client"] } # office formats only, no HTML, no network: -# markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] } +# markkit = { version = "0.1", default-features = false, features = ["docx", "pptx", "spreadsheet"] } ``` ### Resource limits diff --git a/crates/markkit-cli/Cargo.toml b/crates/markkit-cli/Cargo.toml index 9325c0e..825bc43 100644 --- a/crates/markkit-cli/Cargo.toml +++ b/crates/markkit-cli/Cargo.toml @@ -23,6 +23,7 @@ default = ["all_formats"] all_formats = ["markkit/all_formats"] pdf = ["markkit/pdf"] docx = ["markkit/docx"] +pptx = ["markkit/pptx"] spreadsheet = ["markkit/spreadsheet"] markdown = ["markkit/markdown"] diff --git a/crates/markkit/Cargo.toml b/crates/markkit/Cargo.toml index ba1edfd..df1e3cf 100644 --- a/crates/markkit/Cargo.toml +++ b/crates/markkit/Cargo.toml @@ -18,6 +18,7 @@ default = ["html"] html = ["dep:scraper", "dep:html5ever", "dep:ego-tree", "dep:url"] pdf = ["dep:hayro-syntax", "dep:miniz_oxide"] docx = ["dep:zip", "dep:quick-xml"] +pptx = ["dep:zip", "dep:quick-xml"] spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"] markdown = ["dep:pulldown-cmark"] # Opt-in: the default build has no network stack (no reqwest/hyper/TLS). @@ -27,7 +28,7 @@ logging = ["dep:tracing"] tool = ["dep:tower", "dep:futures-core", "dep:base64"] # JSON Schema for the IR (`markkit::ir::json_schema()`) json-schema = ["dep:schemars"] -all_formats = ["html", "pdf", "docx", "spreadsheet", "markdown"] +all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown"] [dependencies] # Async diff --git a/crates/markkit/src/docx.rs b/crates/markkit/src/docx.rs index 49f7bca..d68189c 100644 --- a/crates/markkit/src/docx.rs +++ b/crates/markkit/src/docx.rs @@ -23,8 +23,12 @@ use std::collections::HashMap; use crate::archive::{ArchiveError, ZipGuard}; use crate::config::MarkkitBuilder; use crate::error::Error; -use crate::ir::{Document, ListItem, Node, Source, TableCell}; +use crate::ir::{Document, Node, Source, TableCell}; use crate::limits::Budget; +use crate::ooxml::{ + Fmt, ListEntry, Relationship, Seg, build_inline, build_list, is_blank, parse_rels, plain_text, + rel_target_by_type, resolve_target, split_dir, table_cell, +}; use crate::xml::{self, Element, XmlError}; /// Result of parsing a DOCX package. @@ -70,73 +74,6 @@ impl Parts<'_, '_> { } } -/// Resolve a relationship target relative to the directory of the source part. -fn resolve_target(base_dir: &str, target: &str) -> String { - if let Some(abs) = target.strip_prefix('/') { - return abs.to_string(); - } - let mut parts: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect(); - for seg in target.split('/') { - match seg { - "" | "." => {} - ".." => { - parts.pop(); - } - s => parts.push(s), - } - } - parts.join("/") -} - -fn split_dir(path: &str) -> (&str, &str) { - match path.rsplit_once('/') { - Some((d, f)) => (d, f), - None => ("", path), - } -} - -struct Relationship { - rel_type: String, - target: String, - external: bool, -} - -fn parse_rels(root: Option<Element>) -> HashMap<String, Relationship> { - let mut map = HashMap::new(); - let Some(root) = root else { return map }; - let Some(rels) = root.child("Relationships") else { - return map; - }; - for r in rels.elements().filter(|e| e.name == "Relationship") { - if let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) { - map.insert( - id.to_string(), - Relationship { - rel_type: r.attr("Type").unwrap_or_default().to_string(), - target: target.to_string(), - external: r - .attr("TargetMode") - .is_some_and(|m| m.eq_ignore_ascii_case("External")), - }, - ); - } - } - map -} - -fn rel_target_by_type<'a>( - rels: &'a HashMap<String, Relationship>, - suffix: &str, -) -> Option<&'a str> { - // Deterministic choice if several match. - let mut matches: Vec<(&String, &Relationship)> = rels - .iter() - .filter(|(_, r)| r.rel_type.ends_with(suffix) && !r.external) - .collect(); - matches.sort_by(|a, b| a.0.cmp(b.0)); - matches.first().map(|(_, r)| r.target.as_str()) -} - // ─── Styles ───────────────────────────────────────────────────────── #[derive(Default)] @@ -326,32 +263,6 @@ impl Numbering { // ─── Inline content ───────────────────────────────────────────────── -#[derive(Clone, Copy, Default, PartialEq, Eq)] -struct Fmt { - bold: bool, - italic: bool, - strike: bool, -} - -enum Seg { - Text { - text: String, - fmt: Fmt, - link: Option<String>, - }, - Break { - link: Option<String>, - }, -} - -impl Seg { - fn link(&self) -> Option<&str> { - match self { - Seg::Text { link, .. } | Seg::Break { link } => link.as_deref(), - } - } -} - fn toggle_on(el: Option<&Element>) -> bool { match el { None => false, @@ -386,7 +297,7 @@ struct Ctx<'a> { impl Ctx<'_> { fn hyperlink_url(&self, el: &Element) -> Option<String> { - if let Some(rel) = el.attr("id").and_then(|id| self.rels.get(id)) { + if let Some(rel) = el.attr_prefixed("id").and_then(|id| self.rels.get(id)) { let mut url = rel.target.replace(' ', "%20"); if let Some(anchor) = el.attr("anchor") { url.push('#'); @@ -455,178 +366,8 @@ impl Ctx<'_> { } } -/// Merge segments and turn them into IR inline nodes. -fn build_inline(segs: Vec<Seg>) -> Vec<Node> { - // 1. Merge adjacent text with identical formatting and link. - let mut merged: Vec<Seg> = Vec::with_capacity(segs.len()); - for seg in segs { - if let ( - Some(Seg::Text { - text: prev, - fmt: pf, - link: pl, - }), - Seg::Text { text, fmt, link }, - ) = (merged.last_mut(), &seg) - && pf == fmt - && pl == link - { - prev.push_str(text); - continue; - } - merged.push(seg); - } - - // 2. Group by link. - let mut out: Vec<Node> = Vec::new(); - let mut i = 0; - while i < merged.len() { - let link = merged[i].link().map(str::to_string); - let mut j = i; - while j < merged.len() && merged[j].link() == link.as_deref() { - j += 1; - } - let nodes = format_nodes(&merged[i..j]); - match link { - Some(url) if !is_blank(&nodes) => { - let (lead, core, trail) = split_ws_nodes(nodes); - out.extend(lead); - out.push(Node::Link { - url, - title: None, - children: core, - }); - out.extend(trail); - } - _ => out.extend(nodes), - } - i = j; - } - tidy_inline(out) -} - -fn format_nodes(segs: &[Seg]) -> Vec<Node> { - let mut out = Vec::new(); - for seg in segs { - match seg { - Seg::Break { .. } => out.push(Node::LineBreak), - Seg::Text { text, fmt, .. } => { - let core = text.trim(); - if core.is_empty() || *fmt == Fmt::default() { - out.push(Node::Text(text.clone())); - continue; - } - let start = text.len() - text.trim_start().len(); - let lead = &text[..start]; - let trail = &text[start + core.len()..]; - if !lead.is_empty() { - out.push(Node::Text(lead.to_string())); - } - let mut node = Node::Text(core.to_string()); - if fmt.strike { - node = Node::Strikethrough { - children: vec![node], - }; - } - if fmt.italic { - node = Node::Italic { - children: vec![node], - }; - } - if fmt.bold { - node = Node::Bold { - children: vec![node], - }; - } - out.push(node); - if !trail.is_empty() { - out.push(Node::Text(trail.to_string())); - } - } - } - } - out -} - -/// Split leading/trailing whitespace text out of a node list (for link text). -fn split_ws_nodes(mut nodes: Vec<Node>) -> (Vec<Node>, Vec<Node>, Vec<Node>) { - let mut lead = Vec::new(); - let mut trail = Vec::new(); - if let Some(Node::Text(t)) = nodes.first_mut() { - let trimmed = t.trim_start().to_string(); - if trimmed.len() != t.len() { - lead.push(Node::Text(t[..t.len() - trimmed.len()].to_string())); - *t = trimmed; - } - } - if let Some(Node::Text(t)) = nodes.last_mut() { - let trimmed = t.trim_end().to_string(); - if trimmed.len() != t.len() { - trail.push(Node::Text(t[trimmed.len()..].to_string())); - *t = trimmed; - } - } - nodes.retain(|n| !matches!(n, Node::Text(t) if t.is_empty())); - (lead, nodes, trail) -} - -/// Merge adjacent text nodes and strip leading/trailing line breaks. -fn tidy_inline(nodes: Vec<Node>) -> Vec<Node> { - let mut out: Vec<Node> = Vec::with_capacity(nodes.len()); - for n in nodes { - match (out.last_mut(), n) { - (Some(Node::Text(prev)), Node::Text(t)) => prev.push_str(&t), - (_, Node::Text(t)) if t.is_empty() => {} - (_, n) => out.push(n), - } - } - while matches!(out.first(), Some(Node::LineBreak)) { - out.remove(0); - } - while matches!(out.last(), Some(Node::LineBreak)) { - out.pop(); - } - out -} - -fn is_blank(nodes: &[Node]) -> bool { - nodes.iter().all(|n| match n { - Node::Text(t) => t.trim().is_empty(), - Node::LineBreak => true, - Node::Bold { children } - | Node::Italic { children } - | Node::Strikethrough { children } - | Node::Link { children, .. } => is_blank(children), - _ => false, - }) -} - -fn plain_text(nodes: &[Node], out: &mut String) { - for n in nodes { - match n { - Node::Text(t) | Node::InlineCode(t) => out.push_str(t), - Node::LineBreak => out.push(' '), - Node::Bold { children } - | Node::Italic { children } - | Node::Strikethrough { children } - | Node::Link { children, .. } - | Node::Heading { children, .. } - | Node::Paragraph { children, .. } => plain_text(children, out), - _ => {} - } - } -} - // ─── Block content ────────────────────────────────────────────────── -struct ListEntry { - level: u32, - ordered: bool, - start: Option<u64>, - content: Vec<Node>, - index: usize, -} - #[derive(Default)] struct Body { blocks: Vec<Node>, @@ -664,43 +405,6 @@ impl Body { } } -/// Build a (nested) list starting at `entries[*pos]` for `level`. -fn build_list(entries: &[ListEntry], pos: &mut usize, level: u32) -> Node { - let first = &entries[*pos]; - let ordered = first.ordered; - let start = if ordered { - first.start.filter(|&s| s != 1) - } else { - None - }; - let mut items: Vec<ListItem> = Vec::new(); - while *pos < entries.len() { - let e = &entries[*pos]; - if e.level < level || (e.level == level && e.ordered != ordered) { - break; - } - if e.level > level { - let sub = build_list(entries, pos, e.level); - match items.last_mut() { - Some(last) => last.children.push(sub), - None => items.push(ListItem::new(vec![sub])), - } - continue; - } - items.push(ListItem::new(vec![Node::Paragraph { - children: e.content.clone(), - source: None, - }])); - *pos += 1; - } - Node::List { - ordered, - start, - items, - source: None, - } -} - impl Ctx<'_> { fn blocks(&self, container: &Element, body: &mut Body) { for child in container.elements() { @@ -892,35 +596,6 @@ impl Ctx<'_> { } } -/// Make cell content single-line: hard breaks become spaces (the renderer escapes pipes). -fn table_cell(nodes: Vec<Node>) -> Vec<Node> { - fn fix(n: Node) -> Node { - match n { - Node::LineBreak => Node::Text(" ".into()), - Node::Bold { children } => Node::Bold { - children: children.into_iter().map(fix).collect(), - }, - Node::Italic { children } => Node::Italic { - children: children.into_iter().map(fix).collect(), - }, - Node::Strikethrough { children } => Node::Strikethrough { - children: children.into_iter().map(fix).collect(), - }, - Node::Link { - url, - title, - children, - } => Node::Link { - url, - title, - children: children.into_iter().map(fix).collect(), - }, - other => other, - } - } - tidy_inline(nodes.into_iter().map(fix).collect()) -} - // ─── Entry point ──────────────────────────────────────────────────── pub(crate) fn parse( diff --git a/crates/markkit/src/error.rs b/crates/markkit/src/error.rs index 9e3f80b..47c7599 100644 --- a/crates/markkit/src/error.rs +++ b/crates/markkit/src/error.rs @@ -11,6 +11,9 @@ pub enum Error { #[error("DOCX extraction error: {0}")] DocxExtract(String), + #[error("PPTX extraction error: {0}")] + PptxExtract(String), + #[error("Spreadsheet extraction error: {0}")] SpreadsheetExtract(String), diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index 9f7dd28..3d12941 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -142,7 +142,12 @@ impl ParsedDocument { /// Run a parser, converting a panic into an error (defence in depth for /// third-party parsers; only effective when built with `panic = "unwind"`, which /// markkit's own release profile uses). -#[cfg(any(feature = "docx", feature = "spreadsheet", feature = "pdf"))] +#[cfg(any( + feature = "docx", + feature = "pptx", + feature = "spreadsheet", + feature = "pdf" +))] fn guarded<T>( what: &str, make_err: fn(String) -> Error, @@ -230,7 +235,7 @@ impl Markkit { .map(|e| e.markdown) } - /// Convert a document (HTML, PDF, DOCX, XLSX/XLSB/XLS/ODS, plain text, Markdown) to Markdown with metadata. + /// Convert a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown) 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; @@ -266,6 +271,7 @@ impl Markkit { #[cfg(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf", feature = "markdown" @@ -276,6 +282,7 @@ impl Markkit { #[cfg(not(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf", feature = "markdown" @@ -283,7 +290,7 @@ impl Markkit { Ok(parsed.render()) } - /// Parse a document (HTML, DOCX, XLSX/XLS/ODS, plain text, Markdown) into the IR without + /// Parse a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown) into the IR without /// rendering. Same detection and errors as [`Markkit::extract_document`]. pub async fn parse_document( &self, @@ -322,8 +329,9 @@ impl Markkit { Format::Doc => Err(Error::UnsupportedFormat( "legacy Word 97-2003 (.doc) documents are not supported".into(), )), - Format::Pptx => Err(Error::UnsupportedFormat( - "presentations (.pptx) are not supported".into(), + Format::Pptx => self.parse_pptx(bytes, budget), + Format::Ppt => Err(Error::UnsupportedFormat( + "legacy PowerPoint 97-2003 (.ppt) presentations are not supported".into(), )), other => Err(Error::UnsupportedFormat(match hint.content_type { Some(ct) => format!("{ct} ({other})"), @@ -404,6 +412,28 @@ impl Markkit { )) } + #[cfg(feature = "pptx")] + fn parse_pptx(&self, bytes: &[u8], budget: &Budget) -> crate::Result<ParsedDocument> { + let out = guarded("PPTX", Error::PptxExtract, || { + crate::pptx::parse(bytes, &self.config, budget) + })?; + let mut doc = out.doc; + self.config.transforms.apply(&mut doc)?; + let mut p = ParsedDocument::new(doc, Format::Pptx); + p.title = out.title; + p.warnings = out.warnings; + p.stats.pages = Some(out.slides); + p.stats.tables = Some(out.tables); + Ok(p) + } + + #[cfg(not(feature = "pptx"))] + fn parse_pptx(&self, _bytes: &[u8], _budget: &Budget) -> crate::Result<ParsedDocument> { + Err(Error::UnsupportedFormat( + "PPTX support requires the 'pptx' feature".to_string(), + )) + } + #[cfg(feature = "spreadsheet")] fn parse_spreadsheet(&self, bytes: &[u8], budget: &Budget) -> crate::Result<ParsedDocument> { let out = guarded("spreadsheet", Error::SpreadsheetExtract, || { diff --git a/crates/markkit/src/format.rs b/crates/markkit/src/format.rs index fea7b95..094866d 100644 --- a/crates/markkit/src/format.rs +++ b/crates/markkit/src/format.rs @@ -28,8 +28,10 @@ pub enum Format { Xls, /// OpenDocument spreadsheet (`.ods`). Ods, - /// Office Open XML presentation (`.pptx`). Detected but not supported. + /// Office Open XML presentation (`.pptx`, `.pptm`, `.ppsx`, `.potx`). Pptx, + /// Legacy PowerPoint 97-2003 binary (`.ppt`). Detected but not supported. + Ppt, Csv, /// Plain text (`text/plain`, `.txt`). Never sniffed from bytes. /// Serialized as `"text"`, same as `as_str`. @@ -86,6 +88,7 @@ impl Format { Format::Xls => "xls", Format::Ods => "ods", Format::Pptx => "pptx", + Format::Ppt => "ppt", Format::Csv => "csv", Format::PlainText => "text", Format::Markdown => "markdown", @@ -113,8 +116,11 @@ impl Format { Format::Xls } else if ct.contains("opendocument.spreadsheet") { Format::Ods - } else if ct.contains("presentationml") || ct.contains("ms-powerpoint") { + } else if ct.contains("presentationml") || ct.contains("ms-powerpoint.") { + // `ms-powerpoint.presentation.macroEnabled.12` & co. are OOXML. Format::Pptx + } else if ct.contains("ms-powerpoint") { + Format::Ppt } else if ct.contains("text/csv") { Format::Csv } else if ct.contains("text/plain") { @@ -137,7 +143,8 @@ impl Format { "xlsb" => Format::Xlsb, "xls" | "xlt" => Format::Xls, "ods" => Format::Ods, - "pptx" | "pptm" | "ppt" => Format::Pptx, + "pptx" | "pptm" | "ppsx" | "ppsm" | "potx" | "potm" => Format::Pptx, + "ppt" | "pps" | "pot" => Format::Ppt, "csv" | "tsv" => Format::Csv, "txt" | "text" => Format::PlainText, "md" | "markdown" | "mdown" | "mkd" | "mkdn" => Format::Markdown, @@ -225,6 +232,8 @@ fn sniff_ole2(bytes: &[u8]) -> Format { let utf16 = |s: &str| -> Vec<u8> { s.encode_utf16().flat_map(u16::to_le_bytes).collect() }; if contains(bytes, &utf16("WordDocument")) { Format::Doc + } else if contains(bytes, &utf16("PowerPoint Document")) { + Format::Ppt } else if contains(bytes, &utf16("Workbook")) || contains(bytes, &utf16("Book")) { Format::Xls } else { @@ -291,6 +300,20 @@ mod tests { Format::from_content_type("application/vnd.ms-excel"), Format::Xls ); + assert_eq!( + Format::from_content_type( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ), + Format::Pptx + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-powerpoint.presentation.macroEnabled.12"), + Format::Pptx + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-powerpoint"), + Format::Ppt + ); } #[test] diff --git a/crates/markkit/src/ir.rs b/crates/markkit/src/ir.rs index 356e9ce..e3ec9b3 100644 --- a/crates/markkit/src/ir.rs +++ b/crates/markkit/src/ir.rs @@ -373,7 +373,7 @@ pub struct Source { /// Spreadsheets: A1-style cell range, e.g. `A1:D20`. #[serde(default, skip_serializing_if = "Option::is_none")] pub range: Option<String>, - /// Paged formats: 1-based page number. + /// Paged formats: 1-based page number (PPTX: slide number). #[serde(default, skip_serializing_if = "Option::is_none")] pub page: Option<u32>, } diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index edb7249..ddbebd1 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -33,6 +33,7 @@ //! //! - **HTML** (`html`, default): Convert HTML to markdown with configurable rules //! - **DOCX** (`docx`): Convert Word documents +//! - **PPTX** (`pptx`): Convert PowerPoint presentations, one section per slide //! - **Spreadsheet** (`spreadsheet`): Convert XLSX/XLSM, XLSB, XLS and ODS workbooks to tables //! - **Plain text** (always on): `text/plain`, `.txt` //! - **Markdown** (`markdown`): `text/markdown`, `.md`, normalized through the IR @@ -47,7 +48,7 @@ //! `Markkit::extract_url`. Office formats only, no HTML: //! `markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] }`. -#[cfg(any(feature = "docx", feature = "spreadsheet"))] +#[cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] mod archive; pub mod config; #[cfg(feature = "docx")] @@ -63,8 +64,12 @@ pub mod ir; pub mod limits; #[cfg(feature = "markdown")] mod markdown; +#[cfg(any(feature = "docx", feature = "pptx"))] +mod ooxml; #[cfg(feature = "pdf")] mod pdf; +#[cfg(feature = "pptx")] +mod pptx; pub mod render; pub mod rules; #[cfg(feature = "spreadsheet")] @@ -73,8 +78,8 @@ mod text; #[cfg(feature = "tool")] pub mod tool; pub mod transform; -#[cfg(any(feature = "docx", feature = "spreadsheet"))] -#[cfg_attr(not(feature = "docx"), allow(dead_code))] // spreadsheet uses only `find`/`text` +#[cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] +#[cfg_attr(not(feature = "docx"), allow(dead_code))] // DOCX uses every helper mod xml; pub use config::MarkkitBuilder; diff --git a/crates/markkit/src/ooxml.rs b/crates/markkit/src/ooxml.rs new file mode 100644 index 0000000..1139713 --- /dev/null +++ b/crates/markkit/src/ooxml.rs @@ -0,0 +1,343 @@ +//! Shared Office Open XML helpers for the DOCX and PPTX readers: package +//! relationships, run formatting → IR inline nodes, nested lists and +//! table-safe cells. +//! +//! Decision: relationship targets are resolved lexically (no filesystem), and +//! only internal targets are followed for parts; external targets are kept as +//! hyperlink URLs only. + +use std::collections::HashMap; + +use crate::ir::{ListItem, Node}; +use crate::xml::Element; + +/// Resolve a relationship target relative to the directory of the source part. +pub(crate) fn resolve_target(base_dir: &str, target: &str) -> String { + if let Some(abs) = target.strip_prefix('/') { + return abs.to_string(); + } + let mut parts: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect(); + for seg in target.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +pub(crate) fn split_dir(path: &str) -> (&str, &str) { + match path.rsplit_once('/') { + Some((d, f)) => (d, f), + None => ("", path), + } +} + +pub(crate) struct Relationship { + pub rel_type: String, + pub target: String, + pub external: bool, +} + +pub(crate) fn parse_rels(root: Option<Element>) -> HashMap<String, Relationship> { + let mut map = HashMap::new(); + let Some(root) = root else { return map }; + let Some(rels) = root.child("Relationships") else { + return map; + }; + for r in rels.elements().filter(|e| e.name == "Relationship") { + if let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) { + map.insert( + id.to_string(), + Relationship { + rel_type: r.attr("Type").unwrap_or_default().to_string(), + target: target.to_string(), + external: r + .attr("TargetMode") + .is_some_and(|m| m.eq_ignore_ascii_case("External")), + }, + ); + } + } + map +} + +pub(crate) fn rel_target_by_type<'a>( + rels: &'a HashMap<String, Relationship>, + suffix: &str, +) -> Option<&'a str> { + // Deterministic choice if several match. + let mut matches: Vec<(&String, &Relationship)> = rels + .iter() + .filter(|(_, r)| r.rel_type.ends_with(suffix) && !r.external) + .collect(); + matches.sort_by(|a, b| a.0.cmp(b.0)); + matches.first().map(|(_, r)| r.target.as_str()) +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Fmt { + pub bold: bool, + pub italic: bool, + pub strike: bool, +} + +pub(crate) enum Seg { + Text { + text: String, + fmt: Fmt, + link: Option<String>, + }, + Break { + link: Option<String>, + }, +} + +impl Seg { + pub(crate) fn link(&self) -> Option<&str> { + match self { + Seg::Text { link, .. } | Seg::Break { link } => link.as_deref(), + } + } +} + +/// Merge segments and turn them into IR inline nodes. +pub(crate) fn build_inline(segs: Vec<Seg>) -> Vec<Node> { + // 1. Merge adjacent text with identical formatting and link. + let mut merged: Vec<Seg> = Vec::with_capacity(segs.len()); + for seg in segs { + if let ( + Some(Seg::Text { + text: prev, + fmt: pf, + link: pl, + }), + Seg::Text { text, fmt, link }, + ) = (merged.last_mut(), &seg) + && pf == fmt + && pl == link + { + prev.push_str(text); + continue; + } + merged.push(seg); + } + + // 2. Group by link. + let mut out: Vec<Node> = Vec::new(); + let mut i = 0; + while i < merged.len() { + let link = merged[i].link().map(str::to_string); + let mut j = i; + while j < merged.len() && merged[j].link() == link.as_deref() { + j += 1; + } + let nodes = format_nodes(&merged[i..j]); + match link { + Some(url) if !is_blank(&nodes) => { + let (lead, core, trail) = split_ws_nodes(nodes); + out.extend(lead); + out.push(Node::Link { + url, + title: None, + children: core, + }); + out.extend(trail); + } + _ => out.extend(nodes), + } + i = j; + } + tidy_inline(out) +} + +pub(crate) fn format_nodes(segs: &[Seg]) -> Vec<Node> { + let mut out = Vec::new(); + for seg in segs { + match seg { + Seg::Break { .. } => out.push(Node::LineBreak), + Seg::Text { text, fmt, .. } => { + let core = text.trim(); + if core.is_empty() || *fmt == Fmt::default() { + out.push(Node::Text(text.clone())); + continue; + } + let start = text.len() - text.trim_start().len(); + let lead = &text[..start]; + let trail = &text[start + core.len()..]; + if !lead.is_empty() { + out.push(Node::Text(lead.to_string())); + } + let mut node = Node::Text(core.to_string()); + if fmt.strike { + node = Node::Strikethrough { + children: vec![node], + }; + } + if fmt.italic { + node = Node::Italic { + children: vec![node], + }; + } + if fmt.bold { + node = Node::Bold { + children: vec![node], + }; + } + out.push(node); + if !trail.is_empty() { + out.push(Node::Text(trail.to_string())); + } + } + } + } + out +} + +/// Split leading/trailing whitespace text out of a node list (for link text). +pub(crate) fn split_ws_nodes(mut nodes: Vec<Node>) -> (Vec<Node>, Vec<Node>, Vec<Node>) { + let mut lead = Vec::new(); + let mut trail = Vec::new(); + if let Some(Node::Text(t)) = nodes.first_mut() { + let trimmed = t.trim_start().to_string(); + if trimmed.len() != t.len() { + lead.push(Node::Text(t[..t.len() - trimmed.len()].to_string())); + *t = trimmed; + } + } + if let Some(Node::Text(t)) = nodes.last_mut() { + let trimmed = t.trim_end().to_string(); + if trimmed.len() != t.len() { + trail.push(Node::Text(t[trimmed.len()..].to_string())); + *t = trimmed; + } + } + nodes.retain(|n| !matches!(n, Node::Text(t) if t.is_empty())); + (lead, nodes, trail) +} + +/// Merge adjacent text nodes and strip leading/trailing line breaks. +pub(crate) fn tidy_inline(nodes: Vec<Node>) -> Vec<Node> { + let mut out: Vec<Node> = Vec::with_capacity(nodes.len()); + for n in nodes { + match (out.last_mut(), n) { + (Some(Node::Text(prev)), Node::Text(t)) => prev.push_str(&t), + (_, Node::Text(t)) if t.is_empty() => {} + (_, n) => out.push(n), + } + } + while matches!(out.first(), Some(Node::LineBreak)) { + out.remove(0); + } + while matches!(out.last(), Some(Node::LineBreak)) { + out.pop(); + } + out +} + +pub(crate) fn is_blank(nodes: &[Node]) -> bool { + nodes.iter().all(|n| match n { + Node::Text(t) => t.trim().is_empty(), + Node::LineBreak => true, + Node::Bold { children } + | Node::Italic { children } + | Node::Strikethrough { children } + | Node::Link { children, .. } => is_blank(children), + _ => false, + }) +} + +pub(crate) fn plain_text(nodes: &[Node], out: &mut String) { + for n in nodes { + match n { + Node::Text(t) | Node::InlineCode(t) => out.push_str(t), + Node::LineBreak => out.push(' '), + Node::Bold { children } + | Node::Italic { children } + | Node::Strikethrough { children } + | Node::Link { children, .. } + | Node::Heading { children, .. } + | Node::Paragraph { children, .. } => plain_text(children, out), + _ => {} + } + } +} + +pub(crate) struct ListEntry { + pub level: u32, + pub ordered: bool, + pub start: Option<u64>, + pub content: Vec<Node>, + /// Source block index of the first paragraph (DOCX `track_sources`). + #[cfg_attr(not(feature = "docx"), allow(dead_code))] + pub index: usize, +} + +/// Build a (nested) list starting at `entries[*pos]` for `level`. +pub(crate) fn build_list(entries: &[ListEntry], pos: &mut usize, level: u32) -> Node { + let first = &entries[*pos]; + let ordered = first.ordered; + let start = if ordered { + first.start.filter(|&s| s != 1) + } else { + None + }; + let mut items: Vec<ListItem> = Vec::new(); + while *pos < entries.len() { + let e = &entries[*pos]; + if e.level < level || (e.level == level && e.ordered != ordered) { + break; + } + if e.level > level { + let sub = build_list(entries, pos, e.level); + match items.last_mut() { + Some(last) => last.children.push(sub), + None => items.push(ListItem::new(vec![sub])), + } + continue; + } + items.push(ListItem::new(vec![Node::Paragraph { + children: e.content.clone(), + source: None, + }])); + *pos += 1; + } + Node::List { + ordered, + start, + items, + source: None, + } +} + +/// Make cell content single-line: hard breaks become spaces (the renderer escapes pipes). +pub(crate) fn table_cell(nodes: Vec<Node>) -> Vec<Node> { + fn fix(n: Node) -> Node { + match n { + Node::LineBreak => Node::Text(" ".into()), + Node::Bold { children } => Node::Bold { + children: children.into_iter().map(fix).collect(), + }, + Node::Italic { children } => Node::Italic { + children: children.into_iter().map(fix).collect(), + }, + Node::Strikethrough { children } => Node::Strikethrough { + children: children.into_iter().map(fix).collect(), + }, + Node::Link { + url, + title, + children, + } => Node::Link { + url, + title, + children: children.into_iter().map(fix).collect(), + }, + other => other, + } + } + tidy_inline(nodes.into_iter().map(fix).collect()) +} diff --git a/crates/markkit/src/pptx.rs b/crates/markkit/src/pptx.rs new file mode 100644 index 0000000..214fb14 --- /dev/null +++ b/crates/markkit/src/pptx.rs @@ -0,0 +1,515 @@ +//! PPTX (Office Open XML presentation) → IR. +//! +//! Decisions: +//! - Same approach as DOCX: read the package directly with `zip` + the bounded +//! XML tree; every part goes through [`ZipGuard`]. No third-party PPTX crate. +//! - Slides follow `p:sldIdLst` order in `presentation.xml` (the order a viewer +//! shows), resolved through the presentation's relationships. Each slide is a +//! `## Slide N: <title>` section; shapes render in `spTree` document order. +//! - Text in body/content placeholders renders as a bulleted list (nesting from +//! `a:pPr@lvl`) unless a paragraph says `a:buNone`; free text boxes render as +//! paragraphs unless a paragraph declares a bullet. `a:buAutoNum` → ordered. +//! - Tables (`a:tbl` in a graphic frame) → GFM tables, first row = header; +//! `gridSpan`/`rowSpan` kept, merge-continuation cells dropped. +//! - Speaker notes (body placeholder of the notes slide) render under +//! `### Notes`: they often carry the content a presenter says out loud. +//! - Hidden slides (`p:sld@show="0"`) are skipped by default with a +//! `hidden_slide_skipped` warning, like hidden sheets (content invisible to a +//! viewer is a prompt-injection vector); opt in with `include_hidden_content`. +//! - Skipped: pictures, charts, SmartArt, media, comments, slide masters and +//! layouts (template text), and macros (`vbaProject.bin` is never read). + +use std::collections::HashMap; + +use crate::archive::{ArchiveError, ZipGuard}; +use crate::config::MarkkitBuilder; +use crate::error::Error; +use crate::ir::{Document, Node, Source, TableCell}; +use crate::limits::Budget; +use crate::ooxml::{ + Fmt, ListEntry, Relationship, Seg, build_inline, build_list, is_blank, parse_rels, plain_text, + rel_target_by_type, resolve_target, split_dir, table_cell, +}; +use crate::xml::{self, Element, XmlError}; + +/// Result of parsing a PPTX package. +pub(crate) struct PptxOutput { + pub doc: Document, + pub title: Option<String>, + pub warnings: Vec<String>, + pub slides: usize, + pub tables: usize, +} + +/// Deepest `a:pPr@lvl` honoured (PresentationML allows 0–8). +const MAX_LEVEL: u32 = 8; +/// Largest table span kept (matches DOCX `gridSpan` clamp). +const MAX_SPAN: u32 = 64; + +fn archive_err(e: ArchiveError) -> Error { + match e { + ArchiveError::Limit(m) => Error::ResourceLimit(m), + ArchiveError::Invalid(m) => Error::PptxExtract(m), + } +} + +fn xml_err(part: &str, e: XmlError) -> Error { + match e { + XmlError::Limit(m) => Error::ResourceLimit(format!("{part}: {m}")), + XmlError::Invalid(m) => Error::PptxExtract(format!("{part}: {m}")), + } +} + +/// Bounded XML reader: archive limits, nesting depth, node count and deadline. +struct Parts<'z, 'b> { + zip: ZipGuard<'z>, + max_depth: usize, + budget: &'b Budget, +} + +impl Parts<'_, '_> { + fn read_xml(&mut self, path: &str) -> Result<Option<Element>, Error> { + match self.zip.read(path).map_err(archive_err)? { + Some(bytes) => xml::parse(&bytes, self.max_depth, self.budget) + .map(Some) + .map_err(|e| xml_err(path, e)), + None => Ok(None), + } + } + + /// Relationships of `part` (`dir/_rels/file.rels`). + fn part_rels(&mut self, part: &str) -> Result<HashMap<String, Relationship>, Error> { + let (dir, file) = split_dir(part); + Ok(parse_rels( + self.read_xml(&format!("{dir}/_rels/{file}.rels"))?, + )) + } +} + +/// Internal part a relationship id points to, if its type ends with `suffix`. +fn internal_target( + rels: &HashMap<String, Relationship>, + id: &str, + suffix: &str, + base_dir: &str, +) -> Option<String> { + rels.get(id) + .filter(|r| !r.external && r.rel_type.ends_with(suffix)) + .map(|r| resolve_target(base_dir, &r.target)) +} + +// ─── Text ─────────────────────────────────────────────────────────── + +fn attr_on(el: &Element, name: &str) -> bool { + matches!(el.attr(name), Some("1" | "true")) +} + +fn run_fmt(rpr: Option<&Element>) -> Fmt { + let Some(rpr) = rpr else { + return Fmt::default(); + }; + Fmt { + bold: attr_on(rpr, "b"), + italic: attr_on(rpr, "i"), + strike: rpr.attr("strike").is_some_and(|s| s != "noStrike"), + } +} + +/// Placeholder kind of a shape (`p:nvSpPr/p:nvPr/p:ph`). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Placeholder { + /// Not a placeholder: a free text box. + None, + Title, + /// Body / content placeholder: bulleted by default. + Body, + /// Subtitle, date, footer, slide number and other text placeholders. + Other, +} + +fn placeholder(sp: &Element) -> Placeholder { + let Some(ph) = sp + .child("nvSpPr") + .and_then(|n| n.child("nvPr")) + .and_then(|n| n.child("ph")) + else { + return Placeholder::None; + }; + match ph.attr("type") { + Some("title" | "ctrTitle") => Placeholder::Title, + // A placeholder without a type is the generic content placeholder. + None | Some("body" | "obj") => Placeholder::Body, + _ => Placeholder::Other, + } +} + +struct Para { + inline: Vec<Node>, + level: u32, + /// `Some(ordered)` when the paragraph renders as a list item. + bullet: Option<(bool, Option<u64>)>, +} + +struct SlideCtx<'a> { + rels: &'a HashMap<String, Relationship>, +} + +impl SlideCtx<'_> { + fn link(&self, rpr: Option<&Element>) -> Option<String> { + let id = rpr?.child("hlinkClick")?.attr_prefixed("id")?; + let rel = self.rels.get(id).filter(|r| r.external)?; + Some(rel.target.replace(' ', "%20")) + } + + fn paragraph(&self, p: &Element, default_bullet: bool) -> Para { + let mut segs = Vec::new(); + for c in p.elements() { + match c.name.as_str() { + "r" | "fld" => { + let rpr = c.child("rPr"); + if let Some(t) = c.child("t") { + segs.push(Seg::Text { + text: t.text(), + fmt: run_fmt(rpr), + link: self.link(rpr), + }); + } + } + "br" => segs.push(Seg::Break { link: None }), + _ => {} + } + } + let ppr = p.child("pPr"); + let level = ppr + .and_then(|p| p.attr("lvl")) + .and_then(|l| l.parse().ok()) + .unwrap_or(0u32) + .min(MAX_LEVEL); + let bullet = match ppr { + Some(p) if p.child("buNone").is_some() => None, + Some(p) if p.child("buAutoNum").is_some() => { + let start = p + .child("buAutoNum") + .and_then(|b| b.attr("startAt")) + .and_then(|s| s.parse().ok()); + Some((true, start)) + } + Some(p) if p.child("buChar").is_some() || p.child("buBlip").is_some() => { + Some((false, None)) + } + _ if default_bullet => Some((false, None)), + _ => None, + }; + Para { + inline: build_inline(segs), + level, + bullet, + } + } + + /// Paragraphs of a `txBody`, blank ones dropped. + fn paragraphs(&self, body: &Element, default_bullet: bool) -> Vec<Para> { + body.elements() + .filter(|e| e.name == "p") + .map(|p| self.paragraph(p, default_bullet)) + .filter(|p| !is_blank(&p.inline)) + .collect() + } +} + +/// Turn paragraphs into blocks: runs of bulleted paragraphs become lists. +fn paras_to_blocks(paras: Vec<Para>, out: &mut Vec<Node>) { + let mut list: Vec<ListEntry> = Vec::new(); + let flush = |list: &mut Vec<ListEntry>, out: &mut Vec<Node>| { + if list.is_empty() { + return; + } + let entries = std::mem::take(list); + let base = entries.iter().map(|e| e.level).min().unwrap_or(0); + let mut pos = 0; + while pos < entries.len() { + out.push(build_list(&entries, &mut pos, base)); + } + }; + for p in paras { + match p.bullet { + Some((ordered, start)) => list.push(ListEntry { + level: p.level, + ordered, + start, + content: p.inline, + index: 0, + }), + None => { + flush(&mut list, out); + out.push(Node::paragraph(p.inline)); + } + } + } + flush(&mut list, out); +} + +// ─── Slides ───────────────────────────────────────────────────────── + +#[derive(Default)] +struct Slide { + title: Option<Vec<Node>>, + blocks: Vec<Node>, + tables: usize, +} + +impl SlideCtx<'_> { + /// Walk a shape tree (`p:spTree` / `p:grpSp`) in document order. + fn shapes(&self, tree: &Element, slide: &mut Slide) { + for shape in tree.elements() { + match shape.name.as_str() { + "sp" => self.shape(shape, slide), + "grpSp" => self.shapes(shape, slide), + "graphicFrame" => { + if let Some(t) = shape.find("tbl").and_then(|t| self.table(t)) { + slide.tables += 1; + slide.blocks.push(t); + } + } + // Alternate content: render the first choice / fallback that has shapes. + "AlternateContent" => { + if let Some(branch) = shape + .elements() + .find(|e| e.name == "Choice" || e.name == "Fallback") + { + self.shapes(branch, slide); + } + } + _ => {} + } + } + } + + fn shape(&self, sp: &Element, slide: &mut Slide) { + let Some(body) = sp.child("txBody") else { + return; + }; + let kind = placeholder(sp); + let paras = self.paragraphs(body, kind == Placeholder::Body); + if paras.is_empty() { + return; + } + if kind == Placeholder::Title && slide.title.is_none() { + // Multi-paragraph titles join with spaces. + let mut title: Vec<Node> = Vec::new(); + for p in paras { + if !title.is_empty() { + title.push(Node::Text(" ".into())); + } + title.extend(p.inline.into_iter().map(|n| match n { + Node::LineBreak => Node::Text(" ".into()), + n => n, + })); + } + slide.title = Some(title); + return; + } + let paras = if kind == Placeholder::Title { + paras + .into_iter() + .map(|p| Para { bullet: None, ..p }) + .collect() + } else { + paras + }; + paras_to_blocks(paras, &mut slide.blocks); + } + + fn table(&self, tbl: &Element) -> Option<Node> { + let mut rows: Vec<Vec<TableCell>> = Vec::new(); + for tr in tbl.elements().filter(|e| e.name == "tr") { + let mut row = Vec::new(); + for tc in tr.elements().filter(|e| e.name == "tc") { + // Continuation cells of a merge are covered by the anchor's span. + if attr_on(tc, "hMerge") || attr_on(tc, "vMerge") { + continue; + } + let mut content: Vec<Node> = Vec::new(); + if let Some(body) = tc.child("txBody") { + for p in self.paragraphs(body, false) { + if !content.is_empty() { + content.push(Node::Text(" ".into())); + } + content.extend(p.inline); + } + } + let span = |name: &str| { + tc.attr(name) + .and_then(|v| v.parse::<u32>().ok()) + .unwrap_or(1) + .clamp(1, MAX_SPAN) + }; + row.push( + TableCell::new(table_cell(content)) + .with_span(span("gridSpan"), span("rowSpan")), + ); + } + rows.push(row); + } + if rows.is_empty() || rows.iter().all(|r| r.iter().all(|c| is_blank(&c.children))) { + return None; + } + let headers = rows.remove(0); + Some(Node::Table { + alignments: Vec::new(), + headers, + rows, + source: None, + }) + } +} + +/// Speaker notes: paragraphs of the notes slide's body placeholder. +fn notes_blocks(notes: &Element, rels: &HashMap<String, Relationship>) -> Vec<Node> { + let ctx = SlideCtx { rels }; + let mut out = Vec::new(); + let Some(tree) = notes.find("spTree") else { + return out; + }; + for sp in tree.elements().filter(|e| e.name == "sp") { + if placeholder(sp) != Placeholder::Body { + continue; + } + if let Some(body) = sp.child("txBody") { + paras_to_blocks(ctx.paragraphs(body, false), &mut out); + } + } + out +} + +fn slide_heading(n: usize, title: Option<Vec<Node>>) -> Node { + let mut children = vec![Node::Text(format!("Slide {n}"))]; + if let Some(title) = title.filter(|t| !is_blank(t)) { + children.push(Node::Text(": ".into())); + children.extend(title); + } + Node::heading(2, children) +} + +// ─── Entry point ──────────────────────────────────────────────────── + +pub(crate) fn parse( + bytes: &[u8], + config: &MarkkitBuilder, + budget: &Budget, +) -> Result<PptxOutput, Error> { + let mut zip = Parts { + zip: ZipGuard::open(bytes, config).map_err(archive_err)?, + max_depth: config.limits.nesting_depth(), + budget, + }; + + let root_rels = parse_rels(zip.read_xml("_rels/.rels")?); + let main_path = rel_target_by_type(&root_rels, "/officeDocument") + .map(|t| resolve_target("", t)) + .unwrap_or_else(|| "ppt/presentation.xml".to_string()); + let core_path = rel_target_by_type(&root_rels, "/core-properties") + .map(|t| resolve_target("", t)) + .unwrap_or_else(|| "docProps/core.xml".to_string()); + + let main = zip + .read_xml(&main_path)? + .ok_or_else(|| Error::PptxExtract("missing presentation part".into()))?; + let presentation = main + .child("presentation") + .ok_or_else(|| Error::PptxExtract("main part is not a p:presentation".into()))?; + let (dir, _) = split_dir(&main_path); + let rels = zip.part_rels(&main_path)?; + + let slide_ids: Vec<&str> = presentation + .child("sldIdLst") + .map(|l| { + l.elements() + .filter(|e| e.name == "sldId") + .filter_map(|e| e.attr_prefixed("id")) + .collect() + }) + .unwrap_or_default(); + + let mut warnings = Vec::new(); + let mut nodes = Vec::new(); + let (mut slides, mut tables) = (0usize, 0usize); + let mut first_title = None; + for (i, rid) in slide_ids.into_iter().enumerate() { + let n = i + 1; + budget.check_time()?; + let Some(path) = internal_target(&rels, rid, "/slide", dir) else { + warnings.push(format!("slide_error: slide {n}: dangling relationship")); + continue; + }; + let Some(xml) = zip.read_xml(&path)? else { + warnings.push(format!("slide_error: slide {n}: missing part {path}")); + continue; + }; + let Some(sld) = xml.child("sld") else { + warnings.push(format!("slide_error: slide {n}: not a p:sld")); + continue; + }; + if matches!(sld.attr("show"), Some("0" | "false")) && !config.include_hidden_content { + warnings.push(format!("hidden_slide_skipped: slide {n} is hidden")); + continue; + } + let slide_rels = zip.part_rels(&path)?; + let ctx = SlideCtx { rels: &slide_rels }; + let mut slide = Slide::default(); + if let Some(tree) = sld.child("cSld").and_then(|c| c.child("spTree")) { + ctx.shapes(tree, &mut slide); + } + let notes_path = slide_rels + .iter() + .filter(|(_, r)| !r.external && r.rel_type.ends_with("/notesSlide")) + .map(|(_, r)| resolve_target(split_dir(&path).0, &r.target)) + .min(); + let notes = match notes_path { + Some(p) => { + let notes_rels = zip.part_rels(&p)?; + zip.read_xml(&p)? + .map(|x| notes_blocks(&x, ¬es_rels)) + .unwrap_or_default() + } + None => Vec::new(), + }; + + if first_title.is_none() + && let Some(t) = &slide.title + { + let mut s = String::new(); + plain_text(t, &mut s); + first_title = + Some(s.split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty()); + } + slides += 1; + tables += slide.tables; + let start = nodes.len(); + nodes.push(slide_heading(n, slide.title)); + nodes.extend(slide.blocks); + if !notes.is_empty() { + nodes.push(Node::heading(3, vec![Node::Text("Notes".into())])); + nodes.extend(notes); + } + if config.track_sources { + let page = u32::try_from(n).unwrap_or(u32::MAX); + for node in &mut nodes[start..] { + node.set_source(Source::default().with_page(page)); + } + } + } + + let core_title = zip + .read_xml(&core_path)? + .and_then(|c| c.find("title").map(|t| t.text().trim().to_string())) + .filter(|t| !t.is_empty()); + + Ok(PptxOutput { + doc: Document::new(nodes), + title: core_title.or(first_title), + warnings, + slides, + tables, + }) +} diff --git a/crates/markkit/src/xml.rs b/crates/markkit/src/xml.rs index 45187af..7e8d433 100644 --- a/crates/markkit/src/xml.rs +++ b/crates/markkit/src/xml.rs @@ -24,6 +24,7 @@ pub(crate) enum XmlError { #[derive(Debug, Default)] pub(crate) struct Element { pub name: String, + /// `(qualified name, value)`; looked up by local name. pub attrs: Vec<(String, String)>, pub children: Vec<XNode>, } @@ -35,11 +36,20 @@ pub(crate) enum XNode { } impl Element { - /// Attribute value by local name. - pub fn attr(&self, local: &str) -> Option<&str> { + /// Attribute value by local name (any or no prefix). + pub fn attr(&self, local_name: &str) -> Option<&str> { self.attrs .iter() - .find(|(k, _)| k == local) + .find(|(k, _)| local_of(k) == local_name) + .map(|(_, v)| v.as_str()) + } + + /// Attribute value by local name, prefixed attributes only. Tells + /// `r:id` (relationship) from a plain `id` on the same element. + pub fn attr_prefixed(&self, local_name: &str) -> Option<&str> { + self.attrs + .iter() + .find(|(k, _)| k.contains(':') && local_of(k) == local_name) .map(|(_, v)| v.as_str()) } @@ -93,17 +103,18 @@ impl Element { } } +fn local_of(name: &str) -> &str { + name.rsplit_once(':').map_or(name, |(_, l)| l) +} + fn local(name: &str) -> String { - match name.rsplit_once(':') { - Some((_, local)) => local.to_string(), - None => name.to_string(), - } + local_of(name).to_string() } fn element_from(start: &BytesStart<'_>) -> Element { let mut attrs = Vec::new(); for attr in start.attributes().with_checks(false).flatten() { - let key = local(attr.key.as_ref()); + let key = attr.key.as_ref().to_string(); let value = match attr.normalized_value(XmlVersion::Implicit1_0) { Ok(v) => v.into_owned(), Err(_) => attr.value.into_owned(), @@ -237,6 +248,15 @@ mod tests { assert_eq!(root.find("c").map(|e| e.name.as_str()), Some("c")); } + #[test] + fn prefixed_attribute_lookup() { + let root = parse(br#"<sldId id="256" r:id="rId2"/>"#).unwrap(); + let e = root.child("sldId").unwrap(); + assert_eq!(e.attr("id"), Some("256")); + assert_eq!(e.attr_prefixed("id"), Some("rId2")); + assert_eq!(e.attr_prefixed("missing"), None); + } + #[test] fn depth_limited() { let deep = "<a>".repeat(256 + 10); diff --git a/crates/markkit/tests/common/mod.rs b/crates/markkit/tests/common/mod.rs index 7b7b9a0..ac4e6c2 100644 --- a/crates/markkit/tests/common/mod.rs +++ b/crates/markkit/tests/common/mod.rs @@ -671,3 +671,115 @@ pub fn pdf_pages_with(contents: &[&str], fonts: &str, extra: &[Vec<u8>]) -> Vec< objs.extend(extra.iter().cloned()); pdf_bytes(&objs) } + +// ─── PPTX ─────────────────────────────────────────────────────────── + +pub const PPTX_CT: &str = + "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +const P_NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; +const REL_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// A generated slide: `spTree` inner XML, optional notes text, hidden flag, +/// extra slide relationships (`Id`, `Target`) as external hyperlinks. +#[derive(Default)] +pub struct PSlide<'a> { + pub shapes: String, + pub notes: Option<&'a str>, + pub hidden: bool, + pub links: Vec<(&'a str, &'a str)>, +} + +impl PSlide<'_> { + pub fn new(shapes: String) -> Self { + Self { + shapes, + ..Default::default() + } + } +} + +/// `<p:sp>` with an optional placeholder type (`Some("")` = untyped content placeholder). +pub fn p_shape(ph: Option<&str>, paragraphs: &str) -> String { + let ph = match ph { + None => String::new(), + Some("") => r#"<p:ph idx="1"/>"#.to_string(), + Some(t) => format!(r#"<p:ph type="{t}"/>"#), + }; + format!( + r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="s"/><p:cNvSpPr/><p:nvPr>{ph}</p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/>{paragraphs}</p:txBody></p:sp>"# + ) +} + +/// `<a:p>` with one plain run. +pub fn a_para(text: &str) -> String { + format!("<a:p><a:r><a:t>{}</a:t></a:r></a:p>", xml_escape(text)) +} + +pub fn pptx_with(slides: &[PSlide<'_>], extra: &[(&str, &[u8])]) -> Vec<u8> { + let mut ids = String::new(); + let mut rels = String::new(); + let mut entries: Vec<(String, Vec<u8>)> = Vec::new(); + for (i, s) in slides.iter().enumerate() { + let n = i + 1; + ids.push_str(&format!(r#"<p:sldId id="{}" r:id="rId{n}"/>"#, 255 + n)); + rels.push_str(&format!( + r#"<Relationship Id="rId{n}" Type="{REL_NS}/slide" Target="slides/slide{n}.xml"/>"# + )); + let show = if s.hidden { r#" show="0""# } else { "" }; + entries.push(( + format!("ppt/slides/slide{n}.xml"), + format!(r#"<?xml version="1.0" encoding="UTF-8"?><p:sld {P_NS}{show}><p:cSld><p:spTree>{}</p:spTree></p:cSld></p:sld>"#, s.shapes).into_bytes(), + )); + let mut srels = String::new(); + for (id, target) in &s.links { + srels.push_str(&format!(r#"<Relationship Id="{id}" Type="{REL_NS}/hyperlink" Target="{}" TargetMode="External"/>"#, xml_escape(target))); + } + if let Some(notes) = s.notes { + srels.push_str(&format!(r#"<Relationship Id="rIdN" Type="{REL_NS}/notesSlide" Target="../notesSlides/notesSlide{n}.xml"/>"#)); + entries.push(( + format!("ppt/notesSlides/notesSlide{n}.xml"), + format!( + r#"<p:notes {P_NS}><p:cSld><p:spTree>{}{}</p:spTree></p:cSld></p:notes>"#, + p_shape(Some("sldImg"), ""), + p_shape(Some("body"), &a_para(notes)) + ) + .into_bytes(), + )); + } + entries.push(( + format!("ppt/slides/_rels/slide{n}.xml.rels"), + format!(r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{srels}</Relationships>"#).into_bytes(), + )); + } + let pres = format!( + r#"<?xml version="1.0" encoding="UTF-8"?><p:presentation {P_NS}><p:sldIdLst>{ids}</p:sldIdLst></p:presentation>"# + ); + let pres_rels = format!( + r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"# + ); + let root_rels = format!( + r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL_NS}/officeDocument" Target="ppt/presentation.xml"/></Relationships>"# + ); + let mut all: Vec<(String, Vec<u8>)> = vec![ + ("_rels/.rels".into(), root_rels.into_bytes()), + ("ppt/presentation.xml".into(), pres.into_bytes()), + ( + "ppt/_rels/presentation.xml.rels".into(), + pres_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 pptx(slides: &[PSlide<'_>]) -> Vec<u8> { + pptx_with(slides, &[]) +} diff --git a/crates/markkit/tests/document_detection.rs b/crates/markkit/tests/document_detection.rs index 86d0dfb..91b51b9 100644 --- a/crates/markkit/tests/document_detection.rs +++ b/crates/markkit/tests/document_detection.rs @@ -42,6 +42,11 @@ fn content_type_detection() { "application/vnd.openxmlformats-officedocument.presentationml.presentation", Format::Pptx, ), + ( + "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + Format::Pptx, + ), + ("application/vnd.ms-powerpoint", Format::Ppt), ("APPLICATION/PDF", Format::Pdf), ("application/octet-stream", Format::Unknown), ]; @@ -61,6 +66,8 @@ fn extension_detection() { ("xls", Format::Xls), ("ods", Format::Ods), ("pptx", Format::Pptx), + ("PPSX", Format::Pptx), + ("ppt", Format::Ppt), ("pdf", Format::Pdf), ]; for (ext, f) in cases { @@ -117,6 +124,10 @@ fn ole2_sniffing_distinguishes_doc_and_xls() { doc.extend(utf16("WordDocument")); assert_eq!(Format::sniff(&doc), Format::Doc); + let mut ppt = cfb.clone(); + ppt.extend(utf16("PowerPoint Document")); + assert_eq!(Format::sniff(&ppt), Format::Ppt); + assert_eq!(Format::sniff(&cfb), Format::Unknown); } @@ -187,12 +198,9 @@ fn extract_document_unknown_is_unsupported() { } #[test] -fn extract_document_legacy_doc_and_pptx_unsupported() { +fn extract_document_legacy_doc_and_ppt_unsupported() { let m = Markkit::builder().build(); - for ct in [ - "application/msword", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ] { + for ct in ["application/msword", "application/vnd.ms-powerpoint"] { let err = m .extract_document_sync(b"whatever", DocumentHint::content_type(ct)) .unwrap_err(); diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index 9e594db..07f4625 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -1,7 +1,7 @@ //! Security tests for office-document parsing: zip bombs, resource caps, //! malformed input, XML entity tricks, oversized spreadsheet grids. -#![cfg(any(feature = "docx", feature = "spreadsheet"))] +#![cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] mod common; @@ -25,6 +25,7 @@ fn hints() -> Vec<DocumentHint<'static>> { 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"), + DocumentHint::content_type(PPTX_CT), ] } @@ -283,6 +284,75 @@ fn docx_billion_laughs_not_expanded() { } } +#[cfg(feature = "pptx")] +#[test] +fn pptx_zip_bomb_slide_rejected() { + let mut text = String::from("x"); + text.push_str(&" ".repeat(8 * 1024 * 1024)); + let bytes = pptx(&[PSlide::new(p_shape(None, &a_para(&text)))]); + assert!(bytes.len() < 256 * 1024); + let m = Markkit::builder() + .max_archive_entry_size(1024 * 1024) + .build(); + let err = run(&m, &bytes, PPTX_CT).unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + +#[cfg(feature = "pptx")] +#[test] +fn pptx_repeated_slide_references_bounded_by_total_size() { + // 2000 slide ids all point at one 64 KiB slide: re-reading it must count + // against the total decompressed budget. + let big = p_shape(None, &a_para(&"y".repeat(64 * 1024))); + let base = pptx(&[PSlide::new(big)]); + let ids: String = (0..2000) + .map(|i| format!(r#"<p:sldId id="{}" r:id="rId1"/>"#, 256 + i)) + .collect(); + let pres = format!( + r#"<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst>{ids}</p:sldIdLst></p:presentation>"# + ); + let bytes = rezip( + &base, + &["ppt/presentation.xml"], + &[("ppt/presentation.xml", pres.as_bytes())], + ); + let m = Markkit::builder() + .max_archive_total_size(16 * 1024 * 1024) + .build(); + let err = run(&m, &bytes, PPTX_CT).unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + +#[cfg(feature = "pptx")] +#[test] +fn pptx_billion_laughs_and_deep_groups() { + let laughs = r#"<?xml version="1.0"?><!DOCTYPE l [<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;"><!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">]><p:sld xmlns:p="p" xmlns:a="a"><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>&c;safe</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#; + let base = pptx(&[PSlide::new(String::new())]); + let bytes = rezip( + &base, + &["ppt/slides/slide1.xml"], + &[("ppt/slides/slide1.xml", laughs.as_bytes())], + ); + let e = run(&Markkit::builder().build(), &bytes, PPTX_CT).unwrap(); + assert!(e.markdown.contains("safe")); + assert!( + !e.markdown.contains("aaaaaaaaaaaaaaaaaaaa"), + "{}", + e.markdown + ); + + // Group shapes nested past the XML depth cap fail cleanly, no stack overflow. + let deep = format!( + "{}{}{}", + "<p:grpSp>".repeat(10_000), + p_shape(None, &a_para("deep")), + "</p:grpSp>".repeat(10_000) + ); + let bytes = pptx(&[PSlide::new(deep)]); + let err = run(&Markkit::builder().build(), &bytes, PPTX_CT).unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + #[cfg(feature = "docx")] #[test] fn docx_external_entity_not_resolved() { @@ -356,6 +426,21 @@ fn valid_fixtures() -> Vec<Vec<u8>> { title: Some("t"), ..Default::default() })); + #[cfg(feature = "pptx")] + { + let mut slide = PSlide::new( + p_shape(Some("title"), &a_para("T")) + &p_shape(Some(""), &a_para("bullet")), + ); + slide.notes = Some("note"); + v.push(pptx(&[slide])); + v.push( + std::fs::read(format!( + "{}/tests/fixtures/pptx/deck.pptx", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(), + ); + } #[cfg(feature = "spreadsheet")] { v.push(xlsx(&[Sheet::new( @@ -462,7 +547,7 @@ proptest! { /// Byte flips in valid documents never panic. #[test] fn mutated_fixtures_never_panic( - idx in 0usize..9, + idx in 0usize..11, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..16), ) { let fixtures = valid_fixtures(); diff --git a/crates/markkit/tests/fixtures/pptx/README.md b/crates/markkit/tests/fixtures/pptx/README.md new file mode 100644 index 0000000..d334542 --- /dev/null +++ b/crates/markkit/tests/fixtures/pptx/README.md @@ -0,0 +1,7 @@ +# PPTX fixtures + +- `deck.pptx`: generated by `make_deck.py` with python-pptx 1.0.2 (default template): + title slide, bullets with nesting, bold run and hyperlink, speaker notes, a table with + a pipe in a cell, a free text box, and a hidden slide. + +Regenerate: `python3 make_deck.py deck.pptx`. diff --git a/crates/markkit/tests/fixtures/pptx/deck.pptx b/crates/markkit/tests/fixtures/pptx/deck.pptx new file mode 100644 index 0000000000000000000000000000000000000000..5360fadab326d7bdab2eff217b3811667af401ef GIT binary patch literal 35928 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 Q5BS;v>|yl%e!Tnt00`<Wy#N3J literal 0 HcmV?d00001 diff --git a/crates/markkit/tests/fixtures/pptx/make_deck.py b/crates/markkit/tests/fixtures/pptx/make_deck.py new file mode 100644 index 0000000..2757b26 --- /dev/null +++ b/crates/markkit/tests/fixtures/pptx/make_deck.py @@ -0,0 +1,19 @@ +from pptx import Presentation +from pptx.util import Inches +import sys +p=Presentation() +p.core_properties.title="Quarterly Review" +s=p.slides.add_slide(p.slide_layouts[0]); s.shapes.title.text="Q3 Results"; s.placeholders[1].text="Prepared by Finance" +s=p.slides.add_slide(p.slide_layouts[1]); s.shapes.title.text="Highlights" +tf=s.placeholders[1].text_frame; tf.text="Revenue up 12%" +para=tf.add_paragraph(); para.text="EMEA strongest"; para.level=1 +para=tf.add_paragraph(); r=para.add_run(); r.text="Bold "; r.font.bold=True; r2=para.add_run(); r2.text="docs"; r2.hyperlink.address="https://example.com/q3" +s.notes_slide.notes_text_frame.text="Mention the hiring freeze." +s=p.slides.add_slide(p.slide_layouts[5]); s.shapes.title.text="Numbers" +t=s.shapes.add_table(3,2,Inches(1),Inches(2),Inches(4),Inches(2)).table +for i,row in enumerate([["Region","Revenue"],["EMEA","1.2M"],["APAC","0.9 | 1.0M"]]): + for j,v in enumerate(row): t.cell(i,j).text=v +tb=s.shapes.add_textbox(Inches(1),Inches(5),Inches(4),Inches(1)); tb.text_frame.text="Source: internal" +s=p.slides.add_slide(p.slide_layouts[1]); s.shapes.title.text="Secret"; s.placeholders[1].text="hidden content" +s._element.set("show","0") +p.save(sys.argv[1]) diff --git a/crates/markkit/tests/pptx_integration.rs b/crates/markkit/tests/pptx_integration.rs new file mode 100644 index 0000000..beba996 --- /dev/null +++ b/crates/markkit/tests/pptx_integration.rs @@ -0,0 +1,249 @@ +//! PPTX → Markdown integration tests. + +#![cfg(feature = "pptx")] + +mod common; + +use common::*; +use markkit::{DocumentHint, Error, Markkit, format::Format}; + +fn extract_with(m: &Markkit, bytes: &[u8]) -> markkit::Extraction { + m.extract_document_sync(bytes, DocumentHint::default()) + .unwrap() +} + +fn extract(bytes: &[u8]) -> markkit::Extraction { + extract_with(&Markkit::builder().build(), bytes) +} + +fn title_and_body(title: &str, body: &str) -> String { + p_shape(Some("title"), &a_para(title)) + &p_shape(Some(""), body) +} + +#[test] +fn slides_in_presentation_order_with_titles_and_bullets() { + let body = format!( + "{}<a:p><a:pPr lvl=\"1\"/><a:r><a:t>Nested</a:t></a:r></a:p>{}", + a_para("First"), + a_para("Second") + ); + let bytes = pptx(&[ + PSlide::new(title_and_body("Intro", &body)), + PSlide::new(p_shape(Some("title"), &a_para("Only a title"))), + ]); + let e = extract(&bytes); + assert_eq!(e.format, Format::Pptx); + assert_eq!( + e.markdown, + "## Slide 1: Intro\n\n- First\n - Nested\n- Second\n\n## Slide 2: Only a title\n" + ); + assert_eq!(e.stats.pages, Some(2)); + assert_eq!(e.title.as_deref(), Some("Intro")); + assert!(e.warnings.is_empty(), "{:?}", e.warnings); +} + +#[test] +fn text_boxes_are_paragraphs_and_explicit_bullets_win() { + let text_box = p_shape( + None, + &format!( + "{}<a:p><a:pPr><a:buChar char=\"•\"/></a:pPr><a:r><a:t>Bulleted</a:t></a:r></a:p>", + a_para("Plain") + ), + ); + let numbered = p_shape( + Some("body"), + r#"<a:p><a:pPr><a:buAutoNum type="arabicPeriod" startAt="3"/></a:pPr><a:r><a:t>Third</a:t></a:r></a:p><a:p><a:pPr><a:buNone/></a:pPr><a:r><a:t>No bullet</a:t></a:r></a:p>"#, + ); + let e = extract(&pptx(&[PSlide::new(text_box + &numbered)])); + assert_eq!( + e.markdown, + "## Slide 1\n\nPlain\n\n- Bulleted\n\n3. Third\n\nNo bullet\n" + ); +} + +#[test] +fn run_formatting_links_and_breaks() { + let para = r#"<a:p><a:r><a:rPr b="1"/><a:t>Bold</a:t></a:r><a:r><a:t> and </a:t></a:r><a:r><a:rPr i="1"><a:hlinkClick r:id="rIdL"/></a:rPr><a:t>link</a:t></a:r><a:br/><a:r><a:rPr strike="sngStrike"/><a:t>gone</a:t></a:r><a:r><a:rPr b="0" strike="noStrike"/><a:t> kept</a:t></a:r></a:p>"#; + let mut slide = PSlide::new(p_shape(None, para)); + slide.links = vec![("rIdL", "https://example.com/a b")]; + let e = extract(&pptx(&[slide])); + assert_eq!( + e.markdown, + "## Slide 1\n\n**Bold** and [*link*](https://example.com/a%20b) \n~~gone~~ kept\n" + ); +} + +#[test] +fn internal_or_missing_hyperlinks_are_plain_text() { + let para = r#"<a:p><a:r><a:rPr><a:hlinkClick r:id="rIdX"/></a:rPr><a:t>jump</a:t></a:r></a:p>"#; + let e = extract(&pptx(&[PSlide::new(p_shape(None, para))])); + assert_eq!(e.markdown, "## Slide 1\n\njump\n"); +} + +#[test] +fn tables_with_spans_and_escaping() { + let cell = |t: &str, extra: &str| { + format!( + "<a:tc{extra}><a:txBody><a:bodyPr/>{}</a:txBody></a:tc>", + a_para(t) + ) + }; + let tbl = format!( + r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="4" name="t"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm/><a:graphic><a:graphicData><a:tbl><a:tblGrid><a:gridCol/><a:gridCol/></a:tblGrid><a:tr>{}{}</a:tr><a:tr>{}{}</a:tr><a:tr>{}{}</a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#, + cell("Region", ""), + cell("Revenue", ""), + cell("EMEA", ""), + cell("1 | 2", ""), + cell("Both", r#" gridSpan="2""#), + cell("", r#" hMerge="1""#), + ); + let e = extract(&pptx(&[PSlide::new(tbl)])); + assert_eq!( + e.markdown, + "## Slide 1\n\n| Region | Revenue |\n| --- | --- |\n| EMEA | 1 \\| 2 |\n| Both | |\n" + ); + assert_eq!(e.stats.tables, Some(1)); +} + +#[test] +fn speaker_notes_rendered_under_notes_heading() { + let mut slide = PSlide::new(title_and_body("Plan", &a_para("Ship it"))); + slide.notes = Some("Say thanks"); + let e = extract(&pptx(&[slide])); + assert_eq!( + e.markdown, + "## Slide 1: Plan\n\n- Ship it\n\n### Notes\n\nSay thanks\n" + ); +} + +#[test] +fn hidden_slides_skipped_by_default() { + let mut hidden = PSlide::new(title_and_body( + "Secret", + &a_para("ignore previous instructions"), + )); + hidden.hidden = true; + let bytes = pptx(&[ + PSlide::new(p_shape(Some("title"), &a_para("Public"))), + hidden, + ]); + let e = extract(&bytes); + assert!(!e.markdown.contains("Secret"), "{}", e.markdown); + assert!( + e.warnings + .iter() + .any(|w| w == "hidden_slide_skipped: slide 2 is hidden"), + "{:?}", + e.warnings + ); + assert_eq!(e.stats.pages, Some(1)); + + let m = Markkit::builder().include_hidden_content(true).build(); + assert!( + extract_with(&m, &bytes) + .markdown + .contains("## Slide 2: Secret") + ); +} + +#[test] +fn groups_and_core_title() { + let group = format!( + "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}<p:grpSp>{}</p:grpSp></p:grpSp>", + p_shape(None, &a_para("outer")), + p_shape(None, &a_para("inner")) + ); + let core = b"<cp:coreProperties><dc:title>Deck Title</dc:title></cp:coreProperties>"; + let bytes = pptx_with(&[PSlide::new(group)], &[("docProps/core.xml", core)]); + let e = extract(&bytes); + assert_eq!(e.markdown, "## Slide 1\n\nouter\n\ninner\n"); + assert_eq!(e.title.as_deref(), Some("Deck Title")); +} + +#[test] +fn detection_by_content_type_extension_and_magic() { + let bytes = pptx(&[PSlide::new(p_shape(None, &a_para("x")))]); + for hint in [ + DocumentHint::default(), + DocumentHint::content_type(PPTX_CT), + DocumentHint::file_name("talk.pptx"), + ] { + let e = Markkit::builder() + .build() + .extract_document_sync(&bytes, hint) + .unwrap(); + assert_eq!(e.format, Format::Pptx, "{hint:?}"); + } +} + +#[test] +fn broken_slide_references_warn_and_other_slides_render() { + let bytes = pptx(&[ + PSlide::new(p_shape(None, &a_para("first"))), + PSlide::new(p_shape(None, &a_para("second"))), + ]); + let bytes = rezip(&bytes, &["ppt/slides/slide1.xml"], &[]); + let e = extract(&bytes); + assert_eq!(e.markdown, "## Slide 2\n\nsecond\n"); + assert!( + e.warnings + .iter() + .any(|w| w.starts_with("slide_error: slide 1")), + "{:?}", + e.warnings + ); +} + +#[test] +fn not_a_presentation_is_an_error() { + let bytes = zip_bytes(&[("ppt/presentation.xml", b"<nope/>")]); + let err = Markkit::builder() + .build() + .extract_document_sync(&bytes, DocumentHint::content_type(PPTX_CT)) + .unwrap_err(); + assert!(matches!(err, Error::PptxExtract(_)), "{err:?}"); +} + +#[test] +fn real_deck_from_python_pptx() { + let bytes = std::fs::read(format!( + "{}/tests/fixtures/pptx/deck.pptx", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + let e = extract(&bytes); + assert_eq!(e.title.as_deref(), Some("Quarterly Review")); + assert_eq!( + e.markdown, + "## Slide 1: Q3 Results\n\nPrepared by Finance\n\n## Slide 2: Highlights\n\n- Revenue up 12%\n - EMEA strongest\n- **Bold** [docs](https://example.com/q3)\n\n### Notes\n\nMention the hiring freeze.\n\n## Slide 3: Numbers\n\n| Region | Revenue |\n| --- | --- |\n| EMEA | 1.2M |\n| APAC | 0.9 \\| 1.0M |\n\nSource: internal\n" + ); + assert_eq!( + e.warnings, + vec!["hidden_slide_skipped: slide 4 is hidden".to_string()] + ); +} + +#[test] +fn sources_record_slide_number() { + let bytes = pptx(&[ + PSlide::new(p_shape(None, &a_para("one"))), + PSlide::new(title_and_body("Two", &a_para("b"))), + ]); + let m = Markkit::builder().track_sources(true).build(); + let p = m + .parse_document_sync(&bytes, DocumentHint::default()) + .unwrap(); + let pages: Vec<_> = p + .document + .nodes + .iter() + .map(|n| n.source().and_then(|s| s.page)) + .collect(); + assert_eq!(pages, vec![Some(1), Some(1), Some(2), Some(2)]); + let p = Markkit::builder() + .build() + .parse_document_sync(&bytes, DocumentHint::default()) + .unwrap(); + assert!(p.document.nodes.iter().all(|n| n.source().is_none())); +} diff --git a/docs/getting-started.md b/docs/getting-started.md index 3dfc280..7e81319 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,11 +9,11 @@ Add markkit to your `Cargo.toml`: markkit = "0.1" ``` -For PDF, DOCX and spreadsheets: +For PDF, DOCX, PPTX and spreadsheets: ```toml [dependencies] -markkit = { version = "0.1", features = ["pdf", "docx", "spreadsheet"] } +markkit = { version = "0.1", features = ["pdf", "docx", "pptx", "spreadsheet"] } ``` ## Basic Usage diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 7b8c3b3..ed627c1 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -22,6 +22,7 @@ tags: ### Tier 2 (Feature-gated) - **DOCX** (`docx`): Word documents via a direct `zip` + `quick-xml` reader (no docx-rs) +- **PPTX/PPTM/PPSX/POTX** (`pptx`): presentations via the same direct reader - **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) @@ -29,7 +30,7 @@ tags: - **PDF** (`pdf`): Text layer via hayro-syntax (pure Rust); no OCR, encrypted PDFs rejected ### Detected, not supported -- **DOC** (Word 97-2003 binary), **PPTX**: explicit `UnsupportedFormat` errors +- **DOC** (Word 97-2003 binary), **PPT** (PowerPoint 97-2003 binary): explicit `UnsupportedFormat` errors ### Tier 3 (Planned) - **EPUB**: E-book extraction @@ -67,6 +68,7 @@ concatenate cleanly); an empty document renders `""`. This is intended and stabl | DOCX | `docProps/core.xml` `dc:title` (via `_rels/.rels`), else the first `Title`-style paragraph | | XLSX, XLSB | `docProps/core.xml` `dc:title` | | ODS | `meta.xml` `dc:title` | +| PPTX | `docProps/core.xml` `dc:title`, else the first slide title | | XLS | `\u{5}SummaryInformation` property set, `PIDSI_TITLE` (code page aware: 1200 UTF-16, UTF-8, else Latin-1) | | PDF | `/Info /Title` (UTF-16BE with BOM, else PDFDocEncoding) | @@ -78,21 +80,24 @@ only the Markdown. Parsing is CPU-bound; async callers should use the sync varia `spawn_blocking`. Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`, `truncated_pdf`, -`pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`, `hidden_content_skipped`. +`pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`, `hidden_content_skipped`, +`hidden_slide_skipped`, `slide_error`. ## Format Detection Priority order (`Format::detect`): 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 + generic `ms-excel.sheet` → XLSX rule. `application/vnd.ms-powerpoint.*.macroEnabled.12` + is PPTX; bare `application/vnd.ms-powerpoint` is legacy PPT. 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.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. + - OLE2 (`D0 CF 11 E0`): UTF-16 `WordDocument` stream → DOC; `PowerPoint Document` → + PPT; `Workbook`/`Book` → XLS. - `%PDF-` within the first 1 KiB → PDF. - `<!doctype html`, `<html`, `<head`, `<body` (after BOM/whitespace) → HTML. @@ -120,6 +125,27 @@ mislabelled as another Excel type still parses. The main part is located through `_rels/.rels` (fallback `word/document.xml`); styles and numbering through the main part's relationships. +## PPTX Mapping + +| PresentationML | Markdown | +|---|---| +| `p:sldIdLst` order (via `ppt/_rels/presentation.xml.rels`) | One `## Slide N: <title>` section per slide (`## Slide N` without a title) | +| Title / centered-title placeholder | Slide heading text (multi-paragraph titles joined with spaces) | +| Body / content placeholder (`ph` with `type="body"`, `obj` or no type) | Bulleted list, nested by `a:pPr@lvl` (0–8); `a:buNone` paragraphs stay paragraphs | +| Text box / other placeholders (subtitle, footer, ...) | Paragraphs; paragraphs with `a:buChar`/`a:buBlip` become bullets | +| `a:buAutoNum` (`startAt`) | Ordered list | +| `a:rPr` `b`, `i`, `strike` (≠ `noStrike`) | `**`, `*`, `~~` (shared run merging with DOCX) | +| `a:hlinkClick r:id` → external relationship | `[text](url)`; internal jumps (slide links) stay plain text | +| `a:br` | Hard line break | +| `a:tbl` in a graphic frame | GFM table, first row = header, `gridSpan`/`rowSpan` kept, `hMerge`/`vMerge` continuation cells dropped | +| `p:grpSp`, `mc:AlternateContent` | Walked in document order (first branch) | +| Notes slide body placeholder | `### Notes` section after the slide | +| `p:sld@show="0"` | Skipped with `hidden_slide_skipped` unless `include_hidden_content(true)` | +| Pictures, charts, SmartArt, media, comments, masters/layouts | Skipped | + +A slide whose relationship is dangling or whose part is missing emits `slide_error`; the +other slides still render. `stats.pages` counts rendered slides, `stats.tables` tables. + ## Spreadsheet Mapping - One `## <Sheet name>` section per non-empty worksheet, in workbook order, followed by a @@ -211,7 +237,7 @@ Office formats additionally enforce (see [Threat Model](../security/threat-model - `max_archive_entries` (10 000), `max_archive_entry_size` (64 MiB decompressed), `max_archive_total_size` (256 MiB decompressed) - `max_spreadsheet_cells` (4 000 000) for the dense grid calamine builds for XLS/ODS -- XML nesting depth 256 for DOCX parts +- XML nesting depth 256 for DOCX and PPTX parts PDF additionally enforces (T8): `max_pdf_pages` (10 000; truncate + `truncated_pdf`), `max_pdf_stream_size` (64 MiB per decoded stream), `max_pdf_decoded_size` (256 MiB total diff --git a/knowledge/foundations/ir.md b/knowledge/foundations/ir.md index d76784a..81707a3 100644 --- a/knowledge/foundations/ir.md +++ b/knowledge/foundations/ir.md @@ -96,7 +96,7 @@ Every block variant except `ThematicBreak` has `source: Option<Box<Source>>` | `path` | HTML | Absolute XPath in the parsed DOM, every step indexed: `/html[1]/body[1]/div[1]/p[2]`. Indexes count same-name element siblings (excluded elements included). Cached per parent, O(depth) per block. | | `index` | DOCX | 0-based index of the body-level `w:p`/`w:tbl` in document order, counting blank paragraphs and those inside `w:sdt`/`w:ins`. Lists take their first item's index. | | `sheet`, `range` | Spreadsheets | Sheet name on the heading and table; table gets the A1 bounding box of kept cells (`B2:C3`). | -| `page` | Paged formats (PDF, later) | 1-based page. | +| `page` | PPTX; paged formats (PDF, later) | 1-based page; PPTX: slide number on every block of the slide, notes included. | Blocks synthesized by normalization (inline runs wrapped in `Paragraph`) and blocks nested inside inline/table-cell content have no source. Accessors: diff --git a/knowledge/log.md b/knowledge/log.md index 58ade41..50affdd 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-27 +* **PPTX support (#11)**: new `pptx` feature (in `all_formats`) converts presentations slide by slide: titles, bulleted body placeholders, text boxes, tables, links, speaker notes; hidden slides skipped. Shared OOXML helpers moved from `docx.rs` to `ooxml.rs`. Legacy `.ppt` is now its own `Format::Ppt` (rejected). See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6. * **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. * **MCP server**: `markkit mcp` serves `MarkkitTool` over MCP stdio (initialize, ping, tools/list, tools/call); line size capped from `--max-size`, tool failures as `isError` results. New [MCP Server](integrations/mcp-server.md) concept; MCP messages row in the [Threat Model](security/threat-model.md). * **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. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 2eaa002..80ee9cc 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -224,7 +224,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 / XLSB / XLS / ODS) +### T6: Malicious Office Documents (DOCX / PPTX / XLSX / XLSB / XLS / ODS) **Description**: Crafted containers aimed at exhausting memory/CPU, crashing the process, or smuggling content past a human reviewer. @@ -269,6 +269,10 @@ process, or smuggling content past a human reviewer. 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`. +- PPTX reuses the DOCX stack: every part through `ZipGuard`, depth-capped XML tree, entities + never expanded. Each slide reference re-reads its part through the same guard, so a + presentation listing one slide thousands of times is bounded by `max_archive_total_size`. + Group shapes recurse only as deep as the XML tree (256). - XLS `SummaryInformation` title parsing is bounds-checked (1 MiB stream cap, checked offsets, 1024-property cap); failures yield no title, never an error. @@ -282,6 +286,7 @@ process, or smuggling content past a human reviewer. #### T6e: Macros and hidden content - No code execution: VBA projects and XLM macro sheets are never read or rendered; formulas are not evaluated (cached values only). +- Hidden PPTX slides skipped by default (`hidden_slide_skipped`), same rationale as sheets. - Hidden / very hidden sheets skipped by default (`hidden_sheet_skipped` warning) since invisible content is a prompt-injection vector. DOCX deleted revisions and field instructions are dropped. @@ -304,6 +309,8 @@ process, or smuggling content past a human reviewer. `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` +- `pptx_zip_bomb_slide_rejected`, `pptx_repeated_slide_references_bounded_by_total_size`, + `pptx_billion_laughs_and_deep_groups`, `pptx_integration::hidden_slides_skipped_by_default` - `spreadsheet_integration::xlsx_hidden_sheet_skipped_by_default`, `spreadsheet_integration::xlsm_with_vba_project_extracts_values_only` diff --git a/schema/ir.schema.json b/schema/ir.schema.json index 84045ee..269a520 100644 --- a/schema/ir.schema.json +++ b/schema/ir.schema.json @@ -675,7 +675,7 @@ ] }, "page": { - "description": "Paged formats: 1-based page number.", + "description": "Paged formats: 1-based page number (PPTX: slide number).", "format": "uint32", "minimum": 0, "type": [ From 5e2469b3cddef09aa4d552ddf05ef38a54733a57 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 02:56:05 +0000 Subject: [PATCH 07/10] test(js): use legacy .ppt as the unsupported-format example --- crates/markkit-js/__test__/options.test.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/markkit-js/__test__/options.test.cjs b/crates/markkit-js/__test__/options.test.cjs index e469028..35eb814 100644 --- a/crates/markkit-js/__test__/options.test.cjs +++ b/crates/markkit-js/__test__/options.test.cjs @@ -56,7 +56,7 @@ test("extractDocument returns metadata", async () => { assert.equal(out.format, "html"); assert.deepEqual(out.warnings, []); assert.equal(out.title ?? null, null); - assert.throws(() => m.extractDocumentSync(Buffer.from("PK"), { fileName: "a.pptx" }), /Unsupported/); + assert.throws(() => m.extractDocumentSync(Buffer.from("PK"), { fileName: "a.ppt" }), /Unsupported/); }); test("extractDocument exposes page metadata", () => { From 41e90ee18041444db83985e257a3930ee8d6b5c9 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 03:53:13 +0000 Subject: [PATCH 08/10] chore(knowledge): drop trailing whitespace --- knowledge/formats/format-support.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 11a1531..9ef70f4 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -82,7 +82,7 @@ only the Markdown. Parsing is CPU-bound; async callers should use the sync varia Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`, `truncated_pdf`, `pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`, `hidden_content_skipped`, -`hidden_slide_skipped`, `slide_error`, +`hidden_slide_skipped`, `slide_error`, `malformed_csv`, `truncated_table`. ## Format Detection From f4daf3b52128d3b177dcb4c94a9f10b0ff65e655 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 00:19:51 +0000 Subject: [PATCH 09/10] feat(api): structured warnings; Markkit is Clone + Debug Extraction/ParsedDocument warnings become Vec<Warning> { code: WarningCode, detail } so embedders match on a stable code instead of parsing strings. Display keeps the old "code: detail" form; the agent tool output is unchanged. Markkit derives Clone and Debug. Part of #11 --- CHANGELOG.md | 6 + README.md | 3 +- crates/markkit-js/README.md | 2 +- crates/markkit-js/src/lib.rs | 22 ++- crates/markkit-wasm/js/index.d.ts | 8 +- crates/markkit/src/csv.rs | 17 ++- crates/markkit/src/docx.rs | 7 +- crates/markkit/src/extract.rs | 142 +++++++++++++++++- crates/markkit/src/lib.rs | 4 +- crates/markkit/src/pdf/mod.rs | 24 +-- crates/markkit/src/pptx.rs | 23 ++- crates/markkit/src/spreadsheet.rs | 42 +++--- crates/markkit/src/text.rs | 22 ++- crates/markkit/src/tool.rs | 15 +- crates/markkit/tests/csv_integration.rs | 12 +- crates/markkit/tests/document_security.rs | 8 +- crates/markkit/tests/hidden_content.rs | 2 +- crates/markkit/tests/pdf_integration.rs | 8 +- crates/markkit/tests/pptx_integration.rs | 11 +- .../markkit/tests/spreadsheet_integration.rs | 28 ++-- crates/markkit/tests/text_integration.rs | 2 +- knowledge/formats/format-support.md | 14 +- knowledge/integrations/tool-contract.md | 6 +- knowledge/log.md | 1 + knowledge/runtimes/node-package.md | 2 +- knowledge/runtimes/wasm-package.md | 2 +- 26 files changed, 330 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f387f42..e32d7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). no HTTP/TLS stack. Library users calling `extract_url` add `features = ["http_client"]`. The CLI and the Python/Node packages still enable it, so their `extract_url` is unchanged. - **Breaking:** `Error` is `#[non_exhaustive]` (new `Error::Transform`); matches need a `_` arm. +- **Breaking:** `Extraction::warnings` and `ParsedDocument::warnings` are `Vec<Warning>` + (`code: WarningCode` + `detail: String`) instead of `"code: detail"` strings, so callers can match + on the code. `Warning`'s `Display` keeps the old string form; JSON serializes + `{"code": "truncated_sheet", "detail": "..."}`. The agent tool output is unchanged. + Node and WASM `extractDocument` return `warnings` as `{ code, detail }` objects too. +- `Markkit` implements `Clone` and `Debug`. - **Breaking (IR API):** `Node`, `Document`, `ListItem` and the new IR structs are `#[non_exhaustive]`; matches need a `_` arm and structs are built with constructors. `Node::Table` cells are now `TableCell` (`children`, `colspan`, `rowspan`) with an `alignments` field. Block variants gain a diff --git a/README.md b/README.md index 323a715..82e7d8f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ println!("{:?} {:?} {:?}", doc.format, doc.title, doc.warnings); println!("{}", doc.markdown); ``` -`Extraction` carries `markdown`, `title`, `format`, `warnings` (e.g. `truncated_sheet: …`), +`Extraction` carries `markdown`, `title`, `format`, `warnings` (structured: `code` such as +`WarningCode::TruncatedSheet` plus a human-readable `detail`), `stats` (sheets/rows/paragraphs/tables) and, for HTML, `metadata` (description, author, published date, language, canonical URL, site name, lead image from `<meta>`, Open Graph and JSON-LD). `extract_bytes` remains as a Markdown-only shortcut. diff --git a/crates/markkit-js/README.md b/crates/markkit-js/README.md index 3789f8b..3014518 100644 --- a/crates/markkit-js/README.md +++ b/crates/markkit-js/README.md @@ -28,7 +28,7 @@ await extractHtml(html, { includeSelectors: ['article'], excludeSelectors: ['.ad // URLs: non-public addresses are blocked by default. await extractUrl('https://docs.example.com/page', { allowedHosts: ['docs.example.com'], maxRedirects: 2 }); -// Documents with metadata (HTML, DOCX, XLSX/XLS/ODS). +// Documents with metadata (HTML, DOCX, PPTX, XLSX/XLSB/XLS/ODS). `warnings` are `{ code, detail }`. const doc = extractDocumentSync(fs.readFileSync('report.docx'), { fileName: 'report.docx' }, { maxSheetRows: 500 }); console.log(doc.title, doc.format, doc.warnings, doc.markdown); ``` diff --git a/crates/markkit-js/src/lib.rs b/crates/markkit-js/src/lib.rs index 7fe7fbc..22ed961 100644 --- a/crates/markkit-js/src/lib.rs +++ b/crates/markkit-js/src/lib.rs @@ -88,14 +88,23 @@ pub struct Metadata { pub image: Option<String>, } +/// Structured extraction warning. +#[napi(object)] +pub struct Warning { + /// Stable machine code (`truncated_sheet`, `hidden_slide_skipped`, ...). + pub code: String, + /// Human-readable detail. + pub detail: String, +} + /// Markdown plus metadata. #[napi(object)] pub struct Extraction { pub markdown: String, pub title: Option<String>, - /// Detected format (`html`, `docx`, `xlsx`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`). + /// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`). pub format: String, - pub warnings: Vec<String>, + pub warnings: Vec<Warning>, pub stats: ExtractionStats, pub metadata: Metadata, } @@ -222,7 +231,14 @@ fn to_js(e: markkit::Extraction) -> Extraction { markdown: e.markdown, title: e.title, format: e.format.as_str().to_string(), - warnings: e.warnings, + warnings: e + .warnings + .into_iter() + .map(|w| Warning { + code: w.code.as_str().to_string(), + detail: w.detail, + }) + .collect(), stats: ExtractionStats { pages: count(e.stats.pages), sheets: count(e.stats.sheets), diff --git a/crates/markkit-wasm/js/index.d.ts b/crates/markkit-wasm/js/index.d.ts index b465432..8e69612 100644 --- a/crates/markkit-wasm/js/index.d.ts +++ b/crates/markkit-wasm/js/index.d.ts @@ -61,11 +61,17 @@ export interface Metadata { image: string | null; } +export interface Warning { + /** Stable machine code (`truncated_sheet`, `hidden_slide_skipped`, ...). */ + code: string; + detail: string; +} + export interface Extraction { markdown: string; title: string | null; format: string; - warnings: string[]; + warnings: Warning[]; stats: ExtractionStats; metadata: Metadata; } diff --git a/crates/markkit/src/csv.rs b/crates/markkit/src/csv.rs index 4c648bc..53dc11d 100644 --- a/crates/markkit/src/csv.rs +++ b/crates/markkit/src/csv.rs @@ -22,7 +22,7 @@ use crate::limits::Budget; /// Parsed CSV/TSV document. pub(crate) struct CsvOutput { pub doc: Document, - pub warnings: Vec<String>, + pub warnings: Vec<crate::extract::Warning>, /// Data rows (header excluded). pub rows: usize, /// 1 when a table was produced, else 0. @@ -59,16 +59,21 @@ pub(crate) fn parse( records.push(rec); } if reader.unterminated { - warnings.push("malformed_csv: unterminated quoted field".into()); + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::MalformedCsv, + "unterminated quoted field", + )); } if truncated_rows { - warnings.push(format!( - "truncated_table: table truncated to {max_rows} rows" + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::TruncatedTable, + format!("table truncated to {max_rows} rows"), )); } if reader.truncated_cols { - warnings.push(format!( - "truncated_table: table truncated to {max_cols} columns" + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::TruncatedTable, + format!("table truncated to {max_cols} columns"), )); } diff --git a/crates/markkit/src/docx.rs b/crates/markkit/src/docx.rs index d68189c..c0ee40c 100644 --- a/crates/markkit/src/docx.rs +++ b/crates/markkit/src/docx.rs @@ -34,7 +34,7 @@ use crate::xml::{self, Element, XmlError}; /// Result of parsing a DOCX package. pub(crate) struct DocxOutput { pub doc: Document, - pub warnings: Vec<String>, + pub warnings: Vec<crate::extract::Warning>, pub title: Option<String>, pub paragraphs: usize, pub tables: usize, @@ -669,8 +669,9 @@ pub(crate) fn parse( doc: Document::new(body.blocks), warnings: match ctx.hidden_runs.get() { 0 => Vec::new(), - n => vec![format!( - "hidden_content_skipped: {n} hidden text run(s) skipped" + n => vec![crate::extract::Warning::new( + crate::extract::WarningCode::HiddenContentSkipped, + format!("{n} hidden text run(s) skipped"), )], }, }) diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index bd0444d..2f74b2c 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -20,17 +20,103 @@ pub struct Extraction { pub title: Option<String>, /// Format the document was parsed as. pub format: Format, - /// Non-fatal issues, each prefixed with a stable code - /// (`truncated_sheet:`, `hidden_sheet_skipped:`, `sheet_error:`, - /// `truncated_pdf:`, `pdf_unmapped_font:`, `pdf_no_text:`, `invalid_utf8:`, - /// `malformed_csv:`, `truncated_table:`). - pub warnings: Vec<String>, + /// Non-fatal issues, each with a stable [`WarningCode`]. + pub warnings: Vec<Warning>, /// Format-specific counters. pub stats: ExtractionStats, /// Document metadata (HTML `<meta>`, Open Graph, JSON-LD); empty when unknown. pub metadata: Metadata, } +/// Stable code of a [`Warning`]. Serialized (and displayed) in `snake_case`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum WarningCode { + /// A sheet exceeded `max_sheet_rows` / `max_sheet_cols` and was cut. + TruncatedSheet, + /// A hidden sheet was skipped (`include_hidden_sheets` is off). + HiddenSheetSkipped, + /// A sheet could not be read; the other sheets still render. + SheetError, + /// A hidden slide was skipped (`include_hidden_content` is off). + HiddenSlideSkipped, + /// A slide could not be read; the other slides still render. + SlideError, + /// Hidden HTML elements or DOCX runs were skipped (`include_hidden_content` is off). + HiddenContentSkipped, + /// PDF pages past the page cap were not extracted. + TruncatedPdf, + /// A PDF font has no Unicode mapping; its text may be garbled or missing. + PdfUnmappedFont, + /// A PDF has no extractable text layer (scanned images, no OCR). + PdfNoText, + /// Input was not valid UTF-8 and was decoded lossily. + InvalidUtf8, + /// A CSV/TSV row was malformed (ragged or unterminated quote). + MalformedCsv, + /// A CSV/TSV table exceeded the row/column caps and was cut. + TruncatedTable, + /// Output was cut to a caller's size limit (agent tool `max_chars`). + Truncated, + /// Any other issue, e.g. from a custom [`crate::registry::FormatParser`]. + Other, +} + +impl WarningCode { + /// The `snake_case` code, e.g. `"truncated_sheet"`. + pub fn as_str(&self) -> &'static str { + match self { + WarningCode::TruncatedSheet => "truncated_sheet", + WarningCode::HiddenSheetSkipped => "hidden_sheet_skipped", + WarningCode::SheetError => "sheet_error", + WarningCode::HiddenSlideSkipped => "hidden_slide_skipped", + WarningCode::SlideError => "slide_error", + WarningCode::HiddenContentSkipped => "hidden_content_skipped", + WarningCode::TruncatedPdf => "truncated_pdf", + WarningCode::PdfUnmappedFont => "pdf_unmapped_font", + WarningCode::PdfNoText => "pdf_no_text", + WarningCode::InvalidUtf8 => "invalid_utf8", + WarningCode::MalformedCsv => "malformed_csv", + WarningCode::TruncatedTable => "truncated_table", + WarningCode::Truncated => "truncated", + WarningCode::Other => "other", + } + } +} + +impl std::fmt::Display for WarningCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A non-fatal issue found while converting a document. +/// +/// Match on [`Warning::code`]; `detail` is human-readable and may change. +/// `Display` renders `"<code>: <detail>"`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct Warning { + pub code: WarningCode, + pub detail: String, +} + +impl Warning { + pub fn new(code: WarningCode, detail: impl Into<String>) -> Self { + Self { + code, + detail: detail.into(), + } + } +} + +impl std::fmt::Display for Warning { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.detail) + } +} + /// Document-level metadata. Every field is `None` when absent. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[non_exhaustive] @@ -86,7 +172,7 @@ pub struct ParsedDocument { /// Format the document was parsed as. pub format: Format, /// Non-fatal issues (see [`Extraction::warnings`]). - pub warnings: Vec<String>, + pub warnings: Vec<Warning>, /// Format-specific counters. pub stats: ExtractionStats, /// Document metadata (see [`Extraction::metadata`]). @@ -160,7 +246,10 @@ fn guarded<T>( /// Core extraction engine. /// -/// Use [`MarkkitBuilder`] to create a configured instance. +/// Use [`MarkkitBuilder`] to create a configured instance. Holds only its +/// configuration: cloning is cheap enough to give each task its own copy, and +/// one instance can be shared across threads (`Send + Sync`). +#[derive(Debug, Clone)] pub struct Markkit { config: MarkkitBuilder, } @@ -566,6 +655,45 @@ mod tests { assert!(m.config().preserve_links); } + #[test] + fn markkit_is_clone_debug_send_sync() { + fn assert_traits<T: Clone + std::fmt::Debug + Send + Sync + 'static>() {} + assert_traits::<Markkit>(); + let m = Markkit::builder().max_sheet_rows(7).build(); + let c = m.clone(); + assert_eq!(c.config().limits.max_sheet_rows, 7); + assert!(format!("{m:?}").contains("max_sheet_rows: 7")); + } + + #[test] + fn warnings_display_and_serialize_structured() { + let w = Warning::new( + WarningCode::TruncatedSheet, + "sheet 'S' truncated to 10 rows", + ); + assert_eq!( + w.to_string(), + "truncated_sheet: sheet 'S' truncated to 10 rows" + ); + assert_eq!( + serde_json::to_value(&w).unwrap(), + serde_json::json!({"code": "truncated_sheet", "detail": "sheet 'S' truncated to 10 rows"}) + ); + for code in [ + WarningCode::TruncatedSheet, + WarningCode::HiddenSheetSkipped, + WarningCode::SheetError, + WarningCode::HiddenSlideSkipped, + WarningCode::SlideError, + WarningCode::Truncated, + ] { + assert_eq!( + serde_json::to_value(code).unwrap(), + serde_json::Value::String(code.as_str().into()) + ); + } + } + #[tokio::test] async fn test_resource_limit() { let m = Markkit::builder().max_input_size(10).build(); diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index 26772bf..00b72b7 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -87,7 +87,9 @@ mod xml; pub use config::MarkkitBuilder; pub use error::Error; -pub use extract::{Extraction, ExtractionStats, Markkit, Metadata, ParsedDocument}; +pub use extract::{ + Extraction, ExtractionStats, Markkit, Metadata, ParsedDocument, Warning, WarningCode, +}; pub use format::{DocumentHint, Format}; pub use ir::Document; pub use limits::Limits; diff --git a/crates/markkit/src/pdf/mod.rs b/crates/markkit/src/pdf/mod.rs index 26c94e3..064dad1 100644 --- a/crates/markkit/src/pdf/mod.rs +++ b/crates/markkit/src/pdf/mod.rs @@ -51,7 +51,7 @@ pub(crate) struct PdfOutput { pub doc: Document, pub title: Option<String>, pub pages: usize, - pub warnings: Vec<String>, + pub warnings: Vec<crate::extract::Warning>, } pub(crate) fn parse( @@ -120,24 +120,30 @@ pub(crate) fn parse( page_lines.push(build_lines(std::mem::take(&mut interp.spans))); } if pages.len() > cfg.limits.max_pdf_pages { - warnings.push(format!( - "truncated_pdf: rendered {} of {} pages (max_pdf_pages)", - cfg.limits.max_pdf_pages, - pages.len() + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::TruncatedPdf, + format!( + "rendered {} of {} pages (max_pdf_pages)", + cfg.limits.max_pdf_pages, + pages.len() + ), )); } let mut unmapped: Vec<&String> = interp.unmapped.iter().collect(); unmapped.sort(); for name in unmapped { - warnings.push(format!( - "pdf_unmapped_font: text in font {name} has no Unicode mapping and was skipped" + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::PdfUnmappedFont, + format!("text in font {name} has no Unicode mapping and was skipped"), )); } let nodes = build_nodes(page_lines, cfg.track_sources); if nodes.is_empty() && !pages.is_empty() { - warnings - .push("pdf_no_text: no text layer found (scanned PDF? OCR is not supported)".into()); + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::PdfNoText, + "no text layer found (scanned PDF? OCR is not supported)", + )); } Ok(PdfOutput { doc: Document::new(nodes), diff --git a/crates/markkit/src/pptx.rs b/crates/markkit/src/pptx.rs index 214fb14..2b4cf3d 100644 --- a/crates/markkit/src/pptx.rs +++ b/crates/markkit/src/pptx.rs @@ -24,6 +24,7 @@ use std::collections::HashMap; use crate::archive::{ArchiveError, ZipGuard}; use crate::config::MarkkitBuilder; use crate::error::Error; +use crate::extract::{Warning, WarningCode}; use crate::ir::{Document, Node, Source, TableCell}; use crate::limits::Budget; use crate::ooxml::{ @@ -36,7 +37,7 @@ use crate::xml::{self, Element, XmlError}; pub(crate) struct PptxOutput { pub doc: Document, pub title: Option<String>, - pub warnings: Vec<String>, + pub warnings: Vec<Warning>, pub slides: usize, pub tables: usize, } @@ -439,19 +440,31 @@ pub(crate) fn parse( let n = i + 1; budget.check_time()?; let Some(path) = internal_target(&rels, rid, "/slide", dir) else { - warnings.push(format!("slide_error: slide {n}: dangling relationship")); + warnings.push(Warning::new( + WarningCode::SlideError, + format!("slide {n}: dangling relationship"), + )); continue; }; let Some(xml) = zip.read_xml(&path)? else { - warnings.push(format!("slide_error: slide {n}: missing part {path}")); + warnings.push(Warning::new( + WarningCode::SlideError, + format!("slide {n}: missing part {path}"), + )); continue; }; let Some(sld) = xml.child("sld") else { - warnings.push(format!("slide_error: slide {n}: not a p:sld")); + warnings.push(Warning::new( + WarningCode::SlideError, + format!("slide {n}: not a p:sld"), + )); continue; }; if matches!(sld.attr("show"), Some("0" | "false")) && !config.include_hidden_content { - warnings.push(format!("hidden_slide_skipped: slide {n} is hidden")); + warnings.push(Warning::new( + WarningCode::HiddenSlideSkipped, + format!("slide {n} is hidden"), + )); continue; } let slide_rels = zip.part_rels(&path)?; diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs index 2d0a436..9da36d1 100644 --- a/crates/markkit/src/spreadsheet.rs +++ b/crates/markkit/src/spreadsheet.rs @@ -37,6 +37,7 @@ use quick_xml::events::{BytesStart, Event}; use crate::archive::{ArchiveError, ZipGuard}; use crate::config::MarkkitBuilder; use crate::error::Error; +use crate::extract::{Warning, WarningCode}; use crate::format::Format; use crate::ir::{Document, Node, Source, TableCell}; use crate::limits::Budget; @@ -47,7 +48,7 @@ pub(crate) struct SheetOutput { pub doc: Document, pub format: Format, pub title: Option<String>, - pub warnings: Vec<String>, + pub warnings: Vec<Warning>, pub sheets: usize, pub rows: usize, } @@ -148,7 +149,7 @@ pub(crate) fn parse( budget.check_time()?; match res { Ok(()) => out.push_sheet(&name, grid), - Err(e) => out.warn(format!("sheet_error: sheet '{name}': {e}")), + Err(e) => out.warn(WarningCode::SheetError, format!("sheet '{name}': {e}")), } } out.finish(Format::Xlsx, title) @@ -202,7 +203,10 @@ fn parse_xlsb( continue; }; let Some(data) = zip.read(part).map_err(archive_err)? else { - out.warn(format!("sheet_error: sheet '{name}': missing part {part}")); + out.warn( + WarningCode::SheetError, + format!("sheet '{name}': missing part {part}"), + ); continue; }; let mut grid = Grid::new(config); @@ -219,7 +223,7 @@ fn parse_xlsb( budget.check_time()?; match res { Ok(()) => out.push_sheet(&name, grid), - Err(e) => out.warn(format!("sheet_error: sheet '{name}': {e}")), + Err(e) => out.warn(WarningCode::SheetError, format!("sheet '{name}': {e}")), } } out.finish(Format::Xlsb, title) @@ -256,7 +260,7 @@ where budget.check_time()?; out.push_sheet(&name, grid); } - Err(e) => out.warn(format!("sheet_error: sheet '{name}': {e}")), + Err(e) => out.warn(WarningCode::SheetError, format!("sheet '{name}': {e}")), } } Ok(()) @@ -468,7 +472,7 @@ impl Grid { struct Collector<'c> { config: &'c MarkkitBuilder, nodes: Vec<Node>, - warnings: Vec<String>, + warnings: Vec<Warning>, sheets: usize, rows: usize, } @@ -484,8 +488,8 @@ impl<'c> Collector<'c> { } } - fn warn(&mut self, w: String) { - self.warnings.push(w); + fn warn(&mut self, code: WarningCode, detail: String) { + self.warnings.push(Warning::new(code, detail)); } /// Sheet names in workbook order, flagged with whether to render them. @@ -497,10 +501,10 @@ impl<'c> Collector<'c> { } let hidden = s.visible != SheetVisible::Visible; if hidden && !self.config.include_hidden_sheets { - self.warn(format!( - "hidden_sheet_skipped: sheet '{}' is hidden", - s.name - )); + self.warn( + WarningCode::HiddenSheetSkipped, + format!("sheet '{}' is hidden", s.name), + ); out.push((s.name.clone(), false)); } else { out.push((s.name.clone(), true)); @@ -522,14 +526,16 @@ impl<'c> Collector<'c> { return; }; if trunc_rows { - self.warn(format!( - "truncated_sheet: sheet '{name}' truncated to {max_rows} rows" - )); + self.warn( + WarningCode::TruncatedSheet, + format!("sheet '{name}' truncated to {max_rows} rows"), + ); } if trunc_cols { - self.warn(format!( - "truncated_sheet: sheet '{name}' truncated to {max_cols} columns" - )); + self.warn( + WarningCode::TruncatedSheet, + format!("sheet '{name}' truncated to {max_cols} columns"), + ); } self.sheets += 1; self.rows += rows.len(); diff --git a/crates/markkit/src/text.rs b/crates/markkit/src/text.rs index 1cd6f1d..419b148 100644 --- a/crates/markkit/src/text.rs +++ b/crates/markkit/src/text.rs @@ -19,7 +19,7 @@ use crate::limits::Budget; /// Decoded text plus non-fatal warnings. pub(crate) struct Decoded { pub text: String, - pub warnings: Vec<String>, + pub warnings: Vec<crate::extract::Warning>, } /// Decode bytes to normalized text (see module docs). @@ -40,17 +40,24 @@ pub(crate) fn decode(bytes: &[u8]) -> Decoded { } } -fn utf8(bytes: &[u8], warnings: &mut Vec<String>) -> String { +fn utf8(bytes: &[u8], warnings: &mut Vec<crate::extract::Warning>) -> String { match std::str::from_utf8(bytes) { Ok(s) => s.to_string(), Err(_) => { - warnings.push("invalid_utf8: invalid UTF-8 sequences replaced with U+FFFD".into()); + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::InvalidUtf8, + "invalid UTF-8 sequences replaced with U+FFFD", + )); String::from_utf8_lossy(bytes).into_owned() } } } -fn utf16(bytes: &[u8], read: fn([u8; 2]) -> u16, warnings: &mut Vec<String>) -> String { +fn utf16( + bytes: &[u8], + read: fn([u8; 2]) -> u16, + warnings: &mut Vec<crate::extract::Warning>, +) -> String { let units = bytes.as_chunks::<2>().0.iter().map(|c| read(*c)); let mut lossy = !bytes.len().is_multiple_of(2); let s: String = char::decode_utf16(units) @@ -62,7 +69,10 @@ fn utf16(bytes: &[u8], read: fn([u8; 2]) -> u16, warnings: &mut Vec<String>) -> }) .collect(); if lossy { - warnings.push("invalid_utf8: invalid UTF-16 sequences replaced with U+FFFD".into()); + warnings.push(crate::extract::Warning::new( + crate::extract::WarningCode::InvalidUtf8, + "invalid UTF-16 sequences replaced with U+FFFD", + )); } s } @@ -157,7 +167,7 @@ mod tests { fn decode_invalid_utf8_warns() { let d = decode(b"ok \xFF\xFE!"); assert_eq!(d.text, "ok \u{FFFD}\u{FFFD}!"); - assert!(d.warnings[0].starts_with("invalid_utf8:")); + assert_eq!(d.warnings[0].code, crate::extract::WarningCode::InvalidUtf8); } #[test] diff --git a/crates/markkit/src/tool.rs b/crates/markkit/src/tool.rs index 2b15e8f..0708259 100644 --- a/crates/markkit/src/tool.rs +++ b/crates/markkit/src/tool.rs @@ -62,7 +62,7 @@ use serde::{Deserialize, Serialize}; use crate::config::MarkkitBuilder; use crate::error::Error; -use crate::extract::{Extraction, Markkit}; +use crate::extract::{Extraction, Markkit, Warning, WarningCode}; use crate::format::DocumentHint; type ToolExecutionFuture = Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send>>; @@ -655,9 +655,9 @@ async fn run(inner: &ToolInner, req: Request) -> Result<Extraction, ToolError> { .map_or(extraction.markdown.len(), |(i, _)| i); let full_chars = extraction.markdown.chars().count(); extraction.markdown.truncate(cut); - extraction.warnings.push(format!( - "truncated: output cut at {} of {full_chars} chars", - req.max_chars + extraction.warnings.push(Warning::new( + WarningCode::Truncated, + format!("output cut at {} of {full_chars} chars", req.max_chars), )); } Ok(extraction) @@ -695,7 +695,7 @@ fn output_from_extraction( let truncated = extraction .warnings .iter() - .any(|w| w.starts_with("truncated:")); + .any(|w| w.code == WarningCode::Truncated); let stats = serde_json::to_value(&extraction.stats) .map_err(|e| ToolError::Internal(format!("failed to serialize stats: {e}")))?; let chars = extraction.markdown.chars().count(); @@ -704,7 +704,8 @@ fn output_from_extraction( "markdown": extraction.markdown, "title": extraction.title, "format": extraction.format, - "warnings": extraction.warnings, + // Tool contract: warnings are `"<code>: <detail>"` strings. + "warnings": extraction.warnings.iter().map(ToString::to_string).collect::<Vec<_>>(), "truncated": truncated, }), images: Vec::new(), @@ -763,7 +764,7 @@ fn output_schema() -> serde_json::Value { "properties": { "markdown": { "type": "string", "description": "Extracted Markdown" }, "title": { "type": ["string", "null"], "description": "Document title when known" }, - "format": { "type": "string", "description": "Detected format (html, docx, xlsx, xls, ods)" }, + "format": { "type": "string", "description": "Detected format (html, docx, pptx, xlsx, xlsb, xls, ods)" }, "warnings": { "type": "array", "items": { "type": "string" }, diff --git a/crates/markkit/tests/csv_integration.rs b/crates/markkit/tests/csv_integration.rs index 846da61..4f15385 100644 --- a/crates/markkit/tests/csv_integration.rs +++ b/crates/markkit/tests/csv_integration.rs @@ -97,7 +97,7 @@ fn security_row_cap_stops_reading() { assert_eq!(e.stats.rows, Some(3)); assert_eq!(e.markdown.lines().count(), 5); assert_eq!( - e.warnings, + warns(&e), vec!["truncated_table: table truncated to 3 rows"] ); } @@ -113,7 +113,7 @@ fn security_column_cap_never_stores_extra_fields() { ); assert_eq!(e.markdown, "| a | |\n| --- | --- |\n"); assert_eq!( - e.warnings, + warns(&e), vec!["truncated_table: table truncated to 2 columns"] ); } @@ -135,7 +135,7 @@ fn security_unterminated_quote_warns() { let input = format!("a,\"{}", "x\n".repeat(100_000)); let e = extract(input.as_bytes(), DocumentHint::file_name("a.csv")); assert_eq!(e.markdown.lines().count(), 2); - assert_eq!(e.warnings, vec!["malformed_csv: unterminated quoted field"]); + assert_eq!(warns(&e), vec!["malformed_csv: unterminated quoted field"]); } #[test] @@ -154,7 +154,7 @@ fn security_control_chars_and_invalid_utf8() { DocumentHint::file_name("a.tsv"), ); assert_eq!(e.markdown, "| h\\[2J |\n| --- |\n| \u{FFFD}v |\n"); - assert!(e.warnings[0].starts_with("invalid_utf8:")); + assert_eq!(e.warnings[0].code, markkit::WarningCode::InvalidUtf8); } #[test] @@ -206,3 +206,7 @@ fn security_timeout_applies() { "{r:?}" ); } + +fn warns(e: &markkit::Extraction) -> Vec<String> { + e.warnings.iter().map(ToString::to_string).collect() +} diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index ba68be3..e5a6ed3 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -6,7 +6,7 @@ mod common; use common::*; -use markkit::{DocumentHint, Error, Markkit}; +use markkit::{DocumentHint, Error, Markkit, WarningCode}; use proptest::prelude::*; const DOCX_CT: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -156,7 +156,11 @@ fn xlsb_huge_declared_dimensions_do_not_allocate() { .unwrap(); assert!(e.markdown.contains("far")); assert!(e.markdown.len() < 64 * 1024); - assert!(e.warnings.iter().any(|w| w.starts_with("truncated_sheet:"))); + assert!( + e.warnings + .iter() + .any(|w| w.code == WarningCode::TruncatedSheet) + ); } #[cfg(feature = "spreadsheet")] diff --git a/crates/markkit/tests/hidden_content.rs b/crates/markkit/tests/hidden_content.rs index 4518468..529548e 100644 --- a/crates/markkit/tests/hidden_content.rs +++ b/crates/markkit/tests/hidden_content.rs @@ -102,7 +102,7 @@ mod docx { assert!( e.warnings .iter() - .any(|w| w.starts_with("hidden_content_skipped")), + .any(|w| w.code == markkit::WarningCode::HiddenContentSkipped), "{:?}", e.warnings ); diff --git a/crates/markkit/tests/pdf_integration.rs b/crates/markkit/tests/pdf_integration.rs index 47236c3..71483dc 100644 --- a/crates/markkit/tests/pdf_integration.rs +++ b/crates/markkit/tests/pdf_integration.rs @@ -191,7 +191,7 @@ fn multiple_pages_and_page_cap() { assert_eq!(e.markdown.trim(), "Page one"); assert_eq!(e.stats.pages, Some(1)); assert!( - e.warnings[0].starts_with("truncated_pdf:"), + e.warnings[0].code == markkit::WarningCode::TruncatedPdf, "{:?}", e.warnings ); @@ -238,8 +238,8 @@ fn unmapped_composite_font_is_skipped_with_warning() { let e = extract(&pdf); assert_eq!(e.markdown.trim(), "Visible"); assert_eq!(e.warnings.len(), 1, "{:?}", e.warnings); - assert!(e.warnings[0].starts_with("pdf_unmapped_font: ")); - assert!(e.warnings[0].contains("NoMap")); + assert_eq!(e.warnings[0].code, markkit::WarningCode::PdfUnmappedFont); + assert!(e.warnings[0].detail.contains("NoMap")); } #[test] @@ -247,7 +247,7 @@ fn page_without_text_warns() { let e = extract(&pdf_pages(&["0 0 m 100 100 l S"])); assert_eq!(e.markdown.trim(), ""); assert!( - e.warnings[0].starts_with("pdf_no_text:"), + e.warnings[0].code == markkit::WarningCode::PdfNoText, "{:?}", e.warnings ); diff --git a/crates/markkit/tests/pptx_integration.rs b/crates/markkit/tests/pptx_integration.rs index beba996..3784a01 100644 --- a/crates/markkit/tests/pptx_integration.rs +++ b/crates/markkit/tests/pptx_integration.rs @@ -5,7 +5,7 @@ mod common; use common::*; -use markkit::{DocumentHint, Error, Markkit, format::Format}; +use markkit::{DocumentHint, Error, Markkit, Warning, WarningCode, format::Format}; fn extract_with(m: &Markkit, bytes: &[u8]) -> markkit::Extraction { m.extract_document_sync(bytes, DocumentHint::default()) @@ -133,7 +133,7 @@ fn hidden_slides_skipped_by_default() { assert!( e.warnings .iter() - .any(|w| w == "hidden_slide_skipped: slide 2 is hidden"), + .any(|w| w.to_string() == "hidden_slide_skipped: slide 2 is hidden"), "{:?}", e.warnings ); @@ -189,7 +189,7 @@ fn broken_slide_references_warn_and_other_slides_render() { assert!( e.warnings .iter() - .any(|w| w.starts_with("slide_error: slide 1")), + .any(|w| w.code == WarningCode::SlideError && w.detail.starts_with("slide 1")), "{:?}", e.warnings ); @@ -220,7 +220,10 @@ fn real_deck_from_python_pptx() { ); assert_eq!( e.warnings, - vec!["hidden_slide_skipped: slide 4 is hidden".to_string()] + vec![Warning::new( + WarningCode::HiddenSlideSkipped, + "slide 4 is hidden" + )] ); } diff --git a/crates/markkit/tests/spreadsheet_integration.rs b/crates/markkit/tests/spreadsheet_integration.rs index 457e593..cf9dabf 100644 --- a/crates/markkit/tests/spreadsheet_integration.rs +++ b/crates/markkit/tests/spreadsheet_integration.rs @@ -6,7 +6,7 @@ mod common; use common::Cell::*; use common::*; -use markkit::{DocumentHint, Markkit, format::Format}; +use markkit::{DocumentHint, Markkit, WarningCode, format::Format}; const XLSX_CT: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; @@ -101,11 +101,11 @@ fn xlsx_row_cap_truncates_with_warning() { ); assert_eq!(e.warnings.len(), 1, "{:?}", e.warnings); assert!( - e.warnings[0].starts_with("truncated_sheet:"), + e.warnings[0].code == WarningCode::TruncatedSheet, "{:?}", e.warnings ); - assert!(e.warnings[0].contains("Big")); + assert!(e.warnings[0].detail.contains("Big")); } #[test] @@ -123,7 +123,11 @@ fn xlsx_col_cap_truncates_with_warning() { e.markdown, "## Wide\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n" ); - assert!(e.warnings.iter().any(|w| w.starts_with("truncated_sheet:"))); + assert!( + e.warnings + .iter() + .any(|w| w.code == WarningCode::TruncatedSheet) + ); } #[test] @@ -137,7 +141,7 @@ fn xlsx_hidden_sheet_skipped_by_default() { assert!( e.warnings .iter() - .any(|w| w.starts_with("hidden_sheet_skipped:")), + .any(|w| w.code == WarningCode::HiddenSheetSkipped), "{:?}", e.warnings ); @@ -173,7 +177,11 @@ fn xlsx_sparse_far_cell_does_not_allocate_grid() { let e = extract(&bytes); assert!(e.markdown.contains("top"), "{}", e.markdown); assert!(e.markdown.len() < 64 * 1024); - assert!(e.warnings.iter().any(|w| w.starts_with("truncated_sheet:"))); + assert!( + e.warnings + .iter() + .any(|w| w.code == WarningCode::TruncatedSheet) + ); } #[test] @@ -474,7 +482,7 @@ fn xlsb_hidden_sheet_skipped_by_default() { assert!( e.warnings .iter() - .any(|w| w.starts_with("hidden_sheet_skipped:")) + .any(|w| w.code == WarningCode::HiddenSheetSkipped) ); let m = Markkit::builder().include_hidden_sheets(true).build(); assert!(extract_with(&m, &bytes).markdown.contains("## Secret")); @@ -495,7 +503,7 @@ fn xlsb_caps_and_title() { assert_eq!( e.warnings .iter() - .filter(|w| w.starts_with("truncated_sheet:")) + .filter(|w| w.code == WarningCode::TruncatedSheet) .count(), 2, "{:?}", @@ -510,7 +518,7 @@ fn xlsb_missing_and_truncated_sheet_parts_warn() { 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.iter().any(|w| w.code == WarningCode::SheetError), "{:?}", e.warnings ); @@ -523,7 +531,7 @@ fn xlsb_missing_and_truncated_sheet_parts_warn() { ); let e = extract(&cut); assert!( - e.warnings.iter().any(|w| w.starts_with("sheet_error:")), + e.warnings.iter().any(|w| w.code == WarningCode::SheetError), "{:?}", e.warnings ); diff --git a/crates/markkit/tests/text_integration.rs b/crates/markkit/tests/text_integration.rs index 56b9f7b..0688362 100644 --- a/crates/markkit/tests/text_integration.rs +++ b/crates/markkit/tests/text_integration.rs @@ -127,7 +127,7 @@ fn security_invalid_utf8_lossy_with_warning() { let e = extract(b"caf\xE9 \xC3", DocumentHint::file_name("a.txt")); assert_eq!(e.markdown, "caf\u{FFFD} \u{FFFD}\n"); assert_eq!(e.warnings.len(), 1); - assert!(e.warnings[0].starts_with("invalid_utf8:")); + assert_eq!(e.warnings[0].code, markkit::WarningCode::InvalidUtf8); } #[test] diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 9ef70f4..3e76e18 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -52,7 +52,7 @@ pub struct Extraction { // #[non_exhaustive] pub markdown: String, pub title: Option<String>, // see "Document titles" below pub format: Format, - pub warnings: Vec<String>, // "<code>: <message>" + pub warnings: Vec<Warning>, // { code: WarningCode, detail: String }; Display "<code>: <detail>" pub stats: ExtractionStats, // pages / sheets / rows / paragraphs / tables (Option each) pub metadata: Metadata, // HTML page metadata, see HTML Extraction; empty otherwise } @@ -80,10 +80,14 @@ malformed property part yields `None` without failing the extraction. only the Markdown. Parsing is CPU-bound; async callers should use the sync variant in `spawn_blocking`. -Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`, `truncated_pdf`, -`pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`, `hidden_content_skipped`, -`hidden_slide_skipped`, `slide_error`, -`malformed_csv`, `truncated_table`. +Warning codes (`WarningCode`, serialized `snake_case`): `truncated_sheet`, +`hidden_sheet_skipped`, `sheet_error`, `hidden_slide_skipped`, `slide_error`, `truncated_pdf`, +`pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`, `hidden_content_skipped`, `malformed_csv`, +`truncated_table`, `truncated` (agent tool only) and `other` (custom parsers). Callers match on +`code`; `detail` is for humans and may change. + +`Markkit` is `Clone + Debug + Send + Sync` and holds only its configuration, so embedders can +clone it per task instead of wrapping it in an `Arc`. ## Format Detection diff --git a/knowledge/integrations/tool-contract.md b/knowledge/integrations/tool-contract.md index d9843f5..9ccf654 100644 --- a/knowledge/integrations/tool-contract.md +++ b/knowledge/integrations/tool-contract.md @@ -80,11 +80,13 @@ Exactly one source per call. `ToolOutput::result`: ```json -{ "markdown": "string", "title": "string|null", "format": "html|docx|xlsx|xls|ods", +{ "markdown": "string", "title": "string|null", "format": "html|docx|pptx|xlsx|xlsb|xls|ods", "warnings": ["string"], "truncated": false } ``` -Truncation cuts on a character boundary and adds a `truncated:` warning. +Truncation cuts on a character boundary and adds a `WarningCode::Truncated` warning. Tool +warnings stay `"<code>: <detail>"` strings (the `Warning` `Display` form), so the tool schema +is unchanged by the library's structured warnings. `metadata.extra` is `{ "stats": ExtractionStats, "chars": <returned length> }`. `metadata.duration` is zero on `wasm32-unknown-unknown` (no `Instant` there). diff --git a/knowledge/log.md b/knowledge/log.md index bd7c9f9..7cf6ecb 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-27 +* **Embedder API (#11)**: `Markkit` derives `Clone` + `Debug`; warnings are structured (`Warning { code: WarningCode, detail }`) instead of `"code: detail"` strings. The agent tool keeps string warnings. See [Format Support](formats/format-support.md) and [Tool Contract](integrations/tool-contract.md). * **PPTX support (#11)**: new `pptx` feature (in `all_formats`) converts presentations slide by slide: titles, bulleted body placeholders, text boxes, tables, links, speaker notes; hidden slides skipped. Shared OOXML helpers moved from `docx.rs` to `ooxml.rs`. Legacy `.ppt` is now its own `Format::Ppt` (rejected). See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6. * **Fuzzing**: new [Fuzzing](security/fuzzing.md) concept. Eleven cargo-fuzz targets (`crates/markkit/fuzz`, every parser incl. XLSB, PDF, Markdown, CSV, plus the renderer) assert the S17 oracle and run nightly (`fuzz.yml`). The `xls_biff` target found calamine panicking on a dozen truncated BIFF record shapes and in its formula decompiler; the XLS pre-scan now rejects them, neutralises formula tokens and hands calamine a container rebuilt from the scanned stream. See [Threat Model](security/threat-model.md) T6c. * **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. diff --git a/knowledge/runtimes/node-package.md b/knowledge/runtimes/node-package.md index 7343ced..552ecb0 100644 --- a/knowledge/runtimes/node-package.md +++ b/knowledge/runtimes/node-package.md @@ -25,7 +25,7 @@ const sync = m.extractHtmlSync('<h1>Hello</h1>'); const fromUrl = await m.extractUrl('https://example.com', { allowedHosts: ['example.com'] }); const fromBuffer = await m.extractBytes(Buffer.from('<h1>Hello</h1>'), 'text/html'); const doc = m.extractDocumentSync(buf, { fileName: 'report.docx' }); -// { markdown, title, format, warnings, stats, metadata } +// { markdown, title, format, warnings: [{ code, detail }], stats, metadata } ``` Every function takes an optional trailing `options` object (`MarkkitOptions`): diff --git a/knowledge/runtimes/wasm-package.md b/knowledge/runtimes/wasm-package.md index e1b65e9..4ba6967 100644 --- a/knowledge/runtimes/wasm-package.md +++ b/knowledge/runtimes/wasm-package.md @@ -31,7 +31,7 @@ import { initMarkkit, Markkit, MarkkitTool, version } from "@everruns/markkit-wa await initMarkkit(); // once; idempotent const mk = new Markkit({ includeSelectors: ["article"] }); mk.extractHtml(html); // string -mk.extractDocument(bytes, { fileName }); // { markdown, title, format, warnings, stats, metadata } +mk.extractDocument(bytes, { fileName }); // { markdown, title, format, warnings: [{ code, detail }], stats, metadata } mk.extractBytes(bytes, contentType); // string const tool = new MarkkitTool({ locale, maxOutputChars }); tool.toolDefinition(); await tool.execute(args); From 6415396ad85ead87eb38df27c675a9e28ffd3540 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi <mike@chaliy.name> Date: Tue, 27 Oct 2026 01:24:36 +0000 Subject: [PATCH 10/10] feat(limits): structured ResourceLimit errors with LimitKind --- CHANGELOG.md | 3 + README.md | 3 +- crates/markkit/src/archive.rs | 25 +++--- crates/markkit/src/docx.rs | 6 +- crates/markkit/src/error.rs | 14 ++- crates/markkit/src/fetch.rs | 26 +++--- crates/markkit/src/lib.rs | 2 +- crates/markkit/src/limits.rs | 103 ++++++++++++++++++++-- crates/markkit/src/pdf/guard.rs | 14 ++- crates/markkit/src/pdf/mod.rs | 6 +- crates/markkit/src/pptx.rs | 5 +- crates/markkit/src/render/markdown.rs | 6 +- crates/markkit/src/spreadsheet.rs | 19 ++-- crates/markkit/src/xml.rs | 13 +-- crates/markkit/tests/csv_integration.rs | 2 +- crates/markkit/tests/document_security.rs | 56 +++++++++--- crates/markkit/tests/limits.rs | 47 ++++++---- crates/markkit/tests/pdf_security.rs | 43 +++++++-- crates/markkit/tests/text_integration.rs | 2 +- knowledge/log.md | 1 + knowledge/security/threat-model.md | 4 +- 21 files changed, 306 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e32d7ec..db20f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed +- **Breaking:** `Error::ResourceLimit` carries a `LimitExceeded { kind: LimitKind, detail }` instead of + a `String`. `LimitKind` names the `Limits` field (`MaxInputSize`, `Timeout`, `MaxArchiveEntrySize`, + ...); `Error::limit_kind()` returns it. `ResourceLimit(_)` patterns and the error message are unchanged. - **Breaking**: limit fields moved from `MarkkitBuilder` to `MarkkitBuilder::limits` (`config().max_input_size` → `config().limits.max_input_size`); `max_processing_secs` field is now `limits.timeout: Duration`. Builder setters are unchanged, plus new `timeout`, diff --git a/README.md b/README.md index 82e7d8f..6a342af 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,8 @@ markkit = { version = "0.1", features = ["http_client"] } ### Resource limits Every resource an input can drive is bounded by `markkit::Limits`. Defaults are -generous; violations return `Error::ResourceLimit` naming the limit. +generous; violations return `Error::ResourceLimit`, whose `LimitKind` (`err.limit_kind()`) +names the field that was exceeded. | Limit | Default | Bounds | |-------|---------|--------| diff --git a/crates/markkit/src/archive.rs b/crates/markkit/src/archive.rs index 3166a02..6e71bd0 100644 --- a/crates/markkit/src/archive.rs +++ b/crates/markkit/src/archive.rs @@ -12,12 +12,13 @@ use zip::ZipArchive; use zip::result::ZipError; use crate::config::MarkkitBuilder; +use crate::limits::{LimitExceeded, LimitKind}; /// Archive failure, split so callers can map limit violations to /// [`crate::Error::ResourceLimit`] and everything else to a format error. #[derive(Debug)] pub(crate) enum ArchiveError { - Limit(String), + Limit(LimitExceeded), Invalid(String), } @@ -42,10 +43,13 @@ impl<'a> ZipGuard<'a> { pub fn open(bytes: &'a [u8], config: &MarkkitBuilder) -> Result<Self, ArchiveError> { let archive = ZipArchive::new(Cursor::new(bytes))?; if archive.len() > config.limits.max_archive_entries { - return Err(ArchiveError::Limit(format!( - "archive has {} entries, limit is {}", - archive.len(), - config.limits.max_archive_entries + return Err(ArchiveError::Limit(LimitExceeded::new( + LimitKind::MaxArchiveEntries, + format!( + "archive has {} entries, limit is {}", + archive.len(), + config.limits.max_archive_entries + ), ))); } let names = archive @@ -109,9 +113,9 @@ impl<'a> ZipGuard<'a> { fn add_total(&mut self, n: u64) -> Result<(), ArchiveError> { self.total = self.total.saturating_add(n); if self.total > self.max_total { - return Err(ArchiveError::Limit(format!( - "archive decompresses to more than {} bytes", - self.max_total + return Err(ArchiveError::Limit(LimitExceeded::new( + LimitKind::MaxArchiveTotalSize, + format!("archive decompresses to more than {} bytes", self.max_total), ))); } Ok(()) @@ -127,8 +131,9 @@ fn copy_capped<R: Read, W: std::io::Write>( let n = std::io::copy(&mut reader.take(cap.saturating_add(1)), out) .map_err(|e| ArchiveError::Invalid(format!("cannot read entry '{name}': {e}")))?; if n > cap { - return Err(ArchiveError::Limit(format!( - "entry '{name}' decompresses to more than {cap} bytes" + return Err(ArchiveError::Limit(LimitExceeded::new( + LimitKind::MaxArchiveEntrySize, + format!("entry '{name}' decompresses to more than {cap} bytes"), ))); } Ok(n) diff --git a/crates/markkit/src/docx.rs b/crates/markkit/src/docx.rs index c0ee40c..d7927c9 100644 --- a/crates/markkit/src/docx.rs +++ b/crates/markkit/src/docx.rs @@ -24,7 +24,7 @@ use crate::archive::{ArchiveError, ZipGuard}; use crate::config::MarkkitBuilder; use crate::error::Error; use crate::ir::{Document, Node, Source, TableCell}; -use crate::limits::Budget; +use crate::limits::{Budget, LimitExceeded}; use crate::ooxml::{ Fmt, ListEntry, Relationship, Seg, build_inline, build_list, is_blank, parse_rels, plain_text, rel_target_by_type, resolve_target, split_dir, table_cell, @@ -51,7 +51,9 @@ fn archive_err(e: ArchiveError) -> Error { fn xml_err(part: &str, e: XmlError) -> Error { match e { - XmlError::Limit(m) => Error::ResourceLimit(format!("{part}: {m}")), + XmlError::Limit(m) => { + Error::ResourceLimit(LimitExceeded::new(m.kind, format!("{part}: {}", m.detail))) + } XmlError::Invalid(m) => Error::DocxExtract(format!("{part}: {m}")), } } diff --git a/crates/markkit/src/error.rs b/crates/markkit/src/error.rs index e1eb794..db7b2c5 100644 --- a/crates/markkit/src/error.rs +++ b/crates/markkit/src/error.rs @@ -31,8 +31,10 @@ pub enum Error { #[error("Unsupported format: {0}")] UnsupportedFormat(String), + /// A [`Limits`](crate::Limits) value was exceeded; match on + /// [`LimitExceeded::kind`](crate::LimitExceeded) (or use [`Error::limit_kind`]). #[error("Resource limit exceeded: {0}")] - ResourceLimit(String), + ResourceLimit(crate::limits::LimitExceeded), /// A custom [`crate::registry::FormatParser`] failed or panicked. #[error("Parser error: {0}")] @@ -45,3 +47,13 @@ pub enum Error { #[error("I/O error: {0}")] Io(#[from] std::io::Error), } + +impl Error { + /// The exceeded limit, for [`Error::ResourceLimit`]. + pub fn limit_kind(&self) -> Option<crate::limits::LimitKind> { + match self { + Error::ResourceLimit(e) => Some(e.kind), + _ => None, + } + } +} diff --git a/crates/markkit/src/fetch.rs b/crates/markkit/src/fetch.rs index 57ad41a..05292d4 100644 --- a/crates/markkit/src/fetch.rs +++ b/crates/markkit/src/fetch.rs @@ -26,6 +26,7 @@ use reqwest::dns::{Addrs, Name, Resolve, Resolving}; use crate::config::MarkkitBuilder; use crate::error::Error; +use crate::limits::{LimitExceeded, LimitKind}; /// Fetched body plus its declared content type. pub(crate) struct Fetched { @@ -56,9 +57,11 @@ pub(crate) async fn fetch(url: &str, cfg: &MarkkitBuilder) -> crate::Result<Fetc if let Some(len) = response.content_length() && len > limit as u64 { - return Ok(Err(Error::ResourceLimit(format!( - "response Content-Length {len} exceeds max_input_size {limit}" - )))); + return Ok(Err(LimitExceeded::new( + LimitKind::MaxInputSize, + format!("response Content-Length {len} exceeds max_input_size {limit}"), + ) + .into())); } let content_type = response .headers() @@ -69,9 +72,11 @@ pub(crate) async fn fetch(url: &str, cfg: &MarkkitBuilder) -> crate::Result<Fetc let mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await? { if bytes.len() + chunk.len() > limit { - return Ok(Err(Error::ResourceLimit(format!( - "response body exceeds max_input_size {limit}" - )))); + return Ok(Err(LimitExceeded::new( + LimitKind::MaxInputSize, + format!("response body exceeds max_input_size {limit}"), + ) + .into())); } bytes.extend_from_slice(&chunk); } @@ -90,10 +95,11 @@ pub(crate) async fn fetch(url: &str, cfg: &MarkkitBuilder) -> crate::Result<Fetc return Err(Error::UrlBlocked(reason)); } if e.is_timeout() { - return Err(Error::ResourceLimit(format!( - "fetch exceeded timeout of {:?}", - cfg.limits.timeout - ))); + return Err(LimitExceeded::new( + LimitKind::Timeout, + format!("fetch exceeded timeout of {:?}", cfg.limits.timeout), + ) + .into()); } Err(Error::Http(e.to_string())) } diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index 00b72b7..4b72714 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -92,7 +92,7 @@ pub use extract::{ }; pub use format::{DocumentHint, Format}; pub use ir::Document; -pub use limits::Limits; +pub use limits::{LimitExceeded, LimitKind, Limits}; pub use render::MarkdownRenderer; /// Result type alias for markkit operations. diff --git a/crates/markkit/src/limits.rs b/crates/markkit/src/limits.rs index 7dcf027..e6db4f3 100644 --- a/crates/markkit/src/limits.rs +++ b/crates/markkit/src/limits.rs @@ -23,8 +23,84 @@ use std::cell::Cell; use std::time::{Duration, Instant}; +use serde::{Deserialize, Serialize}; + use crate::error::Error; +/// Which [`Limits`] value an input exceeded. Serialized (and displayed) as the +/// field name (`max_input_size`, `timeout`, ...). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum LimitKind { + MaxInputSize, + MaxOutputSize, + Timeout, + MaxNestingDepth, + MaxNodes, + MaxArchiveEntries, + MaxArchiveEntrySize, + MaxArchiveTotalSize, + MaxSpreadsheetCells, + MaxPdfStreamSize, + MaxPdfDecodedSize, +} + +impl LimitKind { + /// The [`Limits`] field name. + pub fn as_str(&self) -> &'static str { + match self { + LimitKind::MaxInputSize => "max_input_size", + LimitKind::MaxOutputSize => "max_output_size", + LimitKind::Timeout => "timeout", + LimitKind::MaxNestingDepth => "max_nesting_depth", + LimitKind::MaxNodes => "max_nodes", + LimitKind::MaxArchiveEntries => "max_archive_entries", + LimitKind::MaxArchiveEntrySize => "max_archive_entry_size", + LimitKind::MaxArchiveTotalSize => "max_archive_total_size", + LimitKind::MaxSpreadsheetCells => "max_spreadsheet_cells", + LimitKind::MaxPdfStreamSize => "max_pdf_stream_size", + LimitKind::MaxPdfDecodedSize => "max_pdf_decoded_size", + } + } +} + +impl std::fmt::Display for LimitKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Payload of [`Error::ResourceLimit`]: the limit hit plus a human-readable +/// detail. Match on `kind`; `detail` may change. `Display` renders `detail`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct LimitExceeded { + pub kind: LimitKind, + pub detail: String, +} + +impl LimitExceeded { + pub fn new(kind: LimitKind, detail: impl Into<String>) -> Self { + Self { + kind, + detail: detail.into(), + } + } +} + +impl std::fmt::Display for LimitExceeded { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.detail) + } +} + +impl From<LimitExceeded> for Error { + fn from(e: LimitExceeded) -> Self { + Error::ResourceLimit(e) + } +} + // THREAT[T2b]: recursion depth must stay safe on small host stacks whatever // the embedder configures. Measured worst case (nested blockquotes, renderer // recursion): 768 levels fit a 2 MiB thread in release, 1024 do not; 512 keeps @@ -189,10 +265,14 @@ impl Limits { /// Reject inputs larger than `max_input_size`. pub(crate) fn check_input(&self, len: usize) -> crate::Result<()> { if len > self.max_input_size { - return Err(Error::ResourceLimit(format!( - "input size {len} exceeds max_input_size {}", - self.max_input_size - ))); + return Err(LimitExceeded::new( + LimitKind::MaxInputSize, + format!( + "input size {len} exceeds max_input_size {}", + self.max_input_size + ), + ) + .into()); } Ok(()) } @@ -318,7 +398,11 @@ impl Budget { } pub fn timeout_error(&self) -> Error { - Error::ResourceLimit(format!("processing exceeded timeout of {:?}", self.timeout)) + LimitExceeded::new( + LimitKind::Timeout, + format!("processing exceeded timeout of {:?}", self.timeout), + ) + .into() } /// Charge `n` parsed nodes against `max_nodes`. @@ -327,10 +411,11 @@ impl Budget { let total = self.nodes.get().saturating_add(n); self.nodes.set(total); if total > self.max_nodes { - return Err(Error::ResourceLimit(format!( - "document has more than max_nodes {} nodes", - self.max_nodes - ))); + return Err(LimitExceeded::new( + LimitKind::MaxNodes, + format!("document has more than max_nodes {} nodes", self.max_nodes), + ) + .into()); } Ok(()) } diff --git a/crates/markkit/src/pdf/guard.rs b/crates/markkit/src/pdf/guard.rs index 012d83a..09dc22f 100644 --- a/crates/markkit/src/pdf/guard.rs +++ b/crates/markkit/src/pdf/guard.rs @@ -19,6 +19,8 @@ use hayro_syntax::Filter; +use crate::limits::{LimitExceeded, LimitKind}; + /// Raised when a stream would decode past a limit. #[derive(Debug)] pub(crate) struct Limit(pub String); @@ -118,11 +120,15 @@ pub(crate) fn prescan( let budget = stream_cap.min(total_cap.saturating_sub(total)); let out = decode(&chain, &bytes[data_start..end], budget).map_err(|_| { if budget < stream_cap { - PrescanError::Limit(format!( - "PDF streams decode to more than {total_cap} bytes in total" + PrescanError::Limit(LimitExceeded::new( + LimitKind::MaxPdfDecodedSize, + format!("PDF streams decode to more than {total_cap} bytes in total (max_pdf_decoded_size)"), )) } else { - PrescanError::Limit(over(stream_cap).0) + PrescanError::Limit(LimitExceeded::new( + LimitKind::MaxPdfStreamSize, + format!("{} (max_pdf_stream_size)", over(stream_cap).0), + )) } })?; total += out.len(); @@ -134,7 +140,7 @@ pub(crate) fn prescan( pub(crate) enum PrescanError { Encrypted, Unverifiable, - Limit(String), + Limit(LimitExceeded), } fn find(hay: &[u8], needle: &[u8]) -> Option<usize> { diff --git a/crates/markkit/src/pdf/mod.rs b/crates/markkit/src/pdf/mod.rs index 064dad1..1263f82 100644 --- a/crates/markkit/src/pdf/mod.rs +++ b/crates/markkit/src/pdf/mod.rs @@ -258,7 +258,11 @@ struct Interpreter<'c> { } fn limit() -> Error { - Error::ResourceLimit("PDF content exceeds max_pdf_decoded_size".into()) + crate::limits::LimitExceeded::new( + crate::limits::LimitKind::MaxPdfDecodedSize, + "PDF content exceeds max_pdf_decoded_size", + ) + .into() } fn num(o: Option<&Object<'_>>) -> f32 { diff --git a/crates/markkit/src/pptx.rs b/crates/markkit/src/pptx.rs index 2b4cf3d..04538bb 100644 --- a/crates/markkit/src/pptx.rs +++ b/crates/markkit/src/pptx.rs @@ -56,7 +56,10 @@ fn archive_err(e: ArchiveError) -> Error { fn xml_err(part: &str, e: XmlError) -> Error { match e { - XmlError::Limit(m) => Error::ResourceLimit(format!("{part}: {m}")), + XmlError::Limit(m) => Error::ResourceLimit(crate::limits::LimitExceeded::new( + m.kind, + format!("{part}: {}", m.detail), + )), XmlError::Invalid(m) => Error::PptxExtract(format!("{part}: {m}")), } } diff --git a/crates/markkit/src/render/markdown.rs b/crates/markkit/src/render/markdown.rs index 5e9074a..aea9109 100644 --- a/crates/markkit/src/render/markdown.rs +++ b/crates/markkit/src/render/markdown.rs @@ -733,7 +733,11 @@ impl Default for MarkdownRenderer { feature = "markdown" ))] fn output_error(max: usize) -> crate::Error { - crate::Error::ResourceLimit(format!("output exceeds max_output_size {max}")) + crate::limits::LimitExceeded::new( + crate::limits::LimitKind::MaxOutputSize, + format!("output exceeds max_output_size {max}"), + ) + .into() } /// Trim leading and trailing whitespace from inline content rendered since `start`. diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs index 9da36d1..553720a 100644 --- a/crates/markkit/src/spreadsheet.rs +++ b/crates/markkit/src/spreadsheet.rs @@ -40,7 +40,7 @@ use crate::error::Error; use crate::extract::{Warning, WarningCode}; use crate::format::Format; use crate::ir::{Document, Node, Source, TableCell}; -use crate::limits::Budget; +use crate::limits::{Budget, LimitExceeded, LimitKind}; mod xlsb; @@ -859,6 +859,10 @@ fn bof_is_biff8(data: &[u8]) -> bool { /// Scan the workbook stream and return a fresh OLE2 container holding only the /// scanned (and formula-neutralised) stream, which is what calamine reads. +fn cells_error(detail: String) -> Error { + LimitExceeded::new(LimitKind::MaxSpreadsheetCells, detail).into() +} + fn prescan_xls<F: Read + Seek>( comp: &mut cfb::CompoundFile<F>, config: &MarkkitBuilder, @@ -875,9 +879,11 @@ fn prescan_xls<F: Read + Seek>( .read_to_end(&mut stream) .map_err(|e| err(format!("cannot read Workbook stream: {e}")))?; if stream.len() as u64 > cap { - return Err(Error::ResourceLimit(format!( - "XLS Workbook stream exceeds {cap} bytes" - ))); + return Err(LimitExceeded::new( + LimitKind::MaxArchiveEntrySize, + format!("XLS Workbook stream exceeds {cap} bytes"), + ) + .into()); } // Neutralising rewrites bytes, which can reframe records of an overlapping // sheet; rescan until stable. Honest files settle on the second pass. @@ -921,8 +927,7 @@ fn scan_biff(stream: &[u8], max_cells: u64) -> Result<Vec<usize>, Error> { _ => {} } } - let limit = - |n: u64| Error::ResourceLimit(format!("XLS grid of {n} cells exceeds limit {max_cells}")); + let limit = |n: u64| cells_error(format!("XLS grid of {n} cells exceeds limit {max_cells}")); let mut total: u64 = 0; let mut edits = Vec::new(); for pos in offsets { @@ -1116,7 +1121,7 @@ fn prescan_ods(content: &[u8], max_cells: u64) -> Result<(), Error> { let mut total: u64 = 0; let check = |total: u64| { if total > max_cells { - Err(Error::ResourceLimit(format!( + Err(cells_error(format!( "ODS grid of at least {total} cells exceeds limit {max_cells}" ))) } else { diff --git a/crates/markkit/src/xml.rs b/crates/markkit/src/xml.rs index 7e8d433..9ea01f8 100644 --- a/crates/markkit/src/xml.rs +++ b/crates/markkit/src/xml.rs @@ -13,11 +13,11 @@ use quick_xml::events::{BytesStart, Event}; use quick_xml::{Reader, XmlVersion}; use crate::error::Error; -use crate::limits::Budget; +use crate::limits::{Budget, LimitExceeded, LimitKind}; #[derive(Debug)] pub(crate) enum XmlError { - Limit(String), + Limit(LimitExceeded), Invalid(String), } @@ -169,8 +169,9 @@ pub(crate) fn parse(bytes: &[u8], max_depth: usize, budget: &Budget) -> Result<E match event { Event::Start(s) => { if stack.len() > max_depth { - return Err(XmlError::Limit(format!( - "XML nesting deeper than max_nesting_depth {max_depth}" + return Err(XmlError::Limit(LimitExceeded::new( + LimitKind::MaxNestingDepth, + format!("XML nesting deeper than max_nesting_depth {max_depth}"), ))); } stack.push(element_from(&s)); @@ -276,7 +277,7 @@ mod tests { let budget = Budget::new(&Limits::default().max_nodes(50)); assert!(matches!( super::parse(many.as_bytes(), 256, &budget), - Err(XmlError::Limit(m)) if m.contains("max_nodes") + Err(XmlError::Limit(m)) if m.kind == LimitKind::MaxNodes )); } @@ -286,7 +287,7 @@ mod tests { let budget = Budget::new(&Limits::default().timeout(std::time::Duration::ZERO)); assert!(matches!( super::parse(many.as_bytes(), 256, &budget), - Err(XmlError::Limit(m)) if m.contains("timeout") + Err(XmlError::Limit(m)) if m.kind == LimitKind::Timeout )); } diff --git a/crates/markkit/tests/csv_integration.rs b/crates/markkit/tests/csv_integration.rs index 4f15385..58b8739 100644 --- a/crates/markkit/tests/csv_integration.rs +++ b/crates/markkit/tests/csv_integration.rs @@ -202,7 +202,7 @@ fn security_timeout_applies() { .build(); let r = m.extract_document_sync(b"a,b\n1,2\n", DocumentHint::file_name("a.csv")); assert!( - matches!(&r, Err(markkit::Error::ResourceLimit(msg)) if msg.contains("timeout")), + matches!(&r, Err(e) if e.limit_kind() == Some(markkit::LimitKind::Timeout)), "{r:?}" ); } diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index e5a6ed3..ffecfb6 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -6,7 +6,7 @@ mod common; use common::*; -use markkit::{DocumentHint, Error, Markkit, WarningCode}; +use markkit::{DocumentHint, Error, LimitKind, Markkit, WarningCode}; use proptest::prelude::*; const DOCX_CT: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -47,7 +47,11 @@ fn docx_zip_bomb_entry_rejected() { .max_archive_entry_size(1024 * 1024) .build(); let err = run(&m, &bytes, DOCX_CT).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxArchiveEntrySize), + "{err:?}" + ); } #[cfg(feature = "docx")] @@ -66,7 +70,11 @@ fn docx_total_decompressed_size_capped() { .max_archive_total_size(1024 * 1024) .build(); let err = run(&m, &bytes, DOCX_CT).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxArchiveTotalSize), + "{err:?}" + ); } #[test] @@ -86,7 +94,11 @@ fn archive_entry_count_capped() { } for ct in types { let err = run(&m, &bytes, ct).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{ct}: {err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxArchiveEntries), + "{ct}: {err:?}" + ); } } @@ -105,7 +117,11 @@ fn xlsx_zip_bomb_sheet_rejected() { .max_archive_entry_size(1024 * 1024) .build(); let err = run(&m, &bytes, XLSX_CT).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxArchiveEntrySize), + "{err:?}" + ); } #[cfg(feature = "spreadsheet")] @@ -177,7 +193,11 @@ fn xlsx_bomb_in_unreferenced_entry_rejected() { .max_archive_entry_size(1024 * 1024) .build(); let err = run(&m, &bytes, XLSX_CT).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxArchiveEntrySize), + "{err:?}" + ); } #[test] @@ -186,7 +206,11 @@ fn max_input_size_honoured_for_office_documents() { let m = Markkit::builder().max_input_size(bytes.len() - 1).build(); for hint in hints() { let err = m.extract_document_sync(&bytes, hint).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{hint:?}: {err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxInputSize), + "{hint:?}: {err:?}" + ); } } @@ -204,7 +228,11 @@ fn xls_huge_dimensions_rejected() { let err = m .extract_document_sync(&bytes, DocumentHint::default()) .unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxSpreadsheetCells), + "{err:?}" + ); } #[cfg(feature = "spreadsheet")] @@ -219,7 +247,11 @@ fn xls_far_apart_cells_rejected() { let err = m .extract_document_sync(&bytes, DocumentHint::default()) .unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxSpreadsheetCells), + "{err:?}" + ); } #[cfg(feature = "spreadsheet")] @@ -450,7 +482,11 @@ fn ods_repeated_rows_and_columns_rejected() { let err = m .extract_document_sync(&bytes, DocumentHint::default()) .unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert_eq!( + err.limit_kind(), + Some(LimitKind::MaxSpreadsheetCells), + "{err:?}" + ); } // ─── XML tricks ───────────────────────────────────────────────────── diff --git a/crates/markkit/tests/limits.rs b/crates/markkit/tests/limits.rs index eb1239b..742e396 100644 --- a/crates/markkit/tests/limits.rs +++ b/crates/markkit/tests/limits.rs @@ -8,16 +8,24 @@ mod common; use std::time::Duration; -use markkit::{DocumentHint, Error, Limits, Markkit}; +use markkit::{DocumentHint, Error, LimitKind, Limits, Markkit}; #[allow(dead_code)] const DOCX_CT: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; #[allow(dead_code)] const XLSX_CT: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; -fn limit_msg<T: std::fmt::Debug>(r: markkit::Result<T>) -> String { +/// The exceeded limit; also checks the message names it and `limit_kind` agrees. +fn limit<T: std::fmt::Debug>(r: markkit::Result<T>) -> LimitKind { match r { - Err(Error::ResourceLimit(m)) => m, + Err(e @ Error::ResourceLimit(_)) => { + let Error::ResourceLimit(m) = &e else { + unreachable!() + }; + assert_eq!(e.limit_kind(), Some(m.kind)); + assert!(m.detail.contains(m.kind.as_str()), "{m}"); + m.kind + } other => panic!("expected ResourceLimit, got {other:?}"), } } @@ -77,7 +85,7 @@ mod html { fn output_cap_rejects_large_output() { let html = "<p>hello world</p>".repeat(1000); let m = Markkit::builder().max_output_size(1000).build(); - assert!(limit_msg(m.extract_html_sync(&html)).contains("max_output_size")); + assert!(limit(m.extract_html_sync(&html)) == LimitKind::MaxOutputSize); let m = Markkit::builder().max_output_size(100_000).build(); assert!(m.extract_html_sync(&html).is_ok()); } @@ -92,7 +100,7 @@ mod html { ); let m = Markkit::builder().max_output_size(1 << 20).build(); let start = std::time::Instant::now(); - assert!(limit_msg(m.extract_html_sync(&html)).contains("max_output_size")); + assert!(limit(m.extract_html_sync(&html)) == LimitKind::MaxOutputSize); assert!(start.elapsed() < Duration::from_secs(10)); } @@ -105,24 +113,24 @@ mod html { format!("<ul><li><p>a</p>{table}</li></ul>"), ] { let m = Markkit::builder().max_output_size(64 * 1024).build(); - assert!(limit_msg(m.extract_html_sync(&html)).contains("max_output_size")); + assert!(limit(m.extract_html_sync(&html)) == LimitKind::MaxOutputSize); } } #[test] fn zero_timeout_rejects() { let m = Markkit::builder().timeout(Duration::ZERO).build(); - assert!(limit_msg(m.extract_html_sync("<p>x</p>")).contains("timeout")); + assert!(limit(m.extract_html_sync("<p>x</p>")) == LimitKind::Timeout); let r = m.extract_document_sync(b"<p>x</p>", DocumentHint::content_type("text/html")); - assert!(limit_msg(r).contains("timeout")); + assert!(limit(r) == LimitKind::Timeout); } #[test] fn node_cap_rejects() { let html = "<p>a</p>".repeat(1000); let m = Markkit::builder().max_nodes(500).build(); - assert!(limit_msg(m.extract_html_sync(&html)).contains("max_nodes")); - assert!(limit_msg(m.parse_html(&html)).contains("max_nodes")); + assert!(limit(m.extract_html_sync(&html)) == LimitKind::MaxNodes); + assert!(limit(m.parse_html(&html)) == LimitKind::MaxNodes); let m = Markkit::builder().max_nodes(10_000).build(); assert!(m.extract_html_sync(&html).is_ok()); } @@ -179,7 +187,7 @@ mod docx { let body: String = (0..200).map(|i| para(&format!("p{i}"))).collect(); let bytes = docx(&body); let m = Markkit::builder().max_nodes(100).build(); - assert!(limit_msg(run(&m, &bytes)).contains("max_nodes")); + assert!(limit(run(&m, &bytes)) == LimitKind::MaxNodes); assert!(run(&Markkit::builder().build(), &bytes).is_ok()); } @@ -194,7 +202,7 @@ mod docx { let bytes = docx(&body); assert!(run(&Markkit::builder().build(), &bytes).is_ok()); let m = Markkit::builder().max_nesting_depth(8).build(); - assert!(limit_msg(run(&m, &bytes)).contains("max_nesting_depth")); + assert!(limit(run(&m, &bytes)) == LimitKind::MaxNestingDepth); } #[test] @@ -213,7 +221,7 @@ mod docx { .spawn(move || { let m = Markkit::builder().max_nesting_depth(usize::MAX).build(); assert!(run(&m, &bytes).unwrap().markdown.contains('x')); - assert!(limit_msg(run(&m, &deeper)).contains("max_nesting_depth")); + assert!(limit(run(&m, &deeper)) == LimitKind::MaxNestingDepth); }) .unwrap() .join() @@ -224,7 +232,7 @@ mod docx { fn zero_timeout_rejects() { let bytes = docx(¶("x")); let m = Markkit::builder().timeout(Duration::ZERO).build(); - assert!(limit_msg(run(&m, &bytes)).contains("timeout")); + assert!(limit(run(&m, &bytes)) == LimitKind::Timeout); } #[test] @@ -232,7 +240,7 @@ mod docx { let body: String = (0..200).map(|i| para(&format!("paragraph {i}"))).collect(); let bytes = docx(&body); let m = Markkit::builder().max_output_size(500).build(); - assert!(limit_msg(run(&m, &bytes)).contains("max_output_size")); + assert!(limit(run(&m, &bytes)) == LimitKind::MaxOutputSize); } } @@ -257,13 +265,13 @@ mod spreadsheet { #[test] fn zero_timeout_rejects() { let m = Markkit::builder().timeout(Duration::ZERO).build(); - assert!(limit_msg(run(&m, &sheet())).contains("timeout")); + assert!(limit(run(&m, &sheet())) == LimitKind::Timeout); } #[test] fn output_cap_rejects() { let m = Markkit::builder().max_output_size(500).build(); - assert!(limit_msg(run(&m, &sheet())).contains("max_output_size")); + assert!(limit(run(&m, &sheet())) == LimitKind::MaxOutputSize); assert!(run(&Markkit::builder().build(), &sheet()).is_ok()); } @@ -275,7 +283,10 @@ mod spreadsheet { let bytes = xlsb(&[Sheet::new("S", rows)]); let hint = DocumentHint::file_name("t.xlsb"); let m = Markkit::builder().timeout(Duration::ZERO).build(); - assert!(limit_msg(m.extract_document_sync(&bytes, hint)).contains("timeout")); + assert_eq!( + limit(m.extract_document_sync(&bytes, hint)), + LimitKind::Timeout + ); assert!( Markkit::builder() .build() diff --git a/crates/markkit/tests/pdf_security.rs b/crates/markkit/tests/pdf_security.rs index ce2fdcd..7f50745 100644 --- a/crates/markkit/tests/pdf_security.rs +++ b/crates/markkit/tests/pdf_security.rs @@ -34,7 +34,10 @@ fn flate_bomb_content_stream_rejected() { let mut objs = catalog_page("5 0 R"); objs.push(pdf_stream("/Filter /FlateDecode", &bomb)); let err = run(&Markkit::builder().build(), &pdf_bytes(&objs)).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert!( + err.limit_kind() == Some(markkit::LimitKind::MaxPdfStreamSize), + "{err:?}" + ); } #[test] @@ -50,7 +53,10 @@ fn bomb_in_unreferenced_object_stream_rejected_before_parsing() { )); let m = Markkit::builder().max_pdf_stream_size(1024 * 1024).build(); let err = run(&m, &pdf_bytes(&objs)).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert!( + err.limit_kind() == Some(markkit::LimitKind::MaxPdfStreamSize), + "{err:?}" + ); } #[test] @@ -65,7 +71,10 @@ fn chained_filters_are_measured_after_every_stage() { )); let m = Markkit::builder().max_pdf_stream_size(1024 * 1024).build(); let err = run(&m, &pdf_bytes(&objs)).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert!( + err.limit_kind() == Some(markkit::LimitKind::MaxPdfStreamSize), + "{err:?}" + ); } #[test] @@ -75,7 +84,10 @@ fn escaped_filter_name_cannot_evade_scan() { objs.push(pdf_stream("/Filter /Fl#61teDecode", &bomb)); let m = Markkit::builder().max_pdf_stream_size(1024 * 1024).build(); let err = run(&m, &pdf_bytes(&objs)).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert!( + err.limit_kind() == Some(markkit::LimitKind::MaxPdfStreamSize), + "{err:?}" + ); } #[test] @@ -89,7 +101,10 @@ fn total_decoded_size_capped_across_streams() { .max_pdf_decoded_size(1024 * 1024) .build(); let err = run(&m, &pdf_bytes(&objs)).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert!( + err.limit_kind() == Some(markkit::LimitKind::MaxPdfDecodedSize), + "{err:?}" + ); // Each stream alone is fine. assert!(run(&Markkit::builder().build(), &pdf_bytes(&objs)).is_ok()); } @@ -124,7 +139,10 @@ fn form_xobject_reuse_hits_interpretation_budget() { .max_pdf_decoded_size(4 * 1024 * 1024) .build(); let err = run(&m, &pdf_bytes(&objs)).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert!( + err.limit_kind() == Some(markkit::LimitKind::MaxPdfDecodedSize), + "{err:?}" + ); } #[test] @@ -149,7 +167,10 @@ fn repeated_contents_reference_is_charged_each_time() { .max_pdf_decoded_size(8 * 1024 * 1024) .build(); let err = run(&m, &pdf_bytes(&objs)).unwrap_err(); - assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); + assert!( + err.limit_kind() == Some(markkit::LimitKind::MaxPdfDecodedSize), + "{err:?}" + ); } #[test] @@ -314,7 +335,7 @@ fn zero_timeout_rejects() { .timeout(std::time::Duration::ZERO) .build(); match run(&m, &bytes) { - Err(Error::ResourceLimit(msg)) => assert!(msg.contains("timeout"), "{msg}"), + Err(e) => assert_eq!(e.limit_kind(), Some(markkit::LimitKind::Timeout), "{e}"), other => panic!("expected timeout, got {other:?}"), } } @@ -345,7 +366,11 @@ fn output_cap_rejects() { let bytes = pdf_pages(&[content.as_str()]); let m = Markkit::builder().max_output_size(500).build(); match run(&m, &bytes) { - Err(Error::ResourceLimit(msg)) => assert!(msg.contains("max_output_size"), "{msg}"), + Err(e) => assert_eq!( + e.limit_kind(), + Some(markkit::LimitKind::MaxOutputSize), + "{e}" + ), other => panic!("expected output cap, got {other:?}"), } } diff --git a/crates/markkit/tests/text_integration.rs b/crates/markkit/tests/text_integration.rs index 0688362..35d53e3 100644 --- a/crates/markkit/tests/text_integration.rs +++ b/crates/markkit/tests/text_integration.rs @@ -237,7 +237,7 @@ fn security_text_and_markdown_share_limits() { continue; } assert!( - matches!(&r, Err(Error::ResourceLimit(msg)) if msg.contains("timeout")), + matches!(&r, Err(e) if e.limit_kind() == Some(markkit::LimitKind::Timeout)), "{name}: {r:?}" ); } diff --git a/knowledge/log.md b/knowledge/log.md index 7cf6ecb..4094e8e 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -3,6 +3,7 @@ ## 2026-10-27 * **Embedder API (#11)**: `Markkit` derives `Clone` + `Debug`; warnings are structured (`Warning { code: WarningCode, detail }`) instead of `"code: detail"` strings. The agent tool keeps string warnings. See [Format Support](formats/format-support.md) and [Tool Contract](integrations/tool-contract.md). +* **Structured limit errors (#11)**: `Error::ResourceLimit` carries `LimitExceeded { kind: LimitKind, detail }`; every breach site (input, output, timeout, nodes, nesting, archive entries/entry size/total size, spreadsheet cells, PDF stream/decoded size) sets its kind. Tests assert the kind, not message text. See [Threat Model](security/threat-model.md) Resource Limits. * **PPTX support (#11)**: new `pptx` feature (in `all_formats`) converts presentations slide by slide: titles, bulleted body placeholders, text boxes, tables, links, speaker notes; hidden slides skipped. Shared OOXML helpers moved from `docx.rs` to `ooxml.rs`. Legacy `.ppt` is now its own `Format::Ppt` (rejected). See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6. * **Fuzzing**: new [Fuzzing](security/fuzzing.md) concept. Eleven cargo-fuzz targets (`crates/markkit/fuzz`, every parser incl. XLSB, PDF, Markdown, CSV, plus the renderer) assert the S17 oracle and run nightly (`fuzz.yml`). The `xls_biff` target found calamine panicking on a dozen truncated BIFF record shapes and in its formula decompiler; the XLS pre-scan now rejects them, neutralises formula tokens and hands calamine a container rebuilt from the scanned stream. See [Threat Model](security/threat-model.md) T6c. * **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. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 2027041..e9c0204 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -92,7 +92,9 @@ that renders as headings, links, code fences or tables). **Sub-threats**: All limits live in one `Limits` struct (`crates/markkit/src/limits.rs`, -`MarkkitBuilder::limits`); violations return `Error::ResourceLimit` naming the limit. +`MarkkitBuilder::limits`); violations return `Error::ResourceLimit(LimitExceeded { kind, detail })`; `kind` is a +`LimitKind` named after the `Limits` field (`Error::limit_kind()`), so callers branch on it +without parsing the message. #### T2a: Oversized Input - Input size checked before parsing against `max_input_size`