Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5df41ad
migrated from `roxmltree` to `quick-xml` as XML parser dependency
nilskruthoff Jul 17, 2026
a556fe1
formatted long namespace constants to improve readability in `constan…
nilskruthoff Jul 17, 2026
09bac2c
refactored error handling to include XML part context, added `xml.rs`…
nilskruthoff Jul 17, 2026
f684e5d
added `xml.rs` module for XML parsing utilities based on `quick-xml`
nilskruthoff Jul 17, 2026
5811f98
expanded `Presentation` and related structs to support detailed metad…
nilskruthoff Jul 17, 2026
264d870
refactored `parse_relationships` to use `quick-xml`, improving XML pa…
nilskruthoff Jul 17, 2026
d49a3b7
refactored slide and notes parsing to use `quick-xml`, added semantic…
nilskruthoff Jul 17, 2026
a872ed6
refactored ODP slide parsing to use `quick-xml`, introduced semantic …
nilskruthoff Jul 17, 2026
eb897ca
enhanced image resource handling with diagnostics, updated `Slide` co…
nilskruthoff Jul 17, 2026
9d19987
refactored `Slide` struct to include semantic blocks, enhanced Markdo…
nilskruthoff Jul 17, 2026
f52f1ed
refactored metadata parsing to use `quick-xml`, unified ODP and PPTX …
nilskruthoff Jul 17, 2026
07d16b9
added strikethrough support, baseline formatting, and improved link t…
nilskruthoff Jul 17, 2026
a4b7b13
added tests for strikethrough, baseline formatting, and improved link…
nilskruthoff Jul 17, 2026
9062c79
added `parse_document` method to `Presentation` for generating semant…
nilskruthoff Jul 17, 2026
4214e66
added unit tests for `ParserConfig` to validate default values and bu…
nilskruthoff Jul 17, 2026
95cbac1
moved ODP-specific unit tests to `odp_pull.rs` and added integration …
nilskruthoff Jul 17, 2026
323a718
added unit tests for `parse_relationships` to handle unclosed XML, un…
nilskruthoff Jul 17, 2026
79cc7dc
added comprehensive unit tests for `Slide` and `ParserConfig`, improv…
nilskruthoff Jul 17, 2026
0dabb01
refactored examples to enhance semantic Markdown handling, streamline…
nilskruthoff Jul 17, 2026
21ccdc5
updated README with detailed semantic document model documentation
nilskruthoff Jul 17, 2026
423e884
updated `CHANGELOG.md` with details on semantic model, parsing improv…
nilskruthoff Jul 17, 2026
8d3524b
added unit tests for improved semantic block handling, spatial orderi…
nilskruthoff Jul 17, 2026
6e50a7b
added #[allow(unused_imports)] annotations for test modules to preven…
nilskruthoff Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,65 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Added a shared semantic document model for PPTX and ODP with presentations,
slides, positioned blocks, text roles, paragraphs, list metadata, tables,
images, and unsupported-content fallbacks
- Added `PresentationContainer::parse_document()` for retrieving presentation
metadata, semantic slides, and aggregated parser diagnostics
- Added configurable per-slide Markdown rendering through `MarkdownOptions`,
including source and spatial reading-order strategies
- Added non-fatal `ParseDiagnostic` values for missing resources and content that
cannot yet be represented structurally
- Added support for paragraph boundaries, hard line breaks, PowerPoint fields,
ordered-list start values, explicit list removal, strikethrough, superscript,
subscript, font sizes, image alternative text, and merged table cells
- Added PPTX/ODP parity tests, semantic parser tests, missing-resource tests, and
additional unit coverage for configuration, presentation, slide, type, and XML
components
- Added detailed architecture and migration documentation in
[`docs/SEMANTIC_MARKDOWN_CONVERSION.md`](docs/SEMANTIC_MARKDOWN_CONVERSION.md)

### Changed

- Replaced DOM-style slide, relationship, and ODP XML processing with
event-driven pull parsing to reduce intermediate allocations and preserve
document structure during parsing
- Unified PPTX and ODP conversion around the same semantic block model before
rendering Markdown
- Improved Markdown accuracy for titles, subtitles, headings, mixed list and
non-list paragraphs, nested lists, hyperlinks, images, and tables
- Tables without merged cells continue to use GFM Markdown; tables containing
row or column spans now use an HTML fallback to preserve their structure
- Missing image resources no longer discard the complete slide and are reported
through diagnostics instead
- Image output now uses standard Markdown image syntax and propagates save or
filesystem failures
- Preserved `Slide::elements` and existing container, streaming, and PPTX-only
APIs as transitional compatibility paths; new code should prefer
`Slide::blocks` or `PresentationContainer::parse_document()`

### Fixed

- Fixed inherited bullet styles overriding paragraphs that explicitly disable
bullets
- Fixed mixed plain and list paragraphs being flattened into one list
- Fixed loss of PPTX and ODP table-cell spans, image alternative text, and
locally inherited run formatting
- Fixed unsafe or ambiguous Markdown link destinations containing whitespace or
parentheses

### Breaking

- `Slide::convert_to_md()` now returns `Result<String>` instead of
`Option<String>` so conversion and filesystem errors cannot be silently lost
- Public `Slide`, `Formatting`, and table-cell struct literals must account for
the newly added semantic and formatting fields or use their constructors and
defaults

## [0.5.1] - 2026-07-14

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ path = "examples/slide_elements.rs"

[dependencies]
zip = "8.6.0"
roxmltree = "0.21.1"
quick-xml = "0.41.0"
thiserror = "2.0.18"
base64 = "0.22.1"
image = "0.25.10"
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
![License](https://img.shields.io/crates/l/pptx-to-md.svg)
[![dependency status](https://deps.rs/repo/github/nilskruthoff/pptx-parser/status.svg)](https://deps.rs/repo/github/nilskruthoff/pptx-parser)
[![Documentation](https://docs.rs/pptx-to-md/badge.svg)](https://docs.rs/pptx-to-md)
![Crates.io Downloads](https://img.shields.io/crates/d/pptx-to-md)

# pptx-to-md

Expand Down Expand Up @@ -61,6 +62,24 @@ a complete PPTX or ODP document. It emits presentation metadata once at the
start and then appends all slide Markdown in order. The existing `parse_all()`,
streaming, and per-slide APIs remain available for structured processing.

### Semantic document model

`PresentationContainer::parse_document()` returns a **semantic** `Presentation`
containing metadata, slides, and non-fatal parser diagnostics. Each slide exposes
ordered `blocks` with bounds and source order. Text blocks preserve title/body
roles, individual paragraphs, list metadata, run formatting, and hyperlinks;
tables preserve merged-cell information and images retain alternative text.

For custom Markdown behavior, call `Slide::to_markdown(&MarkdownOptions)`.
`ReadingOrder::Spatial` is the default and groups content by visual columns;
`ReadingOrder::Source` preserves the package's source order. Per-slide
`convert_to_md()` now returns `Result<String>` so image and filesystem failures
cannot be discarded silently.

See [Semantic presentation-to-Markdown conversion](docs/SEMANTIC_MARKDOWN_CONVERSION.md)
for the architecture, migration notes, test coverage, and known limitations of
the pull-parser and semantic-model changes.

To access metadata as structured Rust values without converting slides, see
[`examples/presentation_metadata.rs`](https://github.com/nilskruthoff/pptx-parser/tree/master/examples/presentation_metadata.rs).

Expand Down
2 changes: 1 addition & 1 deletion examples/basic_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ fn main() -> Result<()> {
let markdown = container.convert_to_md()?;

fs::write("output.md", markdown)?;
println!("Converted {:?} presentation to {}", container.format(), "output.md");
println!("Converted {:?} presentation to output.md", container.format());
Ok(())
}
6 changes: 2 additions & 4 deletions examples/manual_image_extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//!
//! Run with: cargo run --example manual_image_extraction <path/to/your/presentation.pptx>

use base64::engine::general_purpose;
use base64::Engine;
use base64::engine::general_purpose;
use pptx_to_md::{ImageHandlingMode, ParserConfig, PptxContainer, Result};
use std::fs::File;
use std::io::Write;
Expand Down Expand Up @@ -52,9 +52,7 @@ fn main() -> Result<()> {

// Convert each slide to Markdown and save
for slide in slides {
if let Some(md_content) = slide.convert_to_md() {
writeln!(md_file, "{}", md_content).expect("Couldn't write to file");
}
writeln!(md_file, "{}", slide.convert_to_md()?).expect("Couldn't write to file");

// Manually load the base64 encoded image strings from the slide
if let Some(images) = slide.load_images_manually() {
Expand Down
9 changes: 4 additions & 5 deletions examples/memory_efficient_streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,10 @@ fn main() -> Result<()> {
slide.elements.len()
);

if let Some(md_content) = slide.convert_to_md() {
let output_path = format!("{}/slide_{}.md", output_dir, slide.slide_number);
fs::write(&output_path, md_content)?;
println!("Saved slide {} to {}", slide.slide_number, output_path);
}
let md_content = slide.convert_to_md()?;
let output_path = format!("{}/slide_{}.md", output_dir, slide.slide_number);
fs::write(&output_path, md_content)?;
println!("Saved slide {} to {}", slide.slide_number, output_path);
}
Err(e) => {
eprintln!("Error processing slide: {:?}", e);
Expand Down
4 changes: 2 additions & 2 deletions examples/performance_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ fn main() -> Result<()> {
let _md_content = single_thread_bench.measure(|| {
slides
.iter()
.filter_map(|slide| slide.convert_to_md())
.filter_map(|slide| slide.convert_to_md().ok())
.collect::<Vec<String>>()
});

Expand Down Expand Up @@ -218,7 +218,7 @@ fn main() -> Result<()> {
let _md_content = optimized_multi_thread_bench.measure(|| {
slides
.par_iter()
.filter_map(|slide| slide.convert_to_md())
.filter_map(|slide| slide.convert_to_md().ok())
.collect::<Vec<String>>()
});

Expand Down
4 changes: 1 addition & 3 deletions examples/save_images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ fn main() -> Result<()> {

// Convert each slide to Markdown and save the images automatically
for slide in slides {
if let Some(md_content) = slide.convert_to_md() {
writeln!(md_file, "{}", md_content).expect("Couldn't write to file");
}
writeln!(md_file, "{}", slide.convert_to_md()?).expect("Couldn't write to file");
}

println!("All slides converted successfully!");
Expand Down
30 changes: 13 additions & 17 deletions examples/slide_elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//!
//! Run with: cargo run --example slide_elements <path/to/your/presentation.pptx>

use pptx_to_md::{ParserConfig, PptxContainer, Result, SlideElement};
use pptx_to_md::{ParserConfig, PptxContainer, Result, SlideBlockContent};
use std::env;
use std::path::Path;

Expand All @@ -32,28 +32,24 @@ fn main() -> Result<()> {
match slide_result {
Ok(slide) => {
println!(
"Processing slide {} ({} elements)",
"Processing slide {} ({} semantic blocks)",
slide.slide_number,
slide.elements.len()
slide.blocks.len()
);

// iterate over each slide element and match them to add custom logic
for element in &slide.elements {
match element {
SlideElement::Text(text, pos) => {
println!("{:?}\t{:?}\n", text, pos)
for block in &slide.blocks {
match &block.content {
SlideBlockContent::Text(text) => {
println!("{:?}\t{:?}\n", text, block.bounds)
}
SlideElement::Table(table, pos) => {
println!("{:?}\t{:?}\n", table, pos)
SlideBlockContent::Table(table) => {
println!("{:?}\t{:?}\n", table, block.bounds)
}
SlideElement::Image(image, pos) => {
println!("{:?}\t{:?}\n", image, pos)
SlideBlockContent::Image(image) => {
println!("{:?}\t{:?}\n", image, block.bounds)
}
SlideElement::List(list, pos) => {
println!("{:?}\t{:?}\n", list, pos)
}
SlideElement::Unknown => {
println!("An Unknown element was found.\n")
SlideBlockContent::Unsupported(unsupported) => {
println!("Unsupported: {:?}\n", unsupported)
}
}
}
Expand Down
19 changes: 12 additions & 7 deletions src/constants.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
pub const P_NAMESPACE: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
pub const A_NAMESPACE: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
pub const RELS_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
pub const IMAGE_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
pub const SLIDE_LAYOUT_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
pub const SLIDE_MASTER_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster";
pub const NOTES_SLIDE_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
pub const COMMENTS_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
pub const HYPERLINK_NAMESPACE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
pub const IMAGE_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
pub const SLIDE_LAYOUT_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout";
pub const SLIDE_MASTER_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster";
pub const NOTES_SLIDE_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
pub const COMMENTS_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
pub const HYPERLINK_NAMESPACE: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
47 changes: 35 additions & 12 deletions src/container.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use super::{Result, Slide};
use crate::PresentationMetadata;
use crate::constants::{
COMMENTS_NAMESPACE, NOTES_SLIDE_NAMESPACE, SLIDE_LAYOUT_NAMESPACE, SLIDE_MASTER_NAMESPACE,
};
use crate::metadata::{parse_pptx_metadata, render_presentation_markdown};
use crate::parse_rels::{parse_hyperlink_rels, parse_relationships};
use crate::parse_xml::{extract_inherited_positions, InheritedPositions};
use crate::parse_xml::{InheritedPositions, extract_inherited_positions};
use crate::parser_config::ParserConfig;
use crate::PresentationMetadata;
use rayon::prelude::*;
use std::sync::Arc;
use std::{collections::HashMap, io::Read, path::Path};
Expand Down Expand Up @@ -153,15 +153,24 @@ impl PptxContainer {

// Preload images if enabled
let mut slide_images = Vec::new();
let mut resource_diagnostics = Vec::new();
if config.extract_images {
if let Some(ref data) = rels_data {
slide_images = crate::parse_rels::parse_slide_rels(data)?;
}

for img_ref in &slide_images {
let path = PptxContainer::resolve_target_path(slide_path, &img_ref.target);
let data = self.read_file_from_archive(&path)?;
all_image_data.entry(img_ref.target.clone()).or_insert(data);
match self.read_file_from_archive(&path) {
Ok(data) => {
all_image_data.entry(img_ref.target.clone()).or_insert(data);
}
Err(error) => resource_diagnostics.push(crate::ParseDiagnostic {
severity: crate::DiagnosticSeverity::Warning,
message: format!("Image resource could not be loaded: {error}"),
source: Some(path),
}),
}
}
}

Expand All @@ -174,6 +183,7 @@ impl PptxContainer {
speaker_notes,
comments,
hyperlinks,
resource_diagnostics,
));
}

Expand All @@ -193,13 +203,15 @@ impl PptxContainer {
speaker_notes,
comments,
hyperlinks,
resource_diagnostics,
)| {
// Parse XML in parallel (CPU-intensive)
let elements = crate::parse_xml::parse_slide_xml_with_hyperlinks(
let mut parsed = crate::parse_xml::parse_slide_document_with_hyperlinks(
&xml,
&inherited_positions,
&hyperlinks,
)?;
parsed.diagnostics.extend(resource_diagnostics);

// Resolve image data from shared registry
let mut image_map = HashMap::new();
Expand All @@ -212,15 +224,17 @@ impl PptxContainer {
}

// Build slide
let mut slide = Slide::new(
let mut slide = Slide::new_semantic(
path,
number,
elements,
parsed.elements,
parsed.blocks,
speaker_notes,
comments,
images,
image_map,
config.clone(),
parsed.diagnostics,
);
slide.link_images();
Ok(slide)
Expand Down Expand Up @@ -274,7 +288,7 @@ impl PptxContainer {
self.resolve_inherited_positions(slide_path, rels_data.as_deref())?;
let speaker_notes = self.resolve_speaker_notes(slide_path, rels_data.as_deref())?;
let comments = self.resolve_comments(slide_path, rels_data.as_deref())?;
let elements = crate::parse_xml::parse_slide_xml_with_hyperlinks(
let mut parsed = crate::parse_xml::parse_slide_document_with_hyperlinks(
&slide_data,
&inherited_positions,
&hyperlinks,
Expand All @@ -291,23 +305,32 @@ impl PptxContainer {

for img_ref in &images {
let img_path = Self::resolve_target_path(slide_path, &img_ref.target);
if let Ok(data) = self.read_file_from_archive(&img_path) {
image_data.insert(img_ref.id.clone(), data);
match self.read_file_from_archive(&img_path) {
Ok(data) => {
image_data.insert(img_ref.id.clone(), data);
}
Err(error) => parsed.diagnostics.push(crate::ParseDiagnostic {
severity: crate::DiagnosticSeverity::Warning,
message: format!("Image resource could not be loaded: {error}"),
source: Some(img_path),
}),
}
}
}

let config = self.config.clone();

let mut slide = Slide::new(
let mut slide = Slide::new_semantic(
slide_path.to_string(),
slide_number,
elements,
parsed.elements,
parsed.blocks,
speaker_notes,
comments,
images,
image_data,
config,
parsed.diagnostics,
);

slide.link_images();
Expand Down
Loading
Loading