diff --git a/CHANGELOG.md b/CHANGELOG.md index 783e3c6..f387f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). pulldown-cmark and rendered back, so raw HTML never passes through. Front matter `title:` or the first `# ` heading becomes `Extraction::title`. Available in the CLI, Python, Node and WASM packages; `format` serializes as `"text"` and `"markdown"`. +- PPTX support behind the new `pptx` feature (included in `all_formats`, so in the CLI and the + Python/Node bindings): one `## Slide N: ` section per slide with bullets, text boxes, + tables, hyperlinks and speaker notes. Hidden slides are skipped with a `hidden_slide_skipped` + warning unless `include_hidden_content(true)`. Legacy `.ppt` is detected as the new `Format::Ppt` + and rejected explicitly. `Error::PptxExtract` reports malformed presentations. - Nightly cargo-fuzz targets for every parser and the renderer (`crates/markkit/fuzz`, `just fuzz`, `.github/workflows/fuzz.yml`), each checking the output-safety oracle. - HTML page metadata: `extract_document` fills `Extraction::title` (Open Graph, JSON-LD or diff --git a/README.md b/README.md index dbfb1f6..323a715 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,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 - **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) @@ -33,12 +34,13 @@ package for browsers and edge runtimes. |---|---|---| | HTML / XHTML | Supported | `html` (default) | | DOCX | Supported | `docx` | +| PPTX, PPTM, PPSX | Supported | `pptx` | | 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 | – | +| DOC, PPT | Detected, rejected explicitly | – | ## Quick Start @@ -193,10 +195,11 @@ let markkit = Markkit::builder() | `html` | Yes | HTML parsing and conversion | | `http_client` | No | URL fetching via reqwest (`extract_url`) | | `docx` | No | DOCX conversion (zip + quick-xml) | +| `pptx` | No | PPTX conversion (zip + quick-xml) | | `spreadsheet` | No | XLSX/XLSM, XLS, ODS conversion (calamine) | | `pdf` | No | PDF text-layer extraction (hayro-syntax, pure Rust) | | `markdown` | No | Markdown normalization (pulldown-cmark) | -| `all_formats` | No | `html`, `pdf`, `docx`, `spreadsheet`, `markdown` | +| `all_formats` | No | `html`, `pdf`, `docx`, `pptx`, `spreadsheet`, `markdown` | | `tool` | No | `markkit::tool::MarkkitTool` LLM agent tool (Everruns toolkit contract) | | `logging` | No | Reserved: pulls in `tracing`, emits no events yet | @@ -207,7 +210,7 @@ Enable it to get `extract_url` (the CLI and the Python/Node bindings ship with i [dependencies] markkit = { version = "0.1", features = ["http_client"] } # office formats only, no HTML, no network: -# markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] } +# markkit = { version = "0.1", default-features = false, features = ["docx", "pptx", "spreadsheet"] } ``` ### Resource limits diff --git a/crates/markkit-cli/Cargo.toml b/crates/markkit-cli/Cargo.toml index 9325c0e..825bc43 100644 --- a/crates/markkit-cli/Cargo.toml +++ b/crates/markkit-cli/Cargo.toml @@ -23,6 +23,7 @@ default = ["all_formats"] all_formats = ["markkit/all_formats"] pdf = ["markkit/pdf"] docx = ["markkit/docx"] +pptx = ["markkit/pptx"] spreadsheet = ["markkit/spreadsheet"] markdown = ["markkit/markdown"] diff --git a/crates/markkit-js/__test__/options.test.cjs b/crates/markkit-js/__test__/options.test.cjs index e469028..35eb814 100644 --- a/crates/markkit-js/__test__/options.test.cjs +++ b/crates/markkit-js/__test__/options.test.cjs @@ -56,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("PK"), { fileName: "a.pptx" }), /Unsupported/); + assert.throws(() => m.extractDocumentSync(Buffer.from("PK"), { fileName: "a.ppt" }), /Unsupported/); }); test("extractDocument exposes page metadata", () => { diff --git a/crates/markkit/Cargo.toml b/crates/markkit/Cargo.toml index ba1edfd..df1e3cf 100644 --- a/crates/markkit/Cargo.toml +++ b/crates/markkit/Cargo.toml @@ -18,6 +18,7 @@ default = ["html"] html = ["dep:scraper", "dep:html5ever", "dep:ego-tree", "dep:url"] pdf = ["dep:hayro-syntax", "dep:miniz_oxide"] docx = ["dep:zip", "dep:quick-xml"] +pptx = ["dep:zip", "dep:quick-xml"] spreadsheet = ["dep:calamine", "dep:zip", "dep:quick-xml", "dep:cfb"] markdown = ["dep:pulldown-cmark"] # Opt-in: the default build has no network stack (no reqwest/hyper/TLS). @@ -27,7 +28,7 @@ logging = ["dep:tracing"] tool = ["dep:tower", "dep:futures-core", "dep:base64"] # JSON Schema for the IR (`markkit::ir::json_schema()`) json-schema = ["dep:schemars"] -all_formats = ["html", "pdf", "docx", "spreadsheet", "markdown"] +all_formats = ["html", "pdf", "docx", "pptx", "spreadsheet", "markdown"] [dependencies] # Async diff --git a/crates/markkit/src/docx.rs b/crates/markkit/src/docx.rs index 49f7bca..d68189c 100644 --- a/crates/markkit/src/docx.rs +++ b/crates/markkit/src/docx.rs @@ -23,8 +23,12 @@ use std::collections::HashMap; use crate::archive::{ArchiveError, ZipGuard}; use crate::config::MarkkitBuilder; use crate::error::Error; -use crate::ir::{Document, ListItem, Node, Source, TableCell}; +use crate::ir::{Document, Node, Source, TableCell}; use crate::limits::Budget; +use crate::ooxml::{ + Fmt, ListEntry, Relationship, Seg, build_inline, build_list, is_blank, parse_rels, plain_text, + rel_target_by_type, resolve_target, split_dir, table_cell, +}; use crate::xml::{self, Element, XmlError}; /// Result of parsing a DOCX package. @@ -70,73 +74,6 @@ impl Parts<'_, '_> { } } -/// Resolve a relationship target relative to the directory of the source part. -fn resolve_target(base_dir: &str, target: &str) -> String { - if let Some(abs) = target.strip_prefix('/') { - return abs.to_string(); - } - let mut parts: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect(); - for seg in target.split('/') { - match seg { - "" | "." => {} - ".." => { - parts.pop(); - } - s => parts.push(s), - } - } - parts.join("/") -} - -fn split_dir(path: &str) -> (&str, &str) { - match path.rsplit_once('/') { - Some((d, f)) => (d, f), - None => ("", path), - } -} - -struct Relationship { - rel_type: String, - target: String, - external: bool, -} - -fn parse_rels(root: Option<Element>) -> HashMap<String, Relationship> { - let mut map = HashMap::new(); - let Some(root) = root else { return map }; - let Some(rels) = root.child("Relationships") else { - return map; - }; - for r in rels.elements().filter(|e| e.name == "Relationship") { - if let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) { - map.insert( - id.to_string(), - Relationship { - rel_type: r.attr("Type").unwrap_or_default().to_string(), - target: target.to_string(), - external: r - .attr("TargetMode") - .is_some_and(|m| m.eq_ignore_ascii_case("External")), - }, - ); - } - } - map -} - -fn rel_target_by_type<'a>( - rels: &'a HashMap<String, Relationship>, - suffix: &str, -) -> Option<&'a str> { - // Deterministic choice if several match. - let mut matches: Vec<(&String, &Relationship)> = rels - .iter() - .filter(|(_, r)| r.rel_type.ends_with(suffix) && !r.external) - .collect(); - matches.sort_by(|a, b| a.0.cmp(b.0)); - matches.first().map(|(_, r)| r.target.as_str()) -} - // ─── Styles ───────────────────────────────────────────────────────── #[derive(Default)] @@ -326,32 +263,6 @@ impl Numbering { // ─── Inline content ───────────────────────────────────────────────── -#[derive(Clone, Copy, Default, PartialEq, Eq)] -struct Fmt { - bold: bool, - italic: bool, - strike: bool, -} - -enum Seg { - Text { - text: String, - fmt: Fmt, - link: Option<String>, - }, - Break { - link: Option<String>, - }, -} - -impl Seg { - fn link(&self) -> Option<&str> { - match self { - Seg::Text { link, .. } | Seg::Break { link } => link.as_deref(), - } - } -} - fn toggle_on(el: Option<&Element>) -> bool { match el { None => false, @@ -386,7 +297,7 @@ struct Ctx<'a> { impl Ctx<'_> { fn hyperlink_url(&self, el: &Element) -> Option<String> { - if let Some(rel) = el.attr("id").and_then(|id| self.rels.get(id)) { + if let Some(rel) = el.attr_prefixed("id").and_then(|id| self.rels.get(id)) { let mut url = rel.target.replace(' ', "%20"); if let Some(anchor) = el.attr("anchor") { url.push('#'); @@ -455,178 +366,8 @@ impl Ctx<'_> { } } -/// Merge segments and turn them into IR inline nodes. -fn build_inline(segs: Vec<Seg>) -> Vec<Node> { - // 1. Merge adjacent text with identical formatting and link. - let mut merged: Vec<Seg> = Vec::with_capacity(segs.len()); - for seg in segs { - if let ( - Some(Seg::Text { - text: prev, - fmt: pf, - link: pl, - }), - Seg::Text { text, fmt, link }, - ) = (merged.last_mut(), &seg) - && pf == fmt - && pl == link - { - prev.push_str(text); - continue; - } - merged.push(seg); - } - - // 2. Group by link. - let mut out: Vec<Node> = Vec::new(); - let mut i = 0; - while i < merged.len() { - let link = merged[i].link().map(str::to_string); - let mut j = i; - while j < merged.len() && merged[j].link() == link.as_deref() { - j += 1; - } - let nodes = format_nodes(&merged[i..j]); - match link { - Some(url) if !is_blank(&nodes) => { - let (lead, core, trail) = split_ws_nodes(nodes); - out.extend(lead); - out.push(Node::Link { - url, - title: None, - children: core, - }); - out.extend(trail); - } - _ => out.extend(nodes), - } - i = j; - } - tidy_inline(out) -} - -fn format_nodes(segs: &[Seg]) -> Vec<Node> { - let mut out = Vec::new(); - for seg in segs { - match seg { - Seg::Break { .. } => out.push(Node::LineBreak), - Seg::Text { text, fmt, .. } => { - let core = text.trim(); - if core.is_empty() || *fmt == Fmt::default() { - out.push(Node::Text(text.clone())); - continue; - } - let start = text.len() - text.trim_start().len(); - let lead = &text[..start]; - let trail = &text[start + core.len()..]; - if !lead.is_empty() { - out.push(Node::Text(lead.to_string())); - } - let mut node = Node::Text(core.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], - }; - } - out.push(node); - if !trail.is_empty() { - out.push(Node::Text(trail.to_string())); - } - } - } - } - out -} - -/// Split leading/trailing whitespace text out of a node list (for link text). -fn split_ws_nodes(mut nodes: Vec<Node>) -> (Vec<Node>, Vec<Node>, Vec<Node>) { - let mut lead = Vec::new(); - let mut trail = Vec::new(); - if let Some(Node::Text(t)) = nodes.first_mut() { - let trimmed = t.trim_start().to_string(); - if trimmed.len() != t.len() { - lead.push(Node::Text(t[..t.len() - trimmed.len()].to_string())); - *t = trimmed; - } - } - if let Some(Node::Text(t)) = nodes.last_mut() { - let trimmed = t.trim_end().to_string(); - if trimmed.len() != t.len() { - trail.push(Node::Text(t[trimmed.len()..].to_string())); - *t = trimmed; - } - } - nodes.retain(|n| !matches!(n, Node::Text(t) if t.is_empty())); - (lead, nodes, trail) -} - -/// Merge adjacent text nodes and strip leading/trailing line breaks. -fn tidy_inline(nodes: Vec<Node>) -> Vec<Node> { - let mut out: Vec<Node> = Vec::with_capacity(nodes.len()); - for n in nodes { - match (out.last_mut(), n) { - (Some(Node::Text(prev)), Node::Text(t)) => 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_text(nodes: &[Node], out: &mut String) { - for n in nodes { - match n { - Node::Text(t) | Node::InlineCode(t) => out.push_str(t), - Node::LineBreak => out.push(' '), - Node::Bold { children } - | Node::Italic { children } - | Node::Strikethrough { children } - | Node::Link { children, .. } - | Node::Heading { children, .. } - | Node::Paragraph { children, .. } => plain_text(children, out), - _ => {} - } - } -} - // ─── Block content ────────────────────────────────────────────────── -struct ListEntry { - level: u32, - ordered: bool, - start: Option<u64>, - content: Vec<Node>, - index: usize, -} - #[derive(Default)] struct Body { blocks: Vec<Node>, @@ -664,43 +405,6 @@ impl Body { } } -/// Build a (nested) list starting at `entries[*pos]` for `level`. -fn build_list(entries: &[ListEntry], pos: &mut usize, level: u32) -> Node { - let first = &entries[*pos]; - let ordered = first.ordered; - let start = if ordered { - first.start.filter(|&s| s != 1) - } else { - None - }; - let mut items: Vec<ListItem> = Vec::new(); - while *pos < entries.len() { - let e = &entries[*pos]; - if e.level < level || (e.level == level && e.ordered != ordered) { - break; - } - if e.level > level { - let sub = build_list(entries, pos, e.level); - match items.last_mut() { - Some(last) => last.children.push(sub), - None => items.push(ListItem::new(vec![sub])), - } - continue; - } - items.push(ListItem::new(vec![Node::Paragraph { - children: e.content.clone(), - source: None, - }])); - *pos += 1; - } - Node::List { - ordered, - start, - items, - source: None, - } -} - impl Ctx<'_> { fn blocks(&self, container: &Element, body: &mut Body) { for child in container.elements() { @@ -892,35 +596,6 @@ impl Ctx<'_> { } } -/// Make cell content single-line: hard breaks become spaces (the renderer escapes pipes). -fn table_cell(nodes: Vec<Node>) -> Vec<Node> { - fn fix(n: Node) -> Node { - match n { - Node::LineBreak => Node::Text(" ".into()), - Node::Bold { children } => Node::Bold { - children: children.into_iter().map(fix).collect(), - }, - Node::Italic { children } => Node::Italic { - children: children.into_iter().map(fix).collect(), - }, - Node::Strikethrough { children } => Node::Strikethrough { - children: children.into_iter().map(fix).collect(), - }, - Node::Link { - url, - title, - children, - } => Node::Link { - url, - title, - children: children.into_iter().map(fix).collect(), - }, - other => other, - } - } - tidy_inline(nodes.into_iter().map(fix).collect()) -} - // ─── Entry point ──────────────────────────────────────────────────── pub(crate) fn parse( diff --git a/crates/markkit/src/error.rs b/crates/markkit/src/error.rs index 905ab3b..e1eb794 100644 --- a/crates/markkit/src/error.rs +++ b/crates/markkit/src/error.rs @@ -11,6 +11,9 @@ pub enum Error { #[error("DOCX extraction error: {0}")] DocxExtract(String), + #[error("PPTX extraction error: {0}")] + PptxExtract(String), + #[error("Spreadsheet extraction error: {0}")] SpreadsheetExtract(String), diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs index ed44f86..bd0444d 100644 --- a/crates/markkit/src/extract.rs +++ b/crates/markkit/src/extract.rs @@ -117,6 +117,7 @@ impl ParsedDocument { #[cfg(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf", feature = "markdown" @@ -232,7 +233,7 @@ impl Markkit { .map(|e| e.markdown) } - /// Convert a document (HTML, PDF, DOCX, XLSX/XLSB/XLS/ODS, plain text, Markdown, CSV/TSV) + /// Convert a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown, CSV/TSV) /// to Markdown with metadata. /// /// The format is detected from `hint.content_type`, then the extension of @@ -269,6 +270,7 @@ impl Markkit { #[cfg(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf", feature = "markdown" @@ -279,6 +281,7 @@ impl Markkit { #[cfg(not(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf", feature = "markdown" @@ -286,7 +289,7 @@ impl Markkit { Ok(parsed.render()) } - /// Parse a document (HTML, DOCX, XLSX/XLS/ODS, plain text, Markdown) into the IR without + /// Parse a document (HTML, PDF, DOCX, PPTX, XLSX/XLSB/XLS/ODS, plain text, Markdown) into the IR without /// rendering. Same detection and errors as [`Markkit::extract_document`]. pub async fn parse_document( &self, @@ -331,8 +334,9 @@ impl Markkit { Format::Doc => Err(Error::UnsupportedFormat( "legacy Word 97-2003 (.doc) documents are not supported".into(), )), - Format::Pptx => Err(Error::UnsupportedFormat( - "presentations (.pptx) are not supported".into(), + Format::Pptx => self.parse_pptx(bytes, budget), + Format::Ppt => Err(Error::UnsupportedFormat( + "legacy PowerPoint 97-2003 (.ppt) presentations are not supported".into(), )), other => Err(Error::UnsupportedFormat(match hint.content_type { Some(ct) => format!("{ct} ({other})"), @@ -444,6 +448,28 @@ impl Markkit { )) } + #[cfg(feature = "pptx")] + fn parse_pptx(&self, bytes: &[u8], budget: &Budget) -> crate::Result<ParsedDocument> { + let out = guarded("PPTX", Error::PptxExtract, || { + crate::pptx::parse(bytes, &self.config, budget) + })?; + let mut doc = out.doc; + self.config.transforms.apply(&mut doc)?; + let mut p = ParsedDocument::new(doc, Format::Pptx); + p.title = out.title; + p.warnings = out.warnings; + p.stats.pages = Some(out.slides); + p.stats.tables = Some(out.tables); + Ok(p) + } + + #[cfg(not(feature = "pptx"))] + fn parse_pptx(&self, _bytes: &[u8], _budget: &Budget) -> crate::Result<ParsedDocument> { + Err(Error::UnsupportedFormat( + "PPTX support requires the 'pptx' feature".to_string(), + )) + } + #[cfg(feature = "spreadsheet")] fn parse_spreadsheet(&self, bytes: &[u8], budget: &Budget) -> crate::Result<ParsedDocument> { let out = guarded("spreadsheet", Error::SpreadsheetExtract, || { diff --git a/crates/markkit/src/format.rs b/crates/markkit/src/format.rs index 614c363..71d0c0f 100644 --- a/crates/markkit/src/format.rs +++ b/crates/markkit/src/format.rs @@ -28,8 +28,10 @@ pub enum Format { Xls, /// OpenDocument spreadsheet (`.ods`). Ods, - /// Office Open XML presentation (`.pptx`). Detected but not supported. + /// Office Open XML presentation (`.pptx`, `.pptm`, `.ppsx`, `.potx`). Pptx, + /// Legacy PowerPoint 97-2003 binary (`.ppt`). Detected but not supported. + Ppt, /// Comma (or `;` / `|`) separated values (`.csv`). Csv, /// Tab separated values (`.tsv`, `.tab`). @@ -89,6 +91,7 @@ impl Format { Format::Xls => "xls", Format::Ods => "ods", Format::Pptx => "pptx", + Format::Ppt => "ppt", Format::Csv => "csv", Format::Tsv => "tsv", Format::PlainText => "text", @@ -117,8 +120,11 @@ impl Format { Format::Xls } else if ct.contains("opendocument.spreadsheet") { Format::Ods - } else if ct.contains("presentationml") || ct.contains("ms-powerpoint") { + } else if ct.contains("presentationml") || ct.contains("ms-powerpoint.") { + // `ms-powerpoint.presentation.macroEnabled.12` & co. are OOXML. Format::Pptx + } else if ct.contains("ms-powerpoint") { + Format::Ppt } else if ct.contains("tab-separated-values") { Format::Tsv } else if ct.contains("text/csv") || ct.contains("application/csv") { @@ -143,7 +149,8 @@ impl Format { "xlsb" => Format::Xlsb, "xls" | "xlt" => Format::Xls, "ods" => Format::Ods, - "pptx" | "pptm" | "ppt" => Format::Pptx, + "pptx" | "pptm" | "ppsx" | "ppsm" | "potx" | "potm" => Format::Pptx, + "ppt" | "pps" | "pot" => Format::Ppt, "csv" => Format::Csv, "tsv" | "tab" => Format::Tsv, "txt" | "text" => Format::PlainText, @@ -232,6 +239,8 @@ fn sniff_ole2(bytes: &[u8]) -> Format { let utf16 = |s: &str| -> Vec<u8> { s.encode_utf16().flat_map(u16::to_le_bytes).collect() }; if contains(bytes, &utf16("WordDocument")) { Format::Doc + } else if contains(bytes, &utf16("PowerPoint Document")) { + Format::Ppt } else if contains(bytes, &utf16("Workbook")) || contains(bytes, &utf16("Book")) { Format::Xls } else { @@ -298,6 +307,20 @@ mod tests { Format::from_content_type("application/vnd.ms-excel"), Format::Xls ); + assert_eq!( + Format::from_content_type( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ), + Format::Pptx + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-powerpoint.presentation.macroEnabled.12"), + Format::Pptx + ); + assert_eq!( + Format::from_content_type("application/vnd.ms-powerpoint"), + Format::Ppt + ); } #[test] diff --git a/crates/markkit/src/ir.rs b/crates/markkit/src/ir.rs index 356e9ce..e3ec9b3 100644 --- a/crates/markkit/src/ir.rs +++ b/crates/markkit/src/ir.rs @@ -373,7 +373,7 @@ pub struct Source { /// Spreadsheets: A1-style cell range, e.g. `A1:D20`. #[serde(default, skip_serializing_if = "Option::is_none")] pub range: Option<String>, - /// Paged formats: 1-based page number. + /// Paged formats: 1-based page number (PPTX: slide number). #[serde(default, skip_serializing_if = "Option::is_none")] pub page: Option<u32>, } diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs index 42632d5..26772bf 100644 --- a/crates/markkit/src/lib.rs +++ b/crates/markkit/src/lib.rs @@ -33,6 +33,7 @@ //! //! - **HTML** (`html`, default): Convert HTML to markdown with configurable rules //! - **DOCX** (`docx`): Convert Word documents +//! - **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` //! - **CSV / TSV** (always on): `text/csv`, `text/tab-separated-values`, `.csv`, `.tsv`, one table @@ -48,7 +49,7 @@ //! `Markkit::extract_url`. Office formats only, no HTML: //! `markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] }`. -#[cfg(any(feature = "docx", feature = "spreadsheet"))] +#[cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] mod archive; pub mod config; mod csv; @@ -65,8 +66,12 @@ pub mod ir; pub mod limits; #[cfg(feature = "markdown")] mod markdown; +#[cfg(any(feature = "docx", feature = "pptx"))] +mod ooxml; #[cfg(feature = "pdf")] mod pdf; +#[cfg(feature = "pptx")] +mod pptx; pub mod registry; pub mod render; pub mod rules; @@ -76,8 +81,8 @@ mod text; #[cfg(feature = "tool")] pub mod tool; pub mod transform; -#[cfg(any(feature = "docx", feature = "spreadsheet"))] -#[cfg_attr(not(feature = "docx"), allow(dead_code))] // spreadsheet uses only `find`/`text` +#[cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] +#[cfg_attr(not(feature = "docx"), allow(dead_code))] // DOCX uses every helper mod xml; pub use config::MarkkitBuilder; diff --git a/crates/markkit/src/limits.rs b/crates/markkit/src/limits.rs index 7ebe40e..7dcf027 100644 --- a/crates/markkit/src/limits.rs +++ b/crates/markkit/src/limits.rs @@ -204,6 +204,7 @@ impl Limits { not(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf" )), @@ -218,6 +219,7 @@ const CLOCK_EVERY: u32 = 256; not(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf" )), @@ -238,6 +240,7 @@ pub(crate) struct Budget { not(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf" )), diff --git a/crates/markkit/src/ooxml.rs b/crates/markkit/src/ooxml.rs new file mode 100644 index 0000000..1139713 --- /dev/null +++ b/crates/markkit/src/ooxml.rs @@ -0,0 +1,343 @@ +//! Shared Office Open XML helpers for the DOCX and PPTX readers: package +//! relationships, run formatting → IR inline nodes, nested lists and +//! table-safe cells. +//! +//! Decision: relationship targets are resolved lexically (no filesystem), and +//! only internal targets are followed for parts; external targets are kept as +//! hyperlink URLs only. + +use std::collections::HashMap; + +use crate::ir::{ListItem, Node}; +use crate::xml::Element; + +/// Resolve a relationship target relative to the directory of the source part. +pub(crate) fn resolve_target(base_dir: &str, target: &str) -> String { + if let Some(abs) = target.strip_prefix('/') { + return abs.to_string(); + } + let mut parts: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect(); + for seg in target.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +pub(crate) fn split_dir(path: &str) -> (&str, &str) { + match path.rsplit_once('/') { + Some((d, f)) => (d, f), + None => ("", path), + } +} + +pub(crate) struct Relationship { + pub rel_type: String, + pub target: String, + pub external: bool, +} + +pub(crate) fn parse_rels(root: Option<Element>) -> HashMap<String, Relationship> { + let mut map = HashMap::new(); + let Some(root) = root else { return map }; + let Some(rels) = root.child("Relationships") else { + return map; + }; + for r in rels.elements().filter(|e| e.name == "Relationship") { + if let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) { + map.insert( + id.to_string(), + Relationship { + rel_type: r.attr("Type").unwrap_or_default().to_string(), + target: target.to_string(), + external: r + .attr("TargetMode") + .is_some_and(|m| m.eq_ignore_ascii_case("External")), + }, + ); + } + } + map +} + +pub(crate) fn rel_target_by_type<'a>( + rels: &'a HashMap<String, Relationship>, + suffix: &str, +) -> Option<&'a str> { + // Deterministic choice if several match. + let mut matches: Vec<(&String, &Relationship)> = rels + .iter() + .filter(|(_, r)| r.rel_type.ends_with(suffix) && !r.external) + .collect(); + matches.sort_by(|a, b| a.0.cmp(b.0)); + matches.first().map(|(_, r)| r.target.as_str()) +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct Fmt { + pub bold: bool, + pub italic: bool, + pub strike: bool, +} + +pub(crate) enum Seg { + Text { + text: String, + fmt: Fmt, + link: Option<String>, + }, + Break { + link: Option<String>, + }, +} + +impl Seg { + pub(crate) fn link(&self) -> Option<&str> { + match self { + Seg::Text { link, .. } | Seg::Break { link } => link.as_deref(), + } + } +} + +/// Merge segments and turn them into IR inline nodes. +pub(crate) fn build_inline(segs: Vec<Seg>) -> Vec<Node> { + // 1. Merge adjacent text with identical formatting and link. + let mut merged: Vec<Seg> = Vec::with_capacity(segs.len()); + for seg in segs { + if let ( + Some(Seg::Text { + text: prev, + fmt: pf, + link: pl, + }), + Seg::Text { text, fmt, link }, + ) = (merged.last_mut(), &seg) + && pf == fmt + && pl == link + { + prev.push_str(text); + continue; + } + merged.push(seg); + } + + // 2. Group by link. + let mut out: Vec<Node> = Vec::new(); + let mut i = 0; + while i < merged.len() { + let link = merged[i].link().map(str::to_string); + let mut j = i; + while j < merged.len() && merged[j].link() == link.as_deref() { + j += 1; + } + let nodes = format_nodes(&merged[i..j]); + match link { + Some(url) if !is_blank(&nodes) => { + let (lead, core, trail) = split_ws_nodes(nodes); + out.extend(lead); + out.push(Node::Link { + url, + title: None, + children: core, + }); + out.extend(trail); + } + _ => out.extend(nodes), + } + i = j; + } + tidy_inline(out) +} + +pub(crate) fn format_nodes(segs: &[Seg]) -> Vec<Node> { + let mut out = Vec::new(); + for seg in segs { + match seg { + Seg::Break { .. } => out.push(Node::LineBreak), + Seg::Text { text, fmt, .. } => { + let core = text.trim(); + if core.is_empty() || *fmt == Fmt::default() { + out.push(Node::Text(text.clone())); + continue; + } + let start = text.len() - text.trim_start().len(); + let lead = &text[..start]; + let trail = &text[start + core.len()..]; + if !lead.is_empty() { + out.push(Node::Text(lead.to_string())); + } + let mut node = Node::Text(core.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], + }; + } + out.push(node); + if !trail.is_empty() { + out.push(Node::Text(trail.to_string())); + } + } + } + } + out +} + +/// Split leading/trailing whitespace text out of a node list (for link text). +pub(crate) fn split_ws_nodes(mut nodes: Vec<Node>) -> (Vec<Node>, Vec<Node>, Vec<Node>) { + let mut lead = Vec::new(); + let mut trail = Vec::new(); + if let Some(Node::Text(t)) = nodes.first_mut() { + let trimmed = t.trim_start().to_string(); + if trimmed.len() != t.len() { + lead.push(Node::Text(t[..t.len() - trimmed.len()].to_string())); + *t = trimmed; + } + } + if let Some(Node::Text(t)) = nodes.last_mut() { + let trimmed = t.trim_end().to_string(); + if trimmed.len() != t.len() { + trail.push(Node::Text(t[trimmed.len()..].to_string())); + *t = trimmed; + } + } + nodes.retain(|n| !matches!(n, Node::Text(t) if t.is_empty())); + (lead, nodes, trail) +} + +/// Merge adjacent text nodes and strip leading/trailing line breaks. +pub(crate) fn tidy_inline(nodes: Vec<Node>) -> Vec<Node> { + let mut out: Vec<Node> = Vec::with_capacity(nodes.len()); + for n in nodes { + match (out.last_mut(), n) { + (Some(Node::Text(prev)), Node::Text(t)) => 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 +} + +pub(crate) 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, + }) +} + +pub(crate) fn plain_text(nodes: &[Node], out: &mut String) { + for n in nodes { + match n { + Node::Text(t) | Node::InlineCode(t) => out.push_str(t), + Node::LineBreak => out.push(' '), + Node::Bold { children } + | Node::Italic { children } + | Node::Strikethrough { children } + | Node::Link { children, .. } + | Node::Heading { children, .. } + | Node::Paragraph { children, .. } => plain_text(children, out), + _ => {} + } + } +} + +pub(crate) struct ListEntry { + pub level: u32, + pub ordered: bool, + pub start: Option<u64>, + pub content: Vec<Node>, + /// Source block index of the first paragraph (DOCX `track_sources`). + #[cfg_attr(not(feature = "docx"), allow(dead_code))] + pub index: usize, +} + +/// Build a (nested) list starting at `entries[*pos]` for `level`. +pub(crate) fn build_list(entries: &[ListEntry], pos: &mut usize, level: u32) -> Node { + let first = &entries[*pos]; + let ordered = first.ordered; + let start = if ordered { + first.start.filter(|&s| s != 1) + } else { + None + }; + let mut items: Vec<ListItem> = Vec::new(); + while *pos < entries.len() { + let e = &entries[*pos]; + if e.level < level || (e.level == level && e.ordered != ordered) { + break; + } + if e.level > level { + let sub = build_list(entries, pos, e.level); + match items.last_mut() { + Some(last) => last.children.push(sub), + None => items.push(ListItem::new(vec![sub])), + } + continue; + } + items.push(ListItem::new(vec![Node::Paragraph { + children: e.content.clone(), + source: None, + }])); + *pos += 1; + } + Node::List { + ordered, + start, + items, + source: None, + } +} + +/// Make cell content single-line: hard breaks become spaces (the renderer escapes pipes). +pub(crate) fn table_cell(nodes: Vec<Node>) -> Vec<Node> { + fn fix(n: Node) -> Node { + match n { + Node::LineBreak => Node::Text(" ".into()), + Node::Bold { children } => Node::Bold { + children: children.into_iter().map(fix).collect(), + }, + Node::Italic { children } => Node::Italic { + children: children.into_iter().map(fix).collect(), + }, + Node::Strikethrough { children } => Node::Strikethrough { + children: children.into_iter().map(fix).collect(), + }, + Node::Link { + url, + title, + children, + } => Node::Link { + url, + title, + children: children.into_iter().map(fix).collect(), + }, + other => other, + } + } + tidy_inline(nodes.into_iter().map(fix).collect()) +} diff --git a/crates/markkit/src/pptx.rs b/crates/markkit/src/pptx.rs new file mode 100644 index 0000000..214fb14 --- /dev/null +++ b/crates/markkit/src/pptx.rs @@ -0,0 +1,515 @@ +//! PPTX (Office Open XML presentation) → IR. +//! +//! Decisions: +//! - Same approach as DOCX: read the package directly with `zip` + the bounded +//! XML tree; every part goes through [`ZipGuard`]. No third-party PPTX crate. +//! - Slides follow `p:sldIdLst` order in `presentation.xml` (the order a viewer +//! shows), resolved through the presentation's relationships. Each slide is a +//! `## Slide N: <title>` section; shapes render in `spTree` document order. +//! - Text in body/content placeholders renders as a bulleted list (nesting from +//! `a:pPr@lvl`) unless a paragraph says `a:buNone`; free text boxes render as +//! paragraphs unless a paragraph declares a bullet. `a:buAutoNum` → ordered. +//! - Tables (`a:tbl` in a graphic frame) → GFM tables, first row = header; +//! `gridSpan`/`rowSpan` kept, merge-continuation cells dropped. +//! - Speaker notes (body placeholder of the notes slide) render under +//! `### Notes`: they often carry the content a presenter says out loud. +//! - Hidden slides (`p:sld@show="0"`) are skipped by default with a +//! `hidden_slide_skipped` warning, like hidden sheets (content invisible to a +//! viewer is a prompt-injection vector); opt in with `include_hidden_content`. +//! - Skipped: pictures, charts, SmartArt, media, comments, slide masters and +//! layouts (template text), and macros (`vbaProject.bin` is never read). + +use std::collections::HashMap; + +use crate::archive::{ArchiveError, ZipGuard}; +use crate::config::MarkkitBuilder; +use crate::error::Error; +use crate::ir::{Document, Node, Source, TableCell}; +use crate::limits::Budget; +use crate::ooxml::{ + Fmt, ListEntry, Relationship, Seg, build_inline, build_list, is_blank, parse_rels, plain_text, + rel_target_by_type, resolve_target, split_dir, table_cell, +}; +use crate::xml::{self, Element, XmlError}; + +/// Result of parsing a PPTX package. +pub(crate) struct PptxOutput { + pub doc: Document, + pub title: Option<String>, + pub warnings: Vec<String>, + pub slides: usize, + pub tables: usize, +} + +/// Deepest `a:pPr@lvl` honoured (PresentationML allows 0–8). +const MAX_LEVEL: u32 = 8; +/// Largest table span kept (matches DOCX `gridSpan` clamp). +const MAX_SPAN: u32 = 64; + +fn archive_err(e: ArchiveError) -> Error { + match e { + ArchiveError::Limit(m) => Error::ResourceLimit(m), + ArchiveError::Invalid(m) => Error::PptxExtract(m), + } +} + +fn xml_err(part: &str, e: XmlError) -> Error { + match e { + XmlError::Limit(m) => Error::ResourceLimit(format!("{part}: {m}")), + XmlError::Invalid(m) => Error::PptxExtract(format!("{part}: {m}")), + } +} + +/// Bounded XML reader: archive limits, nesting depth, node count and deadline. +struct Parts<'z, 'b> { + zip: ZipGuard<'z>, + max_depth: usize, + budget: &'b Budget, +} + +impl Parts<'_, '_> { + fn read_xml(&mut self, path: &str) -> Result<Option<Element>, Error> { + match self.zip.read(path).map_err(archive_err)? { + Some(bytes) => xml::parse(&bytes, self.max_depth, self.budget) + .map(Some) + .map_err(|e| xml_err(path, e)), + None => Ok(None), + } + } + + /// Relationships of `part` (`dir/_rels/file.rels`). + fn part_rels(&mut self, part: &str) -> Result<HashMap<String, Relationship>, Error> { + let (dir, file) = split_dir(part); + Ok(parse_rels( + self.read_xml(&format!("{dir}/_rels/{file}.rels"))?, + )) + } +} + +/// Internal part a relationship id points to, if its type ends with `suffix`. +fn internal_target( + rels: &HashMap<String, Relationship>, + id: &str, + suffix: &str, + base_dir: &str, +) -> Option<String> { + rels.get(id) + .filter(|r| !r.external && r.rel_type.ends_with(suffix)) + .map(|r| resolve_target(base_dir, &r.target)) +} + +// ─── Text ─────────────────────────────────────────────────────────── + +fn attr_on(el: &Element, name: &str) -> bool { + matches!(el.attr(name), Some("1" | "true")) +} + +fn run_fmt(rpr: Option<&Element>) -> Fmt { + let Some(rpr) = rpr else { + return Fmt::default(); + }; + Fmt { + bold: attr_on(rpr, "b"), + italic: attr_on(rpr, "i"), + strike: rpr.attr("strike").is_some_and(|s| s != "noStrike"), + } +} + +/// Placeholder kind of a shape (`p:nvSpPr/p:nvPr/p:ph`). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Placeholder { + /// Not a placeholder: a free text box. + None, + Title, + /// Body / content placeholder: bulleted by default. + Body, + /// Subtitle, date, footer, slide number and other text placeholders. + Other, +} + +fn placeholder(sp: &Element) -> Placeholder { + let Some(ph) = sp + .child("nvSpPr") + .and_then(|n| n.child("nvPr")) + .and_then(|n| n.child("ph")) + else { + return Placeholder::None; + }; + match ph.attr("type") { + Some("title" | "ctrTitle") => Placeholder::Title, + // A placeholder without a type is the generic content placeholder. + None | Some("body" | "obj") => Placeholder::Body, + _ => Placeholder::Other, + } +} + +struct Para { + inline: Vec<Node>, + level: u32, + /// `Some(ordered)` when the paragraph renders as a list item. + bullet: Option<(bool, Option<u64>)>, +} + +struct SlideCtx<'a> { + rels: &'a HashMap<String, Relationship>, +} + +impl SlideCtx<'_> { + fn link(&self, rpr: Option<&Element>) -> Option<String> { + let id = rpr?.child("hlinkClick")?.attr_prefixed("id")?; + let rel = self.rels.get(id).filter(|r| r.external)?; + Some(rel.target.replace(' ', "%20")) + } + + fn paragraph(&self, p: &Element, default_bullet: bool) -> Para { + let mut segs = Vec::new(); + for c in p.elements() { + match c.name.as_str() { + "r" | "fld" => { + let rpr = c.child("rPr"); + if let Some(t) = c.child("t") { + segs.push(Seg::Text { + text: t.text(), + fmt: run_fmt(rpr), + link: self.link(rpr), + }); + } + } + "br" => segs.push(Seg::Break { link: None }), + _ => {} + } + } + let ppr = p.child("pPr"); + let level = ppr + .and_then(|p| p.attr("lvl")) + .and_then(|l| l.parse().ok()) + .unwrap_or(0u32) + .min(MAX_LEVEL); + let bullet = match ppr { + Some(p) if p.child("buNone").is_some() => None, + Some(p) if p.child("buAutoNum").is_some() => { + let start = p + .child("buAutoNum") + .and_then(|b| b.attr("startAt")) + .and_then(|s| s.parse().ok()); + Some((true, start)) + } + Some(p) if p.child("buChar").is_some() || p.child("buBlip").is_some() => { + Some((false, None)) + } + _ if default_bullet => Some((false, None)), + _ => None, + }; + Para { + inline: build_inline(segs), + level, + bullet, + } + } + + /// Paragraphs of a `txBody`, blank ones dropped. + fn paragraphs(&self, body: &Element, default_bullet: bool) -> Vec<Para> { + body.elements() + .filter(|e| e.name == "p") + .map(|p| self.paragraph(p, default_bullet)) + .filter(|p| !is_blank(&p.inline)) + .collect() + } +} + +/// Turn paragraphs into blocks: runs of bulleted paragraphs become lists. +fn paras_to_blocks(paras: Vec<Para>, out: &mut Vec<Node>) { + let mut list: Vec<ListEntry> = Vec::new(); + let flush = |list: &mut Vec<ListEntry>, out: &mut Vec<Node>| { + if list.is_empty() { + return; + } + let entries = std::mem::take(list); + let base = entries.iter().map(|e| e.level).min().unwrap_or(0); + let mut pos = 0; + while pos < entries.len() { + out.push(build_list(&entries, &mut pos, base)); + } + }; + for p in paras { + match p.bullet { + Some((ordered, start)) => list.push(ListEntry { + level: p.level, + ordered, + start, + content: p.inline, + index: 0, + }), + None => { + flush(&mut list, out); + out.push(Node::paragraph(p.inline)); + } + } + } + flush(&mut list, out); +} + +// ─── Slides ───────────────────────────────────────────────────────── + +#[derive(Default)] +struct Slide { + title: Option<Vec<Node>>, + blocks: Vec<Node>, + tables: usize, +} + +impl SlideCtx<'_> { + /// Walk a shape tree (`p:spTree` / `p:grpSp`) in document order. + fn shapes(&self, tree: &Element, slide: &mut Slide) { + for shape in tree.elements() { + match shape.name.as_str() { + "sp" => self.shape(shape, slide), + "grpSp" => self.shapes(shape, slide), + "graphicFrame" => { + if let Some(t) = shape.find("tbl").and_then(|t| self.table(t)) { + slide.tables += 1; + slide.blocks.push(t); + } + } + // Alternate content: render the first choice / fallback that has shapes. + "AlternateContent" => { + if let Some(branch) = shape + .elements() + .find(|e| e.name == "Choice" || e.name == "Fallback") + { + self.shapes(branch, slide); + } + } + _ => {} + } + } + } + + fn shape(&self, sp: &Element, slide: &mut Slide) { + let Some(body) = sp.child("txBody") else { + return; + }; + let kind = placeholder(sp); + let paras = self.paragraphs(body, kind == Placeholder::Body); + if paras.is_empty() { + return; + } + if kind == Placeholder::Title && slide.title.is_none() { + // Multi-paragraph titles join with spaces. + let mut title: Vec<Node> = Vec::new(); + for p in paras { + if !title.is_empty() { + title.push(Node::Text(" ".into())); + } + title.extend(p.inline.into_iter().map(|n| match n { + Node::LineBreak => Node::Text(" ".into()), + n => n, + })); + } + slide.title = Some(title); + return; + } + let paras = if kind == Placeholder::Title { + paras + .into_iter() + .map(|p| Para { bullet: None, ..p }) + .collect() + } else { + paras + }; + paras_to_blocks(paras, &mut slide.blocks); + } + + fn table(&self, tbl: &Element) -> Option<Node> { + let mut rows: Vec<Vec<TableCell>> = Vec::new(); + for tr in tbl.elements().filter(|e| e.name == "tr") { + let mut row = Vec::new(); + for tc in tr.elements().filter(|e| e.name == "tc") { + // Continuation cells of a merge are covered by the anchor's span. + if attr_on(tc, "hMerge") || attr_on(tc, "vMerge") { + continue; + } + let mut content: Vec<Node> = Vec::new(); + if let Some(body) = tc.child("txBody") { + for p in self.paragraphs(body, false) { + if !content.is_empty() { + content.push(Node::Text(" ".into())); + } + content.extend(p.inline); + } + } + let span = |name: &str| { + tc.attr(name) + .and_then(|v| v.parse::<u32>().ok()) + .unwrap_or(1) + .clamp(1, MAX_SPAN) + }; + row.push( + TableCell::new(table_cell(content)) + .with_span(span("gridSpan"), span("rowSpan")), + ); + } + rows.push(row); + } + if rows.is_empty() || rows.iter().all(|r| r.iter().all(|c| is_blank(&c.children))) { + return None; + } + let headers = rows.remove(0); + Some(Node::Table { + alignments: Vec::new(), + headers, + rows, + source: None, + }) + } +} + +/// Speaker notes: paragraphs of the notes slide's body placeholder. +fn notes_blocks(notes: &Element, rels: &HashMap<String, Relationship>) -> Vec<Node> { + let ctx = SlideCtx { rels }; + let mut out = Vec::new(); + let Some(tree) = notes.find("spTree") else { + return out; + }; + for sp in tree.elements().filter(|e| e.name == "sp") { + if placeholder(sp) != Placeholder::Body { + continue; + } + if let Some(body) = sp.child("txBody") { + paras_to_blocks(ctx.paragraphs(body, false), &mut out); + } + } + out +} + +fn slide_heading(n: usize, title: Option<Vec<Node>>) -> Node { + let mut children = vec![Node::Text(format!("Slide {n}"))]; + if let Some(title) = title.filter(|t| !is_blank(t)) { + children.push(Node::Text(": ".into())); + children.extend(title); + } + Node::heading(2, children) +} + +// ─── Entry point ──────────────────────────────────────────────────── + +pub(crate) fn parse( + bytes: &[u8], + config: &MarkkitBuilder, + budget: &Budget, +) -> Result<PptxOutput, Error> { + let mut zip = Parts { + zip: ZipGuard::open(bytes, config).map_err(archive_err)?, + max_depth: config.limits.nesting_depth(), + budget, + }; + + let root_rels = parse_rels(zip.read_xml("_rels/.rels")?); + let main_path = rel_target_by_type(&root_rels, "/officeDocument") + .map(|t| resolve_target("", t)) + .unwrap_or_else(|| "ppt/presentation.xml".to_string()); + let core_path = rel_target_by_type(&root_rels, "/core-properties") + .map(|t| resolve_target("", t)) + .unwrap_or_else(|| "docProps/core.xml".to_string()); + + let main = zip + .read_xml(&main_path)? + .ok_or_else(|| Error::PptxExtract("missing presentation part".into()))?; + let presentation = main + .child("presentation") + .ok_or_else(|| Error::PptxExtract("main part is not a p:presentation".into()))?; + let (dir, _) = split_dir(&main_path); + let rels = zip.part_rels(&main_path)?; + + let slide_ids: Vec<&str> = presentation + .child("sldIdLst") + .map(|l| { + l.elements() + .filter(|e| e.name == "sldId") + .filter_map(|e| e.attr_prefixed("id")) + .collect() + }) + .unwrap_or_default(); + + let mut warnings = Vec::new(); + let mut nodes = Vec::new(); + let (mut slides, mut tables) = (0usize, 0usize); + let mut first_title = None; + for (i, rid) in slide_ids.into_iter().enumerate() { + let n = i + 1; + budget.check_time()?; + let Some(path) = internal_target(&rels, rid, "/slide", dir) else { + warnings.push(format!("slide_error: slide {n}: dangling relationship")); + continue; + }; + let Some(xml) = zip.read_xml(&path)? else { + warnings.push(format!("slide_error: slide {n}: missing part {path}")); + continue; + }; + let Some(sld) = xml.child("sld") else { + warnings.push(format!("slide_error: slide {n}: not a p:sld")); + continue; + }; + if matches!(sld.attr("show"), Some("0" | "false")) && !config.include_hidden_content { + warnings.push(format!("hidden_slide_skipped: slide {n} is hidden")); + continue; + } + let slide_rels = zip.part_rels(&path)?; + let ctx = SlideCtx { rels: &slide_rels }; + let mut slide = Slide::default(); + if let Some(tree) = sld.child("cSld").and_then(|c| c.child("spTree")) { + ctx.shapes(tree, &mut slide); + } + let notes_path = slide_rels + .iter() + .filter(|(_, r)| !r.external && r.rel_type.ends_with("/notesSlide")) + .map(|(_, r)| resolve_target(split_dir(&path).0, &r.target)) + .min(); + let notes = match notes_path { + Some(p) => { + let notes_rels = zip.part_rels(&p)?; + zip.read_xml(&p)? + .map(|x| notes_blocks(&x, ¬es_rels)) + .unwrap_or_default() + } + None => Vec::new(), + }; + + if first_title.is_none() + && let Some(t) = &slide.title + { + let mut s = String::new(); + plain_text(t, &mut s); + first_title = + Some(s.split_whitespace().collect::<Vec<_>>().join(" ")).filter(|s| !s.is_empty()); + } + slides += 1; + tables += slide.tables; + let start = nodes.len(); + nodes.push(slide_heading(n, slide.title)); + nodes.extend(slide.blocks); + if !notes.is_empty() { + nodes.push(Node::heading(3, vec![Node::Text("Notes".into())])); + nodes.extend(notes); + } + if config.track_sources { + let page = u32::try_from(n).unwrap_or(u32::MAX); + for node in &mut nodes[start..] { + node.set_source(Source::default().with_page(page)); + } + } + } + + let core_title = zip + .read_xml(&core_path)? + .and_then(|c| c.find("title").map(|t| t.text().trim().to_string())) + .filter(|t| !t.is_empty()); + + Ok(PptxOutput { + doc: Document::new(nodes), + title: core_title.or(first_title), + warnings, + slides, + tables, + }) +} diff --git a/crates/markkit/src/render/markdown.rs b/crates/markkit/src/render/markdown.rs index 6241535..5e9074a 100644 --- a/crates/markkit/src/render/markdown.rs +++ b/crates/markkit/src/render/markdown.rs @@ -80,6 +80,7 @@ impl MarkdownRenderer { #[cfg(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf", feature = "markdown" @@ -726,6 +727,7 @@ impl Default for MarkdownRenderer { #[cfg(any( feature = "html", feature = "docx", + feature = "pptx", feature = "spreadsheet", feature = "pdf", feature = "markdown" diff --git a/crates/markkit/src/xml.rs b/crates/markkit/src/xml.rs index 45187af..7e8d433 100644 --- a/crates/markkit/src/xml.rs +++ b/crates/markkit/src/xml.rs @@ -24,6 +24,7 @@ pub(crate) enum XmlError { #[derive(Debug, Default)] pub(crate) struct Element { pub name: String, + /// `(qualified name, value)`; looked up by local name. pub attrs: Vec<(String, String)>, pub children: Vec<XNode>, } @@ -35,11 +36,20 @@ pub(crate) enum XNode { } impl Element { - /// Attribute value by local name. - pub fn attr(&self, local: &str) -> Option<&str> { + /// Attribute value by local name (any or no prefix). + pub fn attr(&self, local_name: &str) -> Option<&str> { self.attrs .iter() - .find(|(k, _)| k == local) + .find(|(k, _)| local_of(k) == local_name) + .map(|(_, v)| v.as_str()) + } + + /// Attribute value by local name, prefixed attributes only. Tells + /// `r:id` (relationship) from a plain `id` on the same element. + pub fn attr_prefixed(&self, local_name: &str) -> Option<&str> { + self.attrs + .iter() + .find(|(k, _)| k.contains(':') && local_of(k) == local_name) .map(|(_, v)| v.as_str()) } @@ -93,17 +103,18 @@ impl Element { } } +fn local_of(name: &str) -> &str { + name.rsplit_once(':').map_or(name, |(_, l)| l) +} + fn local(name: &str) -> String { - match name.rsplit_once(':') { - Some((_, local)) => local.to_string(), - None => name.to_string(), - } + local_of(name).to_string() } fn element_from(start: &BytesStart<'_>) -> Element { let mut attrs = Vec::new(); for attr in start.attributes().with_checks(false).flatten() { - let key = local(attr.key.as_ref()); + let key = attr.key.as_ref().to_string(); let value = match attr.normalized_value(XmlVersion::Implicit1_0) { Ok(v) => v.into_owned(), Err(_) => attr.value.into_owned(), @@ -237,6 +248,15 @@ mod tests { assert_eq!(root.find("c").map(|e| e.name.as_str()), Some("c")); } + #[test] + fn prefixed_attribute_lookup() { + let root = parse(br#"<sldId id="256" r:id="rId2"/>"#).unwrap(); + let e = root.child("sldId").unwrap(); + assert_eq!(e.attr("id"), Some("256")); + assert_eq!(e.attr_prefixed("id"), Some("rId2")); + assert_eq!(e.attr_prefixed("missing"), None); + } + #[test] fn depth_limited() { let deep = "<a>".repeat(256 + 10); diff --git a/crates/markkit/tests/common/mod.rs b/crates/markkit/tests/common/mod.rs index 7b7b9a0..ac4e6c2 100644 --- a/crates/markkit/tests/common/mod.rs +++ b/crates/markkit/tests/common/mod.rs @@ -671,3 +671,115 @@ pub fn pdf_pages_with(contents: &[&str], fonts: &str, extra: &[Vec<u8>]) -> Vec< objs.extend(extra.iter().cloned()); pdf_bytes(&objs) } + +// ─── PPTX ─────────────────────────────────────────────────────────── + +pub const PPTX_CT: &str = + "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +const P_NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; +const REL_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// A generated slide: `spTree` inner XML, optional notes text, hidden flag, +/// extra slide relationships (`Id`, `Target`) as external hyperlinks. +#[derive(Default)] +pub struct PSlide<'a> { + pub shapes: String, + pub notes: Option<&'a str>, + pub hidden: bool, + pub links: Vec<(&'a str, &'a str)>, +} + +impl PSlide<'_> { + pub fn new(shapes: String) -> Self { + Self { + shapes, + ..Default::default() + } + } +} + +/// `<p:sp>` with an optional placeholder type (`Some("")` = untyped content placeholder). +pub fn p_shape(ph: Option<&str>, paragraphs: &str) -> String { + let ph = match ph { + None => String::new(), + Some("") => r#"<p:ph idx="1"/>"#.to_string(), + Some(t) => format!(r#"<p:ph type="{t}"/>"#), + }; + format!( + r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="s"/><p:cNvSpPr/><p:nvPr>{ph}</p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/>{paragraphs}</p:txBody></p:sp>"# + ) +} + +/// `<a:p>` with one plain run. +pub fn a_para(text: &str) -> String { + format!("<a:p><a:r><a:t>{}</a:t></a:r></a:p>", xml_escape(text)) +} + +pub fn pptx_with(slides: &[PSlide<'_>], extra: &[(&str, &[u8])]) -> Vec<u8> { + let mut ids = String::new(); + let mut rels = String::new(); + let mut entries: Vec<(String, Vec<u8>)> = Vec::new(); + for (i, s) in slides.iter().enumerate() { + let n = i + 1; + ids.push_str(&format!(r#"<p:sldId id="{}" r:id="rId{n}"/>"#, 255 + n)); + rels.push_str(&format!( + r#"<Relationship Id="rId{n}" Type="{REL_NS}/slide" Target="slides/slide{n}.xml"/>"# + )); + let show = if s.hidden { r#" show="0""# } else { "" }; + entries.push(( + format!("ppt/slides/slide{n}.xml"), + format!(r#"<?xml version="1.0" encoding="UTF-8"?><p:sld {P_NS}{show}><p:cSld><p:spTree>{}</p:spTree></p:cSld></p:sld>"#, s.shapes).into_bytes(), + )); + let mut srels = String::new(); + for (id, target) in &s.links { + srels.push_str(&format!(r#"<Relationship Id="{id}" Type="{REL_NS}/hyperlink" Target="{}" TargetMode="External"/>"#, xml_escape(target))); + } + if let Some(notes) = s.notes { + srels.push_str(&format!(r#"<Relationship Id="rIdN" Type="{REL_NS}/notesSlide" Target="../notesSlides/notesSlide{n}.xml"/>"#)); + entries.push(( + format!("ppt/notesSlides/notesSlide{n}.xml"), + format!( + r#"<p:notes {P_NS}><p:cSld><p:spTree>{}{}</p:spTree></p:cSld></p:notes>"#, + p_shape(Some("sldImg"), ""), + p_shape(Some("body"), &a_para(notes)) + ) + .into_bytes(), + )); + } + entries.push(( + format!("ppt/slides/_rels/slide{n}.xml.rels"), + format!(r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{srels}</Relationships>"#).into_bytes(), + )); + } + let pres = format!( + r#"<?xml version="1.0" encoding="UTF-8"?><p:presentation {P_NS}><p:sldIdLst>{ids}</p:sldIdLst></p:presentation>"# + ); + let pres_rels = format!( + r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"# + ); + let root_rels = format!( + r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL_NS}/officeDocument" Target="ppt/presentation.xml"/></Relationships>"# + ); + let mut all: Vec<(String, Vec<u8>)> = vec![ + ("_rels/.rels".into(), root_rels.into_bytes()), + ("ppt/presentation.xml".into(), pres.into_bytes()), + ( + "ppt/_rels/presentation.xml.rels".into(), + pres_rels.into_bytes(), + ), + ]; + all.extend(entries); + for (n, d) in extra { + all.push((n.to_string(), d.to_vec())); + } + let refs: Vec<(&str, &[u8])> = all + .iter() + .map(|(n, d)| (n.as_str(), d.as_slice())) + .collect(); + zip_bytes(&refs) +} + +pub fn pptx(slides: &[PSlide<'_>]) -> Vec<u8> { + pptx_with(slides, &[]) +} diff --git a/crates/markkit/tests/document_detection.rs b/crates/markkit/tests/document_detection.rs index 86d0dfb..91b51b9 100644 --- a/crates/markkit/tests/document_detection.rs +++ b/crates/markkit/tests/document_detection.rs @@ -42,6 +42,11 @@ fn content_type_detection() { "application/vnd.openxmlformats-officedocument.presentationml.presentation", Format::Pptx, ), + ( + "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + Format::Pptx, + ), + ("application/vnd.ms-powerpoint", Format::Ppt), ("APPLICATION/PDF", Format::Pdf), ("application/octet-stream", Format::Unknown), ]; @@ -61,6 +66,8 @@ fn extension_detection() { ("xls", Format::Xls), ("ods", Format::Ods), ("pptx", Format::Pptx), + ("PPSX", Format::Pptx), + ("ppt", Format::Ppt), ("pdf", Format::Pdf), ]; for (ext, f) in cases { @@ -117,6 +124,10 @@ fn ole2_sniffing_distinguishes_doc_and_xls() { doc.extend(utf16("WordDocument")); assert_eq!(Format::sniff(&doc), Format::Doc); + let mut ppt = cfb.clone(); + ppt.extend(utf16("PowerPoint Document")); + assert_eq!(Format::sniff(&ppt), Format::Ppt); + assert_eq!(Format::sniff(&cfb), Format::Unknown); } @@ -187,12 +198,9 @@ fn extract_document_unknown_is_unsupported() { } #[test] -fn extract_document_legacy_doc_and_pptx_unsupported() { +fn extract_document_legacy_doc_and_ppt_unsupported() { let m = Markkit::builder().build(); - for ct in [ - "application/msword", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ] { + for ct in ["application/msword", "application/vnd.ms-powerpoint"] { let err = m .extract_document_sync(b"whatever", DocumentHint::content_type(ct)) .unwrap_err(); diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index f353025..ba68be3 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -1,7 +1,7 @@ //! Security tests for office-document parsing: zip bombs, resource caps, //! malformed input, XML entity tricks, oversized spreadsheet grids. -#![cfg(any(feature = "docx", feature = "spreadsheet"))] +#![cfg(any(feature = "docx", feature = "pptx", feature = "spreadsheet"))] mod common; @@ -25,6 +25,7 @@ fn hints() -> Vec<DocumentHint<'static>> { DocumentHint::content_type("application/vnd.ms-excel"), DocumentHint::content_type("application/vnd.oasis.opendocument.spreadsheet"), DocumentHint::content_type("application/vnd.ms-excel.sheet.binary.macroEnabled.12"), + DocumentHint::content_type(PPTX_CT), ] } @@ -470,6 +471,75 @@ fn docx_billion_laughs_not_expanded() { } } +#[cfg(feature = "pptx")] +#[test] +fn pptx_zip_bomb_slide_rejected() { + let mut text = String::from("x"); + text.push_str(&" ".repeat(8 * 1024 * 1024)); + let bytes = pptx(&[PSlide::new(p_shape(None, &a_para(&text)))]); + assert!(bytes.len() < 256 * 1024); + let m = Markkit::builder() + .max_archive_entry_size(1024 * 1024) + .build(); + let err = run(&m, &bytes, PPTX_CT).unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + +#[cfg(feature = "pptx")] +#[test] +fn pptx_repeated_slide_references_bounded_by_total_size() { + // 2000 slide ids all point at one 64 KiB slide: re-reading it must count + // against the total decompressed budget. + let big = p_shape(None, &a_para(&"y".repeat(64 * 1024))); + let base = pptx(&[PSlide::new(big)]); + let ids: String = (0..2000) + .map(|i| format!(r#"<p:sldId id="{}" r:id="rId1"/>"#, 256 + i)) + .collect(); + let pres = format!( + r#"<p:presentation xmlns:p="p" xmlns:r="r"><p:sldIdLst>{ids}</p:sldIdLst></p:presentation>"# + ); + let bytes = rezip( + &base, + &["ppt/presentation.xml"], + &[("ppt/presentation.xml", pres.as_bytes())], + ); + let m = Markkit::builder() + .max_archive_total_size(16 * 1024 * 1024) + .build(); + let err = run(&m, &bytes, PPTX_CT).unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + +#[cfg(feature = "pptx")] +#[test] +fn pptx_billion_laughs_and_deep_groups() { + let laughs = r#"<?xml version="1.0"?><!DOCTYPE l [<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;"><!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">]><p:sld xmlns:p="p" xmlns:a="a"><p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>&c;safe</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#; + let base = pptx(&[PSlide::new(String::new())]); + let bytes = rezip( + &base, + &["ppt/slides/slide1.xml"], + &[("ppt/slides/slide1.xml", laughs.as_bytes())], + ); + let e = run(&Markkit::builder().build(), &bytes, PPTX_CT).unwrap(); + assert!(e.markdown.contains("safe")); + assert!( + !e.markdown.contains("aaaaaaaaaaaaaaaaaaaa"), + "{}", + e.markdown + ); + + // Group shapes nested past the XML depth cap fail cleanly, no stack overflow. + let deep = format!( + "{}{}{}", + "<p:grpSp>".repeat(10_000), + p_shape(None, &a_para("deep")), + "</p:grpSp>".repeat(10_000) + ); + let bytes = pptx(&[PSlide::new(deep)]); + let err = run(&Markkit::builder().build(), &bytes, PPTX_CT).unwrap_err(); + assert!(matches!(err, Error::ResourceLimit(_)), "{err:?}"); +} + #[cfg(feature = "docx")] #[test] fn docx_external_entity_not_resolved() { @@ -543,6 +613,21 @@ fn valid_fixtures() -> Vec<Vec<u8>> { title: Some("t"), ..Default::default() })); + #[cfg(feature = "pptx")] + { + let mut slide = PSlide::new( + p_shape(Some("title"), &a_para("T")) + &p_shape(Some(""), &a_para("bullet")), + ); + slide.notes = Some("note"); + v.push(pptx(&[slide])); + v.push( + std::fs::read(format!( + "{}/tests/fixtures/pptx/deck.pptx", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(), + ); + } #[cfg(feature = "spreadsheet")] { v.push(xlsx(&[Sheet::new( @@ -649,7 +734,7 @@ proptest! { /// Byte flips in valid documents never panic. #[test] fn mutated_fixtures_never_panic( - idx in 0usize..9, + idx in 0usize..11, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..16), ) { let fixtures = valid_fixtures(); diff --git a/crates/markkit/tests/fixtures/pptx/README.md b/crates/markkit/tests/fixtures/pptx/README.md new file mode 100644 index 0000000..d334542 --- /dev/null +++ b/crates/markkit/tests/fixtures/pptx/README.md @@ -0,0 +1,7 @@ +# PPTX fixtures + +- `deck.pptx`: generated by `make_deck.py` with python-pptx 1.0.2 (default template): + title slide, bullets with nesting, bold run and hyperlink, speaker notes, a table with + a pipe in a cell, a free text box, and a hidden slide. + +Regenerate: `python3 make_deck.py deck.pptx`. diff --git a/crates/markkit/tests/fixtures/pptx/deck.pptx b/crates/markkit/tests/fixtures/pptx/deck.pptx new file mode 100644 index 0000000..5360fad Binary files /dev/null and b/crates/markkit/tests/fixtures/pptx/deck.pptx differ diff --git a/crates/markkit/tests/fixtures/pptx/make_deck.py b/crates/markkit/tests/fixtures/pptx/make_deck.py new file mode 100644 index 0000000..2757b26 --- /dev/null +++ b/crates/markkit/tests/fixtures/pptx/make_deck.py @@ -0,0 +1,19 @@ +from pptx import Presentation +from pptx.util import Inches +import sys +p=Presentation() +p.core_properties.title="Quarterly Review" +s=p.slides.add_slide(p.slide_layouts[0]); s.shapes.title.text="Q3 Results"; s.placeholders[1].text="Prepared by Finance" +s=p.slides.add_slide(p.slide_layouts[1]); s.shapes.title.text="Highlights" +tf=s.placeholders[1].text_frame; tf.text="Revenue up 12%" +para=tf.add_paragraph(); para.text="EMEA strongest"; para.level=1 +para=tf.add_paragraph(); r=para.add_run(); r.text="Bold "; r.font.bold=True; r2=para.add_run(); r2.text="docs"; r2.hyperlink.address="https://example.com/q3" +s.notes_slide.notes_text_frame.text="Mention the hiring freeze." +s=p.slides.add_slide(p.slide_layouts[5]); s.shapes.title.text="Numbers" +t=s.shapes.add_table(3,2,Inches(1),Inches(2),Inches(4),Inches(2)).table +for i,row in enumerate([["Region","Revenue"],["EMEA","1.2M"],["APAC","0.9 | 1.0M"]]): + for j,v in enumerate(row): t.cell(i,j).text=v +tb=s.shapes.add_textbox(Inches(1),Inches(5),Inches(4),Inches(1)); tb.text_frame.text="Source: internal" +s=p.slides.add_slide(p.slide_layouts[1]); s.shapes.title.text="Secret"; s.placeholders[1].text="hidden content" +s._element.set("show","0") +p.save(sys.argv[1]) diff --git a/crates/markkit/tests/pptx_integration.rs b/crates/markkit/tests/pptx_integration.rs new file mode 100644 index 0000000..beba996 --- /dev/null +++ b/crates/markkit/tests/pptx_integration.rs @@ -0,0 +1,249 @@ +//! PPTX → Markdown integration tests. + +#![cfg(feature = "pptx")] + +mod common; + +use common::*; +use markkit::{DocumentHint, Error, Markkit, format::Format}; + +fn extract_with(m: &Markkit, bytes: &[u8]) -> markkit::Extraction { + m.extract_document_sync(bytes, DocumentHint::default()) + .unwrap() +} + +fn extract(bytes: &[u8]) -> markkit::Extraction { + extract_with(&Markkit::builder().build(), bytes) +} + +fn title_and_body(title: &str, body: &str) -> String { + p_shape(Some("title"), &a_para(title)) + &p_shape(Some(""), body) +} + +#[test] +fn slides_in_presentation_order_with_titles_and_bullets() { + let body = format!( + "{}<a:p><a:pPr lvl=\"1\"/><a:r><a:t>Nested</a:t></a:r></a:p>{}", + a_para("First"), + a_para("Second") + ); + let bytes = pptx(&[ + PSlide::new(title_and_body("Intro", &body)), + PSlide::new(p_shape(Some("title"), &a_para("Only a title"))), + ]); + let e = extract(&bytes); + assert_eq!(e.format, Format::Pptx); + assert_eq!( + e.markdown, + "## Slide 1: Intro\n\n- First\n - Nested\n- Second\n\n## Slide 2: Only a title\n" + ); + assert_eq!(e.stats.pages, Some(2)); + assert_eq!(e.title.as_deref(), Some("Intro")); + assert!(e.warnings.is_empty(), "{:?}", e.warnings); +} + +#[test] +fn text_boxes_are_paragraphs_and_explicit_bullets_win() { + let text_box = p_shape( + None, + &format!( + "{}<a:p><a:pPr><a:buChar char=\"•\"/></a:pPr><a:r><a:t>Bulleted</a:t></a:r></a:p>", + a_para("Plain") + ), + ); + let numbered = p_shape( + Some("body"), + r#"<a:p><a:pPr><a:buAutoNum type="arabicPeriod" startAt="3"/></a:pPr><a:r><a:t>Third</a:t></a:r></a:p><a:p><a:pPr><a:buNone/></a:pPr><a:r><a:t>No bullet</a:t></a:r></a:p>"#, + ); + let e = extract(&pptx(&[PSlide::new(text_box + &numbered)])); + assert_eq!( + e.markdown, + "## Slide 1\n\nPlain\n\n- Bulleted\n\n3. Third\n\nNo bullet\n" + ); +} + +#[test] +fn run_formatting_links_and_breaks() { + let para = r#"<a:p><a:r><a:rPr b="1"/><a:t>Bold</a:t></a:r><a:r><a:t> and </a:t></a:r><a:r><a:rPr i="1"><a:hlinkClick r:id="rIdL"/></a:rPr><a:t>link</a:t></a:r><a:br/><a:r><a:rPr strike="sngStrike"/><a:t>gone</a:t></a:r><a:r><a:rPr b="0" strike="noStrike"/><a:t> kept</a:t></a:r></a:p>"#; + let mut slide = PSlide::new(p_shape(None, para)); + slide.links = vec![("rIdL", "https://example.com/a b")]; + let e = extract(&pptx(&[slide])); + assert_eq!( + e.markdown, + "## Slide 1\n\n**Bold** and [*link*](https://example.com/a%20b) \n~~gone~~ kept\n" + ); +} + +#[test] +fn internal_or_missing_hyperlinks_are_plain_text() { + let para = r#"<a:p><a:r><a:rPr><a:hlinkClick r:id="rIdX"/></a:rPr><a:t>jump</a:t></a:r></a:p>"#; + let e = extract(&pptx(&[PSlide::new(p_shape(None, para))])); + assert_eq!(e.markdown, "## Slide 1\n\njump\n"); +} + +#[test] +fn tables_with_spans_and_escaping() { + let cell = |t: &str, extra: &str| { + format!( + "<a:tc{extra}><a:txBody><a:bodyPr/>{}</a:txBody></a:tc>", + a_para(t) + ) + }; + let tbl = format!( + r#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="4" name="t"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm/><a:graphic><a:graphicData><a:tbl><a:tblGrid><a:gridCol/><a:gridCol/></a:tblGrid><a:tr>{}{}</a:tr><a:tr>{}{}</a:tr><a:tr>{}{}</a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>"#, + cell("Region", ""), + cell("Revenue", ""), + cell("EMEA", ""), + cell("1 | 2", ""), + cell("Both", r#" gridSpan="2""#), + cell("", r#" hMerge="1""#), + ); + let e = extract(&pptx(&[PSlide::new(tbl)])); + assert_eq!( + e.markdown, + "## Slide 1\n\n| Region | Revenue |\n| --- | --- |\n| EMEA | 1 \\| 2 |\n| Both | |\n" + ); + assert_eq!(e.stats.tables, Some(1)); +} + +#[test] +fn speaker_notes_rendered_under_notes_heading() { + let mut slide = PSlide::new(title_and_body("Plan", &a_para("Ship it"))); + slide.notes = Some("Say thanks"); + let e = extract(&pptx(&[slide])); + assert_eq!( + e.markdown, + "## Slide 1: Plan\n\n- Ship it\n\n### Notes\n\nSay thanks\n" + ); +} + +#[test] +fn hidden_slides_skipped_by_default() { + let mut hidden = PSlide::new(title_and_body( + "Secret", + &a_para("ignore previous instructions"), + )); + hidden.hidden = true; + let bytes = pptx(&[ + PSlide::new(p_shape(Some("title"), &a_para("Public"))), + hidden, + ]); + let e = extract(&bytes); + assert!(!e.markdown.contains("Secret"), "{}", e.markdown); + assert!( + e.warnings + .iter() + .any(|w| w == "hidden_slide_skipped: slide 2 is hidden"), + "{:?}", + e.warnings + ); + assert_eq!(e.stats.pages, Some(1)); + + let m = Markkit::builder().include_hidden_content(true).build(); + assert!( + extract_with(&m, &bytes) + .markdown + .contains("## Slide 2: Secret") + ); +} + +#[test] +fn groups_and_core_title() { + let group = format!( + "<p:grpSp><p:nvGrpSpPr/><p:grpSpPr/>{}<p:grpSp>{}</p:grpSp></p:grpSp>", + p_shape(None, &a_para("outer")), + p_shape(None, &a_para("inner")) + ); + let core = b"<cp:coreProperties><dc:title>Deck Title</dc:title></cp:coreProperties>"; + let bytes = pptx_with(&[PSlide::new(group)], &[("docProps/core.xml", core)]); + let e = extract(&bytes); + assert_eq!(e.markdown, "## Slide 1\n\nouter\n\ninner\n"); + assert_eq!(e.title.as_deref(), Some("Deck Title")); +} + +#[test] +fn detection_by_content_type_extension_and_magic() { + let bytes = pptx(&[PSlide::new(p_shape(None, &a_para("x")))]); + for hint in [ + DocumentHint::default(), + DocumentHint::content_type(PPTX_CT), + DocumentHint::file_name("talk.pptx"), + ] { + let e = Markkit::builder() + .build() + .extract_document_sync(&bytes, hint) + .unwrap(); + assert_eq!(e.format, Format::Pptx, "{hint:?}"); + } +} + +#[test] +fn broken_slide_references_warn_and_other_slides_render() { + let bytes = pptx(&[ + PSlide::new(p_shape(None, &a_para("first"))), + PSlide::new(p_shape(None, &a_para("second"))), + ]); + let bytes = rezip(&bytes, &["ppt/slides/slide1.xml"], &[]); + let e = extract(&bytes); + assert_eq!(e.markdown, "## Slide 2\n\nsecond\n"); + assert!( + e.warnings + .iter() + .any(|w| w.starts_with("slide_error: slide 1")), + "{:?}", + e.warnings + ); +} + +#[test] +fn not_a_presentation_is_an_error() { + let bytes = zip_bytes(&[("ppt/presentation.xml", b"<nope/>")]); + let err = Markkit::builder() + .build() + .extract_document_sync(&bytes, DocumentHint::content_type(PPTX_CT)) + .unwrap_err(); + assert!(matches!(err, Error::PptxExtract(_)), "{err:?}"); +} + +#[test] +fn real_deck_from_python_pptx() { + let bytes = std::fs::read(format!( + "{}/tests/fixtures/pptx/deck.pptx", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + let e = extract(&bytes); + assert_eq!(e.title.as_deref(), Some("Quarterly Review")); + assert_eq!( + e.markdown, + "## Slide 1: Q3 Results\n\nPrepared by Finance\n\n## Slide 2: Highlights\n\n- Revenue up 12%\n - EMEA strongest\n- **Bold** [docs](https://example.com/q3)\n\n### Notes\n\nMention the hiring freeze.\n\n## Slide 3: Numbers\n\n| Region | Revenue |\n| --- | --- |\n| EMEA | 1.2M |\n| APAC | 0.9 \\| 1.0M |\n\nSource: internal\n" + ); + assert_eq!( + e.warnings, + vec!["hidden_slide_skipped: slide 4 is hidden".to_string()] + ); +} + +#[test] +fn sources_record_slide_number() { + let bytes = pptx(&[ + PSlide::new(p_shape(None, &a_para("one"))), + PSlide::new(title_and_body("Two", &a_para("b"))), + ]); + let m = Markkit::builder().track_sources(true).build(); + let p = m + .parse_document_sync(&bytes, DocumentHint::default()) + .unwrap(); + let pages: Vec<_> = p + .document + .nodes + .iter() + .map(|n| n.source().and_then(|s| s.page)) + .collect(); + assert_eq!(pages, vec![Some(1), Some(1), Some(2), Some(2)]); + let p = Markkit::builder() + .build() + .parse_document_sync(&bytes, DocumentHint::default()) + .unwrap(); + assert!(p.document.nodes.iter().all(|n| n.source().is_none())); +} diff --git a/crates/markkit/tests/registry.rs b/crates/markkit/tests/registry.rs index d318678..8da4ef7 100644 --- a/crates/markkit/tests/registry.rs +++ b/crates/markkit/tests/registry.rs @@ -100,10 +100,10 @@ fn custom_parser_for_detected_format() { #[test] fn unregistered_format_still_unsupported() { - // CSV parser registered; PPTX has no built-in and no custom parser. + // CSV parser registered; legacy PPT has no built-in and no custom parser. let m = Markkit::builder().parser(Csv).build(); let err = m - .extract_document_sync(b"PK\x03\x04", DocumentHint::file_name("deck.pptx")) + .extract_document_sync(b"PK\x03\x04", DocumentHint::file_name("deck.ppt")) .unwrap_err(); assert!(matches!(err, Error::UnsupportedFormat(_)), "{err}"); } diff --git a/docs/getting-started.md b/docs/getting-started.md index 3dfc280..7e81319 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,11 +9,11 @@ Add markkit to your `Cargo.toml`: markkit = "0.1" ``` -For PDF, DOCX and spreadsheets: +For PDF, DOCX, PPTX and spreadsheets: ```toml [dependencies] -markkit = { version = "0.1", features = ["pdf", "docx", "spreadsheet"] } +markkit = { version = "0.1", features = ["pdf", "docx", "pptx", "spreadsheet"] } ``` ## Basic Usage diff --git a/knowledge/formats/format-support.md b/knowledge/formats/format-support.md index 21bab51..9ef70f4 100644 --- a/knowledge/formats/format-support.md +++ b/knowledge/formats/format-support.md @@ -24,6 +24,7 @@ tags: ### Tier 2 (Feature-gated) - **DOCX** (`docx`): Word documents via a direct `zip` + `quick-xml` reader (no docx-rs) +- **PPTX/PPTM/PPSX/POTX** (`pptx`): presentations via the same direct reader - **XLSX/XLSM, XLS, ODS** (`spreadsheet`): Workbooks to Markdown tables via calamine - **XLSB** (`spreadsheet`): Excel binary workbooks via markkit's own bounds-checked BIFF12 reader (calamine's XLSB reader panics on malformed records) @@ -31,7 +32,7 @@ tags: - **PDF** (`pdf`): Text layer via hayro-syntax (pure Rust); no OCR, encrypted PDFs rejected ### Detected, not supported -- **DOC** (Word 97-2003 binary), **PPTX**: explicit `UnsupportedFormat` errors +- **DOC** (Word 97-2003 binary), **PPT** (PowerPoint 97-2003 binary): explicit `UnsupportedFormat` errors ### Tier 3 (Planned) - **EPUB**: E-book extraction @@ -68,6 +69,7 @@ concatenate cleanly); an empty document renders `""`. This is intended and stabl | DOCX | `docProps/core.xml` `dc:title` (via `_rels/.rels`), else the first `Title`-style paragraph | | XLSX, XLSB | `docProps/core.xml` `dc:title` | | ODS | `meta.xml` `dc:title` | +| PPTX | `docProps/core.xml` `dc:title`, else the first slide title | | XLS | `\u{5}SummaryInformation` property set, `PIDSI_TITLE` (code page aware: 1200 UTF-16, UTF-8, else Latin-1) | | PDF | `/Info /Title` (UTF-16BE with BOM, else PDFDocEncoding) | @@ -80,6 +82,7 @@ only the Markdown. Parsing is CPU-bound; async callers should use the sync varia Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`, `truncated_pdf`, `pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`, `hidden_content_skipped`, +`hidden_slide_skipped`, `slide_error`, `malformed_csv`, `truncated_table`. ## Format Detection @@ -87,14 +90,16 @@ Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`, `trunca Priority order (`Format::detect`): 1. Content type (`DocumentHint::content_type`; parameters ignored). XLSB (`application/vnd.ms-excel.sheet.binary.macroEnabled.12`) is matched before the - generic `ms-excel.sheet` → XLSX rule. Generic types such as + generic `ms-excel.sheet` → XLSX rule. `application/vnd.ms-powerpoint.*.macroEnabled.12` + is PPTX; bare `application/vnd.ms-powerpoint` is legacy PPT. Generic types such as `application/octet-stream` map to `Unknown` and fall through. 2. File extension (`DocumentHint::file_name`; URL query/fragment ignored). 3. Magic bytes (`Format::sniff`): - `PK\x03\x04` ZIP: ODF `mimetype` entry → ODS; `word/document*` → DOCX; `xl/workbook.bin` → XLSB; `xl/workbook.*` → XLSX; `ppt/presentation.*` → PPTX (part names are stored uncompressed, so no archive parsing is needed for detection). - - OLE2 (`D0 CF 11 E0`): UTF-16 `WordDocument` stream → DOC; `Workbook`/`Book` → XLS. + - OLE2 (`D0 CF 11 E0`): UTF-16 `WordDocument` stream → DOC; `PowerPoint Document` → + PPT; `Workbook`/`Book` → XLS. - `%PDF-` within the first 1 KiB → PDF. - `<!doctype html`, `<html`, `<head`, `<body` (after BOM/whitespace) → HTML. @@ -142,6 +147,27 @@ is public for them. The main part is located through `_rels/.rels` (fallback `word/document.xml`); styles and numbering through the main part's relationships. +## PPTX Mapping + +| PresentationML | Markdown | +|---|---| +| `p:sldIdLst` order (via `ppt/_rels/presentation.xml.rels`) | One `## Slide N: <title>` section per slide (`## Slide N` without a title) | +| Title / centered-title placeholder | Slide heading text (multi-paragraph titles joined with spaces) | +| Body / content placeholder (`ph` with `type="body"`, `obj` or no type) | Bulleted list, nested by `a:pPr@lvl` (0–8); `a:buNone` paragraphs stay paragraphs | +| Text box / other placeholders (subtitle, footer, ...) | Paragraphs; paragraphs with `a:buChar`/`a:buBlip` become bullets | +| `a:buAutoNum` (`startAt`) | Ordered list | +| `a:rPr` `b`, `i`, `strike` (≠ `noStrike`) | `**`, `*`, `~~` (shared run merging with DOCX) | +| `a:hlinkClick r:id` → external relationship | `[text](url)`; internal jumps (slide links) stay plain text | +| `a:br` | Hard line break | +| `a:tbl` in a graphic frame | GFM table, first row = header, `gridSpan`/`rowSpan` kept, `hMerge`/`vMerge` continuation cells dropped | +| `p:grpSp`, `mc:AlternateContent` | Walked in document order (first branch) | +| Notes slide body placeholder | `### Notes` section after the slide | +| `p:sld@show="0"` | Skipped with `hidden_slide_skipped` unless `include_hidden_content(true)` | +| Pictures, charts, SmartArt, media, comments, masters/layouts | Skipped | + +A slide whose relationship is dangling or whose part is missing emits `slide_error`; the +other slides still render. `stats.pages` counts rendered slides, `stats.tables` tables. + ## Spreadsheet Mapping - One `## <Sheet name>` section per non-empty worksheet, in workbook order, followed by a @@ -250,7 +276,7 @@ Office formats additionally enforce (see [Threat Model](../security/threat-model - `max_archive_entries` (10 000), `max_archive_entry_size` (64 MiB decompressed), `max_archive_total_size` (256 MiB decompressed) - `max_spreadsheet_cells` (4 000 000) for the dense grid calamine builds for XLS/ODS -- XML nesting depth 256 for DOCX parts +- XML nesting depth 256 for DOCX and PPTX parts PDF additionally enforces (T8): `max_pdf_pages` (10 000; truncate + `truncated_pdf`), `max_pdf_stream_size` (64 MiB per decoded stream), `max_pdf_decoded_size` (256 MiB total diff --git a/knowledge/foundations/ir.md b/knowledge/foundations/ir.md index d76784a..81707a3 100644 --- a/knowledge/foundations/ir.md +++ b/knowledge/foundations/ir.md @@ -96,7 +96,7 @@ Every block variant except `ThematicBreak` has `source: Option<Box<Source>>` | `path` | HTML | Absolute XPath in the parsed DOM, every step indexed: `/html[1]/body[1]/div[1]/p[2]`. Indexes count same-name element siblings (excluded elements included). Cached per parent, O(depth) per block. | | `index` | DOCX | 0-based index of the body-level `w:p`/`w:tbl` in document order, counting blank paragraphs and those inside `w:sdt`/`w:ins`. Lists take their first item's index. | | `sheet`, `range` | Spreadsheets | Sheet name on the heading and table; table gets the A1 bounding box of kept cells (`B2:C3`). | -| `page` | Paged formats (PDF, later) | 1-based page. | +| `page` | PPTX; paged formats (PDF, later) | 1-based page; PPTX: slide number on every block of the slide, notes included. | Blocks synthesized by normalization (inline runs wrapped in `Paragraph`) and blocks nested inside inline/table-cell content have no source. Accessors: diff --git a/knowledge/log.md b/knowledge/log.md index 9abdc77..bd7c9f9 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-27 +* **PPTX support (#11)**: new `pptx` feature (in `all_formats`) converts presentations slide by slide: titles, bulleted body placeholders, text boxes, tables, links, speaker notes; hidden slides skipped. Shared OOXML helpers moved from `docx.rs` to `ooxml.rs`. Legacy `.ppt` is now its own `Format::Ppt` (rejected). See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6. * **Fuzzing**: new [Fuzzing](security/fuzzing.md) concept. Eleven cargo-fuzz targets (`crates/markkit/fuzz`, every parser incl. XLSB, PDF, Markdown, CSV, plus the renderer) assert the S17 oracle and run nightly (`fuzz.yml`). The `xls_biff` target found calamine panicking on a dozen truncated BIFF record shapes and in its formula decompiler; the XLS pre-scan now rejects them, neutralises formula tokens and hands calamine a container rebuilt from the scanned stream. See [Threat Model](security/threat-model.md) T6c. * **XLSB support (#11)**: Excel binary workbooks are read by a new bounds-checked BIFF12 reader (`spreadsheet/xlsb.rs`) instead of being rejected; calamine's XLSB reader was not used because it panics on malformed records. Same caps, hidden-sheet and macro rules as XLSX. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. * **Over-escaping fix**: line-start `#`, `+` and ordered markers are escaped only when followed by whitespace or the end of the text run, so `4.6 out of 5` no longer renders `4\.6` (eval gap). `> - =` stay always escaped. See [Threat Model](security/threat-model.md) T1, [Evaluation Harness](operations/eval.md). diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 2e9f316..2027041 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -234,7 +234,7 @@ the proxy, or use `allow_host`. - `html_security::very_long_text_node` (100KB text node) - `html_proptest::no_panic_on_arbitrary_input` -### T6: Malicious Office Documents (DOCX / XLSX / XLSB / XLS / ODS) +### T6: Malicious Office Documents (DOCX / PPTX / XLSX / XLSB / XLS / ODS) **Description**: Crafted containers aimed at exhausting memory/CPU, crashing the process, or smuggling content past a human reviewer. @@ -285,6 +285,10 @@ process, or smuggling content past a human reviewer. and XF indexes by lookup. Malformed cell records are skipped; a truncated part fails that sheet with `sheet_error`. Cells stream into the capped grid, so `BrtWsDim` claims never allocate. Proptests drive the reader directly, outside `catch_unwind`. +- PPTX reuses the DOCX stack: every part through `ZipGuard`, depth-capped XML tree, entities + never expanded. Each slide reference re-reads its part through the same guard, so a + presentation listing one slide thousands of times is bounded by `max_archive_total_size`. + Group shapes recurse only as deep as the XML tree (256). - XLS `SummaryInformation` title parsing is bounds-checked (1 MiB stream cap, checked offsets, 1024-property cap); failures yield no title, never an error. @@ -298,6 +302,7 @@ process, or smuggling content past a human reviewer. #### T6e: Macros and hidden content - No code execution: VBA projects and XLM macro sheets are never read or rendered; formulas are not evaluated (cached values only). +- Hidden PPTX slides skipped by default (`hidden_slide_skipped`), same rationale as sheets. - Hidden / very hidden sheets skipped by default (`hidden_sheet_skipped` warning) since invisible content is a prompt-injection vector. DOCX deleted revisions and field instructions are dropped. @@ -323,6 +328,8 @@ process, or smuggling content past a human reviewer. `spreadsheet_integration::xlsx_malformed_core_properties_do_not_fail_extraction` - `truncated_fixtures_never_panic`, `random_bytes_never_panic`, `random_container_bytes_never_panic`, `mutated_fixtures_never_panic` +- `pptx_zip_bomb_slide_rejected`, `pptx_repeated_slide_references_bounded_by_total_size`, + `pptx_billion_laughs_and_deep_groups`, `pptx_integration::hidden_slides_skipped_by_default` - `spreadsheet_integration::xlsx_hidden_sheet_skipped_by_default`, `spreadsheet_integration::xlsm_with_vba_project_extracts_values_only` diff --git a/schema/ir.schema.json b/schema/ir.schema.json index 84045ee..269a520 100644 --- a/schema/ir.schema.json +++ b/schema/ir.schema.json @@ -675,7 +675,7 @@ ] }, "page": { - "description": "Paged formats: 1-based page number.", + "description": "Paged formats: 1-based page number (PPTX: slide number).", "format": "uint32", "minimum": 0, "type": [