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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 6 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"] }
Expand Down
47 changes: 39 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +25,24 @@ let markkit = Markkit::builder().build();
let md = markkit.extract_html("<h1>Hello</h1><p>World</p>").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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/markkit-bench/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ statrs = "0.18"

# Terminal output
colored = "3"
tabled = "0.20"
tabled = "0.22"
17 changes: 4 additions & 13 deletions crates/markkit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
19 changes: 12 additions & 7 deletions crates/markkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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 }
Expand All @@ -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"
Expand Down
132 changes: 132 additions & 0 deletions crates/markkit/src/archive.rs
Original file line number Diff line number Diff line change
@@ -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<ZipError> for ArchiveError {
fn from(e: ZipError) -> Self {
ArchiveError::Invalid(format!("invalid ZIP container: {e}"))
}
}

pub(crate) struct ZipGuard<'a> {
pub archive: ZipArchive<Cursor<&'a [u8]>>,
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<Self, ArchiveError> {
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<Item = &str> {
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<Option<Vec<u8>>, ArchiveError> {
let name = name.trim_start_matches('/');
let index = match self.archive.index_for_name(name) {
Some(i) => i,
None => {
// Part names are case-insensitive in OPC.
let actual = self
.archive
.file_names()
.find(|n| n.eq_ignore_ascii_case(name))
.map(str::to_string);
match actual.and_then(|a| self.archive.index_for_name(&a)) {
Some(i) => i,
None => return Ok(None),
}
}
};
let mut buf = Vec::new();
let n = {
let mut entry = self.archive.by_index(index)?;
copy_capped(&mut entry, self.max_entry, name, &mut buf)?
};
self.add_total(n)?;
Ok(Some(buf))
}

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
)));
}
Ok(())
}
}

fn copy_capped<R: Read, W: std::io::Write>(
reader: &mut R,
cap: u64,
name: &str,
out: &mut W,
) -> Result<u64, ArchiveError> {
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"
)));
}
Ok(n)
}
Loading
Loading