Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
- `markkit mcp`: Model Context Protocol server over stdio exposing the `markkit` tool
(`tools/list`, `tools/call`). URL fetching keeps the SSRF guard; `--no-url` keeps it offline,
the `extract` limit flags and `--max-output-chars` bound each call.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **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
- **URL fetching** – Fetch and extract in one call (`http_client` feature)
Expand All @@ -32,11 +32,11 @@ 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` |
| Plain text | Supported | always on |
| Markdown | Supported | `markdown` |
| PDF (text layer) | Supported | `pdf` |
| DOC, PPTX, XLSB | Detected, rejected explicitly | – |
| DOC, PPTX | Detected, rejected explicitly | – |

## Quick Start

Expand Down
2 changes: 1 addition & 1 deletion crates/markkit/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ impl Markkit {
.map(|e| e.markdown)
}

/// Convert a document (HTML, PDF, DOCX, XLSX/XLS/ODS, plain text, Markdown) to Markdown with metadata.
/// Convert a document (HTML, PDF, DOCX, 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;
Expand Down
2 changes: 1 addition & 1 deletion crates/markkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//! - **Plain text** (always on): `text/plain`, `.txt`
//! - **Markdown** (`markdown`): `text/markdown`, `.md`, normalized through the IR
//! - **PDF** (`pdf`): Text layer of PDF documents (no OCR); encrypted PDFs are
Expand Down
96 changes: 90 additions & 6 deletions crates/markkit/src/spreadsheet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -31,6 +33,8 @@ use crate::format::Format;
use crate::ir::{Document, Node, Source, TableCell};
use crate::limits::Budget;

mod xlsb;

pub(crate) struct SheetOutput {
pub doc: Document,
pub format: Format,
Expand Down Expand Up @@ -59,6 +63,7 @@ const ODS_MAX_COLS: u64 = 16_384;
#[derive(Clone, Copy)]
enum Container {
Xlsx,
Xlsb,
Ods,
Xls,
}
Expand All @@ -76,15 +81,17 @@ pub(crate) fn parse(
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", config, budget)?;
return parse_xlsb(&mut zip, config, budget, title);
}
let title = if let Container::Ods = container {
let content = zip
.read("content.xml")
Expand All @@ -105,7 +112,7 @@ pub(crate) fn parse(
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)"));
};
budget.check_time()?;
let bytes = patched.as_deref().unwrap_or(bytes);
Expand Down Expand Up @@ -150,9 +157,66 @@ pub(crate) fn parse(
read_ranges(&mut wb, sel, &mut out, config, budget)?;
out.finish(Format::Xls, title)
}
Container::Xlsb => unreachable!("XLSB returns early"),
}
}

fn parse_xlsb(
zip: &mut ZipGuard<'_>,
config: &MarkkitBuilder,
budget: &Budget,
title: Option<String>,
) -> Result<SheetOutput, Error> {
let wb = xlsb::open(zip, config.limits.nesting_depth(), budget)?;
budget.check_time()?;
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 = (|| {
for cell in wb.cells(&data) {
if budget.expired() {
break;
}
let (r, c, v) = cell?;
grid.add(r, c, fmt_xlsb(&v, wb.is_1904));
}
Ok::<_, Error>(())
})();
budget.check_time()?;
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)>,
Expand Down Expand Up @@ -238,6 +302,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(),
Expand Down
Loading
Loading