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
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.
- 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`.
- Plain text input (`text/plain`, `.txt`), always on: paragraphs and hard breaks through the IR,
UTF-8/UTF-16 BOM decoding, control characters dropped, `invalid_utf8` warning on lossy decode.
- Markdown input (`markdown` feature, in `all_formats`; `text/markdown`, `.md`): re-parsed with
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package for browsers and edge runtimes.
- **HTML** – Web pages and HTML documents; nav, header, footer, scripts, styles and forms stripped by default
- **DOCX** – Headings, lists, tables, links, emphasis
- **Spreadsheets** – XLSX/XLSM, XLSB, XLS and ODS workbooks to markdown tables, one section per sheet
- **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
- **URL fetching** – Fetch and extract in one call (`http_client` feature)
Expand All @@ -34,6 +35,7 @@ package for browsers and edge runtimes.
| DOCX | Supported | `docx` |
| XLSX, XLSM, XLSB, XLS, ODS | Supported | `spreadsheet` |
| Plain text | Supported | always on |
| CSV, TSV | Supported | always on |
| Markdown | Supported | `markdown` |
| PDF (text layer) | Supported | `pdf` |
| DOC, PPTX | Detected, rejected explicitly | – |
Expand Down
5 changes: 5 additions & 0 deletions crates/markkit-cli/tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ fn extract_text_and_markdown_files() {
"Title\n=====\n\n* a\n\n<script>x()</script>\n",
"# Title\n\n- a\n",
),
(
"data.csv",
"a;b\n1;2\n",
"| a | b |\n| --- | --- |\n| 1 | 2 |\n",
),
];
for (name, input, expected) in cases {
let file_path = dir.path().join(name);
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`).
/// Detected format (`html`, `docx`, `xlsx`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`).
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), plain text and Markdown in the **browser
and any JavaScript runtime**:
spreadsheets (XLSX/XLSM, XLS, ODS), CSV/TSV, 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
9 changes: 9 additions & 0 deletions crates/markkit-wasm/__test__/markkit-wasm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ test("extractDocument reads plain text and Markdown", () => {
assert.ok(!md.markdown.includes("script"));
});

test("extractDocument reads CSV and TSV", () => {
const mk = new Markkit();
const csv = mk.extractDocument(new TextEncoder().encode("a;b\n1;2\n"), { fileName: "t.csv" });
assert.equal(csv.format, "csv");
assert.equal(csv.markdown, "| a | b |\n| --- | --- |\n| 1 | 2 |\n");
const tsv = mk.extractDocument(new TextEncoder().encode("x\ty\n"), { fileName: "t.tsv" });
assert.equal(tsv.format, "tsv");
});

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
257 changes: 257 additions & 0 deletions crates/markkit/src/csv.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
//! CSV / TSV input → one GFM table.
//!
//! Decisions:
//! - Always compiled, hand-written RFC 4180 reader (no dependency). Like plain
//! text, selected only by content type or extension, never sniffed.
//! - Delimiter: tab for TSV; for CSV the candidate (`,` `;` `\t` `|`) seen most
//! often outside quotes in the first record, default `,`.
//! - Quoted fields may span lines and use `""` escapes. A stray quote inside an
//! unquoted field is literal; an unterminated quote takes the rest of the
//! input as one field and emits `malformed_csv`.
//! - First non-empty record is the header; fully empty records are dropped;
//! short rows are padded. Cells are trimmed and kept raw in the IR; the
//! renderer escapes pipes and flattens line breaks in table context (S17).
//! - Bounded by `max_sheet_rows` / `max_sheet_cols`: the reader stops after the
//! row cap and never stores columns past the column cap (`truncated_table`).
//! Each record ticks the call's `timeout` budget.

use crate::config::MarkkitBuilder;
use crate::ir::{Document, Node, TableCell};
use crate::limits::Budget;

/// Parsed CSV/TSV document.
pub(crate) struct CsvOutput {
pub doc: Document,
pub warnings: Vec<String>,
/// Data rows (header excluded).
pub rows: usize,
/// 1 when a table was produced, else 0.
pub tables: usize,
}

/// Parse delimited text. `tab` forces the TSV delimiter.
pub(crate) fn parse(
text: &str,
tab: bool,
config: &MarkkitBuilder,
budget: &Budget,
) -> crate::Result<CsvOutput> {
let delim = if tab { '\t' } else { sniff_delimiter(text) };
let max_cols = config.limits.max_sheet_cols.max(1);
let max_rows = config.limits.max_sheet_rows;
let mut warnings = Vec::new();

let mut reader = Reader::new(text, delim, max_cols);
let mut records: Vec<Vec<String>> = Vec::new();
let mut truncated_rows = false;
while let Some(rec) = reader.next_record() {
if budget.expired() {
return Err(budget.timeout_error());
}
if rec.iter().all(|f| f.trim().is_empty()) {
continue;
}
// Header + `max_rows` data rows.
if records.len() > max_rows {
truncated_rows = true;
break;
}
records.push(rec);
}
if reader.unterminated {
warnings.push("malformed_csv: unterminated quoted field".into());
}
if truncated_rows {
warnings.push(format!(
"truncated_table: table truncated to {max_rows} rows"
));
}
if reader.truncated_cols {
warnings.push(format!(
"truncated_table: table truncated to {max_cols} columns"
));
}

let width = records.iter().map(Vec::len).max().unwrap_or(0);
let mut rows = records.into_iter().map(|rec| {
let mut cells: Vec<TableCell> = rec
.iter()
.map(|f| {
let f = f.trim();
TableCell::new(if f.is_empty() {
vec![]
} else {
vec![Node::Text(f.to_string())]
})
})
.collect();
cells.resize_with(width, || TableCell::new(vec![]));
cells
});
let Some(headers) = rows.next() else {
return Ok(CsvOutput {
doc: Document::new(vec![]),
warnings,
rows: 0,
tables: 0,
});
};
let rows: Vec<_> = rows.collect();
let n = rows.len();
Ok(CsvOutput {
doc: Document::new(vec![Node::Table {
alignments: Vec::new(),
headers,
rows,
source: None,
}]),
warnings,
rows: n,
tables: 1,
})
}

fn sniff_delimiter(text: &str) -> char {
const CANDIDATES: [char; 4] = [',', ';', '\t', '|'];
let mut counts = [0usize; 4];
let mut quoted = false;
let mut seen = false;
for c in text.chars() {
match c {
'"' => quoted = !quoted,
'\n' if !quoted => {
if seen {
break;
}
}
_ if !quoted => {
if let Some(i) = CANDIDATES.iter().position(|d| *d == c) {
counts[i] += 1;
}
if !c.is_whitespace() {
seen = true;
}
}
_ => {}
}
}
// First maximum wins, so ties prefer `,`.
let mut best = 0;
for i in 1..CANDIDATES.len() {
if counts[i] > counts[best] {
best = i;
}
}
CANDIDATES[best]
}

struct Reader<'a> {
chars: std::iter::Peekable<std::str::Chars<'a>>,
delim: char,
max_cols: usize,
unterminated: bool,
truncated_cols: bool,
}

impl<'a> Reader<'a> {
fn new(text: &'a str, delim: char, max_cols: usize) -> Self {
Self {
chars: text.chars().peekable(),
delim,
max_cols,
unterminated: false,
truncated_cols: false,
}
}

/// Next record, or `None` at end of input. Input is LF-normalized.
fn next_record(&mut self) -> Option<Vec<String>> {
self.chars.peek()?;
let mut rec = Vec::new();
let mut field = String::new();
let mut quoted = false;
let mut at_start = true;
loop {
let Some(c) = self.chars.next() else {
if quoted {
self.unterminated = true;
}
self.push_field(&mut rec, &mut field);
return Some(rec);
};
if quoted {
if c == '"' {
if self.chars.peek() == Some(&'"') {
self.chars.next();
field.push('"');
} else {
quoted = false;
}
} else {
field.push(c);
}
continue;
}
if c == self.delim {
self.push_field(&mut rec, &mut field);
at_start = true;
} else if c == '\n' {
self.push_field(&mut rec, &mut field);
return Some(rec);
} else if c == '"' && at_start && field.trim().is_empty() {
field.clear();
quoted = true;
at_start = false;
} else {
field.push(c);
if !c.is_whitespace() {
at_start = false;
}
}
}
}

fn push_field(&mut self, rec: &mut Vec<String>, field: &mut String) {
if rec.len() < self.max_cols {
rec.push(std::mem::take(field));
} else {
self.truncated_cols = true;
field.clear();
}
}
}

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

fn records(text: &str, delim: char) -> Vec<Vec<String>> {
let mut r = Reader::new(text, delim, 100);
std::iter::from_fn(|| r.next_record()).collect()
}

#[test]
fn reader_quotes_and_escapes() {
assert_eq!(
records("a,\"b,c\",\"d \"\"q\"\"\"\n\"multi\nline\",x\"y,\n", ','),
vec![vec!["a", "b,c", "d \"q\""], vec!["multi\nline", "x\"y", ""],]
);
}

#[test]
fn reader_unterminated_quote() {
let mut r = Reader::new("a,\"open\nrest", ',', 100);
assert_eq!(r.next_record().unwrap(), vec!["a", "open\nrest"]);
assert!(r.unterminated);
assert!(r.next_record().is_none());
}

#[test]
fn delimiter_sniffing() {
assert_eq!(sniff_delimiter("a;b;c\n1,5;2;3"), ';');
assert_eq!(sniff_delimiter("\"x;y\",b\n"), ',');
assert_eq!(sniff_delimiter("a\tb\n"), '\t');
assert_eq!(sniff_delimiter("a|b|c\n"), '|');
assert_eq!(sniff_delimiter("\n\nsingle\n"), ',');
}
}
Loading
Loading