diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 546d882..1ce0ae2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 - if cargo tree -p markkit --no-default-features --features docx,spreadsheet,markdown,email -e normal | grep -E 'reqwest|hyper'; then - echo "HTTP client leaked into the docx,spreadsheet,markdown,email feature set" + 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" exit 1 fi if cargo tree -p markkit --no-default-features --features docx,spreadsheet -e normal | grep -E 'tokio'; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da6eef..f8c30df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +- 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`. - Email input (`email` feature, in `all_formats`; `.eml`, `message/rfc822`): subject as title, From / To / Cc / Date, plain-text body (HTML alternative converted when there is no plain part) and an attachment list. New `Format::Eml`, `Error::EmailExtract`, `ExtractionStats::attachments`, `WarningCode::EncodingProblem`. diff --git a/README.md b/README.md index bec1676..7ed41f4 100644 --- a/README.md +++ b/README.md @@ -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 - **PPTX** – One section per slide: titles, bullets, tables, links, speaker notes +- **ODT** – OpenDocument text: headings, lists, tables, links, emphasis - **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 @@ -37,6 +38,7 @@ package for browsers and edge runtimes. | HTML / XHTML | Supported | `html` (default) | | DOCX | Supported | `docx` | | PPTX, PPTM, PPSX | Supported | `pptx` | +| ODT | Supported | `odt` | | XLSX, XLSM, XLSB, XLS, ODS | Supported | `spreadsheet` | | Plain text | Supported | always on | | CSV, TSV | Supported | always on | @@ -205,7 +207,8 @@ let markkit = Markkit::builder() | `pdf` | No | PDF text-layer extraction (hayro-syntax, pure Rust) | | `markdown` | No | Markdown normalization (pulldown-cmark) | | `email` | No | EML conversion (mail-parser) | -| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown`, `email` | +| `odt` | No | ODT conversion (zip + quick-xml) | +| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown`, `email`, `odt` | | `tool` | No | `markkit::tool::MarkkitTool` LLM agent tool (Everruns toolkit contract) | | `logging` | No | Reserved: pulls in `tracing`, emits no events yet | diff --git a/crates/markkit-cli/Cargo.toml b/crates/markkit-cli/Cargo.toml index 0b655a3..d9fc613 100644 --- a/crates/markkit-cli/Cargo.toml +++ b/crates/markkit-cli/Cargo.toml @@ -27,6 +27,7 @@ pptx = ["markkit/pptx"] spreadsheet = ["markkit/spreadsheet"] markdown = ["markkit/markdown"] email = ["markkit/email"] +odt = ["markkit/odt"] [dependencies] markkit = { path = "../markkit", version = "0.1.1", features = ["http_client", "tool"] } diff --git a/crates/markkit-js/src/lib.rs b/crates/markkit-js/src/lib.rs index 5930f89..8ea3e62 100644 --- a/crates/markkit-js/src/lib.rs +++ b/crates/markkit-js/src/lib.rs @@ -102,7 +102,7 @@ pub struct Warning { pub struct Extraction { pub markdown: String, pub title: Option, - /// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`, `eml`). + /// Detected format (`html`, `docx`, `pptx`, `xlsx`, `xlsb`, `xls`, `ods`, `text`, `markdown`, `csv`, `tsv`, `ipynb`, `eml`, `odt`). pub format: String, pub warnings: Vec, pub stats: ExtractionStats, diff --git a/crates/markkit/Cargo.toml b/crates/markkit/Cargo.toml index 9922b0a..9108e0b 100644 --- a/crates/markkit/Cargo.toml +++ b/crates/markkit/Cargo.toml @@ -22,6 +22,7 @@ pptx = ["dep:zip", "dep:quick-xml"] spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"] markdown = ["dep:pulldown-cmark"] email = ["dep:mail-parser"] +odt = ["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"] @@ -29,7 +30,7 @@ logging = ["dep:tracing"] 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"] +all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown", "email", "odt"] [dependencies] # Async (optional): only URL fetching and the tool's output channel need tokio. diff --git a/crates/markkit/src/error.rs b/crates/markkit/src/error.rs index e4efff8..58303b8 100644 --- a/crates/markkit/src/error.rs +++ b/crates/markkit/src/error.rs @@ -13,6 +13,8 @@ pub enum Error { #[error("PPTX extraction error: {0}")] PptxExtract(String), + #[error("ODT extraction error: {0}")] + OdtExtract(String), #[error("Spreadsheet extraction error: {0}")] SpreadsheetExtract(String), diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index a3cfd4f..0a00622 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -157,9 +157,9 @@ pub struct ExtractionStats { pub sheets: Option, /// Data rows rendered across all sheets / the CSV table (header rows excluded). pub rows: Option, - /// Non-empty paragraphs, headings and list items (DOCX, plain text, Markdown). + /// Non-empty paragraphs, headings and list items (DOCX, ODT, plain text, Markdown). pub paragraphs: Option, - /// Tables (DOCX, Markdown, CSV/TSV). + /// Tables (DOCX, ODT, Markdown, CSV/TSV). pub tables: Option, /// Non-empty cells rendered (Jupyter notebooks). pub cells: Option, @@ -321,7 +321,7 @@ impl Markkit { .map(|e| e.markdown) } - /// Convert a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown, + /// Convert a document (HTML, PDF, DOCX, PPTX, ODT, XLSX/XLSB/XLS/ODS, plain text, Markdown, /// CSV/TSV, Jupyter notebooks, email) to Markdown with metadata. /// /// The format is detected from `hint.content_type`, then the extension of @@ -402,6 +402,7 @@ impl Markkit { Format::Csv | Format::Tsv => self.parse_csv(bytes, format, budget), Format::Notebook => self.parse_notebook(bytes, budget), Format::Eml => self.parse_email(bytes, budget), + Format::Odt => self.parse_odt(bytes, budget), Format::Doc => Err(Error::UnsupportedFormat( "legacy Word 97-2003 (.doc) documents are not supported".into(), )), @@ -478,6 +479,28 @@ impl Markkit { Ok(p) } + #[cfg(feature = "odt")] + fn parse_odt(&self, bytes: &[u8], budget: &Budget) -> crate::Result { + let out = guarded("ODT", Error::OdtExtract, || { + crate::odt::parse(bytes, &self.config, budget) + })?; + let mut doc = out.doc; + self.config.transforms.apply(&mut doc)?; + let mut p = ParsedDocument::new(doc, Format::Odt); + p.title = out.title; + p.warnings = out.warnings; + p.stats.paragraphs = Some(out.paragraphs); + p.stats.tables = Some(out.tables); + Ok(p) + } + + #[cfg(not(feature = "odt"))] + fn parse_odt(&self, _bytes: &[u8], _budget: &Budget) -> crate::Result { + Err(Error::UnsupportedFormat( + "ODT support requires the 'odt' feature".to_string(), + )) + } + #[cfg(feature = "email")] fn parse_email(&self, bytes: &[u8], budget: &Budget) -> crate::Result { let out = guarded("email", Error::EmailExtract, || { diff --git a/crates/markkit/src/format.rs b/crates/markkit/src/format.rs index 666739a..59642a9 100644 --- a/crates/markkit/src/format.rs +++ b/crates/markkit/src/format.rs @@ -20,6 +20,8 @@ pub enum Format { Docx, /// Legacy Word 97-2003 binary (`.doc`). Detected but not supported. Doc, + /// OpenDocument text (`.odt`, `.ott`). + Odt, /// Office Open XML spreadsheet (`.xlsx`, `.xlsm`, `.xltx`). Xlsx, /// Excel binary workbook (`.xlsb`). @@ -82,6 +84,7 @@ impl<'a> DocumentHint<'a> { const OLE2_MAGIC: &[u8] = b"\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1"; const ODS_MIMETYPE: &[u8] = b"mimetypeapplication/vnd.oasis.opendocument.spreadsheet"; +const ODT_MIMETYPE: &[u8] = b"mimetypeapplication/vnd.oasis.opendocument.text"; impl Format { /// Short lowercase name (`"docx"`, `"xlsx"`, …). @@ -91,6 +94,7 @@ impl Format { Format::Pdf => "pdf", Format::Docx => "docx", Format::Doc => "doc", + Format::Odt => "odt", Format::Xlsx => "xlsx", Format::Xlsb => "xlsb", Format::Xls => "xls", @@ -125,6 +129,8 @@ impl Format { Format::Xlsx } else if ct.contains("ms-excel") { Format::Xls + } else if ct.contains("opendocument.text") { + Format::Odt } else if ct.contains("opendocument.spreadsheet") { Format::Ods } else if ct.contains("presentationml") || ct.contains("ms-powerpoint.") { @@ -160,6 +166,7 @@ impl Format { "xlsb" => Format::Xlsb, "xls" | "xlt" => Format::Xls, "ods" => Format::Ods, + "odt" | "ott" => Format::Odt, "pptx" | "pptm" | "ppsx" | "ppsm" | "potx" | "potm" => Format::Pptx, "ppt" | "pps" | "pot" => Format::Ppt, "csv" => Format::Csv, @@ -231,8 +238,11 @@ fn contains(haystack: &[u8], needle: &[u8]) -> bool { fn sniff_zip(bytes: &[u8]) -> Format { // ODF stores an uncompressed `mimetype` entry first; OOXML part names are // stored uncompressed in local and central directory headers. - if contains(&bytes[..bytes.len().min(256)], ODS_MIMETYPE) { + let head = &bytes[..bytes.len().min(256)]; + if contains(head, ODS_MIMETYPE) { Format::Ods + } else if contains(head, ODT_MIMETYPE) { + Format::Odt } else if contains(bytes, b"word/document") { Format::Docx } else if contains(bytes, b"xl/workbook.bin") { @@ -293,6 +303,7 @@ mod tests { Format::Tsv, Format::Notebook, Format::Eml, + Format::Odt, Format::PlainText, Format::Markdown, Format::Unknown, diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index 8d9ecca..ec0e379 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -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` +//! - **ODT** (`odt`): OpenDocument text documents //! - **Email** (`email`): `.eml` / `message/rfc822`, headers, body and attachment list //! - **Jupyter notebooks** (always on): `.ipynb`, cells and text outputs //! - **CSV / TSV** (always on): `text/csv`, `text/tab-separated-values`, `.csv`, `.tsv`, one table @@ -51,7 +52,12 @@ //! `Markkit::extract_url`. Office formats only, no HTML: //! `markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] }`. -#[cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] +#[cfg(any( + feature = "docx", + feature = "pptx", + feature = "spreadsheet", + feature = "odt" +))] mod archive; pub mod config; mod csv; @@ -71,6 +77,8 @@ pub mod limits; #[cfg(feature = "markdown")] mod markdown; mod notebook; +#[cfg(feature = "odt")] +mod odt; #[cfg(any(feature = "docx", feature = "pptx"))] mod ooxml; #[cfg(feature = "pdf")] @@ -86,7 +94,12 @@ mod text; #[cfg(feature = "tool")] pub mod tool; pub mod transform; -#[cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] +#[cfg(any( + feature = "docx", + feature = "pptx", + feature = "spreadsheet", + feature = "odt" +))] #[cfg_attr(not(feature = "docx"), allow(dead_code))] // DOCX uses every helper mod xml; diff --git a/crates/markkit/src/odt.rs b/crates/markkit/src/odt.rs new file mode 100644 index 0000000..35918bc --- /dev/null +++ b/crates/markkit/src/odt.rs @@ -0,0 +1,848 @@ +//! OpenDocument text (`.odt`) → IR. +//! +//! Decisions: +//! - Behind the `odt` feature; reuses the bounded ZIP reader (`archive`) and the +//! depth-capped XML tree (`xml`) that DOCX uses, so T6 zip-bomb and entity +//! protections apply unchanged. +//! - Reads `content.xml` (required), `styles.xml` and `meta.xml` (optional). +//! Styles from all three style containers are merged; `parent-style-name` +//! chains are followed at most `MAX_STYLE_CHAIN` deep. +//! - Headings: `text:h` (`outline-level`, default 1, clamped to 1..6), or a +//! paragraph style with `default-outline-level`; style `Title` → level 1 and +//! the title fallback after `meta.xml` `dc:title`. +//! - Inline formatting comes from text properties (`font-weight` bold/≥600, +//! `font-style` italic/oblique, `text-line-through-style` ≠ none). Content is +//! flattened to styled runs and regrouped, so adjacent spans with the same +//! formatting merge and edge whitespace moves outside the markers. +//! - Lists: ordered when the list style's level is `list-level-style-number`. +//! Tables: first row is the header; `number-columns-repeated` / +//! `number-rows-repeated` are capped by `max_sheet_cols` / `max_sheet_rows` +//! and trailing empty cells/rows are dropped (producers pad to 1024 columns). +//! - Skipped: footnotes/endnotes, annotations, tracked-change metadata, frames +//! and drawings, table of contents bodies, page/section layout. +//! - Hidden content (T9) is skipped unless `include_hidden_content`: styles +//! with `text:display="none"`, hidden sections, `text:hidden-text` and +//! `text:hidden-paragraph` fields; a `hidden_content_skipped` warning counts +//! them. +//! - Shares the call's `Budget`: XML parts are bounded by depth/node count and +//! each block ticks the deadline. +//! - Whitespace follows ODF: runs collapse to one space; `text:s c=N` (capped +//! at 64), `text:tab` and `text:line-break` are explicit. + +use std::collections::HashMap; + +use crate::archive::{ArchiveError, ZipGuard}; +use crate::config::MarkkitBuilder; +use crate::error::Error; +use crate::extract::{Warning, WarningCode}; +use crate::ir::{Document, ListItem, Node, TableCell}; +use crate::limits::{Budget, LimitExceeded}; +use crate::xml::{self, Element, XNode, XmlError}; + +const MAX_STYLE_CHAIN: usize = 16; +const MAX_SPACES: usize = 64; + +/// Result of parsing an ODT package. +pub(crate) struct OdtOutput { + pub doc: Document, + pub title: Option, + pub warnings: Vec, + pub paragraphs: usize, + pub tables: usize, +} + +fn archive_err(e: ArchiveError) -> Error { + match e { + ArchiveError::Limit(m) => Error::ResourceLimit(m), + ArchiveError::Invalid(m) => Error::OdtExtract(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::OdtExtract(format!("{part}: {m}")), + } +} + +fn read_xml( + zip: &mut ZipGuard<'_>, + path: &str, + max_depth: usize, + budget: &Budget, +) -> Result, 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), + } +} + +// ─── Styles ───────────────────────────────────────────────────────── + +#[derive(Default, Clone, Copy, PartialEq, Eq)] +struct Fmt { + bold: bool, + italic: bool, + strike: bool, +} + +#[derive(Default)] +struct Style { + parent: Option, + bold: Option, + italic: Option, + strike: Option, + outline: Option, + display_name: Option, + /// `text:display="none"` on the style's text properties. + hidden: Option, +} + +#[derive(Default)] +struct Styles { + styles: HashMap, + /// List style name → ordered flag per level (index 0 = level 1). + lists: HashMap>, +} + +impl Styles { + fn add(&mut self, root: Option<&Element>) { + let Some(root) = root else { return }; + let mut stack = vec![root]; + while let Some(e) = stack.pop() { + match e.name.as_str() { + "style" => { + if let Some(name) = e.attr("name") { + self.styles.insert(name.to_string(), parse_style(e)); + } + } + "list-style" => { + if let Some(name) = e.attr("name") { + let mut levels: Vec<(usize, bool)> = e + .elements() + .filter_map(|l| { + let level = l.attr("level")?.parse::().ok()?; + Some((level, l.name == "list-level-style-number")) + }) + .filter(|(l, _)| (1..=10).contains(l)) + .collect(); + levels.sort_unstable(); + let mut v = vec![false; 10]; + for (l, ordered) in levels { + v[l - 1] = ordered; + } + self.lists.insert(name.to_string(), v); + } + } + // Style containers and the document root hold the definitions. + "document-styles" | "document-content" | "styles" | "automatic-styles" + | "master-styles" | "" => stack.extend(e.elements()), + _ => {} + } + } + } + + /// Resolve a style chain into (text formatting, outline level, is_title). + fn resolve(&self, name: Option<&str>) -> (Fmt, Option, bool) { + let mut fmt = (None, None, None); + let mut outline = None; + let mut title = false; + let mut cur = name; + for _ in 0..MAX_STYLE_CHAIN { + let Some(n) = cur else { break }; + let Some(s) = self.styles.get(n) else { break }; + fmt.0 = fmt.0.or(s.bold); + fmt.1 = fmt.1.or(s.italic); + fmt.2 = fmt.2.or(s.strike); + outline = outline.or(s.outline); + let shown = s.display_name.as_deref().unwrap_or(n); + title |= shown.eq_ignore_ascii_case("title"); + cur = s.parent.as_deref(); + } + ( + Fmt { + bold: fmt.0.unwrap_or(false), + italic: fmt.1.unwrap_or(false), + strike: fmt.2.unwrap_or(false), + }, + outline, + title, + ) + } + + /// Whether a style chain hides its text (`text:display="none"`). + fn hidden(&self, name: Option<&str>) -> bool { + let mut cur = name; + for _ in 0..MAX_STYLE_CHAIN { + let Some(s) = cur.and_then(|n| self.styles.get(n)) else { + break; + }; + if let Some(h) = s.hidden { + return h; + } + cur = s.parent.as_deref(); + } + false + } + + fn list_ordered(&self, style: Option<&str>, level: usize) -> bool { + style + .and_then(|s| self.lists.get(s)) + .and_then(|v| v.get(level.min(9))) + .copied() + .unwrap_or(false) + } +} + +fn parse_style(e: &Element) -> Style { + let props = e.child("text-properties"); + let bold = props + .and_then(|p| p.attr("font-weight")) + .map(|w| w == "bold" || w.parse::().map(|n| n >= 600).unwrap_or(false)); + let italic = props + .and_then(|p| p.attr("font-style")) + .map(|s| s == "italic" || s == "oblique"); + let strike = props + .and_then(|p| p.attr("text-line-through-style")) + .map(|s| s != "none"); + Style { + parent: e.attr("parent-style-name").map(str::to_string), + bold, + italic, + strike, + outline: e + .attr("default-outline-level") + .and_then(|l| l.parse::().ok()) + .filter(|l| *l > 0) + .map(|l| l.min(6)), + display_name: e.attr("display-name").map(str::to_string), + hidden: props.and_then(|p| p.attr("display")).map(|d| d == "none"), + } +} + +// ─── Inline runs ──────────────────────────────────────────────────── + +enum Run { + Text { + text: String, + fmt: Fmt, + link: Option, + }, + Break, +} + +struct Inline<'s> { + styles: &'s Styles, + include_hidden: bool, + /// Hidden spans/fields skipped. + hidden: usize, + runs: Vec, +} + +impl Inline<'_> { + fn push_text(&mut self, text: &str, fmt: Fmt, link: &Option) { + if text.is_empty() { + return; + } + if let Some(Run::Text { + text: prev, + fmt: f, + link: l, + }) = self.runs.last_mut() + && *f == fmt + && l == link + { + prev.push_str(text); + return; + } + self.runs.push(Run::Text { + text: text.to_string(), + fmt, + link: link.clone(), + }); + } + + /// Walk inline content iteratively (XML depth is already capped). + fn walk(&mut self, el: &Element, fmt: Fmt, link: Option) { + let mut stack: Vec<(std::slice::Iter<'_, XNode>, Fmt, Option)> = + vec![(el.children.iter(), fmt, link)]; + while let Some((iter, fmt, link)) = stack.last_mut() { + let Some(node) = iter.next() else { + stack.pop(); + continue; + }; + let (fmt, link) = (*fmt, link.clone()); + match node { + XNode::Text(t) => self.push_text(&collapse(t), fmt, &link), + XNode::Element(e) => match e.name.as_str() { + "span" if !self.include_hidden && self.styles.hidden(e.attr("style-name")) => { + self.hidden += 1; + } + "hidden-text" | "hidden-paragraph" if !self.include_hidden => { + self.hidden += 1; + } + "span" => { + let (f, _, _) = self.styles.resolve(e.attr("style-name")); + let merged = Fmt { + bold: fmt.bold || f.bold, + italic: fmt.italic || f.italic, + strike: fmt.strike || f.strike, + }; + stack.push((e.children.iter(), merged, link)); + } + "a" => { + let href = e + .attr("href") + .map(str::trim) + .filter(|h| !h.is_empty()) + .map(|h| h.replace(' ', "%20")); + stack.push((e.children.iter(), fmt, href.or(link))); + } + "s" => { + let n = e + .attr("c") + .and_then(|c| c.parse::().ok()) + .unwrap_or(1) + .clamp(1, MAX_SPACES); + self.push_text(&" ".repeat(n), fmt, &link); + } + "tab" => self.push_text(" ", fmt, &link), + "line-break" => self.runs.push(Run::Break), + // Not body text. + "note" + | "annotation" + | "annotation-end" + | "frame" + | "custom-shape" + | "bookmark" + | "bookmark-start" + | "bookmark-end" + | "reference-mark" + | "reference-mark-start" + | "reference-mark-end" + | "change" + | "change-start" + | "change-end" + | "soft-page-break" + | "toc-mark" + | "alphabetical-index-mark" + | "user-index-mark" => {} + // Fields (dates, page numbers, …) and anything else: keep text. + _ => stack.push((e.children.iter(), fmt, link)), + }, + } + } + } + + fn into_nodes(self) -> Vec { + let mut out: Vec = Vec::new(); + let mut runs = self.runs.into_iter().peekable(); + while let Some(run) = runs.next() { + match run { + Run::Break => out.push(Node::LineBreak), + Run::Text { text, fmt, link } => { + // Group following runs that share the link into one link node. + let mut group = vec![(text, fmt)]; + while let Some(Run::Text { link: l, .. }) = runs.peek() { + if *l != link { + break; + } + if let Some(Run::Text { text, fmt, .. }) = runs.next() { + group.push((text, fmt)); + } + } + let mut children = Vec::new(); + for (text, fmt) in group { + children.extend(styled(text, fmt)); + } + match link { + Some(url) => { + let (lead, inner, trail) = split_edges(children); + out.extend(lead); + if !inner.is_empty() { + out.push(Node::Link { + url, + title: None, + children: inner, + }); + } + out.extend(trail); + } + None => out.extend(children), + } + } + } + } + tidy(out) + } +} + +/// Collapse XML whitespace runs to single spaces (ODF text semantics). +fn collapse(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut space = false; + for c in s.chars() { + if c.is_whitespace() { + if !space { + out.push(' '); + } + space = true; + } else { + out.push(c); + space = false; + } + } + out +} + +/// Wrap text in formatting, keeping edge whitespace outside the markers. +fn styled(text: String, fmt: Fmt) -> Vec { + if fmt == Fmt::default() || text.trim().is_empty() { + return vec![Node::Text(text)]; + } + let start = text.len() - text.trim_start().len(); + let end = text.trim_end().len(); + let mut node = Node::Text(text[start..end].to_string()); + if fmt.strike { + node = Node::Strikethrough { + children: vec![node], + }; + } + if fmt.italic { + node = Node::Italic { + children: vec![node], + }; + } + if fmt.bold { + node = Node::Bold { + children: vec![node], + }; + } + let mut out = Vec::new(); + if start > 0 { + out.push(Node::Text(text[..start].to_string())); + } + out.push(node); + if end < text.len() { + out.push(Node::Text(text[end..].to_string())); + } + out +} + +/// Split leading/trailing whitespace-only text nodes off a node list. +fn split_edges(mut nodes: Vec) -> (Vec, Vec, Vec) { + let mut lead = Vec::new(); + while matches!(nodes.first(), Some(Node::Text(t)) if t.trim().is_empty()) { + lead.push(nodes.remove(0)); + } + let mut trail = Vec::new(); + while matches!(nodes.last(), Some(Node::Text(t)) if t.trim().is_empty()) { + trail.insert(0, nodes.pop().expect("non-empty")); + } + (lead, nodes, trail) +} + +fn tidy(nodes: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(nodes.len()); + for n in nodes { + match (out.last_mut(), n) { + (Some(Node::Text(prev)), Node::Text(t)) => { + if prev.ends_with(' ') && t.starts_with(' ') { + prev.push_str(t.trim_start()); + } else { + prev.push_str(&t); + } + } + (_, Node::Text(t)) if t.is_empty() => {} + (_, n) => out.push(n), + } + } + while matches!(out.first(), Some(Node::LineBreak)) { + out.remove(0); + } + while matches!(out.last(), Some(Node::LineBreak)) { + out.pop(); + } + out +} + +fn is_blank(nodes: &[Node]) -> bool { + nodes.iter().all(|n| match n { + Node::Text(t) => t.trim().is_empty(), + Node::LineBreak => true, + Node::Bold { children } + | Node::Italic { children } + | Node::Strikethrough { children } + | Node::Link { children, .. } => is_blank(children), + _ => false, + }) +} + +fn plain(nodes: &[Node]) -> String { + let mut out = String::new(); + let mut stack: Vec<&Node> = nodes.iter().rev().collect(); + while let Some(n) = stack.pop() { + match n { + Node::Text(t) => out.push_str(t), + Node::LineBreak => out.push(' '), + Node::Bold { children } + | Node::Italic { children } + | Node::Strikethrough { children } + | Node::Link { children, .. } => stack.extend(children.iter().rev()), + _ => {} + } + } + out +} + +// ─── Blocks ───────────────────────────────────────────────────────── + +struct Ctx<'c> { + styles: &'c Styles, + config: &'c MarkkitBuilder, + budget: &'c Budget, + /// Hidden paragraphs, sections, spans and fields skipped. + hidden: usize, + title: Option, + warnings: Vec, + paragraphs: usize, + tables: usize, +} + +impl Ctx<'_> { + fn inline(&mut self, el: &Element, fmt: Fmt) -> Vec { + let mut inline = Inline { + styles: self.styles, + include_hidden: self.config.include_hidden_content, + hidden: 0, + runs: Vec::new(), + }; + inline.walk(el, fmt, None); + self.hidden += inline.hidden; + inline.into_nodes() + } + + fn is_hidden(&self, el: &Element) -> bool { + !self.config.include_hidden_content + && (el.attr("display") == Some("none") || self.styles.hidden(el.attr("style-name"))) + } + + /// Convert block-level children of `el`. `list` carries the active list + /// style and nesting level for `text:list` children. + fn blocks( + &mut self, + el: &Element, + list: (Option<&str>, usize), + out: &mut Vec, + ) -> Result<(), Error> { + for child in el.elements() { + if self.budget.expired() { + return Err(self.budget.timeout_error()); + } + if matches!(child.name.as_str(), "h" | "p" | "section") && self.is_hidden(child) { + self.hidden += 1; + continue; + } + match child.name.as_str() { + "h" | "p" => self.paragraph(child, out), + "list" => { + let style = child.attr("style-name").or(list.0); + let level = if el.name == "list-item" || el.name == "list-header" { + list.1 + 1 + } else { + 0 + }; + let ordered = self.styles.list_ordered(style, level); + let mut items = Vec::new(); + for item in child.elements() { + if item.name != "list-item" && item.name != "list-header" { + continue; + } + let mut children = Vec::new(); + self.blocks(item, (style, level), &mut children)?; + if !children.is_empty() { + items.push(ListItem::new(children)); + } + } + if !items.is_empty() { + out.push(Node::List { + ordered, + start: None, + items, + source: None, + }); + } + } + "table" => self.table(child, out), + "section" => self.blocks(child, list, out)?, + _ => {} + } + } + Ok(()) + } + + fn paragraph(&mut self, el: &Element, out: &mut Vec) { + let (fmt, outline, is_title) = self.styles.resolve(el.attr("style-name")); + // Paragraph-level weight usually means "styled heading", not inline bold. + let level = if el.name == "h" { + Some( + el.attr("outline-level") + .and_then(|l| l.parse::().ok()) + .unwrap_or(1) + .clamp(1, 6), + ) + } else if is_title { + Some(1) + } else { + outline + }; + let base = if level.is_some() { Fmt::default() } else { fmt }; + let children = self.inline(el, base); + if is_blank(&children) { + return; + } + self.paragraphs += 1; + if is_title && self.title.is_none() { + let t = plain(&children).trim().to_string(); + if !t.is_empty() { + self.title = Some(t); + } + } + out.push(match level { + Some(level) => Node::heading(level, children), + None => Node::paragraph(children), + }); + } + + fn table(&mut self, el: &Element, out: &mut Vec) { + let max_cols = self.config.limits.max_sheet_cols.max(1); + let max_rows = self.config.limits.max_sheet_rows.saturating_add(1); + let mut rows: Vec> = Vec::new(); + let mut truncated = false; + // Rows may sit in header-rows / row-groups; walk them in order. + let mut stack: Vec> = vec![el.children.iter()]; + 'walk: while let Some(iter) = stack.last_mut() { + let Some(node) = iter.next() else { + stack.pop(); + continue; + }; + let XNode::Element(e) = node else { continue }; + match e.name.as_str() { + "table-header-rows" | "table-rows" | "table-row-group" => { + stack.push(e.children.iter()) + } + "table-row" => { + let row = self.table_row(e, max_cols, &mut truncated); + let repeat = e + .attr("number-rows-repeated") + .and_then(|r| r.parse::().ok()) + .unwrap_or(1) + .max(1); + for _ in 0..repeat { + if rows.len() >= max_rows { + // Padding rows past the cap are common and empty. + if row.iter().any(|c| !c.is_empty()) { + truncated = true; + } + break 'walk; + } + rows.push(row.clone()); + } + } + _ => {} + } + } + while rows.last().is_some_and(|r| r.iter().all(String::is_empty)) { + rows.pop(); + } + let width = rows + .iter() + .map(|r| r.iter().rposition(|c| !c.is_empty()).map_or(0, |i| i + 1)) + .max() + .unwrap_or(0); + if width == 0 { + return; + } + if truncated { + self.warnings.push(Warning::new( + WarningCode::TruncatedTable, + format!( + "table truncated to {} rows / {max_cols} columns", + max_rows - 1 + ), + )); + } + let cell = |s: &String| { + TableCell::new(if s.is_empty() { + vec![] + } else { + vec![Node::Text(s.clone())] + }) + }; + let mut iter = rows.iter().map(|r| { + let mut cells: Vec = r.iter().take(width).map(cell).collect(); + cells.resize_with(width, || TableCell::new(vec![])); + cells + }); + let headers = iter.next().unwrap_or_default(); + self.tables += 1; + out.push(Node::Table { + alignments: Vec::new(), + headers, + rows: iter.collect(), + source: None, + }); + } + + fn table_row(&mut self, row: &Element, max_cols: usize, truncated: &mut bool) -> Vec { + let mut cells = Vec::new(); + for c in row.elements() { + if c.name != "table-cell" && c.name != "covered-table-cell" { + continue; + } + let text = if c.name == "table-cell" { + let mut parts = Vec::new(); + // Paragraph text of the cell in document order (nested tables flattened). + let mut stack: Vec> = vec![c.children.iter()]; + while let Some(iter) = stack.last_mut() { + let Some(node) = iter.next() else { + stack.pop(); + continue; + }; + let XNode::Element(child) = node else { + continue; + }; + match child.name.as_str() { + "p" | "h" if self.is_hidden(child) => self.hidden += 1, + "p" | "h" => { + let t = plain(&self.inline(child, Fmt::default())); + if !t.trim().is_empty() { + parts.push(t.trim().to_string()); + } + } + _ => stack.push(child.children.iter()), + } + } + parts.join(" ") + } else { + String::new() + }; + let repeat = c + .attr("number-columns-repeated") + .and_then(|r| r.parse::().ok()) + .unwrap_or(1) + .max(1); + for _ in 0..repeat { + if cells.len() >= max_cols { + if !text.is_empty() { + *truncated = true; + } + return cells; + } + cells.push(text.clone()); + } + } + cells + } +} + +// ─── Entry point ──────────────────────────────────────────────────── + +pub(crate) fn parse( + bytes: &[u8], + config: &MarkkitBuilder, + budget: &Budget, +) -> Result { + let mut zip = ZipGuard::open(bytes, config).map_err(archive_err)?; + let depth = config.limits.nesting_depth(); + if let Some(mime) = zip.read("mimetype").map_err(archive_err)? { + let mime = String::from_utf8_lossy(&mime); + let mime = mime.trim(); + if !mime.starts_with("application/vnd.oasis.opendocument.text") { + return Err(Error::OdtExtract(format!( + "not an OpenDocument text document ({mime})" + ))); + } + } + let content = read_xml(&mut zip, "content.xml", depth, budget)? + .ok_or_else(|| Error::OdtExtract("missing content.xml".into()))?; + let styles_xml = read_xml(&mut zip, "styles.xml", depth, budget)?; + let meta_title = read_xml(&mut zip, "meta.xml", depth, budget)? + .and_then(|m| { + m.find("title") + .map(|t| collapse(&t.text()).trim().to_string()) + }) + .filter(|t| !t.is_empty()); + + let mut styles = Styles::default(); + styles.add(styles_xml.as_ref()); + styles.add(Some(&content)); + + let text = content + .find("body") + .and_then(|b| b.child("text")) + .ok_or_else(|| Error::OdtExtract("content.xml has no office:text body".into()))?; + + let mut ctx = Ctx { + styles: &styles, + config, + budget, + hidden: 0, + title: None, + warnings: Vec::new(), + paragraphs: 0, + tables: 0, + }; + let mut nodes = Vec::new(); + ctx.blocks(text, (None, 0), &mut nodes)?; + budget.check_time()?; + if ctx.hidden > 0 { + ctx.warnings.push(Warning::new( + WarningCode::HiddenContentSkipped, + format!("{} hidden element(s) skipped", ctx.hidden), + )); + } + Ok(OdtOutput { + doc: Document::new(nodes), + title: meta_title.or(ctx.title), + warnings: ctx.warnings, + paragraphs: ctx.paragraphs, + tables: ctx.tables, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collapse_whitespace() { + assert_eq!(collapse("a \n\t b"), "a b"); + assert_eq!(collapse(" "), " "); + } + + #[test] + fn styled_moves_edge_whitespace() { + let nodes = styled( + " x ".into(), + Fmt { + bold: true, + ..Fmt::default() + }, + ); + assert_eq!( + nodes, + vec![ + Node::Text(" ".into()), + Node::Bold { + children: vec![Node::Text("x".into())] + }, + Node::Text(" ".into()), + ] + ); + } +} diff --git a/crates/markkit/src/xml.rs b/crates/markkit/src/xml.rs index 1965f22..12c0ad8 100644 --- a/crates/markkit/src/xml.rs +++ b/crates/markkit/src/xml.rs @@ -113,6 +113,7 @@ impl Element { } /// `val` attribute of the named child (common OOXML property pattern). + #[cfg_attr(not(feature = "docx"), allow(dead_code))] pub fn child_val(&self, local: &str) -> Option<&str> { self.child(local).and_then(|c| c.attr("val")) } diff --git a/crates/markkit/tests/common/mod.rs b/crates/markkit/tests/common/mod.rs index ac4e6c2..d23061a 100644 --- a/crates/markkit/tests/common/mod.rs +++ b/crates/markkit/tests/common/mod.rs @@ -321,6 +321,46 @@ pub fn ods_float_cell(v: f64) -> String { ) } +// ─── ODT ──────────────────────────────────────────────────────────── + +pub const ODT_MIME: &str = "application/vnd.oasis.opendocument.text"; + +const ODF_NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0""#; + +/// ODT with `automatic` styles + `body` in content.xml, optional styles.xml +/// `` inner XML and meta.xml title. +pub fn odt_with(automatic: &str, body: &str, styles: Option<&str>, title: Option<&str>) -> Vec { + let content = format!( + r#"{automatic}{body}"# + ); + let styles_xml = styles.map(|s| { + format!( + r#"{s}"# + ) + }); + let meta = title.map(|t| { + format!( + r#"{}"#, + xml_escape(t) + ) + }); + let mut entries: Vec<(&str, &[u8])> = vec![ + ("mimetype", ODT_MIME.as_bytes()), + ("content.xml", content.as_bytes()), + ]; + if let Some(s) = &styles_xml { + entries.push(("styles.xml", s.as_bytes())); + } + if let Some(m) = &meta { + entries.push(("meta.xml", m.as_bytes())); + } + zip_bytes(&entries) +} + +pub fn odt(body: &str) -> Vec { + odt_with("", body, None, None) +} + // ─── XLS (BIFF8 in an OLE2 compound file) ─────────────────────────── fn record(out: &mut Vec, typ: u16, data: &[u8]) { diff --git a/crates/markkit/tests/odt_integration.rs b/crates/markkit/tests/odt_integration.rs new file mode 100644 index 0000000..320b417 --- /dev/null +++ b/crates/markkit/tests/odt_integration.rs @@ -0,0 +1,326 @@ +//! ODT → Markdown integration and security tests (fixtures generated in-memory). + +#![cfg(feature = "odt")] + +mod common; + +use common::*; +use markkit::{DocumentHint, Error, Markkit, format::Format}; + +fn try_extract(bytes: &[u8]) -> markkit::Result { + Markkit::builder() + .build() + .extract_document_sync(bytes, DocumentHint::file_name("doc.odt")) +} + +fn extract(bytes: &[u8]) -> markkit::Extraction { + try_extract(bytes).unwrap() +} + +fn md(body: &str) -> String { + extract(&odt(body)).markdown +} + +const AUTO: &str = r#" + + + + + + + +"#; + +const STYLES: &str = r#" + + +"#; + +#[test] +fn headings_paragraphs_and_inline_formatting() { + let body = r#" +The Title +Intro +Plain bold run and it, gone, both. +Styled heading + +Deep +"#; + let e = extract(&odt_with(AUTO, body, Some(STYLES), None)); + assert_eq!(e.format, Format::Odt); + assert_eq!(e.title.as_deref(), Some("The Title")); + assert_eq!( + e.markdown, + "# The Title\n\n# Intro\n\nPlain **bold run** and *it*, ~~gone~~, ***both***.\n\n## Styled heading\n\n###### Deep\n" + ); + assert_eq!(e.stats.paragraphs, Some(5)); +} + +#[test] +fn meta_title_wins() { + let e = extract(&odt_with( + "", + "x", + None, + Some("Meta Title"), + )); + assert_eq!(e.title.as_deref(), Some("Meta Title")); +} + +#[test] +fn links_spaces_tabs_breaks() { + assert_eq!( + md( + r#"See the site.abc"# + ), + "See [the site](https://e.com/a%20b). \na b c\n" + ); +} + +#[test] +fn lists_ordered_and_nested() { + let body = r#" + + one + sub + + two + +dot +"#; + let e = extract(&odt_with(AUTO, body, None, None)); + assert_eq!(e.markdown, "1. one\n - sub\n2. two\n\n- dot\n"); +} + +#[test] +fn tables_with_repeats_and_padding() { + let body = r#" + + + + AB|C + + xy + z + + +"#; + let e = extract(&odt(body)); + assert_eq!( + e.markdown, + "| A | B\\|C |\n| --- | --- |\n| x y | x y |\n| | z |\n" + ); + assert_eq!(e.stats.tables, Some(1)); + assert!(e.warnings.is_empty(), "{:?}", e.warnings); +} + +#[test] +fn skipped_content() { + let body = r#" +deleted +TOC entry +Body1footnotecomment text. +In section +"#; + assert_eq!(md(body), "Body text.\n\nIn section\n"); +} + +#[test] +fn detection() { + assert_eq!(Format::from_extension("odt"), Format::Odt); + assert_eq!( + Format::from_content_type("application/vnd.oasis.opendocument.text"), + Format::Odt + ); + assert_eq!(Format::sniff(&odt("x")), Format::Odt); + let e = Markkit::builder() + .build() + .extract_document_sync(&odt("sniffed"), DocumentHint::default()) + .unwrap(); + assert_eq!(e.markdown, "sniffed\n"); +} + +// ─── Security (threat model T6) ───────────────────────────────────── + +#[test] +fn security_wrong_package_and_missing_content() { + let ods = ods(""); + assert!(matches!(try_extract(&ods), Err(Error::OdtExtract(_)))); + let no_content = zip_bytes(&[("mimetype", ODT_MIME.as_bytes())]); + assert!(matches!( + try_extract(&no_content), + Err(Error::OdtExtract(_)) + )); + assert!(matches!( + try_extract(b"not a zip"), + Err(Error::OdtExtract(_)) + )); +} + +#[test] +fn security_zip_bomb_bounded() { + let big = vec![b' '; 2 * 1024 * 1024]; + let bytes = zip_bytes(&[("mimetype", ODT_MIME.as_bytes()), ("content.xml", &big)]); + let err = Markkit::builder() + .max_archive_entry_size(1024 * 1024) + .build() + .extract_document_sync(&bytes, DocumentHint::file_name("a.odt")) + .unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + +#[test] +fn security_entities_not_expanded() { + let content = r#"]>x&b;&xxe;y"#; + let bytes = zip_bytes(&[ + ("mimetype", ODT_MIME.as_bytes()), + ("content.xml", content.as_bytes()), + ]); + match try_extract(&bytes) { + Ok(e) => { + assert!( + !e.markdown.contains("aaaa") && !e.markdown.contains("root:"), + "{}", + e.markdown + ) + } + Err(Error::OdtExtract(_)) => {} + Err(e) => panic!("{e:?}"), + } +} + +#[test] +fn security_deep_nesting_is_limited() { + let depth = 10_000; + let body = format!( + "{}x{}", + "".repeat(depth), + "".repeat(depth) + ); + match try_extract(&odt(&body)) { + Err(Error::ResourceLimit(_)) => {} + other => panic!("{other:?}"), + } +} + +#[test] +fn security_repeat_counts_capped() { + let m = Markkit::builder() + .max_sheet_rows(5) + .max_sheet_cols(3) + .build(); + let body = r#" +h +r + +ab"#; + let e = m + .extract_document_sync(&odt(body), DocumentHint::file_name("a.odt")) + .unwrap(); + assert_eq!(e.markdown.lines().filter(|l| l.starts_with('|')).count(), 7); + assert!(e.markdown.lines().next().unwrap().matches('h').count() == 3); + assert!(e.markdown.contains(&format!("a{}b", " ".repeat(64)))); + assert_eq!(e.warnings.len(), 1, "{:?}", e.warnings); +} + +#[test] +fn security_style_cycle_terminates() { + let auto = r#""#; + let e = extract(&odt_with( + auto, + r#"loop"#, + None, + None, + )); + assert_eq!(e.markdown, "loop\n"); +} + +#[test] +fn security_truncated_archive_never_panics() { + let full = odt_with( + AUTO, + "hello x", + Some(STYLES), + Some("t"), + ); + for cut in (0..full.len()).step_by(7) { + let _ = try_extract(&full[..cut]); + } +} + +const HIDDEN_STYLES: &str = r#" + + + +"#; + +const HIDDEN_BODY: &str = r#" +shown INJECT1INJECT2 +INJECT3 +INJECT4 +tail +"#; + +fn extract_hidden(include: bool) -> markkit::Extraction { + Markkit::builder() + .include_hidden_content(include) + .build() + .extract_document_sync( + &odt_with(HIDDEN_STYLES, HIDDEN_BODY, None, None), + DocumentHint::file_name("doc.odt"), + ) + .unwrap() +} + +#[test] +fn security_hidden_content_skipped_by_default() { + let e = extract_hidden(false); + assert!(!e.markdown.contains("INJECT"), "{}", e.markdown); + assert!(e.markdown.contains("shown") && e.markdown.contains("tail")); + assert!( + e.warnings + .iter() + .any(|w| w.code == markkit::WarningCode::HiddenContentSkipped), + "{:?}", + e.warnings + ); +} + +#[test] +fn hidden_content_kept_when_opted_in() { + let e = extract_hidden(true); + for s in ["INJECT1", "INJECT2", "INJECT3", "INJECT4"] { + assert!(e.markdown.contains(s), "{s}: {}", e.markdown); + } + assert!(e.warnings.is_empty(), "{:?}", e.warnings); +} + +#[test] +fn security_limits_apply() { + let bytes = odt(&"a".repeat(500)); + let r = Markkit::builder() + .max_nodes(100) + .build() + .extract_document_sync(&bytes, DocumentHint::file_name("doc.odt")); + assert!( + matches!(&r, Err(e) if e.limit_kind() == Some(markkit::LimitKind::MaxNodes)), + "{r:?}" + ); + let r = Markkit::builder() + .timeout(std::time::Duration::ZERO) + .build() + .extract_document_sync(&bytes, DocumentHint::file_name("doc.odt")); + assert!( + matches!(&r, Err(e) if e.limit_kind() == Some(markkit::LimitKind::Timeout)), + "{r:?}" + ); +} + +#[test] +fn security_markup_in_text_is_escaped() { + let e = extract(&odt( + "<script>x</script> [a](javascript:b)", + )); + assert!(e.markdown.contains("\\