From 5df41ad49c6ecd8b6f714d1c181dc6e6e18e622b Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:43:56 +0200 Subject: [PATCH 01/23] migrated from `roxmltree` to `quick-xml` as XML parser dependency --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5c3dea9..d24dc53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" From a556fe1d1f81ca99f6f91900346887f96c6b6326 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:44:24 +0200 Subject: [PATCH 02/23] formatted long namespace constants to improve readability in `constants.rs` --- src/constants.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index ae9e8aa..f30c00e 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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"; From 09bac2cfef7c2981b54d974ecffe3bd566ab45db Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:44:33 +0200 Subject: [PATCH 03/23] refactored error handling to include XML part context, added `xml.rs` module --- src/lib.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index bd33d88..9f1959e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ mod parser_config; mod presentation; mod slide; mod types; +mod xml; pub use container::PptxContainer; pub use metadata::PresentationMetadata; @@ -22,8 +23,12 @@ pub enum Error { #[error("Zip error: {0}")] Zip(#[from] zip::result::ZipError), - #[error("XML parse error: {0}")] - Xml(#[from] roxmltree::Error), + #[error("XML parse error in {part}: {source}")] + Xml { + part: String, + #[source] + source: quick_xml::Error, + }, #[error("UTF-8 conversion error: {0}")] Utf8(#[from] std::str::Utf8Error), From f684e5dc4ff96b427118e83049c01b576dc6c6db Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:44:55 +0200 Subject: [PATCH 04/23] added `xml.rs` module for XML parsing utilities based on `quick-xml` --- src/xml.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/xml.rs diff --git a/src/xml.rs b/src/xml.rs new file mode 100644 index 0000000..ff0e550 --- /dev/null +++ b/src/xml.rs @@ -0,0 +1,97 @@ +use crate::{Error, Result}; +use quick_xml::events::{BytesRef, BytesStart, BytesText, Event}; +use quick_xml::name::ResolveResult; +use quick_xml::reader::NsReader; +use std::borrow::Cow; + +pub(crate) type XmlReader<'a> = NsReader<&'a [u8]>; + +pub(crate) fn reader(data: &[u8]) -> XmlReader<'_> { + let mut reader = NsReader::from_reader(data); + reader.config_mut().trim_text(false); + reader +} + +pub(crate) fn event<'a>(reader: &mut XmlReader<'a>, part: &str) -> Result> { + reader.read_event().map_err(|source| Error::Xml { + part: part.to_string(), + source, + }) +} + +pub(crate) fn local(name: &[u8]) -> &[u8] { + name.rsplit(|byte| *byte == b':').next().unwrap_or(name) +} + +pub(crate) fn element_is( + reader: &XmlReader<'_>, + element: &BytesStart<'_>, + namespace: &str, + name: &[u8], +) -> bool { + let (resolved, local_name) = reader.resolver().resolve_element(element.name()); + local_name.as_ref() == name + && matches!(resolved, ResolveResult::Bound(value) if value.as_ref() == namespace.as_bytes()) +} + +pub(crate) fn end_is(name: &[u8], wanted: &[u8]) -> bool { + local(name) == wanted +} + +pub(crate) fn attr(element: &BytesStart<'_>, wanted: &[u8]) -> Option { + element + .attributes() + .with_checks(false) + .flatten() + .find_map(|attribute| { + (local(attribute.key.as_ref()) == wanted).then(|| { + let value = String::from_utf8_lossy(attribute.value.as_ref()).into_owned(); + quick_xml::escape::unescape(&value) + .map(Cow::into_owned) + .unwrap_or(value) + }) + }) +} + +pub(crate) fn text(event: &BytesText<'_>, part: &str) -> Result { + let decoded = event.decode().map_err(|source| Error::Xml { + part: part.to_string(), + source: source.into(), + })?; + Ok(quick_xml::escape::unescape(&decoded) + .map(Cow::into_owned) + .unwrap_or_else(|_| decoded.into_owned())) +} + +pub(crate) fn reference(event: &BytesRef<'_>, part: &str) -> Result { + let decoded = event.decode().map_err(|source| Error::Xml { + part: part.to_string(), + source: source.into(), + })?; + let escaped = format!("&{decoded};"); + Ok(quick_xml::escape::unescape(&escaped) + .map(Cow::into_owned) + .unwrap_or(escaped)) +} + +pub(crate) fn skip_element(reader: &mut XmlReader<'_>, end: &[u8], part: &str) -> Result<()> { + let mut depth = 1usize; + while depth > 0 { + match event(reader, part)? { + Event::Start(_) => depth += 1, + Event::End(element) => { + depth -= 1; + if depth == 0 && end_is(element.name().as_ref(), end) { + break; + } + } + Event::Eof => return Err(Error::ParseError("Unexpected end of XML element")), + _ => {} + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "../tests/unit/xml.rs"] +mod tests; From 5811f98134148295da4dce2b20ac29557a21337d Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:45:05 +0200 Subject: [PATCH 05/23] expanded `Presentation` and related structs to support detailed metadata, text formatting, and diagnostic info --- src/types.rs | 195 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 7 deletions(-) diff --git a/src/types.rs b/src/types.rs index 75569bb..44bc931 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,11 +1,8 @@ #[derive(Debug)] pub struct Presentation { - pub slides: Vec, -} - -#[derive(Debug)] -pub struct Slide { - pub elements: Vec, + pub metadata: crate::PresentationMetadata, + pub slides: Vec, + pub diagnostics: Vec, } #[derive(Debug, Clone)] @@ -45,9 +42,20 @@ pub struct Formatting { pub bold: bool, pub italic: bool, pub underlined: bool, + pub strikethrough: bool, + pub baseline: Baseline, + pub font_size_points: Option, pub lang: String, } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Baseline { + #[default] + Normal, + Superscript, + Subscript, +} + #[derive(Debug, Clone)] pub struct Run { pub text: String, @@ -75,9 +83,13 @@ pub struct TableRow { pub cells: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct TableCell { pub runs: Vec, + pub paragraphs: Vec, + pub row_span: usize, + pub column_span: usize, + pub covered: bool, } #[derive(Debug, Clone)] @@ -97,3 +109,172 @@ pub struct ElementPosition { pub x: i64, pub y: i64, } + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Bounds { + pub x: i64, + pub y: i64, + pub width: i64, + pub height: i64, +} + +impl From for Bounds { + fn from(position: ElementPosition) -> Self { + Self { + x: position.x, + y: position.y, + width: 0, + height: 0, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TextRole { + Title, + Subtitle, + Heading, + Body, + Caption, + #[default] + Other, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ParagraphAlignment { + #[default] + Start, + Center, + End, + Justify, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ListKind { + Bullet { character: Option }, + Ordered { style: Option, start: u32 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListInfo { + pub level: u32, + pub kind: ListKind, +} + +#[derive(Debug, Clone, Default)] +pub struct Paragraph { + pub runs: Vec, + pub alignment: ParagraphAlignment, + pub list: Option, + pub list_explicit: bool, +} + +impl Paragraph { + pub fn plain(runs: Vec) -> Self { + Self { + runs, + ..Self::default() + } + } + + pub fn text(&self) -> String { + self.runs.iter().map(|run| run.text.as_str()).collect() + } +} + +#[derive(Debug, Clone, Default)] +pub struct TextBlock { + pub role: TextRole, + pub paragraphs: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SemanticTableCell { + pub paragraphs: Vec, + pub row_span: usize, + pub column_span: usize, + pub covered: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct SemanticTableRow { + pub cells: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct SemanticTable { + pub rows: Vec, +} + +#[derive(Debug, Clone)] +pub struct ImageBlock { + pub reference: ImageReference, + pub alt_text: Option, + pub mime_type: Option, +} + +#[derive(Debug, Clone)] +pub struct UnsupportedBlock { + pub kind: String, + pub fallback_text: Option, +} + +#[derive(Debug, Clone)] +pub enum SlideBlockContent { + Text(TextBlock), + Table(SemanticTable), + Image(ImageBlock), + Unsupported(UnsupportedBlock), +} + +#[derive(Debug, Clone)] +pub struct SlideBlock { + pub bounds: Bounds, + pub source_order: usize, + pub content: SlideBlockContent, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ReadingOrder { + Source, + #[default] + Spatial, +} + +#[derive(Debug, Clone)] +pub struct MarkdownOptions { + pub reading_order: ReadingOrder, + pub include_slide_number_as_comment: bool, + pub include_speaker_notes: bool, + pub include_comments: bool, + pub render_unsupported_comments: bool, +} + +impl Default for MarkdownOptions { + fn default() -> Self { + Self { + reading_order: ReadingOrder::Spatial, + include_slide_number_as_comment: true, + include_speaker_notes: false, + include_comments: false, + render_unsupported_comments: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiagnosticSeverity { + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseDiagnostic { + pub severity: DiagnosticSeverity, + pub message: String, + pub source: Option, +} + +#[cfg(test)] +#[path = "../tests/unit/types.rs"] +mod tests; From 264d870a05caa8e7203724a07eddc6b63e65cccd Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:45:18 +0200 Subject: [PATCH 06/23] refactored `parse_relationships` to use `quick-xml`, improving XML parsing for `.rels` files --- src/parse_rels.rs | 63 +++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/src/parse_rels.rs b/src/parse_rels.rs index dd420c1..394005f 100644 --- a/src/parse_rels.rs +++ b/src/parse_rels.rs @@ -1,7 +1,10 @@ +use crate::Result; use crate::constants::IMAGE_NAMESPACE; use crate::types::ImageReference; -use crate::{Error, Result}; -use roxmltree::Document; +use crate::xml::{attr, element_is, event, reader}; +use quick_xml::events::Event; + +const RELATIONSHIPS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Relationship { @@ -12,31 +15,49 @@ pub struct Relationship { /// Parses package relationship (`.rels`) XML data and extracts all relationships. pub fn parse_relationships(xml_data: &[u8]) -> Result> { - let xml_str = std::str::from_utf8(xml_data).map_err(|_| Error::Unknown)?; - let doc = Document::parse(xml_str)?; - let root = doc.root_element(); - + let mut xml = reader(xml_data); let mut relationships = Vec::new(); - for rel in root - .children() - .filter(|n| n.is_element() && n.tag_name().name() == "Relationship") - { - if let (Some(id), Some(rel_type), Some(target)) = ( - rel.attribute("Id"), - rel.attribute("Type"), - rel.attribute("Target"), - ) { - relationships.push(Relationship { - id: id.to_string(), - rel_type: rel_type.to_string(), - target: target.to_string(), - }); + let mut depth = 0usize; + loop { + match event(&mut xml, "relationships")? { + Event::Start(element) => { + depth += 1; + if element_is(&xml, &element, RELATIONSHIPS_NS, b"Relationship") { + push_relationship(&element, &mut relationships); + } + } + Event::Empty(element) + if element_is(&xml, &element, RELATIONSHIPS_NS, b"Relationship") => + { + push_relationship(&element, &mut relationships); + } + Event::End(_) if depth > 0 => depth -= 1, + Event::Eof if depth > 0 => { + return Err(crate::Error::ParseError( + "Unexpected end of relationships XML", + )); + } + Event::Eof => break, + _ => {} } } - Ok(relationships) } +fn push_relationship(element: &quick_xml::events::BytesStart<'_>, out: &mut Vec) { + if let (Some(id), Some(rel_type), Some(target)) = ( + attr(element, b"Id"), + attr(element, b"Type"), + attr(element, b"Target"), + ) { + out.push(Relationship { + id, + rel_type, + target, + }); + } +} + /// Parses relationship (`.rels`) XML data from a PPTX slide, extracting image references. /// /// PowerPoint slide relationships data contain mappings between resource IDs and their targets. From d49a3b781e861907e5bc395eeaad04373ccdf07f Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:45:24 +0200 Subject: [PATCH 07/23] refactored slide and notes parsing to use `quick-xml`, added semantic block parsing and improved diagnostic capabilities --- src/parse_xml.rs | 2107 ++++++++++++++++++++++++++++++---------------- 1 file changed, 1399 insertions(+), 708 deletions(-) diff --git a/src/parse_xml.rs b/src/parse_xml.rs index 05c25ca..b7a9ae4 100644 --- a/src/parse_xml.rs +++ b/src/parse_xml.rs @@ -1,23 +1,19 @@ -use crate::constants::{A_NAMESPACE, P_NAMESPACE, RELS_NAMESPACE}; +use crate::constants::{A_NAMESPACE, P_NAMESPACE}; use crate::types::{SlideElement, TableCell, TableElement, TableRow, TextElement}; +use crate::xml::{ + XmlReader, attr, element_is, end_is, event, reader, reference, skip_element, text, +}; use crate::{ - ElementPosition, Error, Formatting, ImageReference, ListElement, ListItem, Result, Run, + Bounds, DiagnosticSeverity, ElementPosition, Error, Formatting, ImageBlock, ImageReference, + ListElement, ListInfo, ListItem, ListKind, Paragraph, ParagraphAlignment, ParseDiagnostic, + Result, Run, SemanticTable, SemanticTableCell, SemanticTableRow, SlideBlock, SlideBlockContent, + TextBlock, TextRole, UnsupportedBlock, }; -use roxmltree::{Document, Node}; +use quick_xml::events::{BytesStart, Event}; use std::collections::HashMap; -enum ParsedContent { - Text(TextElement), - List(ListElement), -} +type ParsedContent = TextBlock; -/// Represents the accumulated coordinate transformation from a local element space -/// into slide-level coordinates. -/// -/// Grouped shapes in PPTX can define their own origin (`chOff`) and size (`chExt`) -/// which must be mapped into the enclosing group frame (`off`/`ext`). This helper -/// stores the current scaling and translation so child elements can be converted -/// into their effective slide position. #[derive(Debug, Clone, Copy)] struct CoordinateTransform { scale_x: f64, @@ -43,8 +39,21 @@ impl CoordinateTransform { } } - fn then(self, next: CoordinateTransform) -> CoordinateTransform { - CoordinateTransform { + fn apply_bounds(self, bounds: Bounds) -> Bounds { + let position = self.apply(ElementPosition { + x: bounds.x, + y: bounds.y, + }); + Bounds { + x: position.x, + y: position.y, + width: (bounds.width as f64 * self.scale_x.abs()).round() as i64, + height: (bounds.height as f64 * self.scale_y.abs()).round() as i64, + } + } + + fn then(self, next: Self) -> Self { + Self { scale_x: self.scale_x * next.scale_x, scale_y: self.scale_y * next.scale_y, translate_x: self.scale_x * next.translate_x + self.translate_x, @@ -53,81 +62,156 @@ impl CoordinateTransform { } } -/// Identifies a placeholder shape across slide, layout, and master documents. -/// -/// PowerPoint placeholders are primarily matched by `type` and `idx`. Some -/// layouts omit the type attribute, so resolution falls back from exact match -/// to looser combinations when needed. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PlaceholderKey { kind: Option, idx: Option, } -/// Holds inherited placeholder positions resolved from slide layouts and masters. #[derive(Debug, Clone, Default)] pub struct InheritedPositions { positions: HashMap, + list_styles: HashMap>, } impl InheritedPositions { fn resolve(&self, key: &PlaceholderKey) -> Option { - self.positions + self.positions.get(key).copied().or_else(|| { + key.idx + .as_ref() + .and_then(|idx| { + self.positions + .iter() + .find(|(candidate, _)| candidate.idx.as_deref() == Some(idx)) + }) + .or_else(|| { + key.kind.as_ref().and_then(|kind| { + self.positions + .iter() + .find(|(candidate, _)| candidate.kind.as_deref() == Some(kind)) + }) + }) + .map(|(_, position)| *position) + }) + } + + fn resolve_list_kind(&self, key: &PlaceholderKey, level: u32) -> Option { + self.list_styles .get(key) - .copied() .or_else(|| { key.idx.as_ref().and_then(|idx| { - self.positions + self.list_styles .iter() - .find(|(candidate, _)| candidate.idx.as_deref() == Some(idx.as_str())) - .map(|(_, position)| *position) + .find(|(candidate, _)| candidate.idx.as_ref() == Some(idx)) + .map(|(_, styles)| styles) }) }) .or_else(|| { key.kind.as_ref().and_then(|kind| { - self.positions + self.list_styles .iter() - .find(|(candidate, _)| candidate.kind.as_deref() == Some(kind.as_str())) - .map(|(_, position)| *position) + .find(|(candidate, _)| candidate.kind.as_ref() == Some(kind)) + .map(|(_, styles)| styles) }) }) + .and_then(|styles| styles.get(&level).or_else(|| styles.get(&0))) + .cloned() + } +} + +#[derive(Default)] +struct PositionData { + x: Option, + y: Option, + width: Option, + height: Option, + placeholder: Option, + fallback_text: String, +} + +impl PositionData { + fn observe_off(&mut self, element: &BytesStart<'_>) { + if self.x.is_none() { + self.x = attr(element, b"x").and_then(|value| value.parse().ok()); + self.y = attr(element, b"y").and_then(|value| value.parse().ok()); + } } + + fn observe_placeholder(&mut self, element: &BytesStart<'_>) { + self.placeholder = Some(PlaceholderKey { + kind: attr(element, b"type"), + idx: attr(element, b"idx"), + }); + } + + fn observe_ext(&mut self, element: &BytesStart<'_>) { + if self.width.is_none() { + self.width = attr(element, b"cx").and_then(|value| value.parse().ok()); + self.height = attr(element, b"cy").and_then(|value| value.parse().ok()); + } + } + + fn raw(&self) -> Option { + Some(ElementPosition { + x: self.x?, + y: self.y?, + }) + } + + fn effective( + &self, + transform: CoordinateTransform, + inherited: &InheritedPositions, + ) -> ElementPosition { + self.raw() + .map(|position| transform.apply(position)) + .or_else(|| { + self.placeholder + .as_ref() + .and_then(|key| inherited.resolve(key)) + }) + .unwrap_or_default() + } + + fn effective_bounds( + &self, + transform: CoordinateTransform, + inherited: &InheritedPositions, + ) -> Bounds { + if let Some(position) = self.raw() { + transform.apply_bounds(Bounds { + x: position.x, + y: position.y, + width: self.width.unwrap_or(0), + height: self.height.unwrap_or(0), + }) + } else { + let position = self.effective(transform, inherited); + Bounds { + x: position.x, + y: position.y, + width: (self.width.unwrap_or(0) as f64 * transform.scale_x.abs()).round() as i64, + height: (self.height.unwrap_or(0) as f64 * transform.scale_y.abs()).round() as i64, + } + } + } +} + +struct ShapeData { + content: Option, + position: PositionData, +} + +pub(crate) struct ParsedSlideDocument { + pub elements: Vec, + pub blocks: Vec, + pub diagnostics: Vec, } -/// Parses raw XML slide data from a PowerPoint (pptx) file and extracts all slide elements. -/// -/// This function processes a single PowerPoint slide's XML data to identify and parse its -/// contained elements into structured variants such as text blocks, tables, images, and lists. -/// Unrecognized or malformed elements will result in inclusion of a [`SlideElement::Unknown`] variant. -/// -/// # Arguments -/// -/// - `xml_data`: Byte slice containing raw XML data of a PowerPoint slide. -/// -/// # Returns -/// -/// Returns a `Result` containing either: -/// - `Vec`: Vector of successfully parsed slide elements. -/// - `Error`: Error information encapsulated in [`crate::Error`] if parsing fails at XML parsing level. -/// -/// # Errors -/// -/// Parsing may fail and return [`Error`] if: -/// - The provided XML data isn't valid UTF-8. -/// - The XML structure is malformed or missing essential schema elements (`` or `` tags). -/// -/// # Notes -/// -/// - The function strictly follows Microsoft's Open XML slide schema. -/// - For best results, ensure input XML data is extracted directly from PPTX files or equivalent sources. pub fn parse_slide_xml(xml_data: &[u8]) -> Result> { parse_slide_xml_with_hyperlinks(xml_data, &InheritedPositions::default(), &HashMap::new()) } -/// Parses the user-authored text from a PPTX notes slide. -/// -/// Notes slides also contain placeholders for dates, footers, and slide numbers. -/// Only the `body` placeholder is speaker-note content and is therefore returned. #[cfg(test)] pub(crate) fn parse_speaker_notes_xml(xml_data: &[u8]) -> Result> { parse_speaker_notes_xml_with_hyperlinks(xml_data, &HashMap::new()) @@ -137,92 +221,92 @@ pub(crate) fn parse_speaker_notes_xml_with_hyperlinks( xml_data: &[u8], hyperlinks: &HashMap, ) -> Result> { - let xml_str = std::str::from_utf8(xml_data).map_err(|_| Error::Unknown)?; - let doc = Document::parse(xml_str)?; - let root = doc.root_element(); - let ns = root.tag_name().namespace(); - - let c_sld = root - .descendants() - .find(|node| node.tag_name().name() == "cSld" && node.tag_name().namespace() == ns) - .ok_or(Error::Unknown)?; - let sp_tree = c_sld - .children() - .find(|node| node.tag_name().name() == "spTree" && node.tag_name().namespace() == ns) - .ok_or(Error::Unknown)?; + let mut xml = reader(xml_data); + loop { + match event(&mut xml, "PPTX notes")? { + Event::Start(element) if element_is(&xml, &element, P_NAMESPACE, b"spTree") => { + return parse_notes_tree(&mut xml, hyperlinks); + } + Event::Eof => return Err(Error::ParseError("PPTX notes shape tree not found")), + _ => {} + } + } +} +fn parse_notes_tree( + xml: &mut XmlReader<'_>, + hyperlinks: &HashMap, +) -> Result> { let mut notes = Vec::new(); - for shape in sp_tree.children().filter(|node| { - node.is_element() - && node.tag_name().name() == "sp" - && node.tag_name().namespace() == Some(P_NAMESPACE) - && is_notes_body_placeholder(*node) - }) { - if let Some(text_body) = shape.children().find(|node| { - node.is_element() - && node.tag_name().name() == "txBody" - && node.tag_name().namespace() == Some(P_NAMESPACE) - }) { - let text = parse_text_with_hyperlinks(&text_body, hyperlinks)?; - if !text.runs.is_empty() { - notes.push(text); + loop { + match event(xml, "PPTX notes")? { + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"sp") => { + let shape = parse_shape(xml, hyperlinks)?; + if shape + .position + .placeholder + .as_ref() + .and_then(|key| key.kind.as_deref()) + == Some("body") + && let Some(content) = shape.content + { + let text = content_to_text(content); + if !text.runs.is_empty() { + notes.push(text); + } + } } + Event::Start(element) => { + let end = element.name().as_ref().to_vec(); + skip_element(xml, &end, "PPTX notes")?; + } + Event::End(element) if end_is(element.name().as_ref(), b"spTree") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX notes")), + _ => {} } } Ok(notes) } -/// Parses text content from both legacy and modern PPTX comment parts. pub(crate) fn parse_comments_xml_with_hyperlinks( xml_data: &[u8], hyperlinks: &HashMap, ) -> Result> { - let xml_str = std::str::from_utf8(xml_data).map_err(|_| Error::Unknown)?; - let doc = Document::parse(xml_str)?; + let mut xml = reader(xml_data); let mut comments = Vec::new(); - - for text_body in doc - .descendants() - .filter(|node| node.is_element() && node.tag_name().name() == "txBody") - { - let text = parse_text_with_hyperlinks(&text_body, hyperlinks)?; - if !text.runs.is_empty() { - comments.push(text); - } - } - - if comments.is_empty() { - for text in doc.descendants().filter(|node| { - node.is_element() - && node.tag_name().name() == "text" - && node.tag_name().namespace() == Some(P_NAMESPACE) - }) { - let content = text.text().unwrap_or_default(); - if !content.is_empty() { - comments.push(TextElement { - runs: vec![Run { - text: format!("{}\n", content), - formatting: Formatting::default(), - link_target: None, - }], - }); + let mut legacy = Vec::new(); + loop { + match event(&mut xml, "PPTX comments")? { + Event::Start(element) if crate::xml::local(element.name().as_ref()) == b"txBody" => { + let content = parse_text_body(&mut xml, true, hyperlinks)?; + let text = content_to_text(content); + if !text.runs.is_empty() { + comments.push(text); + } } + Event::Start(element) if element_is(&xml, &element, P_NAMESPACE, b"text") => { + let value = read_simple_text(&mut xml, b"text", "PPTX comments")?; + if !value.is_empty() { + legacy.push(TextElement { + runs: vec![Run { + text: format!("{value}\n"), + formatting: Formatting::default(), + link_target: None, + }], + }); + } + } + Event::Eof => break, + _ => {} } } - - Ok(comments) -} - -fn is_notes_body_placeholder(shape: Node<'_, '_>) -> bool { - shape.descendants().any(|node| { - node.is_element() - && node.tag_name().name() == "ph" - && node.tag_name().namespace() == Some(P_NAMESPACE) - && node.attribute("type") == Some("body") + Ok(if comments.is_empty() { + legacy + } else { + comments }) } -/// Parses raw slide XML and resolves missing placeholder positions from inherited sources. pub fn parse_slide_xml_with_inherited_positions( xml_data: &[u8], inherited_positions: &InheritedPositions, @@ -230,694 +314,1291 @@ pub fn parse_slide_xml_with_inherited_positions( parse_slide_xml_with_hyperlinks(xml_data, inherited_positions, &HashMap::new()) } -/// Parses slide XML while resolving text-run hyperlink relationship IDs. pub fn parse_slide_xml_with_hyperlinks( xml_data: &[u8], inherited_positions: &InheritedPositions, hyperlinks: &HashMap, ) -> Result> { - let xml_str = std::str::from_utf8(xml_data).map_err(|_| Error::Unknown)?; - let doc = Document::parse(xml_str)?; - let root = doc.root_element(); - let ns = root.tag_name().namespace(); - - let c_sld = root - .descendants() - .find(|n| n.tag_name().name() == "cSld" && n.tag_name().namespace() == ns) - .ok_or(format!("No tag was found for: {:?}", ns)) - .map_err(|_| Error::Unknown)?; - - let sp_tree = c_sld - .children() - .find(|n| n.tag_name().name() == "spTree" && n.tag_name().namespace() == ns) - .ok_or(format!("No tag was found for: {:?}", ns)) - .map_err(|_| Error::Unknown)?; - - let mut elements = Vec::new(); - for child_node in sp_tree.children().filter(|n| n.is_element()) { - elements.extend(parse_group( - &child_node, - CoordinateTransform::identity(), - inherited_positions, - hyperlinks, - )?); + let mut xml = reader(xml_data); + let mut in_common_slide = false; + loop { + match event(&mut xml, "PPTX slide")? { + Event::Start(element) if element_is(&xml, &element, P_NAMESPACE, b"cSld") => { + in_common_slide = true; + } + Event::Start(element) + if in_common_slide && element_is(&xml, &element, P_NAMESPACE, b"spTree") => + { + return parse_shape_tree( + &mut xml, + CoordinateTransform::identity(), + inherited_positions, + hyperlinks, + b"spTree", + ); + } + Event::Empty(element) + if in_common_slide && element_is(&xml, &element, P_NAMESPACE, b"spTree") => + { + return Ok(Vec::new()); + } + Event::End(element) if end_is(element.name().as_ref(), b"cSld") => { + in_common_slide = false; + } + Event::Eof => return Err(Error::ParseError("PPTX slide shape tree not found")), + _ => {} + } } +} - Ok(elements) +pub(crate) fn parse_slide_document_with_hyperlinks( + xml_data: &[u8], + inherited_positions: &InheritedPositions, + hyperlinks: &HashMap, +) -> Result { + let mut xml = reader(xml_data); + let mut in_common_slide = false; + let mut source_order = 0usize; + loop { + match event(&mut xml, "PPTX slide")? { + Event::Start(element) if element_is(&xml, &element, P_NAMESPACE, b"cSld") => { + in_common_slide = true; + } + Event::Start(element) + if in_common_slide && element_is(&xml, &element, P_NAMESPACE, b"spTree") => + { + return parse_semantic_shape_tree( + &mut xml, + CoordinateTransform::identity(), + inherited_positions, + hyperlinks, + b"spTree", + &mut source_order, + ); + } + Event::Empty(element) + if in_common_slide && element_is(&xml, &element, P_NAMESPACE, b"spTree") => + { + return Ok(ParsedSlideDocument { + elements: Vec::new(), + blocks: Vec::new(), + diagnostics: Vec::new(), + }); + } + Event::End(element) if end_is(element.name().as_ref(), b"cSld") => { + in_common_slide = false; + } + Event::Eof => return Err(Error::ParseError("PPTX slide shape tree not found")), + _ => {} + } + } } -/// Parses a slide node and all nested child nodes recursively. -/// -/// The `transform` argument carries the accumulated group transformation so elements -/// inside nested `` containers can be positioned in slide coordinates. -fn parse_group( - node: &Node, +fn parse_semantic_shape_tree( + xml: &mut XmlReader<'_>, transform: CoordinateTransform, - inherited_positions: &InheritedPositions, + inherited: &InheritedPositions, hyperlinks: &HashMap, -) -> Result> { - let mut elements = Vec::new(); + end: &[u8], + source_order: &mut usize, +) -> Result { + let mut parsed = ParsedSlideDocument { + elements: Vec::new(), + blocks: Vec::new(), + diagnostics: Vec::new(), + }; + loop { + match event(xml, "PPTX slide")? { + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"sp") => { + let mut shape = parse_shape(xml, hyperlinks)?; + let position = shape.position.effective(transform, inherited); + let bounds = shape.position.effective_bounds(transform, inherited); + if let Some(mut content) = shape.content.take() { + apply_inherited_list_styles(&mut content, &shape.position, inherited); + content.role = placeholder_role(shape.position.placeholder.as_ref()); + parsed + .elements + .extend(content_to_elements(content.clone(), position)); + push_semantic_block( + &mut parsed, + source_order, + bounds, + SlideBlockContent::Text(content), + ); + } else { + push_unsupported(&mut parsed, source_order, bounds, "shape", None); + } + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"graphicFrame") => { + let (table, position) = parse_graphic_frame(xml, hyperlinks)?; + let bounds = position.effective_bounds(transform, inherited); + if let Some(table) = table { + parsed.elements.push(SlideElement::Table( + table.clone(), + position.effective(transform, inherited), + )); + push_semantic_block( + &mut parsed, + source_order, + bounds, + SlideBlockContent::Table(legacy_table_to_semantic(&table)), + ); + } else { + let fallback_text = (!position.fallback_text.trim().is_empty()) + .then(|| position.fallback_text.trim().to_string()); + push_unsupported( + &mut parsed, + source_order, + bounds, + "graphicFrame", + fallback_text, + ); + } + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"pic") => { + let (image, position, alt_text) = parse_picture(xml)?; + parsed.elements.push(SlideElement::Image( + image.clone(), + position.effective(transform, inherited), + )); + push_semantic_block( + &mut parsed, + source_order, + position.effective_bounds(transform, inherited), + SlideBlockContent::Image(ImageBlock { + reference: image, + alt_text, + mime_type: None, + }), + ); + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSp") => { + merge_parsed( + &mut parsed, + parse_semantic_group(xml, transform, inherited, hyperlinks, source_order)?, + ); + } + Event::Start(element) => { + let local_name = crate::xml::local(element.name().as_ref()).to_vec(); + let is_presentation = { + let (namespace, _) = xml.resolver().resolve_element(element.name()); + matches!(namespace, quick_xml::name::ResolveResult::Bound(value) if value.as_ref() == P_NAMESPACE.as_bytes()) + }; + let element_end = element.name().as_ref().to_vec(); + skip_element(xml, &element_end, "PPTX slide")?; + if is_presentation && !matches!(local_name.as_slice(), b"nvGrpSpPr" | b"grpSpPr") { + push_unsupported( + &mut parsed, + source_order, + Bounds::default(), + &String::from_utf8_lossy(&local_name), + None, + ); + } + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX shape tree")), + _ => {} + } + } + Ok(parsed) +} - let tag_name = node.tag_name().name(); - let namespace = node.tag_name().namespace().unwrap_or(""); +fn parse_semantic_group( + xml: &mut XmlReader<'_>, + parent: CoordinateTransform, + inherited: &InheritedPositions, + hyperlinks: &HashMap, + source_order: &mut usize, +) -> Result { + let mut parsed = ParsedSlideDocument { + elements: Vec::new(), + blocks: Vec::new(), + diagnostics: Vec::new(), + }; + let mut transform = GroupTransformData::default(); + loop { + match event(xml, "PPTX group")? { + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSpPr") => { + parse_group_properties(xml, &mut transform)?; + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"sp") => { + let combined = parent.then(transform.finish()); + let mut shape = parse_shape(xml, hyperlinks)?; + let position = shape.position.effective(combined, inherited); + let bounds = shape.position.effective_bounds(combined, inherited); + if let Some(mut content) = shape.content.take() { + apply_inherited_list_styles(&mut content, &shape.position, inherited); + content.role = placeholder_role(shape.position.placeholder.as_ref()); + parsed + .elements + .extend(content_to_elements(content.clone(), position)); + push_semantic_block( + &mut parsed, + source_order, + bounds, + SlideBlockContent::Text(content), + ); + } else { + push_unsupported(&mut parsed, source_order, bounds, "shape", None); + } + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"graphicFrame") => { + let combined = parent.then(transform.finish()); + let (table, position) = parse_graphic_frame(xml, hyperlinks)?; + let bounds = position.effective_bounds(combined, inherited); + if let Some(table) = table { + parsed.elements.push(SlideElement::Table( + table.clone(), + position.effective(combined, inherited), + )); + push_semantic_block( + &mut parsed, + source_order, + bounds, + SlideBlockContent::Table(legacy_table_to_semantic(&table)), + ); + } else { + let fallback_text = (!position.fallback_text.trim().is_empty()) + .then(|| position.fallback_text.trim().to_string()); + push_unsupported( + &mut parsed, + source_order, + bounds, + "graphicFrame", + fallback_text, + ); + } + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"pic") => { + let combined = parent.then(transform.finish()); + let (image, position, alt_text) = parse_picture(xml)?; + parsed.elements.push(SlideElement::Image( + image.clone(), + position.effective(combined, inherited), + )); + push_semantic_block( + &mut parsed, + source_order, + position.effective_bounds(combined, inherited), + SlideBlockContent::Image(ImageBlock { + reference: image, + alt_text, + mime_type: None, + }), + ); + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSp") => { + merge_parsed( + &mut parsed, + parse_semantic_group( + xml, + parent.then(transform.finish()), + inherited, + hyperlinks, + source_order, + )?, + ); + } + Event::Start(element) => { + let name = crate::xml::local(element.name().as_ref()).to_vec(); + let end = element.name().as_ref().to_vec(); + skip_element(xml, &end, "PPTX group")?; + if !matches!(name.as_slice(), b"nvGrpSpPr") { + push_unsupported( + &mut parsed, + source_order, + Bounds::default(), + &String::from_utf8_lossy(&name), + None, + ); + } + } + Event::End(element) if end_is(element.name().as_ref(), b"grpSp") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX group")), + _ => {} + } + } + Ok(parsed) +} + +fn merge_parsed(target: &mut ParsedSlideDocument, mut source: ParsedSlideDocument) { + target.elements.append(&mut source.elements); + target.blocks.append(&mut source.blocks); + target.diagnostics.append(&mut source.diagnostics); +} - if namespace != P_NAMESPACE { - return Ok(elements); +fn push_semantic_block( + parsed: &mut ParsedSlideDocument, + source_order: &mut usize, + bounds: Bounds, + content: SlideBlockContent, +) { + parsed.blocks.push(SlideBlock { + bounds, + source_order: *source_order, + content, + }); + *source_order += 1; +} + +fn push_unsupported( + parsed: &mut ParsedSlideDocument, + source_order: &mut usize, + bounds: Bounds, + kind: &str, + fallback_text: Option, +) { + parsed.elements.push(SlideElement::Unknown); + parsed.diagnostics.push(ParseDiagnostic { + severity: DiagnosticSeverity::Warning, + message: format!("Unsupported PPTX slide element: {kind}"), + source: None, + }); + push_semantic_block( + parsed, + source_order, + bounds, + SlideBlockContent::Unsupported(UnsupportedBlock { + kind: kind.to_string(), + fallback_text, + }), + ); +} + +fn placeholder_role(placeholder: Option<&PlaceholderKey>) -> TextRole { + match placeholder.and_then(|placeholder| placeholder.kind.as_deref()) { + Some("title") | Some("ctrTitle") => TextRole::Title, + Some("subTitle") => TextRole::Subtitle, + Some("body") | Some("obj") => TextRole::Body, + Some("caption") => TextRole::Caption, + None if placeholder.is_some_and(|placeholder| placeholder.idx.is_some()) => TextRole::Body, + _ => TextRole::Other, + } +} + +fn apply_inherited_list_styles( + content: &mut TextBlock, + position: &PositionData, + inherited: &InheritedPositions, +) { + let Some(placeholder) = position.placeholder.as_ref() else { + return; + }; + for paragraph in &mut content.paragraphs { + if paragraph.list_explicit { + continue; + } + let level = paragraph.list.as_ref().map(|list| list.level).unwrap_or(0); + if let Some(kind) = inherited.resolve_list_kind(placeholder, level) { + paragraph.list = Some(ListInfo { level, kind }); + } } +} - let position = extract_position(node, transform, inherited_positions); +fn legacy_table_to_semantic(table: &TableElement) -> SemanticTable { + SemanticTable { + rows: table + .rows + .iter() + .map(|row| SemanticTableRow { + cells: row + .cells + .iter() + .map(|cell| SemanticTableCell { + paragraphs: if cell.paragraphs.is_empty() { + vec![Paragraph::plain(cell.runs.clone())] + } else { + cell.paragraphs.clone() + }, + row_span: cell.row_span.max(1), + column_span: cell.column_span.max(1), + covered: cell.covered, + }) + .collect(), + }) + .collect(), + } +} - match tag_name { - "sp" => match parse_sp(node, hyperlinks)? { - ParsedContent::Text(text) => elements.push(SlideElement::Text(text, position)), - ParsedContent::List(list) => elements.push(SlideElement::List(list, position)), - }, - "graphicFrame" => { - if let Some(graphic_element) = parse_graphic_frame_with_hyperlinks(node, hyperlinks)? { - elements.push(SlideElement::Table(graphic_element, position)); +fn parse_shape_tree( + xml: &mut XmlReader<'_>, + transform: CoordinateTransform, + inherited: &InheritedPositions, + hyperlinks: &HashMap, + end: &[u8], +) -> Result> { + let mut elements = Vec::new(); + loop { + match event(xml, "PPTX slide")? { + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"sp") => { + let mut shape = parse_shape(xml, hyperlinks)?; + let position = shape.position.effective(transform, inherited); + if let Some(content) = shape.content.as_mut() { + apply_inherited_list_styles(content, &shape.position, inherited); + } + elements.extend(content_to_elements( + shape + .content + .ok_or(Error::ParseError("PPTX shape has no text body"))?, + position, + )); } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"graphicFrame") => { + let (table, position) = parse_graphic_frame(xml, hyperlinks)?; + if let Some(table) = table { + elements.push(SlideElement::Table( + table, + position.effective(transform, inherited), + )); + } + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"pic") => { + let (image, position, _) = parse_picture(xml)?; + elements.push(SlideElement::Image( + image, + position.effective(transform, inherited), + )); + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSp") => { + elements.extend(parse_group(xml, transform, inherited, hyperlinks)?); + } + Event::Start(element) => { + let is_presentation = { + let (namespace, _) = xml.resolver().resolve_element(element.name()); + matches!(namespace, quick_xml::name::ResolveResult::Bound(value) if value.as_ref() == P_NAMESPACE.as_bytes()) + }; + let element_end = element.name().as_ref().to_vec(); + skip_element(xml, &element_end, "PPTX slide")?; + if is_presentation { + elements.push(SlideElement::Unknown); + } + } + Event::Empty(element) => { + let (namespace, _) = xml.resolver().resolve_element(element.name()); + if matches!(namespace, quick_xml::name::ResolveResult::Bound(value) if value.as_ref() == P_NAMESPACE.as_bytes()) + { + elements.push(SlideElement::Unknown); + } + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX shape tree")), + _ => {} } - "pic" => { - let image_reference = parse_pic(node)?; - elements.push(SlideElement::Image(image_reference, position)); - } - "grpSp" => { - let child_transform = transform.then(extract_group_transform(node)); - for child in node.children().filter(|n| n.is_element()) { + } + Ok(elements) +} + +fn parse_group( + xml: &mut XmlReader<'_>, + parent: CoordinateTransform, + inherited: &InheritedPositions, + hyperlinks: &HashMap, +) -> Result> { + let mut elements = Vec::new(); + let mut transform = GroupTransformData::default(); + loop { + match event(xml, "PPTX group")? { + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSpPr") => { + parse_group_properties(xml, &mut transform)?; + elements.push(SlideElement::Unknown); + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"sp") => { + let combined = parent.then(transform.finish()); + let mut shape = parse_shape(xml, hyperlinks)?; + let position = shape.position.effective(combined, inherited); + if let Some(content) = shape.content.as_mut() { + apply_inherited_list_styles(content, &shape.position, inherited); + } + elements.extend(content_to_elements( + shape + .content + .ok_or(Error::ParseError("PPTX shape has no text body"))?, + position, + )); + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"graphicFrame") => { + let combined = parent.then(transform.finish()); + let (table, position) = parse_graphic_frame(xml, hyperlinks)?; + if let Some(table) = table { + elements.push(SlideElement::Table( + table, + position.effective(combined, inherited), + )); + } + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"pic") => { + let combined = parent.then(transform.finish()); + let (image, position, _) = parse_picture(xml)?; + elements.push(SlideElement::Image( + image, + position.effective(combined, inherited), + )); + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSp") => { elements.extend(parse_group( - &child, - child_transform, - inherited_positions, + xml, + parent.then(transform.finish()), + inherited, hyperlinks, )?); } + Event::Start(element) => { + let is_presentation = { + let (namespace, _) = xml.resolver().resolve_element(element.name()); + matches!(namespace, quick_xml::name::ResolveResult::Bound(value) if value.as_ref() == P_NAMESPACE.as_bytes()) + }; + let end = element.name().as_ref().to_vec(); + skip_element(xml, &end, "PPTX group")?; + if is_presentation { + elements.push(SlideElement::Unknown); + } + } + Event::End(element) if end_is(element.name().as_ref(), b"grpSp") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX group")), + _ => {} } - _ => elements.push(SlideElement::Unknown), } - Ok(elements) } -/// Parses the text body node (``) ito search for shape nodes (``) and -/// evaluates if a shape is a formatted list or a common text -fn parse_sp(sp_node: &Node, hyperlinks: &HashMap) -> Result { - let tx_body_node = sp_node - .children() - .find(|n| n.tag_name().name() == "txBody" && n.tag_name().namespace() == Some(P_NAMESPACE)) - .ok_or(Error::Unknown)?; - - let is_list = tx_body_node.descendants().any(|n| { - n.is_element() - && n.tag_name().name() == "pPr" - && n.tag_name().namespace() == Some(A_NAMESPACE) - && (n.attribute("lvl").is_some() - || n.children().any(|child| { - child.is_element() - && (child.tag_name().name() == "buAutoNum" - || child.tag_name().name() == "buChar") - })) - }); - - if is_list { - Ok(ParsedContent::List(parse_list_with_hyperlinks( - &tx_body_node, - hyperlinks, - )?)) - } else { - Ok(ParsedContent::Text(parse_text_with_hyperlinks( - &tx_body_node, - hyperlinks, - )?)) +fn parse_shape(xml: &mut XmlReader<'_>, hyperlinks: &HashMap) -> Result { + let mut position = PositionData::default(); + let mut content = None; + loop { + match event(xml, "PPTX shape")? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"off") => + { + position.observe_off(&element); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"ext") => + { + position.observe_ext(&element); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, P_NAMESPACE, b"ph") => + { + position.observe_placeholder(&element); + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"txBody") => { + content = Some(parse_text_body(xml, true, hyperlinks)?); + } + Event::End(element) if end_is(element.name().as_ref(), b"sp") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX shape")), + _ => {} + } } + Ok(ShapeData { content, position }) } -/// Parses the text body node (``) for all paragraph nodes (``) containing text runs -/// # Returns -/// Returns a `Result` containing either: -/// - `SlideElement::Text`: A text element containing all text runs -/// - `Error`: Error information encapsulated in [`crate::Error`] if parsing fails at XML parsing level. -#[cfg(test)] -fn parse_text(tx_body_node: &Node) -> Result { - parse_text_with_hyperlinks(tx_body_node, &HashMap::new()) +#[derive(Default)] +struct ParagraphData { + runs: Vec, + level: u32, + list: Option, + list_explicit: bool, + alignment: ParagraphAlignment, + default_formatting: Formatting, } -fn parse_text_with_hyperlinks( - tx_body_node: &Node, +fn parse_text_body( + xml: &mut XmlReader<'_>, + add_newline: bool, hyperlinks: &HashMap, -) -> Result { - let mut runs = Vec::new(); +) -> Result { + let mut paragraphs = Vec::new(); + loop { + match event(xml, "DrawingML text body")? { + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"p") => { + paragraphs.push(parse_paragraph_events(xml, add_newline, hyperlinks)?); + } + Event::End(element) if end_is(element.name().as_ref(), b"txBody") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of DrawingML text body")), + _ => {} + } + } + Ok(TextBlock { + role: TextRole::Other, + paragraphs: paragraphs + .into_iter() + .map(|paragraph| Paragraph { + runs: paragraph.runs, + alignment: paragraph.alignment, + list: paragraph.list.map(|kind| ListInfo { + level: paragraph.level, + kind, + }), + list_explicit: paragraph.list_explicit, + }) + .collect(), + }) +} - for p_node in tx_body_node.children().filter(|n| { - n.is_element() - && n.tag_name().name() == "p" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - let mut paragraph_runs = parse_paragraph_with_hyperlinks(&p_node, true, hyperlinks)?; - runs.append(&mut paragraph_runs); +fn parse_paragraph_events( + xml: &mut XmlReader<'_>, + add_newline: bool, + hyperlinks: &HashMap, +) -> Result { + let mut paragraph = ParagraphData::default(); + loop { + match event(xml, "DrawingML paragraph")? { + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"pPr") => { + parse_paragraph_properties(xml, &element, &mut paragraph)?; + } + Event::Empty(element) if element_is(xml, &element, A_NAMESPACE, b"pPr") => { + paragraph.level = attr(&element, b"lvl") + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + if attr(&element, b"lvl").is_some() { + paragraph.list = Some(ListKind::Bullet { character: None }); + } + paragraph.alignment = paragraph_alignment(attr(&element, b"algn").as_deref()); + } + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"r") => { + paragraph.runs.push(parse_run_events_with_base( + xml, + hyperlinks, + b"r", + paragraph.default_formatting.clone(), + )?); + } + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"fld") => { + paragraph.runs.push(parse_run_events_with_base( + xml, + hyperlinks, + b"fld", + paragraph.default_formatting.clone(), + )?); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"br") => + { + paragraph.runs.push(Run { + text: "\n".to_string(), + formatting: paragraph.default_formatting.clone(), + link_target: None, + }); + } + Event::End(element) if end_is(element.name().as_ref(), b"p") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of DrawingML paragraph")), + _ => {} + } + } + if add_newline && let Some(last) = paragraph.runs.last_mut() { + last.text.push('\n'); } + Ok(paragraph) +} - Ok(TextElement { runs }) +fn parse_paragraph_properties( + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + paragraph: &mut ParagraphData, +) -> Result<()> { + paragraph.level = attr(start, b"lvl") + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + if attr(start, b"lvl").is_some() { + paragraph.list = Some(ListKind::Bullet { character: None }); + } + paragraph.alignment = paragraph_alignment(attr(start, b"algn").as_deref()); + loop { + match event(xml, "DrawingML paragraph properties")? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"buAutoNum") => + { + paragraph.list = Some(ListKind::Ordered { + style: attr(&element, b"type"), + start: attr(&element, b"startAt") + .and_then(|value| value.parse().ok()) + .unwrap_or(1), + }); + paragraph.list_explicit = true; + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"buChar") => + { + paragraph.list = Some(ListKind::Bullet { + character: attr(&element, b"char"), + }); + paragraph.list_explicit = true; + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"buNone") => + { + paragraph.list = None; + paragraph.list_explicit = true; + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"defRPr") => + { + apply_run_attributes(&element, &mut paragraph.default_formatting); + } + Event::End(element) if end_is(element.name().as_ref(), b"pPr") => break, + Event::Eof => { + return Err(Error::ParseError( + "Unexpected end of DrawingML paragraph properties", + )); + } + _ => {} + } + } + Ok(()) } #[cfg(test)] -fn parse_graphic_frame(node: &Node) -> Result> { - parse_graphic_frame_with_hyperlinks(node, &HashMap::new()) +fn parse_run_events(xml: &mut XmlReader<'_>, hyperlinks: &HashMap) -> Result { + parse_run_events_with_base(xml, hyperlinks, b"r", Formatting::default()) } -fn parse_graphic_frame_with_hyperlinks( - node: &Node, +fn parse_run_events_with_base( + xml: &mut XmlReader<'_>, hyperlinks: &HashMap, -) -> Result> { - let graphic_data_node = node.descendants().find(|n| { - n.is_element() - && n.tag_name().name() == "graphicData" - && n.tag_name().namespace() == Some(A_NAMESPACE) - && n.attribute("uri") == Some("http://schemas.openxmlformats.org/drawingml/2006/table") - }); - - if let Some(graphic_data) = graphic_data_node { - if let Some(tbl_node) = graphic_data.children().find(|n| { - n.is_element() - && n.tag_name().name() == "tbl" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - let table = parse_table_with_hyperlinks(&tbl_node, hyperlinks)?; - return Ok(Some(table)); + end: &[u8], + mut formatting: Formatting, +) -> Result { + let mut value = String::new(); + let mut link_id = None; + loop { + match event(xml, "DrawingML run")? { + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"rPr") => { + apply_run_attributes(&element, &mut formatting); + link_id = parse_run_properties(xml, link_id)?; + } + Event::Empty(element) if element_is(xml, &element, A_NAMESPACE, b"rPr") => { + apply_run_attributes(&element, &mut formatting); + } + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"t") => { + value.push_str(&read_simple_text(xml, b"t", "DrawingML run")?); + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of DrawingML run")), + _ => {} } } + Ok(Run { + text: value, + formatting, + link_target: link_id.and_then(|id| hyperlinks.get(&id).cloned()), + }) +} - Ok(None) +fn apply_run_attributes(element: &BytesStart<'_>, formatting: &mut Formatting) { + if let Some(value) = attr(element, b"b") { + formatting.bold = value == "1" || value.eq_ignore_ascii_case("true"); + } + if let Some(value) = attr(element, b"i") { + formatting.italic = value == "1" || value.eq_ignore_ascii_case("true"); + } + if let Some(value) = attr(element, b"u") { + formatting.underlined = value != "none"; + } + if let Some(value) = attr(element, b"strike") { + formatting.strikethrough = value != "noStrike" && value != "0" && value != "false"; + } + if let Some(value) = attr(element, b"baseline").and_then(|value| value.parse::().ok()) { + formatting.baseline = if value > 0 { + crate::Baseline::Superscript + } else if value < 0 { + crate::Baseline::Subscript + } else { + crate::Baseline::Normal + }; + } + if let Some(value) = attr(element, b"sz").and_then(|value| value.parse::().ok()) { + formatting.font_size_points = Some(value / 100.0); + } + if let Some(value) = attr(element, b"lang") { + formatting.lang = value; + } } -/// Parses a table node (``) and extracts all -/// table rows ('') elements to construct a `TableElement`. -#[cfg(test)] -fn parse_table(tbl_node: &Node) -> Result { - parse_table_with_hyperlinks(tbl_node, &HashMap::new()) +fn parse_run_properties( + xml: &mut XmlReader<'_>, + mut link_id: Option, +) -> Result> { + loop { + match event(xml, "DrawingML run properties")? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"hlinkClick") => + { + link_id = attr(&element, b"id"); + } + Event::End(element) if end_is(element.name().as_ref(), b"rPr") => break, + Event::Eof => { + return Err(Error::ParseError( + "Unexpected end of DrawingML run properties", + )); + } + _ => {} + } + } + Ok(link_id) } -fn parse_table_with_hyperlinks( - tbl_node: &Node, +fn parse_graphic_frame( + xml: &mut XmlReader<'_>, + hyperlinks: &HashMap, +) -> Result<(Option, PositionData)> { + let mut position = PositionData::default(); + let mut in_table_data = false; + let mut table = None; + loop { + match event(xml, "PPTX graphic frame")? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"off") => + { + position.observe_off(&element); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"ext") => + { + position.observe_ext(&element); + } + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"graphicData") => { + in_table_data = attr(&element, b"uri").as_deref() + == Some("http://schemas.openxmlformats.org/drawingml/2006/table"); + } + Event::Start(element) + if !in_table_data && element_is(xml, &element, A_NAMESPACE, b"t") => + { + let value = read_simple_text(xml, b"t", "PPTX graphic frame")?; + if !value.is_empty() { + if !position.fallback_text.is_empty() { + position.fallback_text.push(' '); + } + position.fallback_text.push_str(&value); + } + } + Event::Start(element) + if in_table_data && element_is(xml, &element, A_NAMESPACE, b"tbl") => + { + table = Some(parse_table_events(xml, hyperlinks)?); + } + Event::End(element) if end_is(element.name().as_ref(), b"graphicData") => { + in_table_data = false; + } + Event::End(element) if end_is(element.name().as_ref(), b"graphicFrame") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX graphic frame")), + _ => {} + } + } + Ok((table, position)) +} + +fn parse_table_events( + xml: &mut XmlReader<'_>, hyperlinks: &HashMap, ) -> Result { let mut rows = Vec::new(); - - for tr_node in tbl_node.children().filter(|n| { - n.is_element() - && n.tag_name().name() == "tr" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - let row = parse_table_row_with_hyperlinks(&tr_node, hyperlinks)?; - rows.push(row); + loop { + match event(xml, "DrawingML table")? { + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"tr") => { + rows.push(parse_table_row_events(xml, hyperlinks)?); + } + Event::End(element) if end_is(element.name().as_ref(), b"tbl") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of DrawingML table")), + _ => {} + } } - Ok(TableElement { rows }) } -/// Parses a table row node (`''`) and extracts all -/// table cells ('') elements to construct a full `TableRow`. -#[cfg(test)] -fn parse_table_row(tr_node: &Node) -> Result { - parse_table_row_with_hyperlinks(tr_node, &HashMap::new()) -} - -fn parse_table_row_with_hyperlinks( - tr_node: &Node, +fn parse_table_row_events( + xml: &mut XmlReader<'_>, hyperlinks: &HashMap, ) -> Result { let mut cells = Vec::new(); - - for tc_node in tr_node.children().filter(|n| { - n.is_element() - && n.tag_name().name() == "tc" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - let cell = parse_table_cell_with_hyperlinks(&tc_node, hyperlinks)?; - cells.push(cell); + loop { + match event(xml, "DrawingML table row")? { + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"tc") => { + cells.push(parse_table_cell_events(xml, &element, hyperlinks)?); + } + Event::End(element) if end_is(element.name().as_ref(), b"tr") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of DrawingML table row")), + _ => {} + } } - Ok(TableRow { cells }) } -/// Parses a table cell node (`''`) and extracts all -/// paragraph nodes ('') to construct a `TableCell`. -#[cfg(test)] -fn parse_table_cell(tc_node: &Node) -> Result { - parse_table_cell_with_hyperlinks(tc_node, &HashMap::new()) -} - -fn parse_table_cell_with_hyperlinks( - tc_node: &Node, +fn parse_table_cell_events( + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, hyperlinks: &HashMap, ) -> Result { let mut runs = Vec::new(); - - if let Some(tx_body_node) = tc_node.children().find(|n| { - n.is_element() - && n.tag_name().name() == "txBody" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - for p_node in tx_body_node.children().filter(|n| { - n.is_element() - && n.tag_name().name() == "p" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - let mut paragraph_runs = parse_paragraph_with_hyperlinks(&p_node, false, hyperlinks)?; - runs.append(&mut paragraph_runs); - } - } - - Ok(TableCell { runs }) -} - -/// Parses an image node (``) to extract an image reference. -/// -/// This function locates and processes the `` element inside a given -/// image node, extracting the necessary attributes to build an `ImageReference` object -/// that is necessary to link the image and extracts the relative image path from the media dir -/// -/// # Returns -/// -/// Returns a `Result` with: -/// - `SlideElement::Image`: A `SlideElement` containing the image's reference `ID` to link it if successfully parsed. -/// - `Error::ImageNotFound`: If the `` element or necessary attributes are missing. -fn parse_pic(pic_node: &Node) -> Result { - let blip_node = pic_node - .descendants() - .find(|n| { - n.is_element() - && n.tag_name().name() == "blip" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) - .ok_or(Error::ImageNotFound)?; - - let embed_attr = blip_node - .attribute((RELS_NAMESPACE, "embed")) - .or_else(|| blip_node.attribute("r:embed")) - .ok_or(Error::ImageNotFound)?; - - let image_ref = ImageReference { - id: embed_attr.to_string(), - target: String::new(), - }; - - Ok(image_ref) + let mut paragraphs = Vec::new(); + loop { + match event(xml, "DrawingML table cell")? { + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"txBody") => { + let content = parse_text_body(xml, false, hyperlinks)?; + paragraphs = content.paragraphs.clone(); + runs = content_to_text(content).runs; + } + Event::End(element) if end_is(element.name().as_ref(), b"tc") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of DrawingML table cell")), + _ => {} + } + } + Ok(TableCell { + runs, + paragraphs, + row_span: attr(start, b"rowSpan") + .and_then(|value| value.parse().ok()) + .unwrap_or(1), + column_span: attr(start, b"gridSpan") + .and_then(|value| value.parse().ok()) + .unwrap_or(1), + covered: attr(start, b"hMerge").as_deref() == Some("1") + || attr(start, b"vMerge").as_deref() == Some("1"), + }) } -/// Parses the paragraph node (``) that is already identified as a list from the text body node (``) -/// and extracts the _text runs_, the _level of indentation_ and weather its _ordered_ or _unordered_ -/// -/// # Returns -/// - `SlideElement::List`: A complete lists with all children of type `ListElement` -/// - `Error`: Error information encapsulated in [`crate::Error`] if parsing fails at XML parsing level. -#[cfg(test)] -fn parse_list(tx_body_node: &Node) -> Result { - parse_list_with_hyperlinks(tx_body_node, &HashMap::new()) +fn content_to_text(content: ParsedContent) -> TextElement { + TextElement { + runs: content + .paragraphs + .into_iter() + .flat_map(|paragraph| paragraph.runs) + .collect(), + } } -fn parse_list_with_hyperlinks( - tx_body_node: &Node, - hyperlinks: &HashMap, -) -> Result { - let mut items = Vec::new(); - - for p_node in tx_body_node.children().filter(|n| { - n.is_element() - && n.tag_name().name() == "p" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - let (level, is_ordered) = parse_list_properties(&p_node)?; - - let runs = parse_paragraph_with_hyperlinks(&p_node, true, hyperlinks)?; - - items.push(ListItem { - level, - is_ordered, - runs, - }); +fn content_to_elements(content: ParsedContent, position: ElementPosition) -> Vec { + let all_text = content + .paragraphs + .iter() + .all(|paragraph| paragraph.list.is_none()); + let all_list = content + .paragraphs + .iter() + .all(|paragraph| paragraph.list.is_some()); + if all_text { + return vec![SlideElement::Text(content_to_text(content), position)]; } - - Ok(ListElement { items }) -} - -/// Extracts list properties from a paragraph node (```). -/// -/// This function analyzes a paragraph node to determine its list level and -/// whether it's an ordered or unordered list in a PowerPoint slide's XML structure. -/// -/// # Returns -/// -/// Returns a `Result` containing: -/// - `Ok((level, is_ordered))`: A tuple where `level` (u32) indicates the list depth level and `is_ordered` (bool) indicates if the list is ordered or unordered. -/// - `Err(Error)`: When parsing fails due to structural inconsistencies in the XML node. -fn parse_list_properties(p_node: &Node) -> Result<(u32, bool)> { - let mut level = 0; - let mut is_ordered = false; - - if let Some(p_pr_node) = p_node.children().find(|n| { - n.is_element() - && n.tag_name().name() == "pPr" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - if let Some(lvl_attr) = p_pr_node.attribute("lvl") { - level = lvl_attr.parse::().unwrap_or(0); - } - - is_ordered = p_pr_node.children().any(|n| { - n.is_element() - && n.tag_name().namespace() == Some(A_NAMESPACE) - && n.tag_name().name() == "buAutoNum" - }); - - if !is_ordered { - is_ordered = p_pr_node.children().any(|n| { - n.is_element() - && n.tag_name().namespace() == Some(A_NAMESPACE) - && n.tag_name().name() == "buChar" - }); - } + if all_list { + return vec![SlideElement::List( + ListElement { + items: content + .paragraphs + .into_iter() + .map(paragraph_to_list_item) + .collect(), + }, + position, + )]; } - - Ok((level, is_ordered)) + content + .paragraphs + .into_iter() + .map(|paragraph| { + if paragraph.list.is_some() { + SlideElement::List( + ListElement { + items: vec![paragraph_to_list_item(paragraph)], + }, + position, + ) + } else { + SlideElement::Text( + TextElement { + runs: paragraph.runs, + }, + position, + ) + } + }) + .collect() } -/// Parses a single text paragraph node (``) into multiple text runs. -/// -/// # Notes -/// Searches for the last run and adds a newline character -#[cfg(test)] -fn parse_paragraph(p_node: &Node, add_new_line: bool) -> Result> { - parse_paragraph_with_hyperlinks(p_node, add_new_line, &HashMap::new()) +fn paragraph_to_list_item(paragraph: Paragraph) -> ListItem { + let list = paragraph.list.expect("list paragraph"); + ListItem { + level: list.level, + is_ordered: matches!(list.kind, ListKind::Ordered { .. }), + runs: paragraph.runs, + } } -fn parse_paragraph_with_hyperlinks( - p_node: &Node, - add_new_line: bool, - hyperlinks: &HashMap, -) -> Result> { - let run_nodes: Vec<_> = p_node - .children() - .filter(|n| { - n.is_element() - && n.tag_name().name() == "r" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) - .collect(); - - let count = run_nodes.len(); - let mut runs: Vec = Vec::new(); - - for (idx, r_node) in run_nodes.iter().enumerate() { - let mut run = parse_run_with_hyperlinks(r_node, hyperlinks)?; +fn paragraph_alignment(value: Option<&str>) -> ParagraphAlignment { + match value { + Some("ctr") | Some("center") => ParagraphAlignment::Center, + Some("r") | Some("right") => ParagraphAlignment::End, + Some("just") | Some("dist") => ParagraphAlignment::Justify, + _ => ParagraphAlignment::Start, + } +} - if add_new_line && idx == count - 1 { - run.text.push('\n'); +fn parse_picture( + xml: &mut XmlReader<'_>, +) -> Result<(ImageReference, PositionData, Option)> { + let mut position = PositionData::default(); + let mut image_id = None; + let mut alt_text = None; + loop { + match event(xml, "PPTX picture")? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"off") => + { + position.observe_off(&element); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"ext") => + { + position.observe_ext(&element); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"blip") => + { + image_id = attr(&element, b"embed"); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, P_NAMESPACE, b"cNvPr") => + { + alt_text = attr(&element, b"descr") + .or_else(|| attr(&element, b"title")) + .or_else(|| attr(&element, b"name")); + } + Event::End(element) if end_is(element.name().as_ref(), b"pic") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX picture")), + _ => {} } - - runs.push(run); } - Ok(runs) + Ok(( + ImageReference { + id: image_id.ok_or(Error::ImageNotFound)?, + target: String::new(), + }, + position, + alt_text, + )) } -/// Parses a single run properties node (``) and extracting the text content from the text node (``) -/// as well as the format including _bold_, _italic_, _underlined_ and the _language_ -#[cfg(test)] -fn parse_run(r_node: &Node) -> Result { - parse_run_with_hyperlinks(r_node, &HashMap::new()) +#[derive(Default)] +struct GroupTransformData { + off_x: i64, + off_y: i64, + child_off_x: i64, + child_off_y: i64, + extent_x: i64, + extent_y: i64, + child_extent_x: i64, + child_extent_y: i64, } -fn parse_run_with_hyperlinks(r_node: &Node, hyperlinks: &HashMap) -> Result { - let mut text = String::new(); - let mut formatting = Formatting::default(); - - if let Some(r_pr_node) = r_node.children().find(|n| { - n.is_element() - && n.tag_name().name() == "rPr" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - if let Some(b_attr) = r_pr_node.attribute("b") { - formatting.bold = b_attr == "1" || b_attr.eq_ignore_ascii_case("true"); - } - if let Some(i_attr) = r_pr_node.attribute("i") { - formatting.italic = i_attr == "1" || i_attr.eq_ignore_ascii_case("true"); - } - if let Some(u_attr) = r_pr_node.attribute("u") { - formatting.underlined = u_attr != "none"; - } - if let Some(lang_attr) = r_pr_node.attribute("lang") { - formatting.lang = lang_attr.to_string(); +impl GroupTransformData { + fn finish(&self) -> CoordinateTransform { + let scale_x = if self.child_extent_x == 0 { + 1.0 + } else { + self.extent_x as f64 / self.child_extent_x as f64 + }; + let scale_y = if self.child_extent_y == 0 { + 1.0 + } else { + self.extent_y as f64 / self.child_extent_y as f64 + }; + CoordinateTransform { + scale_x, + scale_y, + translate_x: self.off_x as f64 - self.child_off_x as f64 * scale_x, + translate_y: self.off_y as f64 - self.child_off_y as f64 * scale_y, } } +} - if let Some(t_node) = r_node.children().find(|n| { - n.is_element() - && n.tag_name().name() == "t" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) { - if let Some(t) = t_node.text() { - text.push_str(t); +fn parse_group_properties(xml: &mut XmlReader<'_>, data: &mut GroupTransformData) -> Result<()> { + loop { + match event(xml, "PPTX group properties")? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"off") => + { + data.off_x = integer_attr(&element, b"x"); + data.off_y = integer_attr(&element, b"y"); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"chOff") => + { + data.child_off_x = integer_attr(&element, b"x"); + data.child_off_y = integer_attr(&element, b"y"); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"ext") => + { + data.extent_x = integer_attr(&element, b"cx"); + data.extent_y = integer_attr(&element, b"cy"); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"chExt") => + { + data.child_extent_x = integer_attr(&element, b"cx"); + data.child_extent_y = integer_attr(&element, b"cy"); + } + Event::End(element) if end_is(element.name().as_ref(), b"grpSpPr") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX group properties")), + _ => {} } } - let link_target = r_node - .children() - .find(|n| { - n.is_element() - && n.tag_name().name() == "rPr" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) - .and_then(|r_pr| { - r_pr.children().find(|n| { - n.is_element() - && n.tag_name().name() == "hlinkClick" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) - }) - .and_then(|link| { - link.attribute((RELS_NAMESPACE, "id")) - .or_else(|| link.attribute("r:id")) - }) - .and_then(|id| hyperlinks.get(id)) - .cloned(); - - Ok(Run { - text, - formatting, - link_target, - }) + Ok(()) } -/// Extracts the effective slide position for a node. -/// -/// The node's local coordinates are read from its transform XML and then converted -/// through the accumulated parent-group transform. -fn extract_position( - node: &Node, - transform: CoordinateTransform, - inherited_positions: &InheritedPositions, -) -> ElementPosition { - extract_raw_position(node) - .map(|position| transform.apply(position)) - .or_else(|| extract_placeholder_key(node).and_then(|key| inherited_positions.resolve(&key))) - .unwrap_or_default() -} - -/// Reads a node's local position directly from its XML transform element. -/// -/// Different PPTX element types store their transform in different places: -/// shapes and pictures use `` inside ``, while tables inside -/// `` use ``. -fn extract_raw_position(node: &Node) -> Option { - let xfrm = match node.tag_name().name() { - "sp" | "pic" => node - .children() - .find(|n| { - n.is_element() - && n.tag_name().name() == "spPr" - && n.tag_name().namespace() == Some(P_NAMESPACE) - }) - .and_then(|sp_pr| { - sp_pr.children().find(|n| { - n.is_element() - && n.tag_name().name() == "xfrm" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) - }), - "graphicFrame" => node.children().find(|n| { - n.is_element() - && n.tag_name().name() == "xfrm" - && n.tag_name().namespace() == Some(P_NAMESPACE) - }), - "grpSp" => node - .children() - .find(|n| { - n.is_element() - && n.tag_name().name() == "grpSpPr" - && n.tag_name().namespace() == Some(P_NAMESPACE) - }) - .and_then(|grp_sp_pr| { - grp_sp_pr.children().find(|n| { - n.is_element() - && n.tag_name().name() == "xfrm" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) - }), - _ => None, - }?; - - Some(ElementPosition { - x: extract_child_attr_i64(&xfrm, A_NAMESPACE, "off", "x")?, - y: extract_child_attr_i64(&xfrm, A_NAMESPACE, "off", "y")?, - }) +fn integer_attr(element: &BytesStart<'_>, name: &[u8]) -> i64 { + attr(element, name) + .and_then(|value| value.parse().ok()) + .unwrap_or(0) } -/// Builds the transformation that maps a group's local child coordinates -/// into the coordinate system of its parent. -/// -/// PowerPoint groups define both a child origin/extent (`chOff`/`chExt`) and a -/// rendered origin/extent (`off`/`ext`). The resulting transform combines scaling -/// and translation so all nested elements can be reported in slide space. -fn extract_group_transform(node: &Node) -> CoordinateTransform { - let Some(xfrm) = node - .children() - .find(|n| { - n.is_element() - && n.tag_name().name() == "grpSpPr" - && n.tag_name().namespace() == Some(P_NAMESPACE) - }) - .and_then(|grp_sp_pr| { - grp_sp_pr.children().find(|n| { - n.is_element() - && n.tag_name().name() == "xfrm" - && n.tag_name().namespace() == Some(A_NAMESPACE) - }) - }) - else { - return CoordinateTransform::identity(); - }; - - let off_x = extract_child_attr_i64(&xfrm, A_NAMESPACE, "off", "x").unwrap_or(0) as f64; - let off_y = extract_child_attr_i64(&xfrm, A_NAMESPACE, "off", "y").unwrap_or(0) as f64; - let ch_off_x = extract_child_attr_i64(&xfrm, A_NAMESPACE, "chOff", "x").unwrap_or(0) as f64; - let ch_off_y = extract_child_attr_i64(&xfrm, A_NAMESPACE, "chOff", "y").unwrap_or(0) as f64; - let ext_x = extract_child_attr_i64(&xfrm, A_NAMESPACE, "ext", "cx").unwrap_or(0) as f64; - let ext_y = extract_child_attr_i64(&xfrm, A_NAMESPACE, "ext", "cy").unwrap_or(0) as f64; - let ch_ext_x = extract_child_attr_i64(&xfrm, A_NAMESPACE, "chExt", "cx").unwrap_or(0) as f64; - let ch_ext_y = extract_child_attr_i64(&xfrm, A_NAMESPACE, "chExt", "cy").unwrap_or(0) as f64; - - let scale_x = if ch_ext_x == 0.0 { - 1.0 - } else { - ext_x / ch_ext_x - }; - let scale_y = if ch_ext_y == 0.0 { - 1.0 - } else { - ext_y / ch_ext_y - }; - - CoordinateTransform { - scale_x, - scale_y, - translate_x: off_x - ch_off_x * scale_x, - translate_y: off_y - ch_off_y * scale_y, - } -} - -/// Reads an integer attribute from a direct child node with the given namespace and tag name. -fn extract_child_attr_i64( - node: &Node, - namespace: &str, - child_name: &str, - attr_name: &str, -) -> Option { - node.children() - .find(|n| { - n.is_element() - && n.tag_name().name() == child_name - && n.tag_name().namespace() == Some(namespace) - }) - .and_then(|child| child.attribute(attr_name)) - .and_then(|value| value.parse::().ok()) -} - -fn extract_placeholder_key(node: &Node) -> Option { - let placeholder = node.descendants().find(|n| { - n.is_element() - && n.tag_name().name() == "ph" - && n.tag_name().namespace() == Some(P_NAMESPACE) - })?; - - Some(PlaceholderKey { - kind: placeholder.attribute("type").map(|value| value.to_string()), - idx: placeholder.attribute("idx").map(|value| value.to_string()), - }) +fn read_simple_text(xml: &mut XmlReader<'_>, end: &[u8], part: &str) -> Result { + let mut value = String::new(); + loop { + match event(xml, part)? { + Event::Text(content) => value.push_str(&text(&content, part)?), + Event::GeneralRef(content) => value.push_str(&reference(&content, part)?), + Event::CData(content) => value.push_str(&String::from_utf8_lossy(content.as_ref())), + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of XML text")), + _ => {} + } + } + Ok(value) } -/// Extracts placeholder positions from a layout or master XML document. -/// -/// Placeholder nodes that do not define their own transform inherit their -/// position from the provided `inherited_positions`. pub fn extract_inherited_positions( xml_data: &[u8], inherited_positions: &InheritedPositions, ) -> Result { - let xml_str = std::str::from_utf8(xml_data).map_err(|_| Error::Unknown)?; - let doc = Document::parse(xml_str)?; - let root = doc.root_element(); - - let c_sld = root - .descendants() - .find(|n| { - n.tag_name().name() == "cSld" && n.tag_name().namespace() == root.tag_name().namespace() - }) - .ok_or(Error::Unknown)?; - - let sp_tree = c_sld - .children() - .find(|n| { - n.tag_name().name() == "spTree" - && n.tag_name().namespace() == root.tag_name().namespace() - }) - .ok_or(Error::Unknown)?; - - let mut positions = inherited_positions.positions.clone(); - collect_placeholder_positions( - &sp_tree, - CoordinateTransform::identity(), - inherited_positions, - &mut positions, - ); - - Ok(InheritedPositions { positions }) + let mut xml = reader(xml_data); + let mut in_common_slide = false; + loop { + match event(&mut xml, "PPTX layout or master")? { + Event::Start(element) if element_is(&xml, &element, P_NAMESPACE, b"cSld") => { + in_common_slide = true; + } + Event::Start(element) + if in_common_slide && element_is(&xml, &element, P_NAMESPACE, b"spTree") => + { + let mut positions = inherited_positions.positions.clone(); + let mut list_styles = inherited_positions.list_styles.clone(); + collect_placeholder_positions( + &mut xml, + b"spTree", + CoordinateTransform::identity(), + inherited_positions, + &mut positions, + &mut list_styles, + )?; + return Ok(InheritedPositions { + positions, + list_styles, + }); + } + Event::Empty(element) + if in_common_slide && element_is(&xml, &element, P_NAMESPACE, b"spTree") => + { + return Ok(inherited_positions.clone()); + } + Event::Eof => return Err(Error::ParseError("PPTX placeholder shape tree not found")), + _ => {} + } + } } fn collect_placeholder_positions( - node: &Node, + xml: &mut XmlReader<'_>, + end: &[u8], transform: CoordinateTransform, - inherited_positions: &InheritedPositions, + inherited: &InheritedPositions, positions: &mut HashMap, -) { - for child in node.children().filter(|n| n.is_element()) { - if child.tag_name().namespace() != Some(P_NAMESPACE) { - continue; - } - - if child.tag_name().name() == "grpSp" { - let child_transform = transform.then(extract_group_transform(&child)); - collect_placeholder_positions(&child, child_transform, inherited_positions, positions); - continue; + list_styles: &mut HashMap>, +) -> Result<()> { + let mut group_transform = GroupTransformData::default(); + loop { + match event(xml, "PPTX placeholders")? { + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSpPr") => { + parse_group_properties(xml, &mut group_transform)?; + } + Event::Start(element) if element_is(xml, &element, P_NAMESPACE, b"grpSp") => { + collect_placeholder_positions( + xml, + b"grpSp", + transform.then(group_transform.finish()), + inherited, + positions, + list_styles, + )?; + } + Event::Start(element) + if element_is(xml, &element, P_NAMESPACE, b"sp") + || element_is(xml, &element, P_NAMESPACE, b"pic") + || element_is(xml, &element, P_NAMESPACE, b"graphicFrame") => + { + let shape_end = crate::xml::local(element.name().as_ref()).to_vec(); + let (data, styles) = scan_position(xml, &shape_end)?; + if let Some(key) = data.placeholder.as_ref() + && let Some(position) = data + .raw() + .map(|position| transform.apply(position)) + .or_else(|| inherited.resolve(key)) + { + insert_placeholder_position(positions, key.clone(), position); + } + if let Some(key) = data.placeholder.as_ref() + && !styles.is_empty() + { + insert_placeholder_list_styles(list_styles, key.clone(), styles); + } + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of PPTX placeholders")), + _ => {} } + } + Ok(()) +} - if let Some(key) = extract_placeholder_key(&child) { - let position = extract_raw_position(&child) - .map(|local| transform.apply(local)) - .or_else(|| inherited_positions.resolve(&key)); - - if let Some(position) = position { - insert_placeholder_position(positions, key, position); +fn scan_position( + xml: &mut XmlReader<'_>, + end: &[u8], +) -> Result<(PositionData, HashMap)> { + let mut data = PositionData::default(); + let mut list_styles = HashMap::new(); + loop { + match event(xml, "PPTX placeholder shape")? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, A_NAMESPACE, b"off") => + { + data.observe_off(&element); } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, P_NAMESPACE, b"ph") => + { + data.observe_placeholder(&element); + } + Event::Start(element) if element_is(xml, &element, A_NAMESPACE, b"pPr") => { + let mut paragraph = ParagraphData::default(); + parse_paragraph_properties(xml, &element, &mut paragraph)?; + if let Some(kind) = paragraph.list { + list_styles.insert(paragraph.level, kind); + } + } + Event::Empty(element) if element_is(xml, &element, A_NAMESPACE, b"pPr") => { + if let Some(level) = attr(&element, b"lvl").and_then(|value| value.parse().ok()) { + list_styles.insert(level, ListKind::Bullet { character: None }); + } + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => { + return Err(Error::ParseError( + "Unexpected end of PPTX placeholder shape", + )); + } + _ => {} } } + Ok((data, list_styles)) } fn insert_placeholder_position( @@ -928,10 +1609,20 @@ fn insert_placeholder_position( if let Some(idx) = key.idx.as_deref() { positions.retain(|candidate, _| candidate.idx.as_deref() != Some(idx)); } - positions.insert(key, position); } +fn insert_placeholder_list_styles( + list_styles: &mut HashMap>, + key: PlaceholderKey, + styles: HashMap, +) { + if let Some(idx) = key.idx.as_deref() { + list_styles.retain(|candidate, _| candidate.idx.as_deref() != Some(idx)); + } + list_styles.insert(key, styles); +} + #[cfg(test)] #[path = "../tests/unit/parse_xml.rs"] mod tests; From a872ed6b849710801fa3a1ea3dc92f245f9527a6 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:45:37 +0200 Subject: [PATCH 08/23] refactored ODP slide parsing to use `quick-xml`, introduced semantic blocks, and improved diagnostic handling --- src/odp.rs | 1245 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 919 insertions(+), 326 deletions(-) diff --git a/src/odp.rs b/src/odp.rs index 6edf247..e09bec3 100644 --- a/src/odp.rs +++ b/src/odp.rs @@ -1,30 +1,36 @@ use crate::metadata::{parse_odp_metadata, render_presentation_markdown}; +use crate::xml::{ + XmlReader, attr, element_is, end_is, event, reader, reference, skip_element, text, +}; use crate::{ - ElementPosition, Error, Formatting, ImageReference, ListElement, ListItem, ParserConfig, - PresentationMetadata, Result, Run, Slide, SlideElement, TableCell, TableElement, TableRow, - TextElement, + ElementPosition, Error, Formatting, ImageReference, ListElement, ListItem, Paragraph, + ParseDiagnostic, ParserConfig, PresentationMetadata, Result, Run, Slide, SlideBlock, + SlideBlockContent, SlideElement, TableCell, TableElement, TableRow, TextBlock, TextElement, + TextRole, }; -use roxmltree::{Document, Node}; +use quick_xml::events::{BytesStart, Event}; use std::collections::HashMap; use std::io::Read; +use std::ops::Range; use std::path::Path; const DRAW_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"; -const OFFICE_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"; const STYLE_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:style:1.0"; const TEXT_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"; const TABLE_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"; -const SVG_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"; -const XLINK_NS: &str = "http://www.w3.org/1999/xlink"; -const FO_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"; const PRESENTATION_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"; +struct PageIndex { + range: Range, + namespaces: Vec<(String, String)>, +} + pub(crate) struct OdpContainer { config: ParserConfig, archive: zip::ZipArchive, content: Vec, - styles: Vec, - page_count: usize, + pages: Vec, + styles: StyleResolver, metadata: PresentationMetadata, } @@ -33,23 +39,23 @@ impl OdpContainer { let file = std::fs::File::open(path)?; let mut archive = zip::ZipArchive::new(file)?; let content = read_archive_file(&mut archive, "content.xml")?; - let styles = read_archive_file(&mut archive, "styles.xml").unwrap_or_default(); + let style_xml = read_archive_file(&mut archive, "styles.xml").unwrap_or_default(); let meta = read_optional_archive_file(&mut archive, "meta.xml")?; - let page_count = presentation_page_count(&content)?; + let styles = StyleResolver::from_documents(&content, &style_xml)?; + let pages = index_pages(&content)?; let metadata = parse_odp_metadata(meta.as_deref())?; - Ok(Self { config, archive, content, + pages, styles, - page_count, metadata, }) } pub(crate) fn parse_all(&mut self) -> Result> { - (0..self.page_count) + (0..self.pages.len()) .map(|index| self.load_slide(index)) .collect() } @@ -68,17 +74,11 @@ impl OdpContainer { } fn load_slide(&mut self, index: usize) -> Result { - let content = std::str::from_utf8(&self.content) - .map_err(|_| Error::ParseError("ODP XML is not UTF-8"))?; - let document = Document::parse(content)?; - let page = presentation_pages(&document) - .nth(index) - .ok_or(Error::SlideNotFound)?; - let styles = StyleResolver::from_documents(&self.content, &self.styles)?; - let elements = parse_page(page, &styles)?; - let speaker_notes = parse_speaker_notes(page, &styles)?; - let comments = parse_comments(page, &styles)?; - let images: Vec = elements + let page = self.pages.get(index).ok_or(Error::SlideNotFound)?; + let fragment = page_fragment(&self.content[page.range.clone()], &page.namespaces); + let mut parsed = parse_page_fragment(&fragment, &self.styles)?; + let images: Vec = parsed + .elements .iter() .filter_map(|element| match element { SlideElement::Image(image, _) => Some(image.clone()), @@ -88,21 +88,29 @@ impl OdpContainer { let mut image_data = HashMap::new(); if self.config.extract_images { for image in &images { - if let Ok(data) = read_archive_file(&mut self.archive, &image.target) { - image_data.insert(image.id.clone(), data); + match read_archive_file(&mut self.archive, &image.target) { + Ok(data) => { + image_data.insert(image.id.clone(), data); + } + Err(error) => parsed.diagnostics.push(ParseDiagnostic { + severity: crate::DiagnosticSeverity::Warning, + message: format!("Image resource could not be loaded: {error}"), + source: Some(image.target.clone()), + }), } } } - - Ok(Slide::new( + Ok(Slide::new_semantic( format!("content.xml#page{}", index + 1), (index + 1) as u32, - elements, - speaker_notes, - comments, + parsed.elements, + parsed.blocks, + parsed.speaker_notes, + parsed.comments, images, image_data, self.config.clone(), + parsed.diagnostics, )) } @@ -123,7 +131,7 @@ impl Iterator for OdpSlideIterator<'_> { type Item = Result; fn next(&mut self) -> Option { - if self.current_index >= self.container.page_count { + if self.current_index >= self.container.pages.len() { return None; } let index = self.current_index; @@ -153,19 +161,130 @@ fn read_optional_archive_file( Ok(Some(bytes)) } -fn presentation_page_count(xml: &[u8]) -> Result { - let xml = std::str::from_utf8(xml).map_err(|_| Error::ParseError("ODP XML is not UTF-8"))?; - let document = Document::parse(xml)?; - Ok(presentation_pages(&document).count()) +fn index_pages(content: &[u8]) -> Result> { + let mut xml = reader(content); + let mut namespace_values: HashMap> = HashMap::new(); + let mut scopes: Vec> = Vec::new(); + let mut pages = Vec::new(); + let mut page_start = None; + let mut page_depth = 0usize; + let mut page_namespaces = Vec::new(); + loop { + let event_start = xml.buffer_position() as usize; + match event(&mut xml, "ODP content.xml")? { + Event::Start(element) => { + let declared = push_namespace_declarations(&element, &mut namespace_values); + scopes.push(declared); + if page_start.is_some() { + page_depth += 1; + } else if element_is(&xml, &element, DRAW_NS, b"page") { + page_start = Some(event_start); + page_depth = 1; + page_namespaces = namespace_values + .iter() + .filter_map(|(name, values)| { + values.last().map(|value| (name.clone(), value.clone())) + }) + .collect(); + } + } + Event::Empty(element) + if page_start.is_none() && element_is(&xml, &element, DRAW_NS, b"page") => + { + let mut namespaces: Vec<_> = namespace_values + .iter() + .filter_map(|(name, values)| { + values.last().map(|value| (name.clone(), value.clone())) + }) + .collect(); + for attribute in element.attributes().with_checks(false).flatten() { + let name = String::from_utf8_lossy(attribute.key.as_ref()); + if name == "xmlns" || name.starts_with("xmlns:") { + namespaces.push(( + name.into_owned(), + String::from_utf8_lossy(attribute.value.as_ref()).into_owned(), + )); + } + } + pages.push(PageIndex { + range: event_start..xml.buffer_position() as usize, + namespaces, + }); + } + Event::End(_) => { + if page_start.is_some() { + page_depth -= 1; + if page_depth == 0 { + pages.push(PageIndex { + range: page_start.take().unwrap()..xml.buffer_position() as usize, + namespaces: std::mem::take(&mut page_namespaces), + }); + } + } + if let Some(declared) = scopes.pop() { + pop_namespace_declarations(declared, &mut namespace_values); + } + } + Event::Eof => break, + _ => {} + } + } + if page_start.is_some() { + return Err(Error::ParseError("Unexpected end of ODP page")); + } + Ok(pages) +} + +fn push_namespace_declarations( + element: &BytesStart<'_>, + namespaces: &mut HashMap>, +) -> Vec { + let mut declared = Vec::new(); + for attribute in element.attributes().with_checks(false).flatten() { + let name = String::from_utf8_lossy(attribute.key.as_ref()); + if name == "xmlns" || name.starts_with("xmlns:") { + let name = name.into_owned(); + namespaces + .entry(name.clone()) + .or_default() + .push(String::from_utf8_lossy(attribute.value.as_ref()).into_owned()); + declared.push(name); + } + } + declared } -fn presentation_pages<'a>(document: &'a Document<'a>) -> impl Iterator> + 'a { - document - .descendants() - .find(|node| is_element(*node, OFFICE_NS, "presentation")) - .into_iter() - .flat_map(|presentation| presentation.children()) - .filter(|node| is_element(*node, DRAW_NS, "page")) +fn pop_namespace_declarations( + declared: Vec, + namespaces: &mut HashMap>, +) { + for name in declared { + let remove = if let Some(values) = namespaces.get_mut(&name) { + values.pop(); + values.is_empty() + } else { + false + }; + if remove { + namespaces.remove(&name); + } + } +} + +fn page_fragment(page: &[u8], namespaces: &[(String, String)]) -> Vec { + let mut fragment = Vec::with_capacity(page.len() + namespaces.len() * 48 + 32); + fragment.extend_from_slice(b"'); + fragment.extend_from_slice(page); + fragment.extend_from_slice(b""); + fragment } #[derive(Default, Clone)] @@ -173,6 +292,9 @@ struct PartialFormatting { bold: Option, italic: Option, underlined: Option, + strikethrough: Option, + baseline: Option, + font_size_points: Option, lang: Option, } @@ -187,6 +309,15 @@ impl PartialFormatting { if let Some(value) = self.underlined { formatting.underlined = value; } + if let Some(value) = self.strikethrough { + formatting.strikethrough = value; + } + if let Some(value) = self.baseline { + formatting.baseline = value; + } + if let Some(value) = self.font_size_points { + formatting.font_size_points = Some(value); + } if let Some(value) = &self.lang { formatting.lang = value.clone(); } @@ -207,62 +338,86 @@ struct StyleResolver { impl StyleResolver { fn from_documents(content: &[u8], styles: &[u8]) -> Result { let mut resolver = Self::default(); - for xml in [content, styles] { - if xml.is_empty() { - continue; + for (part, data) in [("ODP content.xml", content), ("ODP styles.xml", styles)] { + if !data.is_empty() { + resolver.collect(data, part)?; } - let xml = std::str::from_utf8(xml) - .map_err(|_| Error::ParseError("ODP style XML is not UTF-8"))?; - let document = Document::parse(xml)?; - resolver.collect(&document); } Ok(resolver) } - fn collect(&mut self, document: &Document<'_>) { - for node in document - .descendants() - .filter(|node| is_element(*node, STYLE_NS, "style")) - { - let Some(name) = node.attribute((STYLE_NS, "name")) else { - continue; - }; - let formatting = node - .children() - .find(|child| is_element(*child, STYLE_NS, "text-properties")) - .map(parse_formatting_properties) - .unwrap_or_default(); - self.styles.insert( - name.to_string(), - StyleDefinition { - parent: node - .attribute((STYLE_NS, "parent-style-name")) - .map(str::to_string), - formatting, - }, - ); - } - - for list_style in document - .descendants() - .filter(|node| is_element(*node, TEXT_NS, "list-style")) - { - let Some(name) = list_style.attribute((STYLE_NS, "name")) else { - continue; - }; - for level in list_style.children().filter(|child| child.is_element()) { - let ordered = is_element(level, TEXT_NS, "list-level-style-number"); - if !ordered && !is_element(level, TEXT_NS, "list-level-style-bullet") { - continue; + fn collect(&mut self, data: &[u8], part: &str) -> Result<()> { + let mut xml = reader(data); + loop { + match event(&mut xml, part)? { + Event::Start(element) if element_is(&xml, &element, STYLE_NS, b"style") => { + self.parse_style(&mut xml, &element, part)?; } - let level_number = level - .attribute((TEXT_NS, "level")) - .and_then(|value| value.parse().ok()) - .unwrap_or(1); - self.list_levels - .insert((name.to_string(), level_number), ordered); + Event::Start(element) if element_is(&xml, &element, TEXT_NS, b"list-style") => { + self.parse_list_style(&mut xml, &element, part)?; + } + Event::Eof => break, + _ => {} + } + } + Ok(()) + } + + fn parse_style( + &mut self, + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + part: &str, + ) -> Result<()> { + let name = attr(start, b"name"); + let parent = attr(start, b"parent-style-name"); + let mut formatting = PartialFormatting::default(); + loop { + match event(xml, part)? { + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, STYLE_NS, b"text-properties") => + { + formatting = parse_formatting_properties(&element); + } + Event::End(element) if end_is(element.name().as_ref(), b"style") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP style")), + _ => {} + } + } + if let Some(name) = name { + self.styles + .insert(name, StyleDefinition { parent, formatting }); + } + Ok(()) + } + + fn parse_list_style( + &mut self, + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + part: &str, + ) -> Result<()> { + let name = attr(start, b"name"); + loop { + match event(xml, part)? { + Event::Start(element) | Event::Empty(element) => { + let ordered = element_is(xml, &element, TEXT_NS, b"list-level-style-number"); + let bullet = element_is(xml, &element, TEXT_NS, b"list-level-style-bullet"); + if (ordered || bullet) + && let Some(name) = &name + { + let level = attr(&element, b"level") + .and_then(|value| value.parse().ok()) + .unwrap_or(1); + self.list_levels.insert((name.clone(), level), ordered); + } + } + Event::End(element) if end_is(element.name().as_ref(), b"list-style") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP list style")), + _ => {} } } + Ok(()) } fn formatting(&self, style_name: Option<&str>, base: Formatting) -> Formatting { @@ -290,16 +445,28 @@ impl StyleResolver { } } -fn parse_formatting_properties(node: Node<'_, '_>) -> PartialFormatting { +fn parse_formatting_properties(element: &BytesStart<'_>) -> PartialFormatting { PartialFormatting { - bold: node.attribute((FO_NS, "font-weight")).map(is_enabled), - italic: node - .attribute((FO_NS, "font-style")) - .map(|value| value.eq_ignore_ascii_case("italic")), - underlined: node - .attribute((STYLE_NS, "text-underline-style")) + bold: attr(element, b"font-weight").map(|value| is_enabled(&value)), + italic: attr(element, b"font-style").map(|value| value.eq_ignore_ascii_case("italic")), + underlined: attr(element, b"text-underline-style") + .map(|value| !value.eq_ignore_ascii_case("none")), + strikethrough: attr(element, b"text-line-through-style") .map(|value| !value.eq_ignore_ascii_case("none")), - lang: node.attribute((FO_NS, "language")).map(str::to_string), + baseline: attr(element, b"text-position").and_then(|value| { + let value = value.to_ascii_lowercase(); + if value.starts_with("super") || value.starts_with('+') { + Some(crate::Baseline::Superscript) + } else if value.starts_with("sub") || value.starts_with('-') { + Some(crate::Baseline::Subscript) + } else { + None + } + }), + font_size_points: attr(element, b"font-size") + .and_then(|value| value.strip_suffix("pt").map(str::to_string)) + .and_then(|value| value.parse().ok()), + lang: attr(element, b"language"), } } @@ -307,294 +474,713 @@ fn is_enabled(value: &str) -> bool { matches!(value, "bold" | "700" | "800" | "900") } -fn parse_page(page: Node<'_, '_>, styles: &StyleResolver) -> Result> { - let mut elements = Vec::new(); - for child in page.children().filter(|node| node.is_element()) { - parse_node(child, ElementPosition::default(), styles, &mut elements)?; +#[derive(Default)] +struct ParsedPage { + elements: Vec, + blocks: Vec, + speaker_notes: Vec, + comments: Vec, + diagnostics: Vec, +} + +enum PageSection { + Main, + Notes, + Comment, +} + +struct TextContainerContext { + position: ElementPosition, + bounds: crate::Bounds, + section: PageSection, + role: TextRole, +} + +fn parse_page_fragment(data: &[u8], styles: &StyleResolver) -> Result { + let mut xml = reader(data); + loop { + match event(&mut xml, "ODP page")? { + Event::Start(element) if element_is(&xml, &element, DRAW_NS, b"page") => { + let mut page = ParsedPage::default(); + parse_container( + &mut xml, + b"page", + ElementPosition::default(), + PageSection::Main, + styles, + &mut page, + )?; + return Ok(page); + } + Event::Empty(element) if element_is(&xml, &element, DRAW_NS, b"page") => { + return Ok(ParsedPage::default()); + } + Event::Eof => return Err(Error::ParseError("ODP page not found")), + _ => {} + } } - Ok(elements) } -fn parse_speaker_notes(page: Node<'_, '_>, styles: &StyleResolver) -> Result> { - let mut elements = Vec::new(); - for notes in page - .children() - .filter(|node| is_element(*node, PRESENTATION_NS, "notes")) - { - for child in notes.children().filter(|node| node.is_element()) { - parse_node(child, ElementPosition::default(), styles, &mut elements)?; +fn parse_container( + xml: &mut XmlReader<'_>, + end: &[u8], + parent_position: ElementPosition, + section: PageSection, + styles: &StyleResolver, + page: &mut ParsedPage, +) -> Result<()> { + loop { + match event(xml, "ODP page")? { + Event::Start(element) => { + if element_is(xml, &element, PRESENTATION_NS, b"notes") { + parse_container( + xml, + b"notes", + ElementPosition::default(), + PageSection::Notes, + styles, + page, + )?; + } else if crate::xml::local(element.name().as_ref()) == b"annotation" { + let position = add_position(parent_position, node_position(&element)); + let bounds = node_bounds(&element, position); + parse_text_container( + xml, + b"annotation", + TextContainerContext { + position, + bounds, + section: PageSection::Comment, + role: TextRole::Other, + }, + styles, + page, + )?; + } else { + parse_node(xml, &element, parent_position, §ion, styles, page)?; + } + } + Event::Empty(element) => { + parse_empty_node(&element, parent_position, §ion, page); + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP container")), + _ => {} } } - Ok(elements - .into_iter() - .filter_map(|element| match element { - SlideElement::Text(text, _) => Some(text), - _ => None, - }) - .collect()) + Ok(()) } -fn parse_comments(page: Node<'_, '_>, styles: &StyleResolver) -> Result> { - let mut elements = Vec::new(); - for annotation in page - .descendants() - .filter(|node| node.is_element() && node.tag_name().name() == "annotation") - { +fn parse_node( + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + parent_position: ElementPosition, + section: &PageSection, + styles: &StyleResolver, + page: &mut ParsedPage, +) -> Result<()> { + let position = add_position(parent_position, node_position(start)); + let bounds = node_bounds(start, position); + if element_is(xml, start, DRAW_NS, b"g") { + parse_container(xml, b"g", position, clone_section(section), styles, page) + } else if element_is(xml, start, DRAW_NS, b"frame") { + parse_frame(xml, start, position, bounds, section, styles, page) + } else if element_is(xml, start, DRAW_NS, b"custom-shape") { parse_text_container( - annotation, - ElementPosition::default(), + xml, + b"custom-shape", + TextContainerContext { + position, + bounds, + section: clone_section(section), + role: TextRole::Other, + }, styles, - &mut elements, - )?; - } - Ok(elements - .into_iter() - .filter_map(|element| match element { - SlideElement::Text(text, _) => Some(text), - _ => None, - }) - .collect()) + page, + ) + } else if element_is(xml, start, TABLE_NS, b"table") { + let table = parse_table(xml, styles)?; + push_element(SlideElement::Table(table, position), section, page); + set_last_bounds(page, section, bounds); + Ok(()) + } else { + let end = start.name().as_ref().to_vec(); + skip_element(xml, &end, "ODP page")?; + if matches!(section, PageSection::Main) { + push_unsupported_odp(page, crate::xml::local(start.name().as_ref())); + } + Ok(()) + } } -fn parse_node( - node: Node<'_, '_>, +fn clone_section(section: &PageSection) -> PageSection { + match section { + PageSection::Main => PageSection::Main, + PageSection::Notes => PageSection::Notes, + PageSection::Comment => PageSection::Comment, + } +} + +fn parse_empty_node( + element: &BytesStart<'_>, parent_position: ElementPosition, + section: &PageSection, + page: &mut ParsedPage, +) { + if crate::xml::local(element.name().as_ref()) == b"image" + && let Some(reference) = parse_image(element) + { + push_element( + SlideElement::Image( + reference, + add_position(parent_position, node_position(element)), + ), + section, + page, + ); + set_last_bounds( + page, + section, + node_bounds( + element, + add_position(parent_position, node_position(element)), + ), + ); + } +} + +fn parse_frame( + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + position: ElementPosition, + bounds: crate::Bounds, + section: &PageSection, styles: &StyleResolver, - elements: &mut Vec, + page: &mut ParsedPage, ) -> Result<()> { - let position = add_position(parent_position, node_position(node)); - if is_element(node, DRAW_NS, "g") { - for child in node.children().filter(|child| child.is_element()) { - parse_node(child, position, styles, elements)?; - } - } else if is_element(node, DRAW_NS, "frame") { - for child in node.children().filter(|child| child.is_element()) { - if is_element(child, DRAW_NS, "image") { - if let Some(reference) = parse_image(child) { - elements.push(SlideElement::Image(reference, position)); + let role = odp_text_role(attr(start, b"class").as_deref()); + let mut alt_text = attr(start, b"name"); + loop { + match event(xml, "ODP frame")? { + Event::Start(element) if element_is(xml, &element, DRAW_NS, b"image") => { + if let Some(reference) = parse_image(&element) { + push_element(SlideElement::Image(reference, position), section, page); + set_last_bounds(page, section, bounds); + set_last_image_alt(page, section, alt_text.clone()); + } + skip_element(xml, b"image", "ODP image")?; + } + Event::Empty(element) if element_is(xml, &element, DRAW_NS, b"image") => { + if let Some(reference) = parse_image(&element) { + push_element(SlideElement::Image(reference, position), section, page); + set_last_bounds(page, section, bounds); + set_last_image_alt(page, section, alt_text.clone()); + } + } + Event::Start(element) + if matches!( + crate::xml::local(element.name().as_ref()), + b"title" | b"desc" + ) => + { + let end = crate::xml::local(element.name().as_ref()).to_vec(); + let value = read_odp_simple_text(xml, &end)?; + if !value.trim().is_empty() { + alt_text = Some(value.trim().to_string()); + set_last_image_alt(page, section, alt_text.clone()); } - } else if is_element(child, DRAW_NS, "text-box") { - parse_text_container(child, position, styles, elements)?; - } else if is_element(child, TABLE_NS, "table") { - elements.push(SlideElement::Table(parse_table(child, styles)?, position)); } + Event::Start(element) if element_is(xml, &element, DRAW_NS, b"text-box") => { + parse_text_container( + xml, + b"text-box", + TextContainerContext { + position, + bounds, + section: clone_section(section), + role, + }, + styles, + page, + )?; + } + Event::Start(element) if element_is(xml, &element, TABLE_NS, b"table") => { + let table = parse_table(xml, styles)?; + push_element(SlideElement::Table(table, position), section, page); + set_last_bounds(page, section, bounds); + } + Event::End(element) if end_is(element.name().as_ref(), b"frame") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP frame")), + _ => {} } - } else if is_element(node, DRAW_NS, "custom-shape") { - parse_text_container(node, position, styles, elements)?; - } else if is_element(node, TABLE_NS, "table") { - elements.push(SlideElement::Table(parse_table(node, styles)?, position)); } Ok(()) } +fn set_last_image_alt(page: &mut ParsedPage, section: &PageSection, alt_text: Option) { + if !matches!(section, PageSection::Main) { + return; + } + if let Some(SlideBlock { + content: SlideBlockContent::Image(image), + .. + }) = page.blocks.last_mut() + { + image.alt_text = alt_text; + } +} + +fn read_odp_simple_text(xml: &mut XmlReader<'_>, end: &[u8]) -> Result { + let mut value = String::new(); + loop { + match event(xml, "ODP accessible text")? { + Event::Text(content) => value.push_str(&text(&content, "ODP accessible text")?), + Event::GeneralRef(content) => { + value.push_str(&reference(&content, "ODP accessible text")?) + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP accessible text")), + _ => {} + } + } + Ok(value) +} + fn parse_text_container( - node: Node<'_, '_>, - position: ElementPosition, + xml: &mut XmlReader<'_>, + end: &[u8], + context: TextContainerContext, styles: &StyleResolver, - elements: &mut Vec, + page: &mut ParsedPage, ) -> Result<()> { - let paragraphs: Vec<_> = node - .children() - .filter(|child| is_element(*child, TEXT_NS, "p") || is_element(*child, TEXT_NS, "h")) + let TextContainerContext { + position, + bounds, + section, + role, + } = context; + let mut paragraphs = Vec::new(); + loop { + match event(xml, "ODP text container")? { + Event::Start(element) + if element_is(xml, &element, TEXT_NS, b"p") + || element_is(xml, &element, TEXT_NS, b"h") => + { + let paragraph_role = + if element_is(xml, &element, TEXT_NS, b"h") && role == TextRole::Other { + TextRole::Heading + } else { + role + }; + if paragraph_role != role { + flush_odp_text(&mut paragraphs, position, bounds, role, §ion, page); + } + let mut runs = parse_paragraph(xml, &element, styles)?; + if let Some(last) = runs.last_mut() { + last.text.push('\n'); + } + if paragraph_role != role { + let mut heading = vec![Paragraph::plain(runs)]; + flush_odp_text( + &mut heading, + position, + bounds, + paragraph_role, + §ion, + page, + ); + } else { + paragraphs.push(Paragraph::plain(runs)); + } + } + Event::Start(element) if element_is(xml, &element, TEXT_NS, b"list") => { + flush_odp_text(&mut paragraphs, position, bounds, role, §ion, page); + let list = parse_list(xml, &element, styles, 0)?; + push_element(SlideElement::List(list, position), §ion, page); + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP text container")), + _ => {} + } + } + flush_odp_text(&mut paragraphs, position, bounds, role, §ion, page); + Ok(()) +} + +fn flush_odp_text( + paragraphs: &mut Vec, + position: ElementPosition, + bounds: crate::Bounds, + role: TextRole, + section: &PageSection, + page: &mut ParsedPage, +) { + if paragraphs.is_empty() { + return; + } + let runs = paragraphs + .iter() + .flat_map(|paragraph| paragraph.runs.clone()) .collect(); - if !paragraphs.is_empty() { - let mut runs = Vec::new(); - for paragraph in paragraphs { - let mut paragraph_runs = parse_paragraph(paragraph, styles); - if let Some(last) = paragraph_runs.last_mut() { - last.text.push('\n'); + push_element( + SlideElement::Text(TextElement { runs }, position), + section, + page, + ); + set_last_bounds(page, section, bounds); + if matches!(section, PageSection::Main) + && let Some(SlideBlock { + content: SlideBlockContent::Text(text), + .. + }) = page.blocks.last_mut() + { + *text = TextBlock { + role, + paragraphs: std::mem::take(paragraphs), + }; + } else { + paragraphs.clear(); + } +} + +fn push_element(element: SlideElement, section: &PageSection, page: &mut ParsedPage) { + match section { + PageSection::Main => { + let source_order = page.blocks.len(); + page.blocks + .push(crate::slide::legacy_block(&element, source_order)); + page.elements.push(element); + } + PageSection::Notes => { + if let SlideElement::Text(text, _) = element { + page.speaker_notes.push(text); } - runs.append(&mut paragraph_runs); } - if !runs.is_empty() { - elements.push(SlideElement::Text(TextElement { runs }, position)); + PageSection::Comment => { + if let SlideElement::Text(text, _) = element { + page.comments.push(text); + } } } - for list in node - .children() - .filter(|child| is_element(*child, TEXT_NS, "list")) +} + +fn push_unsupported_odp(page: &mut ParsedPage, kind: &[u8]) { + let kind = String::from_utf8_lossy(kind).into_owned(); + page.diagnostics.push(ParseDiagnostic { + severity: crate::DiagnosticSeverity::Warning, + message: format!("Unsupported ODP slide element: {kind}"), + source: None, + }); + let source_order = page.blocks.len(); + page.blocks.push(SlideBlock { + bounds: crate::Bounds::default(), + source_order, + content: SlideBlockContent::Unsupported(crate::UnsupportedBlock { + kind, + fallback_text: None, + }), + }); + page.elements.push(SlideElement::Unknown); +} + +fn set_last_bounds(page: &mut ParsedPage, section: &PageSection, bounds: crate::Bounds) { + if matches!(section, PageSection::Main) + && let Some(block) = page.blocks.last_mut() { - elements.push(SlideElement::List(parse_list(list, styles, 0), position)); + block.bounds = bounds; + } +} + +fn odp_text_role(value: Option<&str>) -> TextRole { + match value { + Some("title") => TextRole::Title, + Some("subtitle") => TextRole::Subtitle, + Some("outline") | Some("body") => TextRole::Body, + Some("notes") => TextRole::Body, + _ => TextRole::Other, } - Ok(()) } -fn parse_image(node: Node<'_, '_>) -> Option { - let target = node.attribute((XLINK_NS, "href"))?.to_string(); +fn parse_image(element: &BytesStart<'_>) -> Option { + let target = attr(element, b"href")?; Some(ImageReference { id: target.clone(), target, }) } -fn parse_paragraph(node: Node<'_, '_>, styles: &StyleResolver) -> Vec { - let formatting = styles.formatting( - node.attribute((TEXT_NS, "style-name")), - Formatting::default(), - ); - let mut runs = Vec::new(); - collect_runs(node, formatting, styles, None, &mut runs); - runs +fn parse_paragraph( + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + styles: &StyleResolver, +) -> Result> { + let formatting = + styles.formatting(attr(start, b"style-name").as_deref(), Formatting::default()); + collect_runs( + xml, + crate::xml::local(start.name().as_ref()), + formatting, + styles, + None, + ) } fn collect_runs( - node: Node<'_, '_>, + xml: &mut XmlReader<'_>, + end: &[u8], formatting: Formatting, styles: &StyleResolver, - link_target: Option<&str>, - runs: &mut Vec, -) { - for child in node.children() { - if child.is_text() { - if let Some(text) = child.text().filter(|text| !text.is_empty()) { - runs.push(Run { - text: text.to_string(), - formatting: formatting.clone(), - link_target: link_target.map(str::to_string), - }); + link_target: Option, +) -> Result> { + let mut runs = Vec::new(); + loop { + match event(xml, "ODP text")? { + Event::Text(content) => { + let value = text(&content, "ODP text")?; + if !value.is_empty() { + runs.push(Run { + text: value, + formatting: formatting.clone(), + link_target: link_target.clone(), + }); + } } - } else if is_element(child, TEXT_NS, "span") { - let next = - styles.formatting(child.attribute((TEXT_NS, "style-name")), formatting.clone()); - collect_runs(child, next, styles, link_target, runs); - } else if is_element(child, TEXT_NS, "a") { - let target = child.attribute((XLINK_NS, "href")).or(link_target); - collect_runs(child, formatting.clone(), styles, target, runs); - } else if is_element(child, TEXT_NS, "line-break") { - runs.push(Run { - text: "\n".to_string(), - formatting: formatting.clone(), - link_target: link_target.map(str::to_string), - }); - } else if is_element(child, TEXT_NS, "tab") { - runs.push(Run { - text: "\t".to_string(), - formatting: formatting.clone(), - link_target: link_target.map(str::to_string), - }); - } else if is_element(child, TEXT_NS, "s") { - let count = child - .attribute((TEXT_NS, "c")) - .and_then(|value| value.parse().ok()) - .unwrap_or(1); - runs.push(Run { - text: " ".repeat(count), - formatting: formatting.clone(), - link_target: link_target.map(str::to_string), - }); - } - } -} - -fn parse_list(node: Node<'_, '_>, styles: &StyleResolver, level: u32) -> ListElement { - let style_name = node.attribute((TEXT_NS, "style-name")); - let mut items = Vec::new(); - for item in node - .children() - .filter(|child| is_element(*child, TEXT_NS, "list-item")) - { - let mut runs = Vec::new(); - for paragraph in item - .children() - .filter(|child| is_element(*child, TEXT_NS, "p")) - { - let mut paragraph_runs = parse_paragraph(paragraph, styles); - if let Some(last) = paragraph_runs.last_mut() { - last.text.push('\n'); + Event::GeneralRef(content) => { + let value = reference(&content, "ODP text")?; + if !value.is_empty() { + runs.push(Run { + text: value, + formatting: formatting.clone(), + link_target: link_target.clone(), + }); + } } - runs.append(&mut paragraph_runs); + Event::Start(element) if element_is(xml, &element, TEXT_NS, b"span") => { + let next = + styles.formatting(attr(&element, b"style-name").as_deref(), formatting.clone()); + runs.extend(collect_runs( + xml, + b"span", + next, + styles, + link_target.clone(), + )?); + } + Event::Start(element) if element_is(xml, &element, TEXT_NS, b"a") => { + let target = attr(&element, b"href").or_else(|| link_target.clone()); + runs.extend(collect_runs(xml, b"a", formatting.clone(), styles, target)?); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, TEXT_NS, b"line-break") => + { + runs.push(special_run("\n", &formatting, link_target.as_deref())); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, TEXT_NS, b"tab") => + { + runs.push(special_run("\t", &formatting, link_target.as_deref())); + } + Event::Start(element) | Event::Empty(element) + if element_is(xml, &element, TEXT_NS, b"s") => + { + let count = attr(&element, b"c") + .and_then(|value| value.parse().ok()) + .unwrap_or(1); + runs.push(special_run( + &" ".repeat(count), + &formatting, + link_target.as_deref(), + )); + } + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP text")), + _ => {} } - if !runs.is_empty() { - items.push(ListItem { - level, - is_ordered: styles.is_ordered_list(style_name, level), - runs, - }); + } + Ok(runs) +} + +fn special_run(value: &str, formatting: &Formatting, link_target: Option<&str>) -> Run { + Run { + text: value.to_string(), + formatting: formatting.clone(), + link_target: link_target.map(str::to_string), + } +} + +fn parse_list( + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + styles: &StyleResolver, + level: u32, +) -> Result { + let style_name = attr(start, b"style-name"); + let mut items = Vec::new(); + loop { + match event(xml, "ODP list")? { + Event::Start(element) if element_is(xml, &element, TEXT_NS, b"list-item") => { + parse_list_item(xml, styles, level, style_name.as_deref(), &mut items)?; + } + Event::End(element) if end_is(element.name().as_ref(), b"list") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP list")), + _ => {} } - for nested in item - .children() - .filter(|child| is_element(*child, TEXT_NS, "list")) - { - items.extend(parse_list(nested, styles, level + 1).items); + } + Ok(ListElement { items }) +} + +fn parse_list_item( + xml: &mut XmlReader<'_>, + styles: &StyleResolver, + level: u32, + style_name: Option<&str>, + items: &mut Vec, +) -> Result<()> { + let mut runs = Vec::new(); + let mut nested = Vec::new(); + loop { + match event(xml, "ODP list item")? { + Event::Start(element) if element_is(xml, &element, TEXT_NS, b"p") => { + let mut paragraph = parse_paragraph(xml, &element, styles)?; + if let Some(last) = paragraph.last_mut() { + last.text.push('\n'); + } + runs.append(&mut paragraph); + } + Event::Start(element) if element_is(xml, &element, TEXT_NS, b"list") => { + nested.extend(parse_list(xml, &element, styles, level + 1)?.items); + } + Event::End(element) if end_is(element.name().as_ref(), b"list-item") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP list item")), + _ => {} } } - ListElement { items } + if !runs.is_empty() { + items.push(ListItem { + level, + is_ordered: styles.is_ordered_list(style_name, level), + runs, + }); + } + items.extend(nested); + Ok(()) } -fn parse_table(node: Node<'_, '_>, styles: &StyleResolver) -> Result { +fn parse_table(xml: &mut XmlReader<'_>, styles: &StyleResolver) -> Result { let mut rows = Vec::new(); - for row in node - .children() - .filter(|child| is_element(*child, TABLE_NS, "table-row")) - { - let parsed = parse_table_row(row, styles); - let repeats = attribute_usize(row, TABLE_NS, "number-rows-repeated").unwrap_or(1); - for _ in 0..repeats { - rows.push(parsed.clone()); + loop { + match event(xml, "ODP table")? { + Event::Start(element) if element_is(xml, &element, TABLE_NS, b"table-row") => { + let repeats = usize_attr(&element, b"number-rows-repeated").unwrap_or(1); + let row = parse_table_row(xml, styles)?; + rows.extend(std::iter::repeat_n(row, repeats)); + } + Event::End(element) if end_is(element.name().as_ref(), b"table") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP table")), + _ => {} } } - let width = rows - .iter() - .map(|row: &TableRow| row.cells.len()) - .max() - .unwrap_or(0); + let width = rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); for row in &mut rows { - while row.cells.len() < width { - row.cells.push(TableCell { runs: Vec::new() }); - } + row.cells.resize_with(width, TableCell::default); } Ok(TableElement { rows }) } -fn parse_table_row(node: Node<'_, '_>, styles: &StyleResolver) -> TableRow { +fn parse_table_row(xml: &mut XmlReader<'_>, styles: &StyleResolver) -> Result { let mut cells = Vec::new(); - for cell in node.children().filter(|child| { - is_element(*child, TABLE_NS, "table-cell") - || is_element(*child, TABLE_NS, "covered-table-cell") - }) { - let parsed = if is_element(cell, TABLE_NS, "covered-table-cell") { - TableCell { runs: Vec::new() } - } else { - parse_table_cell(cell, styles) - }; - let repeats = attribute_usize(cell, TABLE_NS, "number-columns-repeated").unwrap_or(1); - let spans = attribute_usize(cell, TABLE_NS, "number-columns-spanned").unwrap_or(1); - for _ in 0..repeats { - cells.push(parsed.clone()); - for _ in 1..spans { - cells.push(TableCell { runs: Vec::new() }); + loop { + match event(xml, "ODP table row")? { + Event::Start(element) + if element_is(xml, &element, TABLE_NS, b"table-cell") + || element_is(xml, &element, TABLE_NS, b"covered-table-cell") => + { + let covered = element_is(xml, &element, TABLE_NS, b"covered-table-cell"); + let repeats = usize_attr(&element, b"number-columns-repeated").unwrap_or(1); + let spans = usize_attr(&element, b"number-columns-spanned").unwrap_or(1); + let cell = if covered { + skip_element(xml, b"covered-table-cell", "ODP covered cell")?; + TableCell { + covered: true, + ..TableCell::default() + } + } else { + parse_table_cell(xml, &element, styles)? + }; + append_cells(&mut cells, cell, repeats, spans); + } + Event::Empty(element) + if element_is(xml, &element, TABLE_NS, b"table-cell") + || element_is(xml, &element, TABLE_NS, b"covered-table-cell") => + { + append_cells( + &mut cells, + TableCell::default(), + usize_attr(&element, b"number-columns-repeated").unwrap_or(1), + usize_attr(&element, b"number-columns-spanned").unwrap_or(1), + ); } + Event::End(element) if end_is(element.name().as_ref(), b"table-row") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP table row")), + _ => {} } } - TableRow { cells } + Ok(TableRow { cells }) +} + +fn append_cells(cells: &mut Vec, mut cell: TableCell, repeats: usize, spans: usize) { + cell.column_span = spans.max(1); + for _ in 0..repeats { + cells.push(cell.clone()); + cells.extend((1..spans).map(|_| TableCell { + covered: true, + ..TableCell::default() + })); + } } -fn parse_table_cell(node: Node<'_, '_>, styles: &StyleResolver) -> TableCell { +fn parse_table_cell( + xml: &mut XmlReader<'_>, + start: &BytesStart<'_>, + styles: &StyleResolver, +) -> Result { let mut runs = Vec::new(); - for paragraph in node - .children() - .filter(|child| is_element(*child, TEXT_NS, "p")) - { - let mut paragraph_runs = parse_paragraph(paragraph, styles); - if let Some(last) = paragraph_runs.last_mut() { - last.text.push('\n'); + let mut paragraphs = Vec::new(); + loop { + match event(xml, "ODP table cell")? { + Event::Start(element) if element_is(xml, &element, TEXT_NS, b"p") => { + let mut paragraph = parse_paragraph(xml, &element, styles)?; + if let Some(last) = paragraph.last_mut() { + last.text.push('\n'); + } + runs.append(&mut paragraph.clone()); + paragraphs.push(crate::Paragraph::plain(paragraph)); + } + Event::End(element) if end_is(element.name().as_ref(), b"table-cell") => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of ODP table cell")), + _ => {} } - runs.append(&mut paragraph_runs); } - TableCell { runs } + Ok(TableCell { + runs, + paragraphs, + row_span: usize_attr(start, b"number-rows-spanned").unwrap_or(1), + column_span: usize_attr(start, b"number-columns-spanned").unwrap_or(1), + covered: false, + }) } -fn node_position(node: Node<'_, '_>) -> ElementPosition { - let x = node - .attribute((SVG_NS, "x")) +fn node_position(element: &BytesStart<'_>) -> ElementPosition { + let x = attr(element, b"x") + .as_deref() .and_then(parse_length) .unwrap_or(0); - let y = node - .attribute((SVG_NS, "y")) + let y = attr(element, b"y") + .as_deref() .and_then(parse_length) .unwrap_or(0); - let transform = node - .attribute((DRAW_NS, "transform")) + let transform = attr(element, b"transform") + .as_deref() .map(parse_translate) .unwrap_or_default(); ElementPosition { @@ -603,6 +1189,21 @@ fn node_position(node: Node<'_, '_>) -> ElementPosition { } } +fn node_bounds(element: &BytesStart<'_>, position: ElementPosition) -> crate::Bounds { + crate::Bounds { + x: position.x, + y: position.y, + width: attr(element, b"width") + .as_deref() + .and_then(parse_length) + .unwrap_or(0), + height: attr(element, b"height") + .as_deref() + .and_then(parse_length) + .unwrap_or(0), + } +} + fn parse_translate(value: &str) -> ElementPosition { let Some(start) = value.find("translate") else { return ElementPosition::default(); @@ -622,14 +1223,13 @@ fn parse_translate(value: &str) -> ElementPosition { } fn parse_length(value: &str) -> Option { - let units = [ + for (suffix, multiplier) in [ ("cm", 360_000.0), ("mm", 36_000.0), ("in", 914_400.0), ("pt", 12_700.0), ("px", 9_525.0), - ]; - for (suffix, multiplier) in units { + ] { if let Some(number) = value.strip_suffix(suffix) { return number .trim() @@ -651,15 +1251,8 @@ fn add_position(left: ElementPosition, right: ElementPosition) -> ElementPositio } } -fn attribute_usize(node: Node<'_, '_>, namespace: &str, name: &str) -> Option { - node.attribute((namespace, name)) - .and_then(|value| value.parse().ok()) -} - -fn is_element(node: Node<'_, '_>, namespace: &str, name: &str) -> bool { - node.is_element() - && node.tag_name().namespace() == Some(namespace) - && node.tag_name().name() == name +fn usize_attr(element: &BytesStart<'_>, name: &[u8]) -> Option { + attr(element, name).and_then(|value| value.parse().ok()) } #[cfg(test)] From eb897cae727455c2731b65e3d047d68911ae6fd6 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:45:48 +0200 Subject: [PATCH 09/23] enhanced image resource handling with diagnostics, updated `Slide` construction to use semantic blocks and improved error reporting --- src/container.rs | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/src/container.rs b/src/container.rs index e7043d0..b67e119 100644 --- a/src/container.rs +++ b/src/container.rs @@ -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}; @@ -153,6 +153,7 @@ 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)?; @@ -160,8 +161,16 @@ impl PptxContainer { 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), + }), + } } } @@ -174,6 +183,7 @@ impl PptxContainer { speaker_notes, comments, hyperlinks, + resource_diagnostics, )); } @@ -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(); @@ -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) @@ -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, @@ -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(); From 9d19987eac8c99fcd9e9318ac3bc8e761890df42 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:46:09 +0200 Subject: [PATCH 10/23] refactored `Slide` struct to include semantic blocks, enhanced Markdown generation, and improved image handling with diagnostics --- src/slide.rs | 561 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 472 insertions(+), 89 deletions(-) diff --git a/src/slide.rs b/src/slide.rs index bce8e11..4a0aaf2 100644 --- a/src/slide.rs +++ b/src/slide.rs @@ -1,7 +1,12 @@ -use crate::markdown::{render_runs, MarkdownContext}; +use crate::markdown::{MarkdownContext, render_runs}; use crate::parser_config::ImageHandlingMode; -use crate::{ElementPosition, ImageReference, ParserConfig, SlideElement}; -use base64::{engine::general_purpose, Engine as _}; +use crate::{ + Bounds, ImageBlock, ImageReference, ListInfo, ListKind, MarkdownOptions, Paragraph, + ParseDiagnostic, ParserConfig, ReadingOrder, Result, SemanticTable, SemanticTableCell, + SemanticTableRow, SlideBlock, SlideBlockContent, SlideElement, TextBlock, TextRole, + UnsupportedBlock, +}; +use base64::{Engine as _, engine::general_purpose}; use image::codecs::jpeg::JpegEncoder; use std::collections::HashMap; use std::fs; @@ -40,6 +45,8 @@ pub struct Slide { pub images: Vec, pub image_data: HashMap>, pub config: ParserConfig, + pub blocks: Vec, + pub diagnostics: Vec, } impl Slide { @@ -54,6 +61,7 @@ impl Slide { image_data: HashMap>, config: ParserConfig, ) -> Self { + let blocks = legacy_blocks(&elements); Self { rel_path, slide_number, @@ -63,6 +71,35 @@ impl Slide { images, image_data, config, + blocks, + diagnostics: Vec::new(), + } + } + + #[allow(clippy::too_many_arguments)] + pub fn new_semantic( + rel_path: String, + slide_number: u32, + elements: Vec, + blocks: Vec, + speaker_notes: Vec, + comments: Vec, + images: Vec, + image_data: HashMap>, + config: ParserConfig, + diagnostics: Vec, + ) -> Self { + Self { + rel_path, + slide_number, + elements, + speaker_notes, + comments, + images, + image_data, + config, + blocks, + diagnostics, } } @@ -76,55 +113,41 @@ impl Slide { /// Returns an `Option`: /// - `Some(String)`: Markdown representation of slide if conversion succeeds. /// - `None`: If a conversion error occurs during image encoding. - pub fn convert_to_md(&self) -> Option { + pub fn convert_to_md(&self) -> Result { + let options = MarkdownOptions { + include_slide_number_as_comment: self.config.include_slide_number_as_comment, + include_speaker_notes: self.config.include_speaker_notes, + include_comments: self.config.include_comments, + ..MarkdownOptions::default() + }; + self.to_markdown(&options) + } + + pub fn to_markdown(&self, options: &MarkdownOptions) -> Result { let mut slide_txt = String::new(); - if self.config.include_slide_number_as_comment { + if options.include_slide_number_as_comment { slide_txt.push_str(format!("\n\n", self.slide_number).as_str()); } let mut image_count = 0; + let fallback_blocks; + let blocks = if self.blocks.is_empty() { + fallback_blocks = legacy_blocks(&self.elements); + &fallback_blocks + } else { + &self.blocks + }; - let mut sorted_elements = self.elements.clone(); - sorted_elements.sort_by_key(|element| { - let ElementPosition { y, x } = element.position(); - (y, x) - }); - - for element in sorted_elements { - match element { - SlideElement::Text(text, _pos) => { - slide_txt.push_str(&render_runs(&text.runs, MarkdownContext::Flow)); - slide_txt.push('\n'); - } - SlideElement::Table(table, _pos) => { - let mut is_header = true; - for row in &table.rows { - let mut row_texts = Vec::new(); - for cell in &row.cells { - let cell_text = render_runs(&cell.runs, MarkdownContext::TableCell); - row_texts.push(cell_text); - } - - let row_line = format!("| {} |", row_texts.join(" | ")); - slide_txt.push_str(&row_line); + for block in ordered_blocks(blocks, options.reading_order) { + match &block.content { + SlideBlockContent::Text(text) => { + render_text_block(&mut slide_txt, text); + if !slide_txt.ends_with("\n\n") { slide_txt.push('\n'); - - if is_header { - let separator_line = format!( - "|{}|", - row_texts - .iter() - .map(|_| " --- ") - .collect::>() - .join("|") - ); - slide_txt.push_str(&separator_line); - slide_txt.push('\n'); - is_header = false; - } } - slide_txt.push('\n'); } - SlideElement::Image(image_ref, _pos) => { + SlideBlockContent::Table(table) => render_table(&mut slide_txt, table), + SlideBlockContent::Image(image) => { + let image_ref = &image.reference; match self.config.image_handling_mode { ImageHandlingMode::InMarkdown => { if let Some(image_data) = self.image_data.get(&image_ref.id) { @@ -134,17 +157,30 @@ impl Slide { Some(image_data.clone()) }; - let base64_string = general_purpose::STANDARD.encode(image_data?); - let image_name = image_ref.target.split('/').next_back()?; - let file_ext = image_name.split('.').next_back()?; + let Some(image_data) = image_data else { + slide_txt.push_str(&missing_image_markdown(image)); + continue; + }; + let base64_string = general_purpose::STANDARD.encode(image_data); + let image_name = + image_ref.target.split('/').next_back().unwrap_or("image"); + let file_ext = image + .mime_type + .as_deref() + .and_then(|mime| mime.split('/').next_back()) + .or_else(|| image_name.rsplit('.').next()) + .unwrap_or("bin"); + let alt = image.alt_text.as_deref().unwrap_or(image_name); slide_txt.push_str( format!( "![{}](data:image/{};base64,{})", - image_name, file_ext, base64_string + alt, file_ext, base64_string ) .as_str(), ); + } else { + slide_txt.push_str(&missing_image_markdown(image)); } } ImageHandlingMode::Save => { @@ -167,7 +203,7 @@ impl Slide { .clone() .unwrap_or_else(|| PathBuf::from(".")); - let _ = fs::create_dir_all(&output_dir); + fs::create_dir_all(&output_dir)?; let mut image_path = output_dir.clone(); let file_name = format!( @@ -179,14 +215,24 @@ impl Slide { ); image_path.push(&file_name); - let _ = fs::write(&image_path, image_data?); + let Some(image_data) = image_data else { + slide_txt.push_str(&missing_image_markdown(image)); + continue; + }; + fs::write(&image_path, image_data)?; let abs_file_url = self.path_to_file_url(&image_path); - let html_link = - format!(r#"{file_name}"#, abs_file_url?); + let Some(abs_file_url) = abs_file_url else { + slide_txt.push_str(&missing_image_markdown(image)); + continue; + }; + let alt = image.alt_text.as_deref().unwrap_or(&file_name); + let html_link = format!("![{alt}]({abs_file_url})"); image_count += 1; slide_txt.push_str(&html_link); slide_txt.push('\n'); + } else { + slide_txt.push_str(&missing_image_markdown(image)); } } ImageHandlingMode::Manually => { @@ -196,47 +242,27 @@ impl Slide { } slide_txt.push('\n'); } - SlideElement::List(list_element, _pos) => { - let mut counters: Vec = Vec::new(); - let mut previous_level = 0; - - for item in &list_element.items { - let item_text = render_runs(&item.runs, MarkdownContext::ListItem); - - let level = item.level as usize; - if level >= counters.len() { - counters.resize(level + 1, 0); - } - - match level.cmp(&previous_level) { - std::cmp::Ordering::Greater => counters[level] = 0, - std::cmp::Ordering::Less => counters.truncate(level + 1), - std::cmp::Ordering::Equal => {} - } - - counters[level] += 1; - previous_level = level; - - let indent = "\t".repeat(level); - let marker = if item.is_ordered { - format!("{}{}. ", indent, counters[level]) - } else { - format!("{}- ", indent) - }; - - slide_txt.push_str(&format!("{}{}\n", marker, item_text)); + SlideBlockContent::Unsupported(unsupported) => { + if let Some(text) = &unsupported.fallback_text { + slide_txt.push_str(text); + slide_txt.push_str("\n\n"); + } + if options.render_unsupported_comments { + slide_txt.push_str(&format!( + "\n\n", + unsupported.kind.replace("--", "—") + )); } } - _ => (), } } - if self.config.include_speaker_notes && !self.speaker_notes.is_empty() { + if options.include_speaker_notes && !self.speaker_notes.is_empty() { append_quoted_section(&mut slide_txt, "Speaker Notes", &self.speaker_notes); } - if self.config.include_comments && !self.comments.is_empty() { + if options.include_comments && !self.comments.is_empty() { append_quoted_section(&mut slide_txt, "Comments", &self.comments); } - Some(slide_txt) + Ok(slide_txt) } /// Extracts the numeric slide identifier from a slide path. @@ -277,10 +303,20 @@ impl Slide { .collect(); for element in &mut self.elements { - if let SlideElement::Image(img_ref, _pos) = element { - if let Some(target) = id_to_target.get(&img_ref.id) { - img_ref.target = target.clone(); - } + if let SlideElement::Image(img_ref, _pos) = element + && let Some(target) = id_to_target.get(&img_ref.id) + { + img_ref.target = target.clone(); + } + } + for block in &mut self.blocks { + if let SlideBlockContent::Image(image) = &mut block.content + && let Some(target) = id_to_target.get(&image.reference.id) + { + image.reference.target = target.clone(); + image.mime_type = mime_type_from_path(target).map(str::to_string); + } else if let SlideBlockContent::Image(image) = &mut block.content { + image.mime_type = mime_type_from_path(&image.reference.target).map(str::to_string); } } } @@ -372,6 +408,353 @@ impl Slide { } } +pub(crate) fn legacy_blocks(elements: &[SlideElement]) -> Vec { + elements + .iter() + .enumerate() + .map(|(source_order, element)| legacy_block(element, source_order)) + .collect() +} + +pub(crate) fn legacy_block(element: &SlideElement, source_order: usize) -> SlideBlock { + let (bounds, content) = match element { + SlideElement::Text(text, position) => ( + (*position).into(), + SlideBlockContent::Text(TextBlock { + role: TextRole::Other, + paragraphs: vec![Paragraph::plain(text.runs.clone())], + }), + ), + SlideElement::List(list, position) => ( + (*position).into(), + SlideBlockContent::Text(TextBlock { + role: TextRole::Body, + paragraphs: list + .items + .iter() + .map(|item| Paragraph { + runs: item.runs.clone(), + alignment: Default::default(), + list: Some(ListInfo { + level: item.level, + kind: if item.is_ordered { + ListKind::Ordered { + style: None, + start: 1, + } + } else { + ListKind::Bullet { character: None } + }, + }), + list_explicit: true, + }) + .collect(), + }), + ), + SlideElement::Table(table, position) => ( + (*position).into(), + SlideBlockContent::Table(SemanticTable { + rows: table + .rows + .iter() + .map(|row| SemanticTableRow { + cells: row + .cells + .iter() + .map(|cell| SemanticTableCell { + paragraphs: vec![Paragraph::plain(cell.runs.clone())], + row_span: 1, + column_span: 1, + covered: false, + }) + .collect(), + }) + .collect(), + }), + ), + SlideElement::Image(image, position) => ( + (*position).into(), + SlideBlockContent::Image(ImageBlock { + reference: image.clone(), + alt_text: None, + mime_type: None, + }), + ), + SlideElement::Unknown => ( + Bounds::default(), + SlideBlockContent::Unsupported(UnsupportedBlock { + kind: "unknown".to_string(), + fallback_text: None, + }), + ), + }; + SlideBlock { + bounds, + source_order, + content, + } +} + +fn ordered_blocks(blocks: &[SlideBlock], reading_order: ReadingOrder) -> Vec<&SlideBlock> { + if reading_order == ReadingOrder::Source { + let mut ordered: Vec<_> = blocks + .iter() + .filter(|block| !block_is_semantically_empty(block)) + .collect(); + ordered.sort_by_key(|block| block.source_order); + return ordered; + } + + let has_dimensions = blocks + .iter() + .any(|block| block.bounds.width > 0 || block.bounds.height > 0); + if !has_dimensions { + let mut ordered: Vec<_> = blocks + .iter() + .filter(|block| !block_is_semantically_empty(block)) + .collect(); + ordered.sort_by_key(|block| { + ( + role_priority(block), + block.bounds.y, + block.bounds.x, + block.source_order, + ) + }); + return ordered; + } + + let mut ordered = Vec::with_capacity(blocks.len()); + let mut remaining: Vec<_> = blocks + .iter() + .filter(|block| !block_is_semantically_empty(block)) + .collect(); + remaining.sort_by_key(|block| block.source_order); + for priority in [0, 1] { + let mut index = 0; + while index < remaining.len() { + if role_priority(remaining[index]) == priority { + ordered.push(remaining.remove(index)); + } else { + index += 1; + } + } + } + + let left = remaining + .iter() + .map(|block| block.bounds.x) + .min() + .unwrap_or(0); + let right = remaining + .iter() + .map(|block| block.bounds.x + block.bounds.width) + .max() + .unwrap_or(left); + let page_width = (right - left).max(1); + let mut separators: Vec<_> = remaining + .iter() + .copied() + .filter(|block| block.bounds.width * 100 >= page_width * 65) + .collect(); + separators.sort_by_key(|block| (block.bounds.y, block.source_order)); + + let mut last_y = i64::MIN; + for separator in separators { + let mut band: Vec<_> = remaining + .iter() + .copied() + .filter(|block| { + !std::ptr::eq(*block, separator) + && block.bounds.y >= last_y + && block.bounds.y < separator.bounds.y + }) + .collect(); + sort_spatial_band(&mut band); + ordered.extend(band); + ordered.push(separator); + last_y = separator + .bounds + .y + .saturating_add(separator.bounds.height.max(1)); + } + let mut tail: Vec<_> = remaining + .into_iter() + .filter(|block| { + block.bounds.y >= last_y && !ordered.iter().any(|item| std::ptr::eq(*item, *block)) + }) + .collect(); + sort_spatial_band(&mut tail); + ordered.extend(tail); + ordered +} + +fn role_priority(block: &SlideBlock) -> u8 { + match &block.content { + SlideBlockContent::Text(TextBlock { + role: TextRole::Title, + .. + }) => 0, + SlideBlockContent::Text(TextBlock { + role: TextRole::Subtitle, + .. + }) => 1, + _ => 2, + } +} + +fn block_is_semantically_empty(block: &SlideBlock) -> bool { + matches!( + &block.content, + SlideBlockContent::Text(text) + if text + .paragraphs + .iter() + .all(|paragraph| paragraph.runs.iter().all(|run| run.text.is_empty())) + ) +} + +fn sort_spatial_band(blocks: &mut Vec<&SlideBlock>) { + blocks.sort_by_key(|block| (block.bounds.x, block.bounds.y, block.source_order)); +} + +fn render_text_block(output: &mut String, text: &TextBlock) { + let mut counters: HashMap = HashMap::new(); + for (index, paragraph) in text.paragraphs.iter().enumerate() { + let context = if paragraph.list.is_some() { + MarkdownContext::ListItem + } else { + MarkdownContext::Flow + }; + let mut rendered = render_runs(¶graph.runs, context); + if context == MarkdownContext::Flow || context == MarkdownContext::Quote { + if rendered.ends_with('\n') { + rendered.pop(); + } + } else if rendered.ends_with("
") { + rendered.truncate(rendered.len() - "
".len()); + } + if let Some(list) = ¶graph.list { + counters.retain(|level, _| *level <= list.level); + let indent = "\t".repeat(list.level as usize); + let marker = match &list.kind { + ListKind::Bullet { .. } => "- ".to_string(), + ListKind::Ordered { start, .. } => { + let counter = counters.entry(list.level).or_insert(*start); + let marker = format!("{}. ", *counter); + *counter += 1; + marker + } + }; + output.push_str(&indent); + output.push_str(&marker); + output.push_str(&rendered); + output.push('\n'); + continue; + } + + counters.clear(); + let prefix = match text.role { + TextRole::Title => "## ", + TextRole::Heading => "### ", + _ => "", + }; + if text.role == TextRole::Subtitle && !rendered.is_empty() { + output.push('_'); + output.push_str(&rendered); + output.push('_'); + } else { + output.push_str(prefix); + output.push_str(&rendered); + } + if index + 1 < text.paragraphs.len() { + output.push_str("\n\n"); + } else { + output.push('\n'); + } + } +} + +fn render_table(output: &mut String, table: &SemanticTable) { + let complex = table.rows.iter().flat_map(|row| &row.cells).any(|cell| { + cell.row_span > 1 || cell.column_span > 1 || cell.covered || cell.paragraphs.len() > 1 + }); + if complex { + output.push_str("\n"); + for row in &table.rows { + output.push_str(" "); + for cell in &row.cells { + if cell.covered { + continue; + } + let mut attributes = String::new(); + if cell.row_span > 1 { + attributes.push_str(&format!(" rowspan=\"{}\"", cell.row_span)); + } + if cell.column_span > 1 { + attributes.push_str(&format!(" colspan=\"{}\"", cell.column_span)); + } + let value = cell + .paragraphs + .iter() + .map(|paragraph| render_runs(¶graph.runs, MarkdownContext::TableCell)) + .collect::>() + .join("
"); + output.push_str(&format!("{value}")); + } + output.push_str("
\n"); + } + output.push_str("
\n\n"); + return; + } + + for (row_index, row) in table.rows.iter().enumerate() { + let cells = row + .cells + .iter() + .map(|cell| { + cell.paragraphs + .iter() + .map(|paragraph| render_runs(¶graph.runs, MarkdownContext::TableCell)) + .collect::>() + .join("
") + }) + .collect::>(); + output.push_str(&format!("| {} |\n", cells.join(" | "))); + if row_index == 0 { + output.push_str(&format!("|{}|\n", vec![" --- "; cells.len()].join("|"))); + } + } + output.push('\n'); +} + +fn missing_image_markdown(image: &ImageBlock) -> String { + let label = image + .alt_text + .as_deref() + .or_else(|| image.reference.target.split('/').next_back()) + .unwrap_or("image"); + format!("[Image unavailable: {label}]") +} + +fn mime_type_from_path(path: &str) -> Option<&'static str> { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str())? + .to_ascii_lowercase() + .as_str() + { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "gif" => Some("image/gif"), + "svg" => Some("image/svg+xml"), + "webp" => Some("image/webp"), + "bmp" => Some("image/bmp"), + "tif" | "tiff" => Some("image/tiff"), + _ => None, + } +} + fn append_quoted_section(output: &mut String, title: &str, elements: &[crate::TextElement]) { if !output.is_empty() && !output.ends_with("\n\n") { output.push('\n'); From f52f1ede44b6d6881e47343f0eca4158c8472042 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:46:18 +0200 Subject: [PATCH 11/23] refactored metadata parsing to use `quick-xml`, unified ODP and PPTX handling with shared logic and improved error diagnostics --- src/metadata.rs | 200 ++++++++++++++++++++++++++++++------------------ 1 file changed, 127 insertions(+), 73 deletions(-) diff --git a/src/metadata.rs b/src/metadata.rs index 8538978..43b9787 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -1,5 +1,6 @@ +use crate::xml::{element_is, end_is, event, reader, reference, text}; use crate::{Error, Result, Slide}; -use roxmltree::{Document, Node}; +use quick_xml::events::Event; const CORE_PROPERTIES_NS: &str = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"; @@ -7,10 +8,6 @@ const DC_NS: &str = "http://purl.org/dc/elements/1.1/"; const DCTERMS_NS: &str = "http://purl.org/dc/terms/"; const META_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:meta:1.0"; -/// The common document properties of a presentation. -/// -/// These values belong to the presentation as a whole rather than to an -/// individual slide. Timestamps are preserved in their source representation. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PresentationMetadata { pub title: Option, @@ -24,49 +21,135 @@ pub struct PresentationMetadata { } pub(crate) fn parse_pptx_metadata(core_xml: Option<&[u8]>) -> Result { - let mut metadata = PresentationMetadata::default(); + core_xml.map_or_else( + || Ok(PresentationMetadata::default()), + |xml| parse_metadata(xml, MetadataKind::Pptx, "PPTX core properties"), + ) +} - if let Some(xml) = core_xml { - let xml = std::str::from_utf8(xml)?; - let document = Document::parse(xml)?; - metadata.title = element_text(&document, DC_NS, "title"); - metadata.author = element_text(&document, DC_NS, "creator"); - metadata.last_modified_by = element_text(&document, CORE_PROPERTIES_NS, "lastModifiedBy"); - metadata.subject = element_text(&document, DC_NS, "subject"); - metadata.description = element_text(&document, DC_NS, "description"); - metadata.keywords = element_text(&document, CORE_PROPERTIES_NS, "keywords") - .into_iter() - .collect(); - metadata.created_at = element_text(&document, DCTERMS_NS, "created"); - metadata.modified_at = element_text(&document, DCTERMS_NS, "modified"); - } +pub(crate) fn parse_odp_metadata(meta_xml: Option<&[u8]>) -> Result { + meta_xml.map_or_else( + || Ok(PresentationMetadata::default()), + |xml| parse_metadata(xml, MetadataKind::Odp, "ODP metadata"), + ) +} - Ok(metadata) +#[derive(Clone, Copy)] +enum MetadataKind { + Pptx, + Odp, } -pub(crate) fn parse_odp_metadata(meta_xml: Option<&[u8]>) -> Result { +fn parse_metadata(data: &[u8], kind: MetadataKind, part: &str) -> Result { + let mut xml = reader(data); let mut metadata = PresentationMetadata::default(); + let mut depth = 0usize; + loop { + match event(&mut xml, part)? { + Event::Start(element) => { + let field = if element_is(&xml, &element, DC_NS, b"title") { + Some(MetadataField::Title) + } else if element_is(&xml, &element, DC_NS, b"creator") { + Some(MetadataField::Creator) + } else if element_is(&xml, &element, DC_NS, b"subject") { + Some(MetadataField::Subject) + } else if element_is(&xml, &element, DC_NS, b"description") { + Some(MetadataField::Description) + } else if element_is(&xml, &element, CORE_PROPERTIES_NS, b"lastModifiedBy") { + Some(MetadataField::LastModifiedBy) + } else if element_is(&xml, &element, CORE_PROPERTIES_NS, b"keywords") + || element_is(&xml, &element, META_NS, b"keyword") + { + Some(MetadataField::Keyword) + } else if element_is(&xml, &element, DCTERMS_NS, b"created") + || element_is(&xml, &element, META_NS, b"creation-date") + { + Some(MetadataField::Created) + } else if element_is(&xml, &element, DCTERMS_NS, b"modified") + || element_is(&xml, &element, DC_NS, b"date") + { + Some(MetadataField::Modified) + } else if element_is(&xml, &element, META_NS, b"initial-creator") { + Some(MetadataField::InitialCreator) + } else { + None + }; + if let Some(field) = field { + let end = crate::xml::local(element.name().as_ref()).to_vec(); + if let Some(value) = read_element_text(&mut xml, &end, part)? { + assign_metadata(&mut metadata, kind, field, value); + } + } else { + depth += 1; + } + } + Event::End(_) if depth > 0 => depth -= 1, + Event::Eof if depth > 0 => { + return Err(Error::ParseError("Unexpected end of metadata XML")); + } + Event::Eof => break, + _ => {} + } + } + if matches!(kind, MetadataKind::Odp) && metadata.author.is_none() { + metadata.author = metadata.last_modified_by.clone(); + } + Ok(metadata) +} + +#[derive(Clone, Copy)] +enum MetadataField { + Title, + Creator, + InitialCreator, + LastModifiedBy, + Subject, + Description, + Keyword, + Created, + Modified, +} - if let Some(xml) = meta_xml { - let xml = std::str::from_utf8(xml)?; - let document = Document::parse(xml)?; - let last_modified_by = element_text(&document, DC_NS, "creator"); - metadata.title = element_text(&document, DC_NS, "title"); - metadata.author = element_text(&document, META_NS, "initial-creator") - .or_else(|| last_modified_by.clone()); - metadata.last_modified_by = last_modified_by; - metadata.subject = element_text(&document, DC_NS, "subject"); - metadata.description = element_text(&document, DC_NS, "description"); - metadata.keywords = document - .descendants() - .filter(|node| is_element(*node, META_NS, "keyword")) - .filter_map(node_text) - .collect(); - metadata.created_at = element_text(&document, META_NS, "creation-date"); - metadata.modified_at = element_text(&document, DC_NS, "date"); +fn read_element_text( + xml: &mut crate::xml::XmlReader<'_>, + end: &[u8], + part: &str, +) -> Result> { + let mut value = String::new(); + loop { + match event(xml, part)? { + Event::Text(content) => value.push_str(&text(&content, part)?), + Event::GeneralRef(content) => value.push_str(&reference(&content, part)?), + Event::CData(content) => value.push_str(&String::from_utf8_lossy(content.as_ref())), + Event::End(element) if end_is(element.name().as_ref(), end) => break, + Event::Eof => return Err(Error::ParseError("Unexpected end of metadata XML")), + _ => {} + } } + let value = value.trim(); + Ok((!value.is_empty()).then(|| value.to_string())) +} - Ok(metadata) +fn assign_metadata( + metadata: &mut PresentationMetadata, + kind: MetadataKind, + field: MetadataField, + value: String, +) { + match field { + MetadataField::Title => metadata.title = Some(value), + MetadataField::Creator if matches!(kind, MetadataKind::Pptx) => { + metadata.author = Some(value) + } + MetadataField::Creator => metadata.last_modified_by = Some(value), + MetadataField::InitialCreator => metadata.author = Some(value), + MetadataField::LastModifiedBy => metadata.last_modified_by = Some(value), + MetadataField::Subject => metadata.subject = Some(value), + MetadataField::Description => metadata.description = Some(value), + MetadataField::Keyword => metadata.keywords.push(value), + MetadataField::Created => metadata.created_at = Some(value), + MetadataField::Modified => metadata.modified_at = Some(value), + } } pub(crate) fn render_presentation_markdown( @@ -75,13 +158,11 @@ pub(crate) fn render_presentation_markdown( slides: Vec, ) -> Result { let mut parts = Vec::with_capacity(slides.len() + usize::from(include_metadata)); - if include_metadata { - if let Some(comment) = render_metadata_comment(metadata) { - parts.push(comment); - } + if include_metadata && let Some(comment) = render_metadata_comment(metadata) { + parts.push(comment); } for slide in slides { - parts.push(slide.convert_to_md().ok_or(Error::ConversionFailed)?); + parts.push(slide.convert_to_md()?); } Ok(parts.join("\n\n")) } @@ -105,14 +186,7 @@ fn render_metadata_comment(metadata: &PresentationMetadata) -> Option { } push_field(&mut fields, "Created", metadata.created_at.as_deref()); push_field(&mut fields, "Modified", metadata.modified_at.as_deref()); - if fields.is_empty() { - None - } else { - Some(format!( - "", - fields.join("\n") - )) - } + (!fields.is_empty()).then(|| format!("", fields.join("\n"))) } fn push_field(fields: &mut Vec, label: &str, value: Option<&str>) { @@ -129,26 +203,6 @@ fn sanitize_comment_value(value: &str) -> String { .replace("--", "--") } -fn element_text(document: &Document<'_>, namespace: &str, name: &str) -> Option { - document - .descendants() - .find(|node| is_element(*node, namespace, name)) - .and_then(node_text) -} - -fn node_text(node: Node<'_, '_>) -> Option { - node.text() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn is_element(node: Node<'_, '_>, namespace: &str, name: &str) -> bool { - node.is_element() - && node.tag_name().namespace() == Some(namespace) - && node.tag_name().name() == name -} - #[cfg(test)] #[path = "../tests/unit/metadata.rs"] mod tests; From 07d16b946aa9aca8f340d7d06d90a3a796bb221b Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:46:36 +0200 Subject: [PATCH 12/23] added strikethrough support, baseline formatting, and improved link target encoding in Markdown generation logic --- src/markdown.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/markdown.rs b/src/markdown.rs index 5c508ce..5e3649f 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1,4 +1,4 @@ -use crate::Run; +use crate::{Baseline, Run}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum MarkdownContext { @@ -230,13 +230,41 @@ fn render_fragment(run: &Run, escaped_text: &str) -> String { result = format!("{result}"); } + if run.formatting.strikethrough { + result = format!("~~{result}~~"); + } + + result = match run.formatting.baseline { + Baseline::Normal => result, + Baseline::Superscript => format!("{result}"), + Baseline::Subscript => format!("{result}"), + }; + if let Some(target) = &run.link_target { - result = format!("[{result}]({target})"); + result = format!("[{result}]({})", markdown_link_destination(target)); } format!("{leading_whitespace}{result}{trailing_whitespace}") } +fn markdown_link_destination(target: &str) -> String { + if target + .chars() + .any(|character| character.is_whitespace() || matches!(character, '(' | ')' | '<' | '>')) + { + format!( + "<{}>", + target + .replace('<', "%3C") + .replace('>', "%3E") + .replace('\n', "%0A") + .replace('\r', "%0D") + ) + } else { + target.to_string() + } +} + fn render_group(output: &mut String, run: &Run, escaped_text: &str) { let mut fragments = escaped_text.split(MarkdownEscaper::NEWLINE).peekable(); @@ -254,6 +282,8 @@ fn has_same_markdown_style(left: &Run, right: &Run) -> bool { left.formatting.bold == right.formatting.bold && left.formatting.italic == right.formatting.italic && left.formatting.underlined == right.formatting.underlined + && left.formatting.strikethrough == right.formatting.strikethrough + && left.formatting.baseline == right.formatting.baseline && left.link_target == right.link_target } From a4b7b1340adb608116def62dd27591f0b2649a46 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:47:25 +0200 Subject: [PATCH 13/23] added tests for strikethrough, baseline formatting, and improved link destination encoding in Markdown rendering --- tests/unit/markdown.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/unit/markdown.rs b/tests/unit/markdown.rs index 19a0267..2c9cc72 100644 --- a/tests/unit/markdown.rs +++ b/tests/unit/markdown.rs @@ -78,7 +78,7 @@ fn escapes_text_before_adding_formatting_and_links() { }; assert_eq!( render_runs(&[linked], MarkdownContext::Flow), - r"[**\*label\***](https://example.com/a_(b))" + r"[**\*label\***]()" ); } @@ -146,3 +146,31 @@ fn keeps_boundary_whitespace_outside_formatting_markers() { ); assert_eq!(render_runs(&[formatted(" ")], MarkdownContext::Flow), " "); } + +#[test] +fn renders_strikethrough_and_baseline_semantics() { + let mut strike = run("removed"); + strike.formatting.strikethrough = true; + let mut superscript = run("2"); + superscript.formatting.baseline = Baseline::Superscript; + let mut subscript = run("n"); + subscript.formatting.baseline = Baseline::Subscript; + + assert_eq!(strike.render_as_md(), "~~removed~~"); + assert_eq!(superscript.render_as_md(), "2"); + assert_eq!(subscript.render_as_md(), "n"); +} + +#[test] +fn protects_link_destinations_with_spaces_and_parentheses() { + let linked = Run { + text: "destination".to_string(), + formatting: Formatting::default(), + link_target: Some("https://example.com/a path_(draft)".to_string()), + }; + + assert_eq!( + linked.render_as_md(), + "[destination]()" + ); +} From 9062c7993222ddde9bc7be8df4ff96dddf31a847 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:47:55 +0200 Subject: [PATCH 14/23] added `parse_document` method to `Presentation` for generating semantic document model with metadata, slides, and diagnostics --- src/presentation.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/presentation.rs b/src/presentation.rs index 9058433..ee1574c 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -1,6 +1,6 @@ use crate::container::SlideIterator; use crate::odp::{OdpContainer, OdpSlideIterator}; -use crate::{ParserConfig, PptxContainer, PresentationMetadata, Result, Slide}; +use crate::{ParserConfig, PptxContainer, Presentation, PresentationMetadata, Result, Slide}; use std::io::Read; use std::path::Path; @@ -67,6 +67,21 @@ impl PresentationContainer { } } + /// Parses the complete presentation into the semantic document model. + pub fn parse_document(&mut self) -> Result { + let metadata = self.metadata().clone(); + let slides = self.parse_all()?; + let diagnostics = slides + .iter() + .flat_map(|slide| slide.diagnostics.iter().cloned()) + .collect(); + Ok(Presentation { + metadata, + slides, + diagnostics, + }) + } + pub fn parse_all_multi_threaded(&mut self) -> Result> { match &mut self.inner { ContainerInner::Pptx(container) => container.parse_all_multi_threaded(), From 4214e66b81f0172113f9eefd1439d8f3a505343c Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:48:06 +0200 Subject: [PATCH 15/23] added unit tests for `ParserConfig` to validate default values and builder overrides --- src/parser_config.rs | 4 ++++ tests/unit/parser_config.rs | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/unit/parser_config.rs diff --git a/src/parser_config.rs b/src/parser_config.rs index a3640da..7131925 100644 --- a/src/parser_config.rs +++ b/src/parser_config.rs @@ -177,3 +177,7 @@ impl ParserConfigBuilder { } } } + +#[cfg(test)] +#[path = "../tests/unit/parser_config.rs"] +mod tests; diff --git a/tests/unit/parser_config.rs b/tests/unit/parser_config.rs new file mode 100644 index 0000000..ad7ead0 --- /dev/null +++ b/tests/unit/parser_config.rs @@ -0,0 +1,42 @@ +use super::*; + +#[test] +fn default_config_uses_documented_values() { + let config = ParserConfig::default(); + + assert!(config.extract_images); + assert!(config.compress_images); + assert_eq!(config.quality, 80); + assert_eq!(config.image_handling_mode, ImageHandlingMode::InMarkdown); + assert_eq!(config.image_output_path, None); + assert!(config.include_slide_number_as_comment); + assert!(!config.include_speaker_notes); + assert!(!config.include_comments); + assert!(config.include_presentation_metadata); +} + +#[test] +fn builder_applies_every_override() { + let output_path = PathBuf::from("custom-images"); + let config = ParserConfig::builder() + .extract_images(false) + .compress_images(false) + .quality(42) + .image_handling_mode(ImageHandlingMode::Save) + .image_output_path(output_path.clone()) + .include_slide_number_as_comment(false) + .include_speaker_notes(true) + .include_comments(true) + .include_presentation_metadata(false) + .build(); + + assert!(!config.extract_images); + assert!(!config.compress_images); + assert_eq!(config.quality, 42); + assert_eq!(config.image_handling_mode, ImageHandlingMode::Save); + assert_eq!(config.image_output_path, Some(output_path)); + assert!(!config.include_slide_number_as_comment); + assert!(config.include_speaker_notes); + assert!(config.include_comments); + assert!(!config.include_presentation_metadata); +} From 95cbac1f4fa5ab5d6a96e12e2f32f740b92fb7d3 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:48:15 +0200 Subject: [PATCH 16/23] moved ODP-specific unit tests to `odp_pull.rs` and added integration tests for improved ODP handling and semantic Markdown parity --- tests/integration/odp.rs | 163 +++++++++++++++- tests/unit/odp.rs | 396 +-------------------------------------- 2 files changed, 160 insertions(+), 399 deletions(-) diff --git a/tests/integration/odp.rs b/tests/integration/odp.rs index f7b7f1c..8468024 100644 --- a/tests/integration/odp.rs +++ b/tests/integration/odp.rs @@ -88,11 +88,12 @@ fn parses_odp_tables_lists_and_text_formatting() { _ => None, }) .unwrap(); - assert!(text - .0 - .runs - .iter() - .any(|run| run.text.contains("Bold text") && run.formatting.bold)); + assert!( + text.0 + .runs + .iter() + .any(|run| run.text.contains("Bold text") && run.formatting.bold) + ); assert_eq!(text.1.x, 360_000); assert_eq!(text.1.y, 720_000); @@ -190,3 +191,155 @@ fn extracts_and_embeds_the_image_on_slide_seven() { assert!(markdown.contains("data:image/")); assert!(markdown.contains(&expected_base64)); } + +#[test] +fn presentation_container_exercises_odp_detection_and_wrapper_paths() { + let path = odp_fixture_path(); + let config = ParserConfig::builder().extract_images(false).build(); + + let detected = PresentationContainer::open(&path, config.clone()).expect("detect ODP fixture"); + assert_eq!(detected.format(), PresentationFormat::Odp); + let _metadata = detected.metadata(); + + let mut parallel = PresentationContainer::open(&path, config.clone()).expect("open ODP"); + let slides = parallel + .parse_all_multi_threaded() + .expect("parse ODP through parallel-compatible API"); + assert!(!slides.is_empty()); + + let mut converted = PresentationContainer::open(&path, config.clone()).expect("open ODP"); + let markdown = converted.convert_to_md().expect("convert ODP"); + assert!(!markdown.is_empty()); + + let mut converted_parallel = + PresentationContainer::open(&path, config.clone()).expect("open ODP"); + assert_eq!( + converted_parallel + .convert_to_md_multi_threaded() + .expect("convert ODP through parallel-compatible API"), + markdown + ); + + let mut streamed = PresentationContainer::open(&path, config).expect("open ODP"); + let mut iterator = streamed.iter_slides(); + let mut streamed_count = 0; + for slide in iterator.by_ref() { + slide.expect("stream ODP slide"); + streamed_count += 1; + } + assert_eq!(streamed_count, slides.len()); + assert!(iterator.next().is_none()); +} + +#[test] +fn rejects_an_archive_with_an_unknown_presentation_format() { + let path = temporary_odp_path("unsupported-format"); + create_presentation_archive( + &path, + vec![("unrelated.txt".to_string(), b"not a presentation".to_vec())], + ); + + let result = PresentationContainer::open(&path, ParserConfig::default()); + + assert!(matches!( + result, + Err(pptx_to_md::Error::ParseError( + "Unsupported presentation format" + )) + )); + fs::remove_file(path).expect("remove unsupported presentation fixture"); +} + +fn normalize_semantic_markdown(markdown: &str) -> String { + let mut normalized = String::new(); + let mut previous_blank = false; + for line in markdown.lines().map(str::trim_end) { + let blank = line.is_empty(); + if blank && previous_blank { + continue; + } + normalized.push_str(line); + normalized.push('\n'); + previous_blank = blank; + } + normalized.trim().to_string() +} + +#[test] +fn equivalent_pptx_and_odp_slides_produce_equivalent_semantic_markdown() { + let pptx_path = temporary_odp_path("semantic-parity-pptx"); + let pptx_slide = br#"Shared titleShared item"#; + create_presentation_archive( + &pptx_path, + vec![ + ("[Content_Types].xml".to_string(), b"".to_vec()), + ( + "ppt/presentation.xml".to_string(), + b"".to_vec(), + ), + ("ppt/slides/slide1.xml".to_string(), pptx_slide.to_vec()), + ], + ); + + let odp_path = temporary_odp_path("semantic-parity-odp"); + let odp_content = br#"Shared titleShared item"#; + create_presentation_archive( + &odp_path, + vec![ + ( + "mimetype".to_string(), + b"application/vnd.oasis.opendocument.presentation".to_vec(), + ), + ("content.xml".to_string(), odp_content.to_vec()), + ], + ); + + let config = ParserConfig::builder().extract_images(false).build(); + let mut pptx = + PresentationContainer::open_as(&pptx_path, config.clone(), PresentationFormat::Pptx) + .unwrap(); + let mut odp = + PresentationContainer::open_as(&odp_path, config, PresentationFormat::Odp).unwrap(); + + let pptx_markdown = normalize_semantic_markdown(&pptx.convert_to_md().unwrap()); + let odp_markdown = normalize_semantic_markdown(&odp.convert_to_md().unwrap()); + assert_eq!(pptx_markdown, odp_markdown); + + fs::remove_file(pptx_path).unwrap(); + fs::remove_file(odp_path).unwrap(); +} + +#[test] +fn semantic_document_reports_missing_resources_without_losing_the_slide() { + let path = temporary_odp_path("missing-image-diagnostic"); + let content = br#""#; + create_presentation_archive( + &path, + vec![ + ( + "mimetype".to_string(), + b"application/vnd.oasis.opendocument.presentation".to_vec(), + ), + ("content.xml".to_string(), content.to_vec()), + ], + ); + + let mut container = PresentationContainer::open(&path, ParserConfig::default()).unwrap(); + let document = container.parse_document().unwrap(); + + assert_eq!(document.slides.len(), 1); + assert_eq!(document.diagnostics.len(), 1); + assert!( + document.diagnostics[0] + .message + .contains("Image resource could not be loaded") + ); + assert!( + document.slides[0] + .convert_to_md() + .unwrap() + .contains("[Image unavailable: Missing diagram]") + ); + + fs::remove_file(path).unwrap(); +} diff --git a/tests/unit/odp.rs b/tests/unit/odp.rs index 976e323..694170c 100644 --- a/tests/unit/odp.rs +++ b/tests/unit/odp.rs @@ -1,394 +1,2 @@ -use super::*; -use crate::{ParserConfig, PresentationContainer, PresentationFormat, SlideElement}; -use roxmltree::Document; -use std::fs; -use std::path::PathBuf; - -#[test] -fn parses_text_from_odp_speaker_notes_only() { - let xml = r#" - - Slide content - Speaker note - - "#; - let document = Document::parse(xml).expect("parse ODP XML"); - let notes = parse_speaker_notes(document.root_element(), &StyleResolver::default()) - .expect("parse speaker notes"); - assert_eq!(notes.len(), 1); - assert_eq!(notes[0].runs[0].text, "Speaker note\n"); -} - -fn odp_fixture_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("fixtures") - .join("integration") - .join("odp") - .join("basic.odp") -} - -fn text_from_slide(slide: &Slide) -> Vec { - slide - .elements - .iter() - .filter_map(|element| match element { - SlideElement::Text(text, _) => { - Some(text.runs.iter().map(|run| run.text.as_str()).collect()) - } - _ => None, - }) - .collect() -} - -fn speaker_note_text(slide: &Slide) -> String { - slide - .speaker_notes - .iter() - .flat_map(|note| note.runs.iter()) - .map(|run| run.text.as_str()) - .collect() -} - -fn comment_text(slide: &Slide) -> String { - slide - .comments - .iter() - .flat_map(|comment| comment.runs.iter()) - .map(|run| run.text.as_str()) - .collect() -} - -fn fixture_config() -> ParserConfig { - ParserConfig::builder().extract_images(false).build() -} - -fn load_odp_xml(filename: &str) -> Option> { - fs::read( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("fixtures") - .join("unit") - .join("odp") - .join(filename), - ) - .ok() -} - -macro_rules! load_odp_xml_or_skip { - ($filename:expr) => { - match load_odp_xml($filename) { - Some(xml) => xml, - None => return, - } - }; -} - -fn styles() -> Option { - let styles = load_odp_xml("styles.xml")?; - Some(StyleResolver::from_documents(b"", &styles).expect("parse ODP styles")) -} - -fn open_real_odp_fixture() -> Option { - let path = odp_fixture_path(); - if !path.is_file() { - return None; - } - - Some( - PresentationContainer::open_as(&path, fixture_config(), PresentationFormat::Odp) - .expect("open ODP fixture"), - ) -} - -#[test] -fn exposes_and_renders_odp_metadata_once() { - let Some(mut container) = open_real_odp_fixture() else { - return; - }; - assert_eq!( - container.metadata().created_at.as_deref(), - Some("2026-07-12T21:10:22.756411800") - ); - let markdown = container.convert_to_md().expect("convert ODP presentation"); - assert!(markdown.starts_with("")); } diff --git a/tests/unit/types.rs b/tests/unit/types.rs new file mode 100644 index 0000000..5d25ee9 --- /dev/null +++ b/tests/unit/types.rs @@ -0,0 +1,12 @@ +use super::*; + +#[test] +fn run_extract_returns_the_unmodified_text() { + let run = Run { + text: "Text with & whitespace".to_string(), + formatting: Formatting::default(), + link_target: Some("https://example.com".to_string()), + }; + + assert_eq!(run.extract(), "Text with & whitespace"); +} diff --git a/tests/unit/xml.rs b/tests/unit/xml.rs new file mode 100644 index 0000000..71d9189 --- /dev/null +++ b/tests/unit/xml.rs @@ -0,0 +1,15 @@ +use super::*; + +#[test] +fn skip_element_reports_unexpected_eof() { + let mut xml = reader(b""); + assert!(matches!(event(&mut xml, "test.xml"), Ok(Event::Start(_)))); + assert!(matches!(event(&mut xml, "test.xml"), Ok(Event::Start(_)))); + + let result = skip_element(&mut xml, b"child", "test.xml"); + + assert!(matches!( + result, + Err(Error::ParseError("Unexpected end of XML element")) + )); +} From 0dabb010ce24bfb7df9c7c76bae303e4002d3e84 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:49:28 +0200 Subject: [PATCH 19/23] refactored examples to enhance semantic Markdown handling, streamlined `convert_to_md` calls, and updated `SlideElement` to `SlideBlockContent` for improved readability and logic consistency --- examples/basic_usage.rs | 2 +- examples/manual_image_extraction.rs | 6 ++---- examples/memory_efficient_streaming.rs | 9 ++++---- examples/performance_test.rs | 4 ++-- examples/save_images.rs | 4 +--- examples/slide_elements.rs | 30 +++++++++++--------------- 6 files changed, 23 insertions(+), 32 deletions(-) diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index 3a1be65..95bf1e8 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -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(()) } diff --git a/examples/manual_image_extraction.rs b/examples/manual_image_extraction.rs index fc8d203..3a6aeac 100644 --- a/examples/manual_image_extraction.rs +++ b/examples/manual_image_extraction.rs @@ -4,8 +4,8 @@ //! //! Run with: cargo run --example manual_image_extraction -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; @@ -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() { diff --git a/examples/memory_efficient_streaming.rs b/examples/memory_efficient_streaming.rs index 348cd0c..c10cdc9 100644 --- a/examples/memory_efficient_streaming.rs +++ b/examples/memory_efficient_streaming.rs @@ -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); diff --git a/examples/performance_test.rs b/examples/performance_test.rs index 65f0c5e..10dce09 100644 --- a/examples/performance_test.rs +++ b/examples/performance_test.rs @@ -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::>() }); @@ -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::>() }); diff --git a/examples/save_images.rs b/examples/save_images.rs index 0e83652..d7a8c6d 100644 --- a/examples/save_images.rs +++ b/examples/save_images.rs @@ -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!"); diff --git a/examples/slide_elements.rs b/examples/slide_elements.rs index ef4c159..c7acc79 100644 --- a/examples/slide_elements.rs +++ b/examples/slide_elements.rs @@ -5,7 +5,7 @@ //! //! Run with: cargo run --example slide_elements -use pptx_to_md::{ParserConfig, PptxContainer, Result, SlideElement}; +use pptx_to_md::{ParserConfig, PptxContainer, Result, SlideBlockContent}; use std::env; use std::path::Path; @@ -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) } } } From 21ccdc57c7c3ace4a22205c85653a1969a323397 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:53:21 +0200 Subject: [PATCH 20/23] updated README with detailed semantic document model documentation --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 9b64463..e4d9586 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` 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). From 423e8841843394152a6f132a21fdf1c7675fe7a9 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 19:53:35 +0200 Subject: [PATCH 21/23] updated `CHANGELOG.md` with details on semantic model, parsing improvements, Markdown rendering changes, and breaking API updates --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b493e9..608ec3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` instead of + `Option` 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 From 8d3524b8e538839678819cc930cb5e3b7903fc94 Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 20:05:41 +0200 Subject: [PATCH 22/23] added unit tests for improved semantic block handling, spatial ordering, image rendering, and grouped element parsing across multiple XML formats --- tests/unit/odp_pull.rs | 57 +++++++++ tests/unit/parse_xml_pull.rs | 70 +++++++++++ tests/unit/slide.rs | 221 +++++++++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+) diff --git a/tests/unit/odp_pull.rs b/tests/unit/odp_pull.rs index 43f3314..8fce7a1 100644 --- a/tests/unit/odp_pull.rs +++ b/tests/unit/odp_pull.rs @@ -139,3 +139,60 @@ fn converts_lengths_transforms_and_reports_bad_xml() { ); assert!(index_pages(b" + + + + + Accessible & image + + + Frame cell + + + Section heading + + + + "#; + + let pages = index_pages(xml).unwrap(); + let fragment = page_fragment(&xml[pages[0].range.clone()], &pages[0].namespaces); + let parsed = parse_page_fragment(&fragment, &StyleResolver::default()).unwrap(); + + assert_eq!(parsed.elements.len(), 4); + let SlideBlockContent::Image(direct) = &parsed.blocks[0].content else { + panic!("expected direct image") + }; + assert_eq!(direct.reference.target, "Pictures/direct.png"); + assert_eq!(parsed.blocks[0].bounds.x, 360_000); + assert_eq!(parsed.blocks[0].bounds.height, 1_440_000); + + let SlideBlockContent::Image(framed) = &parsed.blocks[1].content else { + panic!("expected framed image") + }; + assert_eq!(framed.alt_text.as_deref(), Some("Accessible & image")); + + let SlideBlockContent::Table(table) = &parsed.blocks[2].content else { + panic!("expected frame table") + }; + assert_eq!(table.rows[0].cells[0].paragraphs[0].text(), "Frame cell\n"); + + let SlideBlockContent::Text(heading) = &parsed.blocks[3].content else { + panic!("expected heading") + }; + assert_eq!(heading.role, TextRole::Heading); + assert_eq!(heading.paragraphs[0].text(), "Section heading\n"); + assert!(parsed.speaker_notes.is_empty()); +} diff --git a/tests/unit/parse_xml_pull.rs b/tests/unit/parse_xml_pull.rs index 78850d9..bdc6a65 100644 --- a/tests/unit/parse_xml_pull.rs +++ b/tests/unit/parse_xml_pull.rs @@ -217,3 +217,73 @@ fn reads_only_body_notes_and_rejects_malformed_xml() { assert_eq!(notes[0].runs[0].text, "Note\n"); assert!(parse_slide_xml(b" + + + + + + + + + + Grouped cell + + + + + Quarterly revenue + + + + + + + + + Nested text + + + + "#; + + let parsed = parse_slide_document_with_hyperlinks( + slide, + &InheritedPositions::default(), + &HashMap::new(), + ) + .unwrap(); + + assert_eq!(parsed.blocks.len(), 6); + assert_eq!(parsed.diagnostics.len(), 3); + assert_eq!(parsed.blocks[1].bounds, Bounds { x: 140, y: 240, width: 200, height: 100 }); + let SlideBlockContent::Table(table) = &parsed.blocks[1].content else { + panic!("expected grouped table") + }; + assert_eq!(table.rows[0].cells[0].paragraphs[0].text(), "Grouped cell"); + + let SlideBlockContent::Unsupported(chart) = &parsed.blocks[2].content else { + panic!("expected chart fallback") + }; + assert_eq!(chart.fallback_text.as_deref(), Some("Quarterly revenue")); + + let SlideBlockContent::Image(image) = &parsed.blocks[3].content else { + panic!("expected grouped image") + }; + assert_eq!(image.reference.id, "rId7"); + assert_eq!(image.alt_text.as_deref(), Some("Accessible chart")); + assert_eq!(parsed.blocks[3].bounds, Bounds { x: 120, y: 220, width: 100, height: 120 }); + + let SlideBlockContent::Text(nested) = &parsed.blocks[4].content else { + panic!("expected nested group text") + }; + assert_eq!(nested.paragraphs[0].text(), "Nested text\n"); + assert!(parsed.diagnostics.iter().any(|diagnostic| diagnostic.message.contains("graphicFrame"))); + assert!(parsed.diagnostics.iter().any(|diagnostic| diagnostic.message.contains("cxnSp"))); +} diff --git a/tests/unit/slide.rs b/tests/unit/slide.rs index 671db0c..8f97b0b 100644 --- a/tests/unit/slide.rs +++ b/tests/unit/slide.rs @@ -777,3 +777,224 @@ fn semantic_renderer_uses_html_for_merged_tables_and_reports_unknown_blocks() { assert!(markdown.contains("Revenue 2026")); assert!(markdown.contains("")); } + +#[test] +fn legacy_constructor_builds_semantic_blocks_for_every_element_kind() { + let run = |text: &str| Run { + text: text.to_string(), + formatting: Formatting::default(), + link_target: None, + }; + let elements = vec![ + SlideElement::Text( + TextElement { + runs: vec![run("Text")], + }, + ElementPosition { x: 1, y: 2 }, + ), + SlideElement::List( + ListElement { + items: vec![ListItem { + level: 1, + is_ordered: true, + runs: vec![run("Second")], + }], + }, + ElementPosition { x: 3, y: 4 }, + ), + SlideElement::Table( + TableElement { + rows: vec![TableRow { + cells: vec![TableCell { + runs: vec![run("Cell")], + ..TableCell::default() + }], + }], + }, + ElementPosition { x: 5, y: 6 }, + ), + image_element("image", "../media/image.png"), + SlideElement::Unknown, + ]; + + let slide = Slide::new( + "ppt/slides/slide9.xml".to_string(), + 9, + elements, + Vec::new(), + Vec::new(), + Vec::new(), + HashMap::new(), + ParserConfig::default(), + ); + + assert_eq!(slide.blocks.len(), 5); + let SlideBlockContent::Text(list) = &slide.blocks[1].content else { + panic!("expected semantic list text") + }; + assert!(matches!( + list.paragraphs[0].list.as_ref().map(|list| &list.kind), + Some(ListKind::Ordered { start: 1, .. }) + )); + assert!(matches!( + slide.blocks[2].content, + SlideBlockContent::Table(_) + )); + assert!(matches!( + slide.blocks[3].content, + SlideBlockContent::Image(_) + )); + assert!(matches!( + slide.blocks[4].content, + SlideBlockContent::Unsupported(_) + )); +} + +#[test] +fn spatial_order_handles_dimensionless_blocks_and_full_width_separators() { + let block = |text: &str, bounds: Bounds, source_order: usize| SlideBlock { + bounds, + source_order, + content: semantic_text(text, TextRole::Body), + }; + let mut slide = mock_slide(); + let options = MarkdownOptions { + include_slide_number_as_comment: false, + ..MarkdownOptions::default() + }; + + slide.blocks = vec![ + block( + "Second", + Bounds { + x: 20, + y: 10, + width: 0, + height: 0, + }, + 0, + ), + block( + "First", + Bounds { + x: 10, + y: 20, + width: 0, + height: 0, + }, + 1, + ), + ]; + let dimensionless = slide.to_markdown(&options).unwrap(); + assert!(dimensionless.find("Second").unwrap() < dimensionless.find("First").unwrap()); + + slide.blocks = vec![ + block( + "Right above", + Bounds { + x: 500, + y: 100, + width: 100, + height: 50, + }, + 0, + ), + block( + "Left above", + Bounds { + x: 0, + y: 150, + width: 100, + height: 50, + }, + 1, + ), + block( + "Separator", + Bounds { + x: 0, + y: 300, + width: 600, + height: 40, + }, + 2, + ), + block( + "Right below", + Bounds { + x: 500, + y: 400, + width: 100, + height: 50, + }, + 3, + ), + block( + "Left below", + Bounds { + x: 0, + y: 450, + width: 100, + height: 50, + }, + 4, + ), + ]; + let spatial = slide.to_markdown(&options).unwrap(); + assert!(spatial.find("Left above").unwrap() < spatial.find("Right above").unwrap()); + assert!(spatial.find("Right above").unwrap() < spatial.find("Separator").unwrap()); + assert!(spatial.find("Separator").unwrap() < spatial.find("Left below").unwrap()); + assert!(spatial.find("Left below").unwrap() < spatial.find("Right below").unwrap()); +} + +#[test] +fn semantic_image_rendering_covers_missing_manual_and_invalid_compression_paths() { + let image = SlideBlock { + bounds: Bounds::default(), + source_order: 0, + content: SlideBlockContent::Image(ImageBlock { + reference: ImageReference { + id: "image".to_string(), + target: "../media/image.png".to_string(), + }, + alt_text: Some("Diagram".to_string()), + mime_type: Some("image/png".to_string()), + }), + }; + let options = MarkdownOptions { + include_slide_number_as_comment: false, + ..MarkdownOptions::default() + }; + + let mut slide = mock_slide(); + slide.blocks = vec![image.clone()]; + assert!( + slide + .to_markdown(&options) + .unwrap() + .contains("Image unavailable: Diagram") + ); + + slide.config.image_handling_mode = ImageHandlingMode::Manually; + assert_eq!(slide.to_markdown(&options).unwrap(), "\n"); + + slide.config.image_handling_mode = ImageHandlingMode::InMarkdown; + slide.config.compress_images = true; + slide + .image_data + .insert("image".to_string(), b"invalid image".to_vec()); + assert!( + slide + .to_markdown(&options) + .unwrap() + .contains("Image unavailable: Diagram") + ); + + slide.config.image_handling_mode = ImageHandlingMode::Save; + assert!( + slide + .to_markdown(&options) + .unwrap() + .contains("Image unavailable: Diagram") + ); +} From 6e50a7bdf328f2ff0aa0150d887cb52adbd28bbe Mon Sep 17 00:00:00 2001 From: nilsk Date: Fri, 17 Jul 2026 20:13:32 +0200 Subject: [PATCH 23/23] added #[allow(unused_imports)] annotations for test modules to prevent warnings during compilation --- tests/unit/odp_pull.rs | 8 +++++--- tests/unit/parse_xml_pull.rs | 10 ++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/unit/odp_pull.rs b/tests/unit/odp_pull.rs index 8fce7a1..49bb053 100644 --- a/tests/unit/odp_pull.rs +++ b/tests/unit/odp_pull.rs @@ -1,6 +1,8 @@ -use super::*; -use std::fs; -use std::path::PathBuf; +#[allow(unused_imports)] +use { + super::*, + std::{fs, path::PathBuf} +}; fn fixture(name: &str) -> Vec { fs::read( diff --git a/tests/unit/parse_xml_pull.rs b/tests/unit/parse_xml_pull.rs index bdc6a65..d542f1c 100644 --- a/tests/unit/parse_xml_pull.rs +++ b/tests/unit/parse_xml_pull.rs @@ -1,7 +1,9 @@ -use super::*; -use crate::Baseline; -use std::fs; -use std::path::PathBuf; +#[allow(unused_imports)] +use { + super::*, + crate::Baseline, + std::{fs, path::PathBuf}, +}; fn fixture(name: &str) -> Vec { fs::read(