Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1cc3236
feat(formats): add plain text and Markdown input
chaliy Oct 26, 2026
7e3a233
fix(text): use as_chunks for UTF-16 decoding (clippy)
chaliy Oct 26, 2026
ba30062
chore(knowledge): scope S13 to markup copying, note text escaping res…
chaliy Oct 26, 2026
510056e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
3b0b504
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
3526d61
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
62afeb8
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
573003f
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
511f20a
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
0ee47be
feat(formats): add CSV and TSV input
chaliy Oct 26, 2026
83a058e
chore: restart branch after #21 squash-merge
chaliy Oct 27, 2026
1340655
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
911360a
feat(formats): add Jupyter notebook input
chaliy Oct 26, 2026
76ea39d
chore: restart branch after #44 squash-merge
chaliy Oct 27, 2026
68ab6e7
fix(limits): enforce output cap and timeout in every feature set
chaliy Oct 27, 2026
a84a1e3
Merge remote-tracking branch 'origin/main' into pr50
chaliy Oct 27, 2026
03b3e3f
Merge remote-tracking branch 'origin/main' into pr50
chaliy Oct 27, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ jobs:
echo "HTTP client leaked into the default feature set"
exit 1
fi
# Always-on formats (text, CSV, notebooks) must keep every limit with no format feature.
cargo test -p markkit --no-default-features
cargo test -p markkit --no-default-features --features docx,spreadsheet,markdown
if cargo tree -p markkit --no-default-features --features docx,spreadsheet,markdown -e normal | grep -E 'reqwest|hyper'; then
echo "HTTP client leaked into the docx,spreadsheet,markdown feature set"
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- `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.
- Jupyter notebook input (`.ipynb`, `application/x-ipynb+json`, nbformat 4), always on: Markdown
cells, code cells as fenced blocks tagged with the kernel language, text outputs and ANSI-free
tracebacks; images/HTML outputs skipped. New `Format::Notebook`, `Error::NotebookExtract` and
`ExtractionStats::cells`.
- CSV and TSV input (`text/csv`, `text/tab-separated-values`, `.csv`, `.tsv`), always on: one GFM
table, RFC 4180 quoting, `;`/`|`/tab delimiter detection, bounded by `max_sheet_rows` /
`max_sheet_cols` (`truncated_table` warning). New `Format::Tsv`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package for browsers and edge runtimes.
- **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
- **Jupyter notebooks** – Markdown cells, code cells with language, text outputs and tracebacks
- **CSV / TSV** – One markdown table; `;` and `|` delimiters detected, RFC 4180 quoting
- **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
Expand All @@ -38,6 +39,7 @@ package for browsers and edge runtimes.
| XLSX, XLSM, XLSB, XLS, ODS | Supported | `spreadsheet` |
| Plain text | Supported | always on |
| CSV, TSV | Supported | always on |
| Jupyter notebooks (ipynb) | Supported | always on |
| Markdown | Supported | `markdown` |
| PDF (text layer) | Supported | `pdf` |
| DOC, PPT | Detected, rejected explicitly | – |
Expand Down
2 changes: 1 addition & 1 deletion crates/markkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ pub struct Metadata {
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`, `xlsx`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`).
pub format: String,
pub warnings: Vec<String>,
pub stats: ExtractionStats,
Expand Down
4 changes: 2 additions & 2 deletions crates/markkit-wasm/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# @everruns/markkit-wasm

Markkit compiled to WebAssembly: extract clean Markdown from HTML, DOCX,
spreadsheets (XLSX/XLSM, XLS, ODS), CSV/TSV, plain text and Markdown in the
**browser and any JavaScript runtime**:
spreadsheets (XLSX/XLSM, XLS, ODS), CSV/TSV, Jupyter notebooks, plain text and
Markdown in the **browser and any JavaScript runtime**:
edge workers (Cloudflare Workers, Vercel Edge, Deno Deploy), Node, Deno and Bun.

Single-threaded `wasm-bindgen` build: no `SharedArrayBuffer`, no `COOP`/`COEP`
Expand Down
8 changes: 8 additions & 0 deletions crates/markkit-wasm/__test__/markkit-wasm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ test("extractDocument reads CSV and TSV", () => {
assert.equal(tsv.format, "tsv");
});

test("extractDocument reads Jupyter notebooks", () => {
const nb = { nbformat: 4, metadata: {}, cells: [{ cell_type: "markdown", source: "# NB" }, { cell_type: "code", source: "1+1", outputs: [] }] };
const out = new Markkit().extractDocument(new TextEncoder().encode(JSON.stringify(nb)), { fileName: "a.ipynb" });
assert.equal(out.format, "ipynb");
assert.equal(out.title, "NB");
assert.ok(out.markdown.includes("1+1"));
});

test("extractBytes and unsupported formats", () => {
const mk = new Markkit();
assert.equal(mk.extractBytes(new TextEncoder().encode("<p>x</p>"), "text/html"), "x\n");
Expand Down
3 changes: 3 additions & 0 deletions crates/markkit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub enum Error {
#[error("Spreadsheet extraction error: {0}")]
SpreadsheetExtract(String),

#[error("Notebook extraction error: {0}")]
NotebookExtract(String),

#[error("HTTP error: {0}")]
Http(String),

Expand Down
52 changes: 22 additions & 30 deletions crates/markkit/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ pub struct ExtractionStats {
pub paragraphs: Option<usize>,
/// Tables (DOCX, Markdown, CSV/TSV).
pub tables: Option<usize>,
/// Non-empty cells rendered (Jupyter notebooks).
pub cells: Option<usize>,
}

/// Result of [`Markkit::parse_document`]: the IR plus metadata, not rendered.
Expand Down Expand Up @@ -114,14 +116,6 @@ impl ParsedDocument {
}

/// [`ParsedDocument::render`] with `max_output_size` and the deadline enforced.
#[cfg(any(
feature = "html",
feature = "docx",
feature = "pptx",
feature = "spreadsheet",
feature = "pdf",
feature = "markdown"
))]
fn render_bounded(self, max_output: usize, budget: &Budget) -> crate::Result<Extraction> {
let markdown = crate::render::MarkdownRenderer::new().render_bounded(
&self.document,
Expand Down Expand Up @@ -233,8 +227,8 @@ impl Markkit {
.map(|e| e.markdown)
}

/// Convert a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown, CSV/TSV)
/// to Markdown with metadata.
/// Convert a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown,
/// CSV/TSV, Jupyter notebooks) 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 Expand Up @@ -267,26 +261,7 @@ impl Markkit {
budget: &Budget,
) -> crate::Result<Extraction> {
let parsed = self.parse_document_with(bytes, hint, budget)?;
#[cfg(any(
feature = "html",
feature = "docx",
feature = "pptx",
feature = "spreadsheet",
feature = "pdf",
feature = "markdown"
))]
{
parsed.render_bounded(self.config.limits.max_output_size, budget)
}
#[cfg(not(any(
feature = "html",
feature = "docx",
feature = "pptx",
feature = "spreadsheet",
feature = "pdf",
feature = "markdown"
)))]
Ok(parsed.render())
parsed.render_bounded(self.config.limits.max_output_size, budget)
}

/// Parse a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown) into the IR without
Expand Down Expand Up @@ -331,6 +306,7 @@ impl Markkit {
Format::PlainText => self.parse_plain_text(bytes, budget),
Format::Markdown => self.parse_markdown(bytes, budget),
Format::Csv | Format::Tsv => self.parse_csv(bytes, format, budget),
Format::Notebook => self.parse_notebook(bytes, budget),
Format::Doc => Err(Error::UnsupportedFormat(
"legacy Word 97-2003 (.doc) documents are not supported".into(),
)),
Expand Down Expand Up @@ -379,6 +355,7 @@ impl Markkit {

fn parse_plain_text(&self, bytes: &[u8], budget: &Budget) -> crate::Result<ParsedDocument> {
let decoded = crate::text::decode(bytes);
budget.check_time()?;
let (mut doc, paragraphs) = crate::text::plain_to_ir(&decoded.text, budget)?;
self.config.transforms.apply(&mut doc)?;
let mut p = ParsedDocument::new(doc, Format::PlainText);
Expand All @@ -394,6 +371,7 @@ impl Markkit {
budget: &Budget,
) -> crate::Result<ParsedDocument> {
let decoded = crate::text::decode(bytes);
budget.check_time()?;
let out = crate::csv::parse(&decoded.text, format == Format::Tsv, &self.config, budget)?;
let mut doc = out.doc;
self.config.transforms.apply(&mut doc)?;
Expand All @@ -405,9 +383,23 @@ impl Markkit {
Ok(p)
}

fn parse_notebook(&self, bytes: &[u8], budget: &Budget) -> crate::Result<ParsedDocument> {
let decoded = crate::text::decode(bytes);
budget.check_time()?;
let out = crate::notebook::parse(&decoded.text, &self.config, budget)?;
let mut doc = out.doc;
self.config.transforms.apply(&mut doc)?;
let mut p = ParsedDocument::new(doc, Format::Notebook);
p.title = out.title;
p.warnings = decoded.warnings;
p.stats.cells = Some(out.cells);
Ok(p)
}

#[cfg(feature = "markdown")]
fn parse_markdown(&self, bytes: &[u8], budget: &Budget) -> crate::Result<ParsedDocument> {
let decoded = crate::text::decode(bytes);
budget.check_time()?;
let out = crate::markdown::parse(&decoded.text, &self.config, budget)?;
let mut doc = out.doc;
self.config.transforms.apply(&mut doc)?;
Expand Down
9 changes: 9 additions & 0 deletions crates/markkit/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ pub enum Format {
Csv,
/// Tab separated values (`.tsv`, `.tab`).
Tsv,
/// Jupyter notebook (`.ipynb`, nbformat 4). Serialized as `"ipynb"`.
#[serde(rename = "ipynb")]
Notebook,
/// Plain text (`text/plain`, `.txt`). Never sniffed from bytes (nor CSV/TSV/Markdown).
/// Serialized as `"text"`, same as `as_str`.
#[serde(rename = "text")]
Expand Down Expand Up @@ -94,6 +97,7 @@ impl Format {
Format::Ppt => "ppt",
Format::Csv => "csv",
Format::Tsv => "tsv",
Format::Notebook => "ipynb",
Format::PlainText => "text",
Format::Markdown => "markdown",
Format::Unknown => "unknown",
Expand Down Expand Up @@ -123,6 +127,8 @@ impl Format {
} else if ct.contains("presentationml") || ct.contains("ms-powerpoint.") {
// `ms-powerpoint.presentation.macroEnabled.12` & co. are OOXML.
Format::Pptx
} else if ct.contains("x-ipynb") {
Format::Notebook
} else if ct.contains("ms-powerpoint") {
Format::Ppt
} else if ct.contains("tab-separated-values") {
Expand Down Expand Up @@ -153,6 +159,7 @@ impl Format {
"ppt" | "pps" | "pot" => Format::Ppt,
"csv" => Format::Csv,
"tsv" | "tab" => Format::Tsv,
"ipynb" => Format::Notebook,
"txt" | "text" => Format::PlainText,
"md" | "markdown" | "mdown" | "mkd" | "mkdn" => Format::Markdown,
_ => Format::Unknown,
Expand Down Expand Up @@ -277,6 +284,8 @@ mod tests {
Format::Ods,
Format::Pptx,
Format::Csv,
Format::Tsv,
Format::Notebook,
Format::PlainText,
Format::Markdown,
Format::Unknown,
Expand Down
2 changes: 2 additions & 0 deletions crates/markkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
//! - **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`
//! - **Jupyter notebooks** (always on): `.ipynb`, cells and text outputs
//! - **CSV / TSV** (always on): `text/csv`, `text/tab-separated-values`, `.csv`, `.tsv`, one table
//! - **Markdown** (`markdown`): `text/markdown`, `.md`, normalized through the IR
//! - **PDF** (`pdf`): Text layer of PDF documents (no OCR); encrypted PDFs are
Expand Down Expand Up @@ -66,6 +67,7 @@ pub mod ir;
pub mod limits;
#[cfg(feature = "markdown")]
mod markdown;
mod notebook;
#[cfg(any(feature = "docx", feature = "pptx"))]
mod ooxml;
#[cfg(feature = "pdf")]
Expand Down
34 changes: 0 additions & 34 deletions crates/markkit/src/limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,54 +198,21 @@ impl Limits {
}
}

// Budget is only driven by the feature-gated parsers.
/// How many [`Budget::expired`] calls share one clock read.
#[cfg_attr(
not(any(
feature = "html",
feature = "docx",
feature = "pptx",
feature = "spreadsheet",
feature = "pdf"
)),
allow(dead_code)
)]
const CLOCK_EVERY: u32 = 256;

/// Per-call deadline and node counter, shared by reference through parsers and
/// the renderer (interior mutability so `&self` walkers can charge it).
#[derive(Debug)]
#[cfg_attr(
not(any(
feature = "html",
feature = "docx",
feature = "pptx",
feature = "spreadsheet",
feature = "pdf"
)),
allow(dead_code)
)]
pub(crate) struct Budget {
deadline: Option<Instant>,
timeout: Duration,
ticks: Cell<u32>,
expired: Cell<bool>,
#[cfg_attr(not(any(feature = "html", feature = "docx")), allow(dead_code))]
nodes: Cell<usize>,
#[cfg_attr(not(any(feature = "html", feature = "docx")), allow(dead_code))]
max_nodes: usize,
}

#[cfg_attr(
not(any(
feature = "html",
feature = "docx",
feature = "pptx",
feature = "spreadsheet",
feature = "pdf"
)),
allow(dead_code)
)]
impl Budget {
/// Budget starting now.
pub fn new(limits: &Limits) -> Self {
Expand Down Expand Up @@ -322,7 +289,6 @@ impl Budget {
}

/// Charge `n` parsed nodes against `max_nodes`.
#[cfg_attr(not(any(feature = "html", feature = "docx")), allow(dead_code))]
pub fn add_nodes(&self, n: usize) -> crate::Result<()> {
let total = self.nodes.get().saturating_add(n);
self.nodes.set(total);
Expand Down
Loading
Loading