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) -> HashMap {
- 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,
- 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,
- },
- Break {
- link: Option,
- },
-}
-
-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 {
- 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) -> Vec {
- // 1. Merge adjacent text with identical formatting and link.
- let mut merged: Vec = 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 = 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 {
- 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) -> (Vec, Vec, Vec) {
- 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) -> 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)) => 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,
- content: Vec,
- index: usize,
-}
-
#[derive(Default)]
struct Body {
blocks: Vec,
@@ -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 = 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) -> Vec {
- 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 {
+ 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 {
+ Err(Error::UnsupportedFormat(
+ "PPTX support requires the 'pptx' feature".to_string(),
+ ))
+ }
+
#[cfg(feature = "spreadsheet")]
fn parse_spreadsheet(&self, bytes: &[u8], budget: &Budget) -> crate::Result {
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 { 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,
- /// 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,
}
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) -> HashMap {
+ 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,
+ 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,
+ },
+ Break {
+ link: Option,
+ },
+}
+
+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) -> Vec {
+ // 1. Merge adjacent text with identical formatting and link.
+ let mut merged: Vec = 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 = 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 {
+ 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) -> (Vec, Vec, Vec) {
+ 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) -> 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)) => 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,
+ pub content: Vec,
+ /// 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 = 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) -> Vec {
+ 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: ` 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,
+ pub warnings: Vec,
+ 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