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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
`<figure>`/`<figcaption>` and table spans/alignment. Row-header `<th>` cells in table bodies are
no longer dropped.
- `Document.ir_version` (`markkit::ir::IR_VERSION = 2`) is serialized with the IR JSON.
- PDF extraction (`pdf` feature, on in the CLI `all_formats` build and the Python/Node bindings):
the text layer becomes paragraphs, font-size headings and bullet lists, with `/Title` as
`Extraction::title` and `stats.pages`. Pure Rust (hayro-syntax), no OCR. Fonts map through
`ToUnicode` CMaps, simple-font encodings and `/Differences`. New warnings `truncated_pdf`,
`pdf_unmapped_font`, `pdf_no_text`. Encrypted PDFs return `UnsupportedFormat`.
- `Limits` fields `max_pdf_pages` (10 000), `max_pdf_stream_size` (64 MiB) and
`max_pdf_decoded_size` (256 MiB), also on the builder, as CLI flags (`--max-pdf-pages`, ...),
Python `Limits(...)` arguments and Node options (`maxPdfPages`, ...). PDF extraction also
honours `timeout` and `max_output_size`.
- Relative URL resolution: links, images and metadata URLs resolve against the new
`MarkkitBuilder::base_url` (CLI `--base-url`) and the page's `<base href>`; `extract_url` uses
the final URL after redirects. Absolute and fragment-only URLs are left unchanged.
Expand Down Expand Up @@ -73,6 +82,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
thousands of rows); `max_nodes` (5 000 000) caps parsed HTML DOM / DOCX XML nodes;
`max_nesting_depth` (256) is configurable, with a hard ceiling of 512 that keeps deep input off
the host stack. Violations return `Error::ResourceLimit` naming the limit.
- PDF decompression bombs: every stream in the file is decoded under the PDF size limits before
the parser runs (object streams included), interpreted content is budgeted so reused form
XObjects cannot multiply work, and stream filters that cannot be verified are refused.

- `extract_url` (CLI, Python and Node `extract_url` too) now refuses non-public destinations by default:
loopback, private, link-local / cloud metadata and other reserved addresses, checked on the URL,
after DNS resolution and on every redirect. Only `http`/`https` are fetched. Redirects are capped
Expand Down
66 changes: 66 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ calamine = "0.37" # Excel/spreadsheet (XLSX/XLS/ODS)
zip = { version = "9", default-features = false, features = ["deflate"] } # OOXML/ODF containers; matches calamine's zip
quick-xml = "0.42" # DOCX/ODS XML parsing
cfb = "0.15" # XLS (OLE2) container pre-scan
hayro-syntax = { version = "0.8", default-features = false, features = ["std"] } # PDF object/content parsing (no image decoders)
miniz_oxide = "0.9" # Bounded FlateDecode for PDF streams
pulldown-cmark = { version = "0.13", default-features = false } # Markdown input; output-safety test oracle

# Logging
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ package for browsers and edge runtimes.
- **Configurable** – CSS include/exclude selectors, link/image preservation, resource limits
- **Safe by default** – No code execution (scripts, macros, formulas), bounded decompression,
depth and size limits, [threat model](knowledge/security/threat-model.md) with a test per mitigation
- **PDF** – Planned (currently an explicit `UnsupportedFormat` error, never an empty success)
- **PDF** – Text layer to paragraphs, headings and bullet lists (pure Rust, no OCR; encrypted PDFs rejected)

| Format | Status | Feature |
|---|---|---|
Expand All @@ -35,7 +35,7 @@ package for browsers and edge runtimes.
| XLSX, XLSM, XLS, ODS | Supported | `spreadsheet` |
| Plain text | Supported | always on |
| Markdown | Supported | `markdown` |
| PDF | Planned | `pdf` |
| PDF (text layer) | Supported | `pdf` |
| DOC, PPTX, XLSB | Detected, rejected explicitly | – |

## Quick Start
Expand Down Expand Up @@ -188,7 +188,7 @@ let markkit = Markkit::builder()
| `http_client` | No | 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` |
| `pdf` | No | PDF text-layer extraction (hayro-syntax, pure Rust) |
| `markdown` | No | Markdown normalization (pulldown-cmark) |
| `all_formats` | No | `html`, `pdf`, `docx`, `spreadsheet`, `markdown` |
| `tool` | No | `markkit::tool::MarkkitTool` LLM agent tool (Everruns toolkit contract) |
Expand Down Expand Up @@ -221,6 +221,9 @@ generous; violations return `Error::ResourceLimit` naming the limit.
| `max_archive_total_size` | 256 MiB | Decompressed bytes per ZIP |
| `max_spreadsheet_cells` | 4 000 000 | XLS/ODS grid before parsing |
| `max_sheet_rows` / `max_sheet_cols` | 10 000 / 256 | Rendered per sheet (truncated with a warning) |
| `max_pdf_pages` | 10 000 | PDF pages rendered (truncated with a warning) |
| `max_pdf_stream_size` | 64 MiB | Decoded bytes per PDF stream |
| `max_pdf_decoded_size` | 256 MiB | Decoded PDF stream bytes in total, and PDF content interpreted |
| `max_redirects` | 5 | `extract_url` redirects |

```rust
Expand Down Expand Up @@ -277,7 +280,8 @@ are deterministic and the better regression signal. Compare runs from the same m
Markkit treats every input as untrusted. HTML scripts, styles, forms, iframes and
SVG are stripped; office containers are decompressed under measured byte limits;
spreadsheet grids are pre-scanned so sparse cells cannot force huge allocations;
XML entities are never expanded; macros and formulas are never executed; hidden
XML entities are never expanded; every PDF stream is decode-checked against size
limits before parsing; macros and formulas are never executed; hidden
sheets are skipped by default. `extract_url` accepts only http/https, refuses
loopback, private, link-local and cloud-metadata addresses (also after DNS
resolution and on every redirect), caps redirects, and bounds the body by
Expand Down
22 changes: 20 additions & 2 deletions crates/markkit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ struct LimitArgs {
/// Maximum redirects followed when fetching a URL [default: 5]
#[arg(long, value_name = "N")]
max_redirects: Option<usize>,
/// Maximum PDF pages rendered [default: 10000]
#[arg(long, value_name = "N")]
max_pdf_pages: Option<usize>,
/// Maximum decoded size of one PDF stream, bytes [default: 67108864]
#[arg(long, value_name = "BYTES")]
max_pdf_stream_size: Option<usize>,
/// Maximum total decoded PDF content, bytes [default: 268435456]
#[arg(long, value_name = "BYTES")]
max_pdf_decoded_size: Option<usize>,
}

impl LimitArgs {
Expand All @@ -140,7 +149,10 @@ impl LimitArgs {
max_sheet_rows,
max_sheet_cols,
max_spreadsheet_cells,
max_redirects
max_redirects,
max_pdf_pages,
max_pdf_stream_size,
max_pdf_decoded_size
);
l
}
Expand Down Expand Up @@ -282,6 +294,9 @@ mod tests {
"--max-sheet-cols=9",
"--max-spreadsheet-cells=10",
"--max-redirects=11",
"--max-pdf-pages=12",
"--max-pdf-stream-size=13",
"--max-pdf-decoded-size=14",
]);
let want = Limits::default()
.max_input_size(1)
Expand All @@ -295,7 +310,10 @@ mod tests {
.max_sheet_rows(8)
.max_sheet_cols(9)
.max_spreadsheet_cells(10)
.max_redirects(11);
.max_redirects(11)
.max_pdf_pages(12)
.max_pdf_stream_size(13)
.max_pdf_decoded_size(14);
assert_eq!(l, want);
}

Expand Down
2 changes: 1 addition & 1 deletion crates/markkit-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,6 @@ rendering; `maxProcessingSecs` is the whole-second form), `maxNodes`, `maxNestin
`includeSelectors`, `excludeSelectors` (added to the defaults), `maxArchiveEntries`,
`maxArchiveEntrySize`, `maxArchiveTotalSize`, `maxSheetRows`, `maxSheetCols`,
`maxSpreadsheetCells`, `includeHiddenSheets`, `allowPrivateNetworks` (trusted URLs only),
`allowedHosts`, `maxRedirects`.
`allowedHosts`, `maxRedirects`, `maxPdfPages`, `maxPdfStreamSize`, `maxPdfDecodedSize`.

For browsers and edge runtimes use [`@everruns/markkit-wasm`](https://www.npmjs.com/package/@everruns/markkit-wasm).
3 changes: 2 additions & 1 deletion crates/markkit-js/__test__/options.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ test("baseUrl and mainContent options", () => {
test("invalid numeric options are rejected", () => {
assert.throws(() => m.extractHtmlSync("<p>x</p>", { maxArchiveTotalSize: -1 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { maxSpreadsheetCells: 1.5 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { maxPdfDecodedSize: -1 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { timeout: -1 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { timeout: NaN }), /invalid options/);
});
Expand All @@ -55,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("%PDF-1.7"), { fileName: "a.pdf" }), /Unsupported/);
assert.throws(() => m.extractDocumentSync(Buffer.from("PK"), { fileName: "a.pptx" }), /Unsupported/);
});

test("extractDocument exposes page metadata", () => {
Expand Down
12 changes: 12 additions & 0 deletions crates/markkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ pub struct MarkkitOptions {
pub allowed_hosts: Option<Vec<String>>,
/// Maximum redirects `extractUrl` follows (default 5).
pub max_redirects: Option<u32>,
pub max_pdf_pages: Option<u32>,
pub max_pdf_stream_size: Option<f64>,
pub max_pdf_decoded_size: Option<f64>,
}

/// Format hints for `extractDocument`.
Expand Down Expand Up @@ -194,6 +197,15 @@ fn markkit(options: Option<MarkkitOptions>) -> napi::Result<markkit::Markkit> {
if let Some(v) = o.max_redirects {
b = b.max_redirects(v as usize);
}
if let Some(v) = o.max_pdf_pages {
b = b.max_pdf_pages(v as usize);
}
if let Some(v) = o.max_pdf_stream_size {
b = b.max_pdf_stream_size(to_u64(v, "maxPdfStreamSize")? as usize);
}
if let Some(v) = o.max_pdf_decoded_size {
b = b.max_pdf_decoded_size(to_u64(v, "maxPdfDecodedSize")? as usize);
}
Ok(b.build())
}

Expand Down
3 changes: 2 additions & 1 deletion crates/markkit-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ markkit.extract_html_sync("<h1>Hello</h1>", limits=limits)
`Limits` fields: `max_input_size`, `max_output_size`, `timeout` (seconds),
`max_nesting_depth`, `max_nodes`, `max_archive_entries`, `max_archive_entry_size`,
`max_archive_total_size`, `max_sheet_rows`, `max_sheet_cols`,
`max_spreadsheet_cells`, `max_redirects`. Every function accepts `limits=`.
`max_spreadsheet_cells`, `max_redirects`, `max_pdf_pages`, `max_pdf_stream_size`,
`max_pdf_decoded_size`. Every function accepts `limits=`.

Publication verifies wheel metadata, installs the actual wheel into a clean
virtual environment and exercises native extraction before upload.
Expand Down
26 changes: 23 additions & 3 deletions crates/markkit-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ use pyo3::prelude::*;
/// Keyword arguments: max_input_size, max_output_size, timeout (seconds,
/// float), max_nesting_depth, max_nodes, max_archive_entries,
/// max_archive_entry_size, max_archive_total_size, max_sheet_rows,
/// max_sheet_cols, max_spreadsheet_cells, max_redirects.
/// max_sheet_cols, max_spreadsheet_cells, max_redirects, max_pdf_pages,
/// max_pdf_stream_size, max_pdf_decoded_size.
#[pyclass(name = "Limits", module = "markkit", frozen, get_all)]
#[derive(Clone)]
struct Limits {
Expand All @@ -26,6 +27,9 @@ struct Limits {
max_sheet_cols: usize,
max_spreadsheet_cells: u64,
max_redirects: usize,
max_pdf_pages: usize,
max_pdf_stream_size: usize,
max_pdf_decoded_size: usize,
}

#[pymethods]
Expand All @@ -45,6 +49,9 @@ impl Limits {
max_sheet_cols=None,
max_spreadsheet_cells=None,
max_redirects=None,
max_pdf_pages=None,
max_pdf_stream_size=None,
max_pdf_decoded_size=None,
))]
#[allow(clippy::too_many_arguments)]
fn new(
Expand All @@ -60,6 +67,9 @@ impl Limits {
max_sheet_cols: Option<usize>,
max_spreadsheet_cells: Option<u64>,
max_redirects: Option<usize>,
max_pdf_pages: Option<usize>,
max_pdf_stream_size: Option<usize>,
max_pdf_decoded_size: Option<usize>,
) -> PyResult<Self> {
let d = markkit::Limits::default();
if let Some(t) = timeout {
Expand All @@ -79,6 +89,9 @@ impl Limits {
max_sheet_cols: max_sheet_cols.unwrap_or(d.max_sheet_cols),
max_spreadsheet_cells: max_spreadsheet_cells.unwrap_or(d.max_spreadsheet_cells),
max_redirects: max_redirects.unwrap_or(d.max_redirects),
max_pdf_pages: max_pdf_pages.unwrap_or(d.max_pdf_pages),
max_pdf_stream_size: max_pdf_stream_size.unwrap_or(d.max_pdf_stream_size),
max_pdf_decoded_size: max_pdf_decoded_size.unwrap_or(d.max_pdf_decoded_size),
})
}

Expand All @@ -87,7 +100,8 @@ impl Limits {
"Limits(max_input_size={}, max_output_size={}, timeout={}, max_nesting_depth={}, \
max_nodes={}, max_archive_entries={}, max_archive_entry_size={}, \
max_archive_total_size={}, max_sheet_rows={}, max_sheet_cols={}, \
max_spreadsheet_cells={}, max_redirects={})",
max_spreadsheet_cells={}, max_redirects={}, max_pdf_pages={}, \
max_pdf_stream_size={}, max_pdf_decoded_size={})",
self.max_input_size,
self.max_output_size,
self.timeout,
Expand All @@ -99,7 +113,10 @@ impl Limits {
self.max_sheet_rows,
self.max_sheet_cols,
self.max_spreadsheet_cells,
self.max_redirects
self.max_redirects,
self.max_pdf_pages,
self.max_pdf_stream_size,
self.max_pdf_decoded_size
)
}
}
Expand All @@ -120,6 +137,9 @@ impl Limits {
.max_sheet_cols(self.max_sheet_cols)
.max_spreadsheet_cells(self.max_spreadsheet_cells)
.max_redirects(self.max_redirects)
.max_pdf_pages(self.max_pdf_pages)
.max_pdf_stream_size(self.max_pdf_stream_size)
.max_pdf_decoded_size(self.max_pdf_decoded_size)
}
}

Expand Down
Loading
Loading