Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
3942d5e
Merge remote-tracking branch 'origin/claude/project-thread-3z7tjs' in…
chaliy Oct 27, 2026
2af2a1d
feat(formats): add email (EML) input
chaliy Oct 26, 2026
3b15b45
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
05f1cfa
Merge remote-tracking branch 'origin/claude/project-thread-3z7tjs' in…
chaliy Oct 27, 2026
90cc15c
feat(formats): add ODT input
chaliy Oct 27, 2026
0ae4874
Merge remote-tracking branch 'origin/main' into claude/project-thread…
chaliy Oct 27, 2026
84ac919
Merge remote-tracking branch 'origin/claude/project-thread-3z7tjs' in…
chaliy Oct 27, 2026
a8d683f
feat(formats): add EPUB input
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ jobs:
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,email,odt
if cargo tree -p markkit --no-default-features --features docx,spreadsheet,markdown,email,odt -e normal | grep -E 'reqwest|hyper'; then
echo "HTTP client leaked into the docx,spreadsheet,markdown,email,odt feature set"
cargo test -p markkit --no-default-features --features docx,spreadsheet,markdown,email,odt,epub
if cargo tree -p markkit --no-default-features --features docx,spreadsheet,markdown,email,odt,epub -e normal | grep -E 'reqwest|hyper'; then
echo "HTTP client leaked into the docx,spreadsheet,markdown,email,odt,epub feature set"
exit 1
fi
if cargo tree -p markkit --no-default-features --features docx,spreadsheet -e normal | grep -E 'tokio'; then
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ 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.
- EPUB input (`epub` feature, implies `html`, in `all_formats`; `.epub`, `application/epub+zip`, sniffed
from the `mimetype` entry): spine chapters through the HTML parser, OPF title, DRM-encrypted books
rejected. New `Format::Epub`, `Error::EpubExtract`, `ExtractionStats::chapters`, `WarningCode::MissingChapter`.
- ODT input (`odt` feature, in `all_formats`; `.odt`, OpenDocument text content type, sniffed from the
ODF `mimetype`): headings, Title style, bold/italic/strike, links, lists, tables, with the same
bounded ZIP/XML reader as DOCX. New `Format::Odt`, `Error::OdtExtract`.
Expand Down
5 changes: 4 additions & 1 deletion 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
- **ODT** – OpenDocument text: headings, lists, tables, links, emphasis
- **EPUB** – E-book chapters in reading order, title from the package metadata
- **Spreadsheets** – XLSX/XLSM, XLSB, XLS and ODS workbooks to markdown tables, one section per sheet
- **Email (EML)** – Subject, sender, recipients, date, body (plain preferred, HTML converted), attachment list
- **Jupyter notebooks** – Markdown cells, code cells with language, text outputs and tracebacks
Expand All @@ -39,6 +40,7 @@ package for browsers and edge runtimes.
| DOCX | Supported | `docx` |
| PPTX, PPTM, PPSX | Supported | `pptx` |
| ODT | Supported | `odt` |
| EPUB | Supported | `epub` |
| XLSX, XLSM, XLSB, XLS, ODS | Supported | `spreadsheet` |
| Plain text | Supported | always on |
| CSV, TSV | Supported | always on |
Expand Down Expand Up @@ -208,7 +210,8 @@ let markkit = Markkit::builder()
| `markdown` | No | Markdown normalization (pulldown-cmark) |
| `email` | No | EML conversion (mail-parser) |
| `odt` | No | ODT conversion (zip + quick-xml) |
| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown`, `email`, `odt` |
| `epub` | No | EPUB conversion (implies `html`) |
| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown`, `email`, `odt`, `epub` |
| `tool` | No | `markkit::tool::MarkkitTool` LLM agent tool (Everruns toolkit contract) |
| `logging` | No | Reserved: pulls in `tracing`, emits no events yet |

Expand Down
1 change: 1 addition & 0 deletions crates/markkit-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ spreadsheet = ["markkit/spreadsheet"]
markdown = ["markkit/markdown"]
email = ["markkit/email"]
odt = ["markkit/odt"]
epub = ["markkit/epub"]

[dependencies]
markkit = { path = "../markkit", version = "0.1.1", features = ["http_client", "tool"] }
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 @@ -102,7 +102,7 @@ pub struct Warning {
pub struct Extraction {
pub markdown: String,
pub title: Option<String>,
/// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`, `eml`, `odt`).
/// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`, `eml`, `odt`, `epub`).
pub format: String,
pub warnings: Vec<Warning>,
pub stats: ExtractionStats,
Expand Down
3 changes: 2 additions & 1 deletion crates/markkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"]
markdown = ["dep:pulldown-cmark"]
email = ["dep:mail-parser"]
odt = ["dep:zip", "dep:quick-xml"]
epub = ["html", "dep:zip", "dep:quick-xml"]
# Opt-in: the default build has no network stack (no reqwest/hyper/TLS).
http_client = ["dep:reqwest", "dep:tokio", "tokio/net"]
logging = ["dep:tracing"]
# LLM tool contract (`markkit::tool`): Everruns toolkit-library shape.
tool = ["dep:tower", "dep:futures-core", "dep:base64", "dep:tokio", "tokio/sync"]
# JSON Schema for the IR (`markkit::ir::json_schema()`)
json-schema = ["dep:schemars"]
all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown", "email", "odt"]
all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown", "email", "odt", "epub"]

[dependencies]
# Async (optional): only URL fetching and the tool's output channel need tokio.
Expand Down
4 changes: 2 additions & 2 deletions crates/markkit/src/archive.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Bounded ZIP access shared by the DOCX and spreadsheet parsers.
//! Bounded ZIP access shared by the DOCX, ODT, EPUB 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
Expand Down Expand Up @@ -66,7 +66,7 @@ impl<'a> ZipGuard<'a> {
}

/// Names of all entries.
#[cfg(feature = "spreadsheet")]
#[cfg(any(feature = "spreadsheet", feature = "epub"))]
pub fn names(&self) -> impl Iterator<Item = &str> {
self.names.iter().map(String::as_str)
}
Expand Down
278 changes: 278 additions & 0 deletions crates/markkit/src/epub.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
//! EPUB (2 and 3) → IR.
//!
//! Decisions:
//! - Behind the `epub` feature (implies `html`): an EPUB is a ZIP of XHTML
//! chapters, so it reuses the bounded ZIP reader, the depth-capped XML tree
//! for the package files, and the HTML parser for chapters (same stripping
//! rules as web pages).
//! - Package: `META-INF/container.xml` → first rootfile (fallback: the only
//! `.opf` entry) → manifest + spine. Chapters render in spine order;
//! `linear="no"` items (notes, covers) are skipped, each item at most once.
//! Only XHTML/HTML manifest items are read.
//! - Title: OPF `dc:title`. Chapter boundaries become thematic breaks.
//! - DRM: if `META-INF/encryption.xml` encrypts a spine document, extraction
//! fails with `Error::EpubExtract` rather than emitting ciphertext. Font
//! obfuscation alone (also declared there) is fine.
//! - Relative hrefs are resolved against the OPF directory, percent-decoded,
//! and may not climb above the archive root.
//! - Limits: package XML and every chapter share the call's `Budget` (nodes,
//! timeout) and nesting depth; chapters go through `HtmlParser::with_budget`,
//! so hidden-content rules (S20) apply as for web pages.

use std::collections::{HashMap, HashSet};

use crate::archive::{ArchiveError, ZipGuard};
use crate::config::MarkkitBuilder;
use crate::error::Error;
use crate::extract::{Warning, WarningCode};
use crate::ir::{Document, Node};
use crate::limits::{Budget, LimitExceeded};
use crate::xml::{self, Element, XmlError};

/// Parsed EPUB.
pub(crate) struct EpubOutput {
pub doc: Document,
pub title: Option<String>,
pub warnings: Vec<Warning>,
pub chapters: usize,
}

fn archive_err(e: ArchiveError) -> Error {
match e {
ArchiveError::Limit(m) => Error::ResourceLimit(m),
ArchiveError::Invalid(m) => Error::EpubExtract(m),
}
}

fn xml_err(part: &str, e: XmlError) -> Error {
match e {
XmlError::Limit(m) => {
Error::ResourceLimit(LimitExceeded::new(m.kind, format!("{part}: {}", m.detail)))
}
XmlError::Invalid(m) => Error::EpubExtract(format!("{part}: {m}")),
}
}

fn read_xml(
zip: &mut ZipGuard<'_>,
path: &str,
max_depth: usize,
budget: &Budget,
) -> Result<Option<Element>, Error> {
match zip.read(path).map_err(archive_err)? {
Some(bytes) => xml::parse(&bytes, max_depth, budget)
.map(Some)
.map_err(|e| xml_err(path, e)),
None => Ok(None),
}
}

/// Resolve `href` against directory `base` (no leading slash). `None` when
/// the path escapes the archive root.
fn resolve(base: &str, href: &str) -> Option<String> {
let href = percent_decode(href.split(['#', '?']).next().unwrap_or(""));
let mut parts: Vec<&str> = if href.starts_with('/') {
Vec::new()
} else {
base.split('/').filter(|p| !p.is_empty()).collect()
};
for seg in href.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop()?;
}
s => parts.push(s),
}
}
let path = parts.join("/");
(!path.is_empty()).then_some(path)
}

fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& i + 2 < b.len()
&& let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2]))
{
out.push(h * 16 + l);
i += 3;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}

fn hex(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}

fn dir_of(path: &str) -> &str {
path.rsplit_once('/').map_or("", |(d, _)| d)
}

pub(crate) fn parse(
bytes: &[u8],
config: &MarkkitBuilder,
budget: &Budget,
) -> Result<EpubOutput, Error> {
let mut zip = ZipGuard::open(bytes, config).map_err(archive_err)?;
let depth = config.limits.nesting_depth();

let opf_path = read_xml(&mut zip, "META-INF/container.xml", depth, budget)?
.and_then(|c| {
c.find("rootfile")
.and_then(|r| r.attr("full-path"))
.and_then(|p| resolve("", p))
})
.or_else(|| {
let mut opfs = zip
.names()
.filter(|n| n.to_ascii_lowercase().ends_with(".opf"));
match (opfs.next(), opfs.next()) {
(Some(only), None) => Some(only.to_string()),
_ => None,
}
})
.ok_or_else(|| Error::EpubExtract("no package document (container.xml rootfile)".into()))?;
let opf = read_xml(&mut zip, &opf_path, depth, budget)?
.ok_or_else(|| Error::EpubExtract(format!("package document '{opf_path}' missing")))?;
let package = opf
.find("package")
.ok_or_else(|| Error::EpubExtract("package document has no <package>".into()))?;
let base = dir_of(&opf_path);

let title = package
.find("metadata")
.and_then(|m| m.child("title"))
.map(|t| t.text().split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|t| !t.is_empty());

// id → (path, is_html)
let mut manifest: HashMap<&str, (String, bool)> = HashMap::new();
if let Some(m) = package.child("manifest") {
for item in m.elements().filter(|e| e.name == "item") {
let (Some(id), Some(href)) = (item.attr("id"), item.attr("href")) else {
continue;
};
let Some(path) = resolve(base, href) else {
continue;
};
let media = item.attr("media-type").unwrap_or("");
let lower = path.to_ascii_lowercase();
let html = media.contains("html")
|| lower.ends_with(".xhtml")
|| lower.ends_with(".html")
|| lower.ends_with(".htm");
manifest.insert(id, (path, html));
}
}

let encrypted: HashSet<String> = read_xml(&mut zip, "META-INF/encryption.xml", depth, budget)?
.map(|enc| {
let mut out = HashSet::new();
let mut stack = vec![&enc];
while let Some(e) = stack.pop() {
if e.name == "CipherReference"
&& let Some(uri) = e.attr("URI").and_then(|u| resolve("", u))
{
out.insert(uri);
}
stack.extend(e.elements());
}
out
})
.unwrap_or_default();

let spine: Vec<&str> = package
.child("spine")
.map(|s| {
s.elements()
.filter(|e| e.name == "itemref" && e.attr("linear") != Some("no"))
.filter_map(|e| e.attr("idref"))
.collect()
})
.unwrap_or_default();

let html = crate::html::HtmlParser::with_budget(config, budget);
let mut warnings = Vec::new();
let mut nodes: Vec<Node> = Vec::new();
let mut seen = HashSet::new();
let mut chapters = 0;
for idref in spine {
if budget.expired() {
return Err(budget.timeout_error());
}
let Some((path, is_html)) = manifest.get(idref) else {
warnings.push(Warning::new(
WarningCode::MissingChapter,
format!("spine item '{idref}' not in manifest"),
));
continue;
};
if !is_html || !seen.insert(path.clone()) {
continue;
}
if encrypted.contains(path) {
return Err(Error::EpubExtract(
"EPUB content is encrypted (DRM); cannot extract".into(),
));
}
let Some(bytes) = zip.read(path).map_err(archive_err)? else {
warnings.push(Warning::new(
WarningCode::MissingChapter,
format!("'{path}' not in archive"),
));
continue;
};
let text = String::from_utf8_lossy(&bytes);
let doc = html.parse(&text)?;
if doc.nodes.is_empty() {
continue;
}
if chapters > 0 {
nodes.push(Node::ThematicBreak);
}
chapters += 1;
nodes.extend(doc.nodes);
}
budget.check_time()?;

Ok(EpubOutput {
doc: Document::new(nodes),
title,
warnings,
chapters,
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn resolves_paths() {
assert_eq!(
resolve("OEBPS", "text/ch1.xhtml").as_deref(),
Some("OEBPS/text/ch1.xhtml")
);
assert_eq!(
resolve("OEBPS/text", "../ch%201.xhtml#x").as_deref(),
Some("OEBPS/ch 1.xhtml")
);
assert_eq!(resolve("", "/abs.xhtml").as_deref(), Some("abs.xhtml"));
assert_eq!(resolve("a", "../../etc/passwd"), None);
assert_eq!(resolve("", ""), None);
assert_eq!(percent_decode("%zz%4"), "%zz%4");
}
}
3 changes: 3 additions & 0 deletions crates/markkit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub enum Error {
#[error("Notebook extraction error: {0}")]
NotebookExtract(String),

#[error("EPUB extraction error: {0}")]
EpubExtract(String),

#[error("Email extraction error: {0}")]
EmailExtract(String),

Expand Down
Loading
Loading