diff --git a/src/constants.rs b/src/constants.rs index 7c1e888..a85f67c 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,4 +1,6 @@ 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"; \ No newline at end of file +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"; diff --git a/src/container.rs b/src/container.rs index 9243d1d..ece796d 100644 --- a/src/container.rs +++ b/src/container.rs @@ -1,4 +1,7 @@ use super::{Result, Slide}; +use crate::constants::{SLIDE_LAYOUT_NAMESPACE, SLIDE_MASTER_NAMESPACE}; +use crate::parse_rels::parse_relationships; +use crate::parse_xml::{extract_inherited_positions, InheritedPositions}; use crate::parser_config::ParserConfig; use rayon::prelude::*; use std::{ @@ -56,7 +59,7 @@ impl PptxContainer { } } - slide_paths.sort(); + sort_slide_paths(&mut slide_paths); Ok(Self { archive, slide_paths, config, slide_count }) } @@ -102,6 +105,7 @@ impl PptxContainer { let rels_path = self.get_slide_rels_path(slide_path); let rels_data = self.read_file_from_archive(&rels_path).ok(); let slide_number = Slide::extract_slide_number(slide_path).unwrap_or(0); + let inherited_positions = self.resolve_inherited_positions(slide_path, rels_data.as_deref())?; // Preload images if enabled let mut slide_images = Vec::new(); @@ -111,13 +115,13 @@ impl PptxContainer { } for img_ref in &slide_images { - let path = PptxContainer::get_full_image_path(slide_path, &img_ref.target); + 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); } } - raw_data.push((slide_path.clone(), slide_number, slide_xml, slide_images)); + raw_data.push((slide_path.clone(), slide_number, slide_xml, slide_images, inherited_positions)); } // Share image data atomically across threads @@ -126,9 +130,9 @@ impl PptxContainer { // Parallel processing starts here (CPU-bound tasks) let slides: Result> = raw_data .into_par_iter() - .map(|(path, number, xml, images)| { + .map(|(path, number, xml, images, inherited_positions)| { // Parse XML in parallel (CPU-intensive) - let elements = crate::parse_xml::parse_slide_xml(&xml)?; + let elements = crate::parse_xml::parse_slide_xml_with_inherited_positions(&xml, &inherited_positions)?; // Resolve image data from shared registry let mut image_map = HashMap::new(); @@ -192,7 +196,8 @@ impl PptxContainer { // parse slide and preload images let slide_number = Slide::extract_slide_number(slide_path).unwrap_or(0); - let elements = crate::parse_xml::parse_slide_xml(&slide_data)?; + let inherited_positions = self.resolve_inherited_positions(slide_path, rels_data.as_deref())?; + let elements = crate::parse_xml::parse_slide_xml_with_inherited_positions(&slide_data, &inherited_positions)?; let mut images = Vec::new(); let mut image_data = HashMap::new(); @@ -204,7 +209,7 @@ impl PptxContainer { } for img_ref in &images { - let img_path = Self::get_full_image_path(slide_path, &img_ref.target); + 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); } @@ -272,14 +277,117 @@ impl PptxContainer { rels_path } - pub fn get_full_image_path(slide_path: &str, target: &str) -> String { - if target.starts_with("../") { - let adjusted_target = target.trim_start_matches("../"); - format!("ppt/{}", adjusted_target) + fn resolve_inherited_positions( + &mut self, + slide_path: &str, + slide_rels_data: Option<&[u8]>, + ) -> Result { + let Some(slide_rels_data) = slide_rels_data else { + return Ok(InheritedPositions::default()); + }; + + let slide_relationships = parse_relationships(slide_rels_data)?; + let Some(layout_target) = slide_relationships + .iter() + .find(|rel| rel.rel_type == SLIDE_LAYOUT_NAMESPACE) + .map(|rel| rel.target.as_str()) else { + return Ok(InheritedPositions::default()); + }; + + let layout_path = Self::resolve_target_path(slide_path, layout_target); + let layout_xml = self.read_file_from_archive(&layout_path)?; + let layout_rels_path = self.get_slide_rels_path(&layout_path); + let layout_rels_data = self.read_file_from_archive(&layout_rels_path).ok(); + + let master_positions = if let Some(layout_rels_data) = layout_rels_data.as_deref() { + let layout_relationships = parse_relationships(layout_rels_data)?; + if let Some(master_target) = layout_relationships + .iter() + .find(|rel| rel.rel_type == SLIDE_MASTER_NAMESPACE) + .map(|rel| rel.target.as_str()) { + let master_path = Self::resolve_target_path(&layout_path, master_target); + let master_xml = self.read_file_from_archive(&master_path)?; + extract_inherited_positions(&master_xml, &InheritedPositions::default())? + } else { + InheritedPositions::default() + } + } else { + InheritedPositions::default() + }; + + extract_inherited_positions(&layout_xml, &master_positions) + } + + pub fn resolve_target_path(base_path: &str, target: &str) -> String { + let mut parts: Vec<&str> = if target.starts_with('/') { + Vec::new() } else { - let slide_dir = slide_path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); - format!("{}/{}", slide_dir, target) + let mut parts: Vec<&str> = base_path.split('/').collect(); + let _ = parts.pop(); + parts + }; + + for part in target.split('/') { + match part { + "" | "." => {} + ".." => { + let _ = parts.pop(); + } + _ => parts.push(part), + } } + + parts.join("/") + } +} + +fn sort_slide_paths(slide_paths: &mut [String]) { + slide_paths.sort_by(|left, right| { + Slide::extract_slide_number(left) + .cmp(&Slide::extract_slide_number(right)) + .then_with(|| left.cmp(right)) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sort_slide_paths_numerically() { + let mut slide_paths = vec![ + "ppt/slides/slide10.xml".to_string(), + "ppt/slides/slide2.xml".to_string(), + "ppt/slides/slide1.xml".to_string(), + ]; + + sort_slide_paths(&mut slide_paths); + + assert_eq!(slide_paths, vec![ + "ppt/slides/slide1.xml".to_string(), + "ppt/slides/slide2.xml".to_string(), + "ppt/slides/slide10.xml".to_string(), + ]); + } + + #[test] + fn test_resolve_target_path_with_parent_segments() { + let resolved = PptxContainer::resolve_target_path( + "ppt/slides/slide3.xml", + "../slideLayouts/slideLayout3.xml", + ); + + assert_eq!(resolved, "ppt/slideLayouts/slideLayout3.xml"); + } + + #[test] + fn test_resolve_target_path_with_root_relative_target() { + let resolved = PptxContainer::resolve_target_path( + "ppt/slides/slide3.xml", + "/ppt/slideLayouts/slideLayout3.xml", + ); + + assert_eq!(resolved, "ppt/slideLayouts/slideLayout3.xml"); } } @@ -353,4 +461,4 @@ impl<'a> Iterator for SlideIterator<'a> { Err(e) => Some(Err(e)), } } -} \ No newline at end of file +} diff --git a/src/parse_rels.rs b/src/parse_rels.rs index 4c4a5c7..a3d0069 100644 --- a/src/parse_rels.rs +++ b/src/parse_rels.rs @@ -3,6 +3,37 @@ use crate::types::ImageReference; use crate::{Error, Result}; use roxmltree::Document; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Relationship { + pub id: String, + pub rel_type: String, + pub target: String, +} + +/// 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 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(), + }); + } + } + + Ok(relationships) +} + /// Parses relationship (`.rels`) XML data from a PPTX slide, extracting image references. /// /// PowerPoint slide relationships data contain mappings between resource IDs and their targets. @@ -25,27 +56,14 @@ use roxmltree::Document; /// - Malformed or invalid XML structure is detected. /// ``` pub fn parse_slide_rels(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 images = Vec::new(); - for rel in root.children().filter(|n| n.is_element() && n.tag_name().name() == "Relationship") { - if let Some(rel_type) = rel.attribute("Type") { - if rel_type == IMAGE_NAMESPACE { - if let Some(id) = rel.attribute("Id") { - if let Some(target) = rel.attribute("Target") { - images.push(ImageReference { - id: id.to_string(), - target: target.to_string(), - }); - } - } - } - } - } - - Ok(images) + Ok(parse_relationships(xml_data)? + .into_iter() + .filter(|rel| rel.rel_type == IMAGE_NAMESPACE) + .map(|rel| ImageReference { + id: rel.id, + target: rel.target, + }) + .collect()) } #[cfg(test)] diff --git a/src/parse_xml.rs b/src/parse_xml.rs index 482c0ef..5e5a65c 100644 --- a/src/parse_xml.rs +++ b/src/parse_xml.rs @@ -2,12 +2,96 @@ use crate::types::{SlideElement, TableCell, TableElement, TableRow, TextElement}; use crate::{ElementPosition, Error, Formatting, ImageReference, ListElement, ListItem, Result, Run}; use roxmltree::{Document, Node}; +use std::collections::HashMap; enum ParsedContent { Text(TextElement), List(ListElement), } +/// 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, + scale_y: f64, + translate_x: f64, + translate_y: f64, +} + +impl CoordinateTransform { + fn identity() -> Self { + Self { + scale_x: 1.0, + scale_y: 1.0, + translate_x: 0.0, + translate_y: 0.0, + } + } + + fn apply(self, position: ElementPosition) -> ElementPosition { + ElementPosition { + x: (position.x as f64 * self.scale_x + self.translate_x).round() as i64, + y: (position.y as f64 * self.scale_y + self.translate_y).round() as i64, + } + } + + fn then(self, next: CoordinateTransform) -> CoordinateTransform { + CoordinateTransform { + 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, + translate_y: self.scale_y * next.translate_y + self.translate_y, + } + } +} + +/// 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, +} + +impl InheritedPositions { + fn resolve(&self, key: &PlaceholderKey) -> Option { + 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.as_str())) + .map(|(_, position)| *position) + }) + }) + .or_else(|| { + key.kind.as_ref().and_then(|kind| { + self.positions + .iter() + .find(|(candidate, _)| candidate.kind.as_deref() == Some(kind.as_str())) + .map(|(_, position)| *position) + }) + }) + } +} + /// 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 @@ -35,6 +119,14 @@ enum ParsedContent { /// - 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_inherited_positions(xml_data, &InheritedPositions::default()) +} + +/// 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, +) -> 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(); @@ -52,14 +144,17 @@ pub fn parse_slide_xml(xml_data: &[u8]) -> Result> { let mut elements = Vec::new(); for child_node in sp_tree.children().filter(|n| n.is_element()) { - elements.extend(parse_group(&child_node)?); + elements.extend(parse_group(&child_node, CoordinateTransform::identity(), inherited_positions)?); } Ok(elements) } -/// Parst eine gesamte Gruppe und alle untergeordneten Kind-Elemente rekursiv -fn parse_group(node: &Node) -> Result> { +/// 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, transform: CoordinateTransform, inherited_positions: &InheritedPositions) -> Result> { let mut elements = Vec::new(); let tag_name = node.tag_name().name(); @@ -69,11 +164,10 @@ fn parse_group(node: &Node) -> Result> { return Ok(elements); } - let position = extract_position(node); + let position = extract_position(node, transform, inherited_positions); match tag_name { "sp" => { - let position = extract_position(node); match parse_sp(node)? { ParsedContent::Text(text) => elements.push(SlideElement::Text(text, position)), ParsedContent::List(list) => elements.push(SlideElement::List(list, position)), @@ -89,8 +183,9 @@ fn parse_group(node: &Node) -> Result> { 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()) { - elements.extend(parse_group(&child)?); + elements.extend(parse_group(&child, child_transform, inherited_positions)?); } }, _ => elements.push(SlideElement::Unknown), @@ -381,25 +476,176 @@ fn parse_run(r_node: &Node) -> Result { Ok(Run { text, formatting }) } -fn extract_position(node: &Node) -> ElementPosition { - let default = ElementPosition::default(); - - node.descendants() - .find(|n| n.tag_name().namespace() == Some(A_NAMESPACE) && n.tag_name().name() == "xfrm") - .and_then(|xfrm| { - let x = xfrm - .children() - .find(|n| n.tag_name().name() == "off" && n.tag_name().namespace() == Some(A_NAMESPACE)) - .and_then(|off| off.attribute("x")?.parse::().ok())?; - - let y = xfrm - .children() - .find(|n| n.tag_name().name() == "off" && n.tag_name().namespace() == Some(A_NAMESPACE)) - .and_then(|off| off.attribute("y")?.parse::().ok())?; - - Some(ElementPosition { x, y }) - }) - .unwrap_or(default) +/// 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")?, + }) +} + +/// 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()), + }) +} + +/// 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 }) +} + +fn collect_placeholder_positions( + node: &Node, + transform: CoordinateTransform, + inherited_positions: &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; + } + + 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 insert_placeholder_position( + positions: &mut HashMap, + key: PlaceholderKey, + position: ElementPosition, +) { + if let Some(idx) = key.idx.as_deref() { + positions.retain(|candidate, _| candidate.idx.as_deref() != Some(idx)); + } + + positions.insert(key, position); } #[cfg(test)] @@ -967,4 +1213,316 @@ mod tests { Err(e) => panic!("Expected ImageNotFound error but got: {:?}", e) } } -} \ No newline at end of file + + #[test] + fn test_parse_slide_xml_reads_graphic_frame_position_from_p_xfrm() { + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + Cell + + + + + + + + + + + + "#; + + let elements = parse_slide_xml(xml.as_bytes()).expect("Failed to parse slide XML"); + + let table = elements.iter().find(|element| matches!(element, SlideElement::Table(_, _))) + .expect("Expected table element"); + + match table { + SlideElement::Table(_, position) => assert_eq!(*position, ElementPosition { x: 111, y: 222 }), + element => panic!("Expected table element, got {:?}", element), + } + } + + #[test] + fn test_parse_slide_xml_applies_group_transform_to_child_positions() { + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Grouped + + + + + + + "#; + + let elements = parse_slide_xml(xml.as_bytes()).expect("Failed to parse slide XML"); + + let text = elements.iter().find(|element| matches!(element, SlideElement::Text(_, _))) + .expect("Expected text element"); + + match text { + SlideElement::Text(_, position) => assert_eq!(*position, ElementPosition { x: 2000, y: 5000 }), + element => panic!("Expected text element, got {:?}", element), + } + } + + #[test] + fn test_parse_slide_xml_uses_inherited_placeholder_position() { + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + Inherited title + + + + + + "#; + + let mut positions = HashMap::new(); + positions.insert( + PlaceholderKey { + kind: Some("title".to_string()), + idx: None, + }, + ElementPosition { x: 695326, y: 333375 }, + ); + + let inherited = InheritedPositions { positions }; + let elements = parse_slide_xml_with_inherited_positions(xml.as_bytes(), &inherited) + .expect("Failed to parse slide XML"); + + let text = elements.iter().find(|element| matches!(element, SlideElement::Text(_, _))) + .expect("Expected text element"); + + match text { + SlideElement::Text(_, position) => assert_eq!(*position, ElementPosition { x: 695326, y: 333375 }), + element => panic!("Expected text element, got {:?}", element), + } + } + + #[test] + fn test_extract_inherited_positions_falls_back_to_master_placeholder() { + let master_xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + "#; + + let layout_xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + "#; + + let master_positions = extract_inherited_positions(master_xml.as_bytes(), &InheritedPositions::default()) + .expect("Failed to parse master positions"); + let layout_positions = extract_inherited_positions(layout_xml.as_bytes(), &master_positions) + .expect("Failed to parse layout positions"); + + let position = layout_positions.resolve(&PlaceholderKey { + kind: Some("ftr".to_string()), + idx: Some("11".to_string()), + }).expect("Expected footer placeholder position"); + + assert_eq!(position, ElementPosition { x: 100, y: 200 }); + } + + #[test] + fn test_extract_inherited_positions_prefers_layout_placeholder_with_same_idx() { + let master_xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + "#; + + let layout_xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + "#; + + let master_positions = extract_inherited_positions(master_xml.as_bytes(), &InheritedPositions::default()) + .expect("Failed to parse master positions"); + let layout_positions = extract_inherited_positions(layout_xml.as_bytes(), &master_positions) + .expect("Failed to parse layout positions"); + + let position = layout_positions.resolve(&PlaceholderKey { + kind: Some("ftr".to_string()), + idx: Some("11".to_string()), + }).expect("Expected footer placeholder position"); + + assert_eq!(position, ElementPosition { x: 900, y: 800 }); + } +}