diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fccd3ce..4e6c7c3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,6 +56,13 @@ jobs:
run: cargo test --workspace
- name: Run tests (all features)
run: cargo test --workspace --all-features
+ - name: Run tests (office formats, no HTTP client)
+ run: |
+ cargo test -p markkit --no-default-features --features docx,spreadsheet
+ if cargo tree -p markkit --no-default-features --features docx,spreadsheet -e normal | grep -E 'reqwest|hyper'; then
+ echo "HTTP client leaked into the docx,spreadsheet feature set"
+ exit 1
+ fi
check:
name: Check
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14d92a8..8840e11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,3 +31,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- **Specs**: IR specification, threat model, comprehensive HTML extraction spec, examples spec
- **Examples**: `basic.rs`, `custom_selectors.rs`, `ir_access.rs`, `configuration.rs`
- **Test fixtures**: 9 paired input/output HTML→Markdown fixture files
+- **Bytes-in document API**: `Markkit::extract_document` / `extract_document_sync` taking a
+ `DocumentHint { content_type, file_name }` and returning `Extraction { markdown, title,
+ format, warnings, stats }`; `extract_bytes` now delegates to it
+- **Format detection**: `Format::detect` (content type → extension → magic bytes),
+ `Format::sniff` (ZIP part names, OLE2 streams, `%PDF`, HTML), `Format::from_file_name`;
+ new `Format::Doc`, `Xls`, `Ods`, `Pptx` variants (`Format` is now `#[non_exhaustive]`)
+- **DOCX → Markdown** (`docx` feature): headings (Title/HeadingN/outline level), emphasis,
+ hyperlinks, bulleted/numbered lists via numbering.xml, tables, line breaks, title from
+ core properties
+- **Spreadsheets → Markdown** (`spreadsheet` feature): XLSX/XLSM, XLS, ODS via calamine; one
+ `## Sheet` section + table per non-empty sheet, row/column caps with `truncated_sheet`
+ warnings, hidden sheets skipped by default
+- **Office-document safety limits**: ZIP entry count, per-entry and total decompressed size,
+ spreadsheet grid pre-scan (`max_spreadsheet_cells`), XML depth cap; fuzz-style tests
+
+### Changed
+
+- `docx` feature now uses `zip` + `quick-xml` directly; `docx-rs` dependency removed
+- `calamine` upgraded to 0.36 (pulls quick-xml 0.41, clearing RUSTSEC-2026-0194/0195); `zip` upgraded to 8 (deflate only), shared with calamine
+- PDF extraction returns `Error::UnsupportedFormat` instead of an empty string
+- `pdf` feature is now a placeholder with no dependencies: unused `pdf-extract` removed
+ (cleared RUSTSEC-2026-0187 in lopdf and unmaintained `ttf-parser`)
+- `pyo3`/`pyo3-async-runtimes` upgraded to 0.29 (RUSTSEC-2026-0176/0177); bench `tabled`
+ upgraded to 0.22 (drops unmaintained `proc-macro-error2`)
+- CLI `--file` detects the format from the extension and file contents
+- HTML-only tests and examples are gated on the `html` feature so non-default feature
+ sets (e.g. `--no-default-features --features docx,spreadsheet`) build and test cleanly
diff --git a/Cargo.toml b/Cargo.toml
index ae719c1..7cc65e2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -50,13 +50,11 @@ lol_html = "2"
regex = "1"
unicode-segmentation = "1"
-# PDF
-pdf-extract = "0.10"
-
# Document formats
-calamine = "0.34" # Excel/spreadsheet
-docx-rs = "0.4" # DOCX
-zip = "2" # Archive extraction
+calamine = "0.36" # Excel/spreadsheet (XLSX/XLS/ODS)
+zip = { version = "8", default-features = false, features = ["deflate"] } # OOXML/ODF containers; matches calamine's zip
+quick-xml = "0.41" # DOCX/ODS XML parsing; matches calamine's quick-xml
+cfb = "0.15" # XLS (OLE2) container pre-scan
# Image / OCR (optional)
image = "0.25"
@@ -71,8 +69,8 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Python bindings
-pyo3 = { version = "0.28" }
-pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] }
+pyo3 = { version = "0.29" }
+pyo3-async-runtimes = { version = "0.29", features = ["tokio-runtime"] }
# Node.js bindings
napi = { version = "3", features = ["async", "serde-json"] }
diff --git a/README.md b/README.md
index 8333a65..04cb16c 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,9 @@ Markkit is a Rust library and CLI tool for converting HTML, PDF, DOCX, spreadshe
## Features
- **HTML** – Convert web pages and HTML documents to markdown
-- **PDF** – Extract text from PDF documents
-- **DOCX** – Convert Word documents
-- **Spreadsheets** – Convert Excel/ODS files to markdown tables
+- **DOCX** – Convert Word documents (headings, lists, tables, links, emphasis)
+- **Spreadsheets** – Convert XLSX/XLSM, XLS and ODS workbooks to markdown tables
+- **PDF** – Planned (currently reported as unsupported)
- **URL fetching** – Fetch and extract from URLs directly
- **Configurable** – CSS selectors for include/exclude, resource limits
- **Safe** – Input sanitization, resource limits, no code execution
@@ -25,6 +25,24 @@ let markkit = Markkit::builder().build();
let md = markkit.extract_html("
Hello
World
").await?;
```
+### Documents from bytes
+
+```rust
+use markkit::{DocumentHint, Markkit};
+
+let markkit = Markkit::builder().build();
+// Detection order: content type, then file extension, then magic bytes.
+let hint = DocumentHint { content_type: Some("application/octet-stream"), file_name: Some("report.docx") };
+// CPU-bound: call the sync variant from `tokio::task::spawn_blocking` in async code.
+let doc = markkit.extract_document_sync(&bytes, hint)?;
+println!("{:?} {:?} {:?}", doc.format, doc.title, doc.warnings);
+println!("{}", doc.markdown);
+```
+
+`Extraction` carries `markdown`, `title`, `format`, `warnings` (e.g. `truncated_sheet: …`)
+and `stats` (sheets/rows/paragraphs/tables). `extract_bytes` remains as a Markdown-only
+shortcut.
+
### CLI
```bash
@@ -105,13 +123,26 @@ let markkit = Markkit::builder()
| Feature | Default | Description |
|---------|---------|-------------|
| `html` | Yes | HTML parsing and conversion |
-| `http_client` | Yes | URL fetching via reqwest |
-| `pdf` | No | PDF text extraction |
-| `docx` | No | DOCX conversion |
-| `spreadsheet` | No | Excel/ODS conversion |
-| `all_formats` | No | Enable all format support |
+| `http_client` | Yes | URL fetching via reqwest (`extract_url`) |
+| `docx` | No | DOCX conversion (zip + quick-xml) |
+| `spreadsheet` | No | XLSX/XLSM, XLS, ODS conversion (calamine) |
+| `pdf` | No | Placeholder; PDF currently returns `UnsupportedFormat` |
+| `all_formats` | No | `html`, `pdf`, `docx`, `spreadsheet` |
| `logging` | No | Structured tracing output |
+`http_client` is optional. Embedders that fetch content themselves can build without
+reqwest (no HTTP/TLS stack in the dependency tree):
+
+```toml
+[dependencies]
+markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] }
+# add "html" if you also convert HTML
+```
+
+Office-document limits are configurable on the builder: `max_archive_entries`,
+`max_archive_entry_size`, `max_archive_total_size`, `max_spreadsheet_cells`,
+`max_sheet_rows`, `max_sheet_cols`, `include_hidden_sheets`.
+
## Development
```bash
diff --git a/crates/markkit-bench/Cargo.toml b/crates/markkit-bench/Cargo.toml
index b6c978e..f5d2f4a 100644
--- a/crates/markkit-bench/Cargo.toml
+++ b/crates/markkit-bench/Cargo.toml
@@ -28,4 +28,4 @@ statrs = "0.18"
# Terminal output
colored = "3"
-tabled = "0.20"
+tabled = "0.22"
diff --git a/crates/markkit-cli/src/main.rs b/crates/markkit-cli/src/main.rs
index 06020f4..4f2742d 100644
--- a/crates/markkit-cli/src/main.rs
+++ b/crates/markkit-cli/src/main.rs
@@ -88,19 +88,10 @@ async fn main() -> Result<()> {
} else if file {
let src = source.as_deref().unwrap();
let bytes = tokio::fs::read(src).await?;
- let ext = std::path::Path::new(src)
- .extension()
- .and_then(|e| e.to_str())
- .unwrap_or("");
- let content_type = match ext {
- "html" | "htm" => Some("text/html"),
- "pdf" => Some("application/pdf"),
- "docx" => Some(
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- ),
- _ => None,
- };
- markkit.extract_bytes(&bytes, content_type).await?
+ // Format detected from the extension, then magic bytes.
+ markkit
+ .extract_document_sync(&bytes, markkit::DocumentHint::file_name(src))?
+ .markdown
} else {
let src = source.as_deref().unwrap();
markkit.extract_url(src).await?
diff --git a/crates/markkit/Cargo.toml b/crates/markkit/Cargo.toml
index d354a1d..e21e445 100644
--- a/crates/markkit/Cargo.toml
+++ b/crates/markkit/Cargo.toml
@@ -16,9 +16,9 @@ readme = "../../README.md"
[features]
default = ["html", "http_client"]
html = ["dep:scraper", "dep:html5ever", "dep:lol_html"]
-pdf = ["dep:pdf-extract"]
-docx = ["dep:docx-rs", "dep:zip"]
-spreadsheet = ["dep:calamine"]
+pdf = []
+docx = ["dep:zip", "dep:quick-xml"]
+spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"]
http_client = ["dep:reqwest"]
logging = ["dep:tracing", "dep:tracing-subscriber"]
all_formats = ["html", "pdf", "docx", "spreadsheet"]
@@ -51,15 +51,14 @@ scraper = { workspace = true, optional = true }
html5ever = { workspace = true, optional = true }
lol_html = { workspace = true, optional = true }
-# PDF (optional)
-pdf-extract = { workspace = true, optional = true }
-# DOCX (optional)
-docx-rs = { workspace = true, optional = true }
+# Office documents (optional): DOCX + spreadsheets share the ZIP/XML stack
zip = { workspace = true, optional = true }
+quick-xml = { workspace = true, optional = true }
# Spreadsheet (optional)
calamine = { workspace = true, optional = true }
+cfb = { workspace = true, optional = true }
# HTTP client (optional)
reqwest = { workspace = true, optional = true }
@@ -73,22 +72,28 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
tempfile.workspace = true
proptest.workspace = true
criterion.workspace = true
+zip = { workspace = true }
+cfb = { workspace = true }
[[example]]
name = "basic"
path = "../../examples/basic.rs"
+required-features = ["html"]
[[example]]
name = "custom_selectors"
path = "../../examples/custom_selectors.rs"
+required-features = ["html"]
[[example]]
name = "ir_access"
path = "../../examples/ir_access.rs"
+required-features = ["html"]
[[example]]
name = "configuration"
path = "../../examples/configuration.rs"
+required-features = ["html"]
[[bench]]
name = "extraction"
diff --git a/crates/markkit/src/archive.rs b/crates/markkit/src/archive.rs
new file mode 100644
index 0000000..4f3defd
--- /dev/null
+++ b/crates/markkit/src/archive.rs
@@ -0,0 +1,132 @@
+//! Bounded ZIP access shared by the DOCX and spreadsheet parsers.
+//!
+//! Decision: declared sizes in ZIP headers are attacker-controlled, so limits are
+//! enforced on bytes actually produced by the decompressor (`Read::take`), never on
+//! header fields. [`ZipGuard::validate_all`] decompresses every entry into a sink
+//! before third-party code (calamine) touches the archive, because that code reads
+//! entries without limits and may follow arbitrary relationship targets.
+
+use std::io::{Cursor, Read};
+
+use zip::ZipArchive;
+use zip::result::ZipError;
+
+use crate::config::MarkkitBuilder;
+
+/// 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),
+ Invalid(String),
+}
+
+impl From for ArchiveError {
+ fn from(e: ZipError) -> Self {
+ ArchiveError::Invalid(format!("invalid ZIP container: {e}"))
+ }
+}
+
+pub(crate) struct ZipGuard<'a> {
+ pub archive: ZipArchive>,
+ max_entry: u64,
+ max_total: u64,
+ total: u64,
+}
+
+impl<'a> ZipGuard<'a> {
+ /// Open the archive and check the entry count.
+ pub fn open(bytes: &'a [u8], config: &MarkkitBuilder) -> Result {
+ let archive = ZipArchive::new(Cursor::new(bytes))?;
+ if archive.len() > config.max_archive_entries {
+ return Err(ArchiveError::Limit(format!(
+ "archive has {} entries, limit is {}",
+ archive.len(),
+ config.max_archive_entries
+ )));
+ }
+ Ok(Self {
+ archive,
+ max_entry: config.max_archive_entry_size,
+ max_total: config.max_archive_total_size,
+ total: 0,
+ })
+ }
+
+ /// Names of all entries.
+ #[cfg(feature = "spreadsheet")]
+ pub fn names(&self) -> impl Iterator- {
+ self.archive.file_names()
+ }
+
+ /// Decompress every entry into a sink, enforcing per-entry and total limits.
+ #[cfg(feature = "spreadsheet")]
+ pub fn validate_all(&mut self) -> Result<(), ArchiveError> {
+ for i in 0..self.archive.len() {
+ let n = {
+ let mut entry = self.archive.by_index(i)?;
+ if entry.is_dir() {
+ continue;
+ }
+ let name = entry.name().to_string();
+ copy_capped(&mut entry, self.max_entry, &name, &mut std::io::sink())?
+ };
+ self.add_total(n)?;
+ }
+ Ok(())
+ }
+
+ /// Read one entry fully (bounded). `Ok(None)` when it does not exist.
+ pub fn read(&mut self, name: &str) -> Result