From 8e4d0e6b4c10db783967759e09c8dcbe230f7cd4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:02:27 +0000 Subject: [PATCH] Refactor pptx for strict Clippy checks --- crates/pptx/src/read.rs | 408 ++++++++++++++---------- crates/pptx/src/read/chart.rs | 347 +++++++++++--------- crates/pptx/src/write/chart.rs | 80 +++-- crates/pptx/tests/it/corpus_charts.rs | 7 + crates/pptx/tests/it/corpus_elements.rs | 16 + crates/pptx/tests/it/corpus_media.rs | 150 +++++---- crates/pptx/tests/it/corpus_style.rs | 49 +-- crates/pptx/tests/it/corpus_themes.rs | 35 +- crates/pptx/tests/it/sample.rs | 46 +-- 9 files changed, 656 insertions(+), 482 deletions(-) diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index bb717d45..18e8eb92 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -413,7 +413,56 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } let pres_part = Part::new(&pkg, &pres_path, &pres_doc); - // Masters, layouts and slides to read. + let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part); + let (docs, max_id) = load_part_docs(&pkg, &master_paths, &slide_entries); + + let mut imp = Imp { + pkg: &pkg, + media: vec![], + media_by_part: HashMap::new(), + next: max_id.max(1), + slide_by_part: HashMap::new(), + custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), + vml_previews: HashMap::new(), + kept_sidecar_bytes: 0, + kept_charts: HashMap::new(), + }; + // Slide ids first so hyperlinks anywhere can point at slides. + let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); + read_custom_show_names(&mut imp, pres_el); + + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + p.masters.clear(); + p.media.clear(); + p.default_text_style = Default::default(); + + read_presentation_settings(&mut imp, &mut p, &pres_part, pres_el); + let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs); + let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part); + read_sections(&mut p, &pres_part, pres_el, &file_slide_ids); + p.tags = tags(&imp, &pres_part, pres_el); + read_custom_shows(&mut p, &imp, &pres_part, pres_el); + read_guides_and_show(&mut p, &imp, &pres_part); + read_notes_master(&mut imp, &mut p, &pres_part); + read_handout_master(&mut imp, &mut p, &pres_part); + read_document_properties(&mut p, &pkg, &root_rels); + + p.kept_parts = kept_parts(&pkg, &pres_path); + + inherit_placeholder_geometry(&mut p); + default_text_anchor(&mut p); + p.header_footer = header_footer(&p); + p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); + p.media = std::mem::take(&mut imp.media); + p.next_id = imp.next; + p.fix_next_id(); + Ok((p, lost_slides)) +} + +/// Masters, layouts and slides to read, in file order and without duplicates: (master paths, +/// (file slide id, slide path)). +fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec, Vec<(u32, String)>) { let master_paths: Vec = pres_el .child("sldMasterIdLst") .map(|l| l.children_named("sldMasterId").filter_map(|m| pres_part.rels.target(m.attr("r:id").unwrap_or(""))).map(String::from).collect()) @@ -429,15 +478,19 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE .collect() }) .unwrap_or_default(); - - let mut docs: HashMap = HashMap::new(); - let mut max_id = 0u32; let mut seen_masters = HashSet::new(); master_paths.retain(|m| seen_masters.insert(opc::norm(m))); + let mut seen_slides = HashSet::new(); + slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p))); + (master_paths, slide_entries) +} - let mut all_parts: Vec = master_paths.clone(); +/// Loads every master, layout and slide part once. Returns them by normalized path and the +/// largest shape id found in them. +fn load_part_docs(pkg: &Package, master_paths: &[String], slide_entries: &[(u32, String)]) -> (HashMap, u32) { + let mut all_parts: Vec = master_paths.to_vec(); let mut seen_layouts = HashSet::new(); - for m in &master_paths { + for m in master_paths { for r in pkg.rels(m).all_of("slideLayout") { let key = opc::norm(&r.target); if seen_layouts.insert(key) { @@ -445,35 +498,32 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } } } - let mut seen_slides = HashSet::new(); - slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p))); all_parts.extend(slide_entries.iter().map(|(_, p)| p.clone())); + let mut docs: HashMap = HashMap::new(); + let mut max_id = 0u32; for p in &all_parts { let key = opc::norm(p); if docs.contains_key(&key) { continue; } - if let Some(d) = load(&pkg, p) { + if let Some(d) = load(pkg, p) { max_cnvpr(&d.root, &mut max_id); docs.insert(key, d); } } + (docs, max_id) +} - let mut imp = Imp { - pkg: &pkg, - media: vec![], - media_by_part: HashMap::new(), - next: max_id.max(1), - slide_by_part: HashMap::new(), - custom_show_names: HashMap::new(), - table_style_ids: Vec::new(), - vml_previews: HashMap::new(), - kept_sidecar_bytes: 0, - kept_charts: HashMap::new(), - }; - // Slide ids first so hyperlinks anywhere can point at slides. +/// Allocates an id for every readable slide; slides that are missing or unreadable are recorded in +/// `lost_slides` and left out. +fn register_slides( + imp: &mut Imp, + docs: &HashMap, + slide_entries: &[(u32, String)], + lost_slides: &mut Vec, +) -> Vec<(u32, String, SlideId)> { let mut slide_ids: Vec<(u32, String, SlideId)> = vec![]; - for (file_id, path) in &slide_entries { + for (file_id, path) in slide_entries { if !docs.contains_key(&opc::norm(path)) { log::warn!("pptx: slide {path} is missing or unreadable; skipped"); lost_slides.push(path.clone()); @@ -483,6 +533,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE imp.slide_by_part.insert(opc::norm(path), id); slide_ids.push((*file_id, path.clone(), id)); } + slide_ids +} + +fn read_custom_show_names(imp: &mut Imp, pres_el: &El) { if let Some(l) = pres_el.child("custShowLst") { for cs in l.children_named("custShow") { if let (Some(id), Some(name)) = (cs.attr("id"), cs.attr("name")) { @@ -490,12 +544,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } } } +} - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - p.masters.clear(); - p.media.clear(); - p.default_text_style = Default::default(); - +/// Slide and notes size, first slide number, default text style and table styles. +fn read_presentation_settings(imp: &mut Imp, p: &mut Presentation, pres_part: &Part, pres_el: &El) { if let Some(sz) = pres_el.child("sldSz") { let w = sz.i64("cx").map(emu_to_pt).unwrap_or(960.0); let h = sz.i64("cy").map(emu_to_pt).unwrap_or(540.0); @@ -508,24 +560,26 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } p.first_slide_number = pres_el.u32("firstSlideNum").unwrap_or(1).min(9999); if let Some(dts) = pres_el.child("defaultTextStyle") { - p.default_text_style = dml::list_style(&mut imp, &pres_part, dts); + p.default_text_style = dml::list_style(imp, pres_part, dts); } if let Some(r) = pres_part.rels.first_of("tableStyles") - && let Some(d) = load(&pkg, &r.target) + && let Some(d) = load(imp.pkg, &r.target) { - let part = Part::new(&pkg, &r.target, &d); - p.table_styles = table_styles(&mut imp, &part, &d.root); + let part = Part::new(imp.pkg, &r.target, &d); + p.table_styles = table_styles(imp, &part, &d.root); imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect(); } +} - // Masters and layouts. +/// Reads the slide masters and their layouts; a file without a readable master gets a default one. +fn read_masters(imp: &mut Imp, p: &mut Presentation, master_paths: &[String], docs: &HashMap) -> HashMap { let mut layout_by_part: HashMap = HashMap::new(); - for mp in &master_paths { + for mp in master_paths { let Some(doc) = docs.get(&opc::norm(mp)) else { log::warn!("pptx: master {mp} missing; skipped"); continue; }; - let master = read_master(&mut imp, mp, doc, &docs, &mut layout_by_part); + let master = read_master(imp, mp, doc, docs, &mut layout_by_part); p.masters.push(Arc::new(master)); } if p.masters.is_empty() { @@ -534,87 +588,111 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE imp.next = imp.next.saturating_add(200); p.masters.push(Arc::new(m)); } - let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); + layout_by_part +} - // Slides. +/// Reads every registered slide with its notes and comments. Returns the slide ids by file id. +fn read_slides( + imp: &mut Imp, + p: &mut Presentation, + pres_part: &Part, + docs: &HashMap, + slide_ids: &[(u32, String, SlideId)], + layout_by_part: &HashMap, +) -> HashMap { + let pkg = imp.pkg; + let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); let mut file_slide_ids: HashMap = HashMap::new(); let mut notes_by_target: HashMap = HashMap::new(); - for (file_id, path, id) in &slide_ids { + for (file_id, path, id) in slide_ids { let Some(doc) = docs.get(&opc::norm(path)) else { continue }; - let part = Part::new(&pkg, path, doc); + let part = Part::new(pkg, path, doc); let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout); - let mut slide = read_slide(&mut imp, &part, doc, *id, layout); - // Notes. - if let Some(n) = part.rels.first_of("notesSlide") { - let n_key = opc::norm(&n.target); - if let Some(cached_notes) = notes_by_target.get(&n_key) { - slide.notes = cached_notes.clone(); - } else if let Some(nd) = load(&pkg, &n.target) { - let np = Part::new(&pkg, &n.target, &nd); - let notes = read_notes(&mut imp, &np, &nd.root); - notes_by_target.insert(n_key, notes.clone()); - slide.notes = notes; - } - } - // Comments. A modern part and the legacy part can describe the same threads (PowerPoint - // writes both): the modern one wins, its flattened legacy copy is skipped. - let mut parts: Vec<(Doc, bool)> = vec![]; - for c in part.rels.all_of("comments") { - if let Some(cd) = pkg.xml(&c.target) { - // The content type names the modern part; a package that types it only through a - // Default entry instead of an Override is still modern when its root is p188. - let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; - parts.push((cd, modern)); - } - } - let has_modern = parts.iter().any(|(_, m)| *m); - for (cd, modern) in parts { - if has_modern && !modern { - continue; - } - slide.comments.extend(read_comments(&pkg, &pres_part, &cd.root, modern)); + let mut slide = read_slide(imp, &part, doc, *id, layout); + if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) { + slide.notes = notes; } + read_slide_comments(pkg, pres_part, &part, &mut slide); file_slide_ids.insert(*file_id, *id); p.slides.push(Arc::new(slide)); } + file_slide_ids +} - // Sections. - if let Some(ext) = pres_el.child("extLst") { - if let Some(sl) = ext.find("sectionLst") { - for s in sl.children_named("section").take(10_000) { - let slides = s - .child("sldIdLst") - .map(|l| l.children_named("sldId").filter_map(|x| x.u32("id").and_then(|i| file_slide_ids.get(&i).copied())).collect()) - .unwrap_or_default(); - p.sections.push(Section { name: s.attr("name").unwrap_or("").to_string(), slides }); - } - } - let kept: String = ext - .children_named("ext") - .filter(|e| e.attr("uri") != Some(crate::SECTION_EXT_URI) && !has_rel_refs(e)) - .map(|e| pres_part.keep(e)) - .collect(); - if !kept.is_empty() { - p.raw_ext = Some(kept); +/// The slide's notes text, read once per notes part. +fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap) -> Option { + let n = part.rels.first_of("notesSlide")?; + let key = opc::norm(&n.target); + if let Some(cached) = cache.get(&key) { + return Some(cached.clone()); + } + let pkg = imp.pkg; + let nd = load(pkg, &n.target)?; + let np = Part::new(pkg, &n.target, &nd); + let notes = read_notes(imp, &np, &nd.root); + cache.insert(key, notes.clone()); + Some(notes) +} + +/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint +/// writes both): the modern one wins, its flattened legacy copy is skipped. +fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) { + let mut parts: Vec<(Doc, bool)> = vec![]; + for c in part.rels.all_of("comments") { + if let Some(cd) = pkg.xml(&c.target) { + // The content type names the modern part; a package that types it only through a + // Default entry instead of an Override is still modern when its root is p188. + let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; + parts.push((cd, modern)); } } - p.tags = tags(&imp, &pres_part, pres_el); - // Custom shows. - if let Some(l) = pres_el.child("custShowLst") { - for cs in l.children_named("custShow") { - let slides = cs - .child("sldLst") - .map(|l| { - l.children_named("sld") - .filter_map(|s| pres_part.rels.target(s.attr("r:id").unwrap_or(""))) - .filter_map(|t| imp.slide_by_part.get(&opc::norm(t)).copied()) - .collect() - }) + let has_modern = parts.iter().any(|(_, m)| *m); + for (cd, modern) in parts { + if has_modern && !modern { + continue; + } + slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern)); + } +} + +/// Sections, and the extension elements the model does not understand (kept verbatim). +fn read_sections(p: &mut Presentation, pres_part: &Part, pres_el: &El, file_slide_ids: &HashMap) { + let Some(ext) = pres_el.child("extLst") else { return }; + if let Some(sl) = ext.find("sectionLst") { + for s in sl.children_named("section").take(10_000) { + let slides = s + .child("sldIdLst") + .map(|l| l.children_named("sldId").filter_map(|x| x.u32("id").and_then(|i| file_slide_ids.get(&i).copied())).collect()) .unwrap_or_default(); - p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); + p.sections.push(Section { name: s.attr("name").unwrap_or("").to_string(), slides }); } } - // Drawing guides (eighths of a point). + let kept: String = + ext.children_named("ext").filter(|e| e.attr("uri") != Some(crate::SECTION_EXT_URI) && !has_rel_refs(e)).map(|e| pres_part.keep(e)).collect(); + if !kept.is_empty() { + p.raw_ext = Some(kept); + } +} + +fn read_custom_shows(p: &mut Presentation, imp: &Imp, pres_part: &Part, pres_el: &El) { + let Some(l) = pres_el.child("custShowLst") else { return }; + for cs in l.children_named("custShow") { + let slides = cs + .child("sldLst") + .map(|l| { + l.children_named("sld") + .filter_map(|s| pres_part.rels.target(s.attr("r:id").unwrap_or(""))) + .filter_map(|t| imp.slide_by_part.get(&opc::norm(t)).copied()) + .collect() + }) + .unwrap_or_default(); + p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); + } +} + +/// Drawing guides (eighths of a point) and slide show settings. +fn read_guides_and_show(p: &mut Presentation, imp: &Imp, pres_part: &Part) { + let pkg = imp.pkg; if let Some(r) = pres_part.rels.first_of("viewProps") && let Some(d) = pkg.xml(&r.target) && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) @@ -628,71 +706,76 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE .take(1000) .collect(); } - // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) && let Some(sp) = d.root.child("showPr") { p.show = read_show(sp, &imp.custom_show_names); } - // Notes master. - if let Some(r) = pres_part.rels.first_of("notesMaster") - && let Some(d) = load(&pkg, &r.target) - { - let part = Part::new(&pkg, &r.target, &d); - let mut ctx = shapes::IdCtx::default(); - let mut m = Master { id: MasterId(imp.alloc()), name: "Notes Master".into(), ..Default::default() }; - if let Some(t) = part.rels.first_of("theme") - && let Some(td) = pkg.xml(&t.target) - { - let tp = Part::new(&pkg, &t.target, &td); - m.theme = theme::read_theme(&mut imp, &tp, &td.root); +} + +/// Theme, background, shapes and colour map shared by the notes and handout masters. +fn read_aux_master(imp: &mut Imp, part: &Part, doc: &Doc, m: &mut Master) { + let mut ctx = shapes::IdCtx::default(); + if let Some(csld) = doc.root.child("cSld") { + m.background = background(imp, part, csld); + if let Some(tree) = csld.child("spTree") { + m.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } - if let Some(csld) = d.root.child("cSld") { - m.background = background(&mut imp, &part, csld); - if let Some(tree) = csld.child("spTree") { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); - } - } - if let Some(cm) = d.root.child("clrMap") { - m.color_map = color_map(cm); - } - // Notes text styles are the body's list style; slide masters use title/body/other instead. - if let Some(ns) = d.root.child("notesStyle") { - m.body_style = dml::list_style(&mut imp, &part, ns); - } - p.notes_master = Some(Arc::new(m)); } - // Handout master (header, footer, date and slide number for printed handouts). - if let Some(r) = pres_part.rels.first_of("handoutMaster") - && let Some(d) = load(&pkg, &r.target) - { - let part = Part::new(&pkg, &r.target, &d); - let mut ctx = shapes::IdCtx::default(); - let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; - if let Some(t) = part.rels.first_of("theme") - && let Some(td) = pkg.xml(&t.target) - { - let tp = Part::new(&pkg, &t.target, &td); - m.theme = theme::read_theme(&mut imp, &tp, &td.root); - } else if let Some(first) = p.masters.first() { - m.theme = first.theme.clone(); - } - if let Some(csld) = d.root.child("cSld") { - if let Some(n) = csld.attr("name").filter(|s| !s.is_empty()) { - m.name = n.to_string(); - } - m.background = background(&mut imp, &part, csld); - if let Some(tree) = csld.child("spTree") { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); - } - } - if let Some(cm) = d.root.child("clrMap") { - m.color_map = color_map(cm); - } - p.handout_master = Some(Arc::new(m)); + if let Some(cm) = doc.root.child("clrMap") { + m.color_map = color_map(cm); } - // Document properties. +} + +/// Reads the part's own theme into the master, if it has one. +fn read_master_theme(imp: &mut Imp, part: &Part, m: &mut Master) -> bool { + let pkg = imp.pkg; + if let Some(t) = part.rels.first_of("theme") + && let Some(td) = pkg.xml(&t.target) + { + let tp = Part::new(pkg, &t.target, &td); + m.theme = theme::read_theme(imp, &tp, &td.root); + return true; + } + false +} + +fn read_notes_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first_of("notesMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Notes Master".into(), ..Default::default() }; + read_master_theme(imp, &part, &mut m); + read_aux_master(imp, &part, &d, &mut m); + // Notes text styles are the body's list style; slide masters use title/body/other instead. + if let Some(ns) = d.root.child("notesStyle") { + m.body_style = dml::list_style(imp, &part, ns); + } + p.notes_master = Some(Arc::new(m)); +} + +/// Handout master (header, footer, date and slide number for printed handouts). +fn read_handout_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first_of("handoutMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; + if !read_master_theme(imp, &part, &mut m) + && let Some(first) = p.masters.first() + { + m.theme = first.theme.clone(); + } + if let Some(n) = d.root.child("cSld").and_then(|c| c.attr("name")).filter(|s| !s.is_empty()) { + m.name = n.to_string(); + } + read_aux_master(imp, &part, &d, &mut m); + p.handout_master = Some(Arc::new(m)); +} + +fn read_document_properties(p: &mut Presentation, pkg: &Package, root_rels: &Rels) { if let Some(r) = root_rels.first_of("core-properties") && let Some(d) = pkg.xml(&r.target) { @@ -703,17 +786,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE { p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default(); } - - p.kept_parts = kept_parts(&pkg, &pres_path); - - inherit_placeholder_geometry(&mut p); - default_text_anchor(&mut p); - p.header_footer = header_footer(&p); - p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); - p.media = std::mem::take(&mut imp.media); - p.next_id = imp.next; - p.fix_next_id(); - Ok((p, lost_slides)) } /// Embedded font faces in one file. Past this, further typefaces are dropped. diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 76158af3..540aaff1 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -129,167 +129,22 @@ fn chart_within(root: &El, mut budget: usize) -> Option { // (a combo chart's line on the right) is on the secondary axis. let val_map: std::collections::HashMap<&str, bool> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect(); - // A group's value axis: its vertical one when it has two (scatter), else its one. - let group_val_axis = |g: &El| { - let ids: Vec<(&str, bool)> = - g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect(); - ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) - }; - // The primary value axis is the one on the left (bottom for bar charts); the secondary one - // sits on the right or top, or crosses the category axis at its maximum. - let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t")); - let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect(); - let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect(); - let primary_val: Option = - used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned(); + let primary_val = primary_value_axis(pa, &val_map); for g in pa.elements() { let name = g.local(); if !name.ends_with("Chart") { continue; } - let grouping = g.child_val("grouping").unwrap_or("clustered"); - let kind = match name { - "barChart" | "bar3DChart" => { - let bar = g.child_val("barDir") == Some("bar"); - match (bar, grouping) { - (false, "stacked") => ChartType::StackedColumn, - (false, "percentStacked") => ChartType::PercentColumn, - (false, _) => ChartType::Column, - (true, "stacked") => ChartType::StackedBar, - (true, "percentStacked") => ChartType::PercentBar, - (true, _) => ChartType::Bar, - } - } - "lineChart" | "line3DChart" => { - let markers = g.child_val("marker").is_some_and(|v| v == "1" || v == "true") - && g.children_named("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) != Some("none")); - match grouping { - "stacked" | "percentStacked" => ChartType::StackedLine, - _ if markers => ChartType::LineMarkers, - _ => ChartType::Line, - } - } - "areaChart" | "area3DChart" => { - if matches!(grouping, "stacked" | "percentStacked") { - ChartType::StackedArea - } else { - ChartType::Area - } - } - "pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie, - "doughnutChart" => ChartType::Doughnut, - "scatterChart" => ChartType::Scatter, - "bubbleChart" => ChartType::Bubble, - "radarChart" => { - if g.child_val("radarStyle") == Some("filled") { - ChartType::FilledRadar - } else { - ChartType::Radar - } - } - "stockChart" => { - c.up_down_bars = g.child("upDownBars").is_some(); - ChartType::Stock - } - "surfaceChart" | "surface3DChart" => ChartType::Surface, - _ => continue, - }; - // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. - c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); - // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), - // not the 219 % / -27 % PowerPoint writes into its new charts. - let bar_group = g.is("barChart") || g.is("bar3DChart"); - if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { - c.gap_width = (v / 100.0).clamp(0.0, 5.0); - } else if bar_group { - c.gap_width = 1.5; - } - if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { - c.overlap = (v / 100.0).clamp(-1.0, 1.0); - } else if bar_group { - c.overlap = 0.0; - } - if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { - c.hole_size = (v / 100.0).clamp(0.0, 0.9); - } - // `` without a val attribute means true, as in the schema's default. - // The setting belongs to this plot group: a group that asks for no labels keeps them - // off, even when another group of a combo chart asks for them (#868). - let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true)); - if group_labels == Some(true) { - c.data_labels = true; + let Some(kind) = group_chart_type(name, g) else { continue }; + if name == "stockChart" { + c.up_down_bars = g.child("upDownBars").is_some(); } + let group_labels = read_group_settings(g, &mut c); kinds.push(kind); - let val_axis = group_val_axis(g); + let val_axis = group_value_axis(&val_map, g); let secondary = val_axis.is_some() && val_axis != primary_val; for s in g.children_named("ser").take(1000) { - let mut ser = Series { - name: s.child("tx").map(|t| strings(t, &mut budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), - fill: simple_fill(s.child("spPr")), - line: simple_line(s.child("spPr")), - smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), - marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from), - kind: Some(kind), - secondary_axis: secondary, - ..Default::default() - }; - // `` without a val attribute means true, as in the schema's default. - if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) { - ser.data_labels = Some(d); - } else if let Some(false) = group_labels { - // The group asks for no labels and the series does not say otherwise. - ser.data_labels = Some(false); - } - for t in s.children_named("trendline").take(16) { - if let Some(t) = trendline(t) { - ser.trendlines.push(t); - } - } - // Data labels' number format: the series' labels', the group's, else the source data's. - let label_fmt = |d: Option<&El>| { - d.and_then(|d| d.child("numFmt")) - .filter(|n| n.attr("sourceLinked") != Some("1")) - .and_then(|n| n.attr("formatCode")) - .map(str::to_string) - }; - let source = - s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()); - ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty()); - for dp in s.children_named("dPt") { - if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) { - ser.point_fills.push((i as usize, f)); - } - } - if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) { - let cats = strings(cat, &mut budget); - if matches!(kind, ChartType::Scatter | ChartType::Bubble) { - ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); - } - if c.categories.is_empty() { - c.categories = cats; - } - } - if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { - ser.values = numbers(v, &mut budget); - } - // Error bars on the values: a scatter chart's y bars, any other chart's only ones. - if let Some(e) = s - .children_named("errBars") - .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble))) - { - let d = ErrorBars::default(); - ser.error_bars = Some(ErrorBars { - bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), - val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), - value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), - plus: e.child("plus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), - minus: e.child("minus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), - end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), - }); - } - if let Some(b) = s.child("bubbleSize") { - ser.sizes = numbers(b, &mut budget); - } + let ser = read_series(s, g, (kind, secondary, group_labels), &mut c.categories, &mut budget); c.series.push(ser); } } @@ -305,11 +160,189 @@ fn chart_within(root: &El, mut budget: usize) -> Option { s.secondary_axis = false; } } - // Axes. c.lang = root.child_val("lang").map(str::to_string); + let (val_ax, cat_ax) = read_axes(&mut c, pa, &primary_val); + read_text_styles(&mut c, root, ch, val_ax, cat_ax); + Some(c) +} + +/// A group's value axis: its vertical one when it has two (scatter), else its one. +fn group_value_axis(val_map: &std::collections::HashMap<&str, bool>, g: &El) -> Option { + let ids: Vec<(&str, bool)> = + g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect(); + ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) +} + +/// The primary value axis is the one on the left (bottom for bar charts); the secondary one +/// sits on the right or top, or crosses the category axis at its maximum. +fn primary_value_axis(pa: &El, val_map: &std::collections::HashMap<&str, bool>) -> Option { + let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t")); + let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect(); + let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(|g| group_value_axis(val_map, g)).collect(); + used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned() +} + +/// The chart type of a plot group (`name` is its element name), or `None` for an unknown group. +fn group_chart_type(name: &str, g: &El) -> Option { + let grouping = g.child_val("grouping").unwrap_or("clustered"); + Some(match name { + "barChart" | "bar3DChart" => { + let bar = g.child_val("barDir") == Some("bar"); + match (bar, grouping) { + (false, "stacked") => ChartType::StackedColumn, + (false, "percentStacked") => ChartType::PercentColumn, + (false, _) => ChartType::Column, + (true, "stacked") => ChartType::StackedBar, + (true, "percentStacked") => ChartType::PercentBar, + (true, _) => ChartType::Bar, + } + } + "lineChart" | "line3DChart" => { + let markers = g.child_val("marker").is_some_and(|v| v == "1" || v == "true") + && g.children_named("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) != Some("none")); + match grouping { + "stacked" | "percentStacked" => ChartType::StackedLine, + _ if markers => ChartType::LineMarkers, + _ => ChartType::Line, + } + } + "areaChart" | "area3DChart" => { + if matches!(grouping, "stacked" | "percentStacked") { + ChartType::StackedArea + } else { + ChartType::Area + } + } + "pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie, + "doughnutChart" => ChartType::Doughnut, + "scatterChart" => ChartType::Scatter, + "bubbleChart" => ChartType::Bubble, + "radarChart" => { + if g.child_val("radarStyle") == Some("filled") { + ChartType::FilledRadar + } else { + ChartType::Radar + } + } + "stockChart" => ChartType::Stock, + "surfaceChart" | "surface3DChart" => ChartType::Surface, + _ => return None, + }) +} + +/// Chart-wide settings a plot group carries (vary colours, gap, overlap, hole, data labels). +/// Returns the group's own data-label setting. +fn read_group_settings(g: &El, c: &mut Chart) -> Option { + // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. + c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is("barChart") || g.is("bar3DChart"); + if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { + c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; + } + if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { + c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; + } + if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { + c.hole_size = (v / 100.0).clamp(0.0, 0.9); + } + // `` without a val attribute means true, as in the schema's default. + // The setting belongs to this plot group: a group that asks for no labels keeps them + // off, even when another group of a combo chart asks for them (#868). + let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true)); + if group_labels == Some(true) { + c.data_labels = true; + } + group_labels +} + +/// Data labels' number format: the series' labels', the group's, else the source data's. +fn label_number_format(s: &El, g: &El) -> Option { + let label_fmt = |d: Option<&El>| { + d.and_then(|d| d.child("numFmt")).filter(|n| n.attr("sourceLinked") != Some("1")).and_then(|n| n.attr("formatCode")).map(str::to_string) + }; + let source = s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()); + label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty()) +} + +/// Error bars on the values: a scatter chart's y bars, any other chart's only ones. +fn read_error_bars(s: &El, kind: ChartType, budget: &mut usize) -> Option { + let e = s + .children_named("errBars") + .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble)))?; + let d = ErrorBars::default(); + Some(ErrorBars { + bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), + val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), + value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), + plus: e.child("plus").map(|p| numbers(p, budget)).unwrap_or_default(), + minus: e.child("minus").map(|p| numbers(p, budget)).unwrap_or_default(), + end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), + }) +} + +/// One `c:ser` of group `g`. `group` is (chart type, on the secondary axis, the group's data-label +/// setting); the first series with categories sets `categories`. +fn read_series(s: &El, g: &El, group: (ChartType, bool, Option), categories: &mut Vec, budget: &mut usize) -> Series { + let (kind, secondary, group_labels) = group; + let mut ser = Series { + name: s.child("tx").map(|t| strings(t, budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), + fill: simple_fill(s.child("spPr")), + line: simple_line(s.child("spPr")), + smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), + marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from), + kind: Some(kind), + secondary_axis: secondary, + ..Default::default() + }; + // `` without a val attribute means true, as in the schema's default. + if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) { + ser.data_labels = Some(d); + } else if let Some(false) = group_labels { + // The group asks for no labels and the series does not say otherwise. + ser.data_labels = Some(false); + } + for t in s.children_named("trendline").take(16) { + if let Some(t) = trendline(t) { + ser.trendlines.push(t); + } + } + ser.number_format = label_number_format(s, g); + for dp in s.children_named("dPt") { + if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) { + ser.point_fills.push((i as usize, f)); + } + } + if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) { + let cats = strings(cat, budget); + if matches!(kind, ChartType::Scatter | ChartType::Bubble) { + ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); + } + if categories.is_empty() { + *categories = cats; + } + } + if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { + ser.values = numbers(v, budget); + } + ser.error_bars = read_error_bars(s, kind, budget); + if let Some(b) = s.child("bubbleSize") { + ser.sizes = numbers(b, budget); + } + ser +} + +/// Axis scaling, titles, number formats and visibility. Returns the primary value axis and the +/// category axis. +fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option) -> (Option<&'a El>, Option<&'a El>) { let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r")); let by_id = |id: &Option| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref()); - let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); + let val_ax = by_id(primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()); // An axis' number format: its own unless linked to the source (`sourceLinked="1"`, or no // c:numFmt at all), in which case the data's format of the first series plotted on it. @@ -357,6 +390,11 @@ fn chart_within(root: &El, mut budget: usize) -> Option { c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin"); c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } + (val_ax, cat_ax) +} + +/// Text sizes and colours as the file sets them. +fn read_text_styles(c: &mut Chart, root: &El, ch: &El, val_ax: Option<&El>, cat_ax: Option<&El>) { // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to // the chart): the axes' and legend's, else the whole chart's; the title's own. let chart_size = text_size(root.child("txPr")); @@ -364,7 +402,6 @@ fn chart_within(root: &El, mut budget: usize) -> Option { // Text colour likewise: the axes' and legend's, else the whole chart's. c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr"))); c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr")))); - Some(c) } fn trend_num(e: &El, k: &str) -> Option { diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 04ae042e..28e62fe9 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -1,7 +1,7 @@ //! Chart parts: a `c:chartSpace` with cached categories and values. //! An unchanged chart also keeps the embedded workbook and the other parts it points at. -use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}; +use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType}; use deckcraft_model::style::Fill; use crate::opc::{NS_A, NS_C, NS_R}; @@ -204,6 +204,48 @@ fn group_of(k: ChartType) -> Group { } } +fn error_bars(w: &mut W, e: &ErrorBars, g: Group) { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open0("c:errBars"); + if matches!(g, Group::Scatter | Group::Bubble) { + w.val("c:errDir", "y"); + } + w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.val("c:errValType", vt); + w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); + if vt == "cust" { + w.open0("c:plus"); + num_lit(w, &e.plus); + w.close("c:plus"); + w.open0("c:minus"); + num_lit(w, &e.minus); + w.close("c:minus"); + } else if e.value.is_finite() { + w.val("c:val", fmt_num(e.value)); + } + w.close("c:errBars"); +} + +/// Per-point fills of pie, doughnut and bar series. +fn point_fills(w: &mut W, s: &Series, g: Group) { + if !matches!(g, Group::Pie | Group::Doughnut | Group::Bar) { + return; + } + for (idx, f) in s.point_fills.iter().take(1000) { + w.open0("c:dPt"); + w.val("c:idx", idx); + if g == Group::Bar { + w.val("c:invertIfNegative", 0); + } + if g != Group::Bar { + w.val("c:bubble3D", 0); + } + sp_pr(w, Some(f), None); + w.close("c:dPt"); + } +} + fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) { w.open0("c:ser"); w.val("c:idx", i); @@ -253,20 +295,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) w.close("c:marker"); } } - if g == Group::Pie || g == Group::Doughnut || g == Group::Bar { - for (idx, f) in s.point_fills.iter().take(1000) { - w.open0("c:dPt"); - w.val("c:idx", idx); - if g == Group::Bar { - w.val("c:invertIfNegative", 0); - } - if g != Group::Bar { - w.val("c:bubble3D", 0); - } - sp_pr(w, Some(f), None); - w.close("c:dPt"); - } - } + point_fills(w, s, g); if let Some(d) = s.data_labels { data_labels(w, c, d); } @@ -279,26 +308,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) let n = c.categories.len().max(s.values.len()); let last = n + 1; if let Some(e) = &s.error_bars { - let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); - w.open0("c:errBars"); - if matches!(g, Group::Scatter | Group::Bubble) { - w.val("c:errDir", "y"); - } - w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); - let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); - w.val("c:errValType", vt); - w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); - if vt == "cust" { - w.open0("c:plus"); - num_lit(w, &e.plus); - w.close("c:plus"); - w.open0("c:minus"); - num_lit(w, &e.minus); - w.close("c:minus"); - } else if e.value.is_finite() { - w.val("c:val", fmt_num(e.value)); - } - w.close("c:errBars"); + error_bars(w, e, g); } if matches!(g, Group::Scatter | Group::Bubble) { let xs: Vec> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() }; diff --git a/crates/pptx/tests/it/corpus_charts.rs b/crates/pptx/tests/it/corpus_charts.rs index ff1e6424..f77d9307 100644 --- a/crates/pptx/tests/it/corpus_charts.rs +++ b/crates/pptx/tests/it/corpus_charts.rs @@ -159,6 +159,11 @@ fn chart_table_and_text_corpus_round_trips() { assert_eq!(a.data_labels, b.data_labels); } + merged_table_survives(slide); + text_columns_and_warp_survive(slide); +} + +fn merged_table_survives(slide: &Slide) { let ShapeKind::Table(t) = &by_name(slide, "Merged table").kind else { panic!("not a table") }; let anchor = t.cell(0, 0).expect("anchor"); assert_eq!((anchor.grid_span, anchor.row_span), (2, 2)); @@ -171,7 +176,9 @@ fn chart_table_and_text_corpus_round_trips() { assert_eq!(diag.diag_down, Some(Line::solid(ColorRef::rgb(Rgba::rgb(180, 40, 40)), 1.5))); assert_eq!(diag.diag_up, Some(Line::solid(ColorRef::rgb(Rgba::rgb(20, 90, 160)), 2.0))); assert_eq!(plain(&diag.text), "Diag"); +} +fn text_columns_and_warp_survive(slide: &Slide) { let cols = by_name(slide, "Columns").text.as_ref().expect("columns"); assert_eq!(plain(cols), "Two columns\nof vertical text"); assert_eq!(cols.body.columns, Some(3)); diff --git a/crates/pptx/tests/it/corpus_elements.rs b/crates/pptx/tests/it/corpus_elements.rs index 7d3a41da..f6bae69a 100644 --- a/crates/pptx/tests/it/corpus_elements.rs +++ b/crates/pptx/tests/it/corpus_elements.rs @@ -215,7 +215,14 @@ fn assert_deck(q: &Presentation) { let inherited = resolve::run(&ctx, body, ¶s[1], ¶s[1].runs[0].props); assert_eq!(inherited.size, Some(28.0), "the second paragraph takes the layout list style"); assert_eq!(inherited.font.as_deref(), Some("Source Serif 4")); + assert_group(q); + assert_comments(q); + assert_click_links(q); + assert_custom_show_links(q); + assert_custom_shows(q); +} +fn assert_group(q: &Presentation) { let cluster = shape(q, 0, "Cluster"); let g = cluster.xfrm.expect("group box"); assert_eq!((g.x, g.y, g.w, g.h, g.rot, g.flip_h), (80.0, 40.0, 220.0, 140.0, 25.0, true)); @@ -225,13 +232,17 @@ fn assert_deck(q: &Presentation) { assert_eq!(gem.text.as_ref().map(|t| t.text()), Some("Tilted".into())); let gx = gem.xfrm.expect("gem"); assert_eq!((gx.x, gx.y, gx.w, gx.h, gx.rot, gx.flip_v), (12.0, 18.0, 80.0, 40.0, 20.0, true)); +} +fn assert_comments(q: &Presentation) { let c = &q.slides[0].comments; assert_eq!(c.len(), 1); assert_eq!((c[0].author.as_str(), c[0].initials.as_str(), c[0].text.as_str()), ("Mina", "ML", "Check the quote")); assert_eq!(c[0].date, "2026-03-01T09:30:00.000"); assert_eq!((c[0].x, c[0].y), (36.0, 48.0)); +} +fn assert_click_links(q: &Presentation) { let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click")); match click("Site").action { Action::Url { url } => assert_eq!(url, "https://example.com/deck"), @@ -259,7 +270,10 @@ fn assert_deck(q: &Presentation) { assert!(matches!(hover.action, Action::PreviousSlide)); assert!(hover.stop_sound); assert_eq!(hover.tooltip, "Back one"); +} +fn assert_custom_show_links(q: &Presentation) { + let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click")); match click("To board").action { Action::CustomShow { name, returns } => { assert_eq!(name, "Board"); @@ -276,7 +290,9 @@ fn assert_deck(q: &Presentation) { } other => panic!("{other:?}"), } +} +fn assert_custom_shows(q: &Presentation) { assert_eq!(q.custom_shows.len(), 2); assert_eq!(q.custom_shows[0].name, "Short"); assert_eq!(q.custom_shows[0].slides, vec![q.slides[0].id]); diff --git a/crates/pptx/tests/it/corpus_media.rs b/crates/pptx/tests/it/corpus_media.rs index 6ffd14aa..ce0494d3 100644 --- a/crates/pptx/tests/it/corpus_media.rs +++ b/crates/pptx/tests/it/corpus_media.rs @@ -198,76 +198,92 @@ fn object_corpus_round_trips() { let twice = round(&once); for q in [&once, &twice] { - let diagram = by_name(q, "Diagram"); - let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; - assert_eq!(label, "SmartArt"); - assert!(xml.contains("Quarter"), "{xml}"); - assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); - - let embedded = by_name(q, "Embedded"); - let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; - assert_eq!(label, "DeckCraft.Note.1"); - assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); - assert!(!xml.contains("r:id"), "an embedding part is issue #203"); - - let block_shape = by_name(q, "Block"); - assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); - assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); - assert_eq!(media_bytes(q, "Block"), block); - let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; - let block_media = q.media(*media).expect("block media"); - assert_eq!(block_media.content_type, "model/gltf-binary"); - assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); - - let globe_shape = by_name(q, "Globe"); - let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; - assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); - assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe.as_slice()); - assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster.as_slice()); - - let narration = by_name(q, "Narration"); - let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; - assert!(!m.video); - assert_eq!(m.trim_start_ms, 400); - assert_eq!(m.trim_end_ms, 2600); - assert_eq!(m.fade_in_ms, 80); - assert_eq!(m.fade_out_ms, 120); - assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); - assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); - - let film = by_name(q, "Film"); - let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; - assert!(m.video); - assert_eq!(m.trim_start_ms, 250); - assert_eq!(m.trim_end_ms, 9000); - assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); - assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); - - assert_field(q, "Slide number", "slidenum", "4"); - assert_field(q, "Date", "datetime4", "14 October 2026"); - assert_eq!(q.first_slide_number, 4); - - assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); - let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); - assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); - assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); - assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); - - assert_ink(by_name(q, "Sketch")); - - let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); - let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); - let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); - assert_eq!(fade.repeat, 4); - assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); - assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); - assert_eq!(fade.shape, by_name(q, "Fader").id); - assert_eq!(pulse.repeat, u32::MAX); - assert_eq!(pulse.class, AnimClass::Emphasis); - assert!(pulse.trigger.is_none()); + opaque_parts_survive(q); + models_survive(q, &block, &globe, &poster); + media_clips_survive(q); + fields_and_sections_survive(q); + ink_and_timing_survive(q); } } +fn opaque_parts_survive(q: &Presentation) { + let diagram = by_name(q, "Diagram"); + let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; + assert_eq!(label, "SmartArt"); + assert!(xml.contains("Quarter"), "{xml}"); + assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); + + let embedded = by_name(q, "Embedded"); + let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; + assert_eq!(label, "DeckCraft.Note.1"); + assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); + assert!(!xml.contains("r:id"), "an embedding part is issue #203"); +} + +fn models_survive(q: &Presentation, block: &[u8], globe: &[u8], poster: &[u8]) { + let block_shape = by_name(q, "Block"); + assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); + assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); + assert_eq!(media_bytes(q, "Block"), block); + let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; + let block_media = q.media(*media).expect("block media"); + assert_eq!(block_media.content_type, "model/gltf-binary"); + assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); + + let globe_shape = by_name(q, "Globe"); + let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; + assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); + assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe); + assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster); +} + +fn media_clips_survive(q: &Presentation) { + let narration = by_name(q, "Narration"); + let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; + assert!(!m.video); + assert_eq!(m.trim_start_ms, 400); + assert_eq!(m.trim_end_ms, 2600); + assert_eq!(m.fade_in_ms, 80); + assert_eq!(m.fade_out_ms, 120); + assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); + assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); + + let film = by_name(q, "Film"); + let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; + assert!(m.video); + assert_eq!(m.trim_start_ms, 250); + assert_eq!(m.trim_end_ms, 9000); + assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); + assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); +} + +fn fields_and_sections_survive(q: &Presentation) { + assert_field(q, "Slide number", "slidenum", "4"); + assert_field(q, "Date", "datetime4", "14 October 2026"); + assert_eq!(q.first_slide_number, 4); + + assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); + let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); + assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); + assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); + assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); +} + +fn ink_and_timing_survive(q: &Presentation) { + assert_ink(by_name(q, "Sketch")); + + let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); + let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); + let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); + assert_eq!(fade.repeat, 4); + assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); + assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); + assert_eq!(fade.shape, by_name(q, "Fader").id); + assert_eq!(pulse.repeat, u32::MAX); + assert_eq!(pulse.class, AnimClass::Emphasis); + assert!(pulse.trigger.is_none()); +} + /// An exported deck with `parts` added (or replaced) and `edit` applied to every XML part. fn with_parts(parts: &[(&str, Vec)], edit: impl Fn(&str, String) -> String) -> Vec { let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index d552002f..6eac7bdd 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -189,9 +189,14 @@ fn style_kinds_round_trip() { .as_slice() ) ); + text_run_effects_survive(&p, &once, &twice); + theme_and_bevel_survive(&once, &twice); + connector_glue_survives(&p, &once, &twice); +} - let got = run_of(&once); - let expect = run_of(&p); +fn text_run_effects_survive(p: &Presentation, once: &Presentation, twice: &Presentation) { + let got = run_of(once); + let expect = run_of(p); assert_eq!(got.fill, expect.fill, "text gradient"); assert_eq!(got.outline, expect.outline, "text outline"); assert_eq!(got.shadow, expect.shadow, "text shadow"); @@ -199,17 +204,19 @@ fn style_kinds_round_trip() { assert_eq!(got.inner_shadow, expect.inner_shadow, "text inner shadow"); assert_eq!(got.reflection, expect.reflection, "text reflection"); assert_eq!(got.soft_edge, expect.soft_edge, "text soft edge"); - assert_eq!(run_of(&twice), run_of(&once)); + assert_eq!(run_of(twice), run_of(once)); assert_eq!( - by_name(&twice, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)), - by_name(&once, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)) + by_name(twice, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)), + by_name(once, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)) ); - assert_eq!(by_name(&once, "Path gradient").fill, by_name(&p, "Path gradient").fill); - assert_eq!(by_name(&once, "Pattern").fill, by_name(&p, "Pattern").fill); - assert_eq!(by_name(&twice, "Path gradient").fill, by_name(&once, "Path gradient").fill); - assert_eq!(by_name(&twice, "Pattern").fill, by_name(&once, "Pattern").fill); + assert_eq!(by_name(once, "Path gradient").fill, by_name(p, "Path gradient").fill); + assert_eq!(by_name(once, "Pattern").fill, by_name(p, "Pattern").fill); + assert_eq!(by_name(twice, "Path gradient").fill, by_name(once, "Path gradient").fill); + assert_eq!(by_name(twice, "Pattern").fill, by_name(once, "Pattern").fill); +} +fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) { let theme = &once.masters[0].theme.format.effects[0]; assert_eq!(theme.glow, Some(glow())); assert_eq!(theme.reflection, Some(reflection())); @@ -220,26 +227,24 @@ fn style_kinds_round_trip() { assert_eq!(theme2.bevel, theme.bevel); assert_eq!(theme2.raw3d, theme.raw3d); - let named = by_name(&once, "Bevel preset").effects.as_ref().expect("effects"); + let named = by_name(once, "Bevel preset").effects.as_ref().expect("effects"); assert_eq!(named.bevel.as_deref(), Some("relaxedInset")); assert!(named.raw3d.as_deref().unwrap_or("").contains("relaxedInset")); - assert_eq!(by_name(&twice, "Bevel preset").effects.as_ref().and_then(|e| e.bevel.as_deref()), Some("relaxedInset")); + assert_eq!(by_name(twice, "Bevel preset").effects.as_ref().and_then(|e| e.bevel.as_deref()), Some("relaxedInset")); - let bevel = by_name(&once, "Bevel").effects.as_ref().expect("effects"); + let bevel = by_name(once, "Bevel").effects.as_ref().expect("effects"); assert_eq!(bevel.bevel.as_deref(), Some("angle")); let raw = bevel.raw3d.as_deref().unwrap_or(""); assert!(raw.contains("bevelT") && raw.contains("angle") && raw.contains("scene3d"), "{raw}"); - assert_eq!(by_name(&twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); + assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); +} - let c = by_name(&once, "Connector"); - assert!( - matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(&p) && e == right_of(&p)), - "{:?}", - c.kind - ); - assert_eq!(c.line, by_name(&p, "Connector").line); - assert_eq!(c.geom, by_name(&p, "Connector").geom); - assert_eq!(by_name(&twice, "Connector").kind, c.kind); +fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) { + let c = by_name(once, "Connector"); + assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind); + assert_eq!(c.line, by_name(p, "Connector").line); + assert_eq!(c.geom, by_name(p, "Connector").geom); + assert_eq!(by_name(twice, "Connector").kind, c.kind); } fn left_of(p: &Presentation) -> ShapeId { diff --git a/crates/pptx/tests/it/corpus_themes.rs b/crates/pptx/tests/it/corpus_themes.rs index 15f76ae0..744b27ba 100644 --- a/crates/pptx/tests/it/corpus_themes.rs +++ b/crates/pptx/tests/it/corpus_themes.rs @@ -148,10 +148,13 @@ fn effects_and_links_survive_a_round_trip() { assert!(matches!(&links[1].1, Some(Hyperlink { action: Action::Url { url }, .. }) if url.starts_with("mailto:"))); let hover = get("Reflected").text.as_ref().and_then(|t| t.paragraphs[0].runs[0].props.hover.clone()); assert!(matches!(hover, Some(Hyperlink { action: Action::Url { .. }, ref tooltip, .. }) if tooltip == "Preview"), "{hover:?}"); + effects_and_links_survive_resave(&a); +} +fn effects_and_links_survive_resave(a: &Presentation) { // Saved and reopened: the same effects and links. - let b = round_trip(&a); - let (ga, gb) = (shapes_named(&a, 0), shapes_named(&b, 0)); + let b = round_trip(a); + let (ga, gb) = (shapes_named(a, 0), shapes_named(&b, 0)); for n in ["Shadowed", "Glowing", "Soft edged", "Reflected"] { assert_eq!(fx(ga(n)), fx(gb(n)), "effects of {n}"); assert_eq!(ga(n).click, gb(n).click, "click of {n}"); @@ -203,24 +206,28 @@ fn two_masters() -> Presentation { p } +fn same_master(ma: &deckcraft_model::Master, mb: &deckcraft_model::Master) { + assert_eq!(ma.name, mb.name); + assert_eq!(ma.theme.name, mb.theme.name); + // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. + let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; + assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); + assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); + assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); + assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); + assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); + assert_eq!(ma.background, mb.background, "background of {}", ma.name); + let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(ma), names(mb)); +} + #[test] fn themes_masters_and_layouts_survive_a_round_trip() { let a = two_masters(); let b = round_trip(&a); assert_eq!(b.masters.len(), 2); for (ma, mb) in a.masters.iter().zip(&b.masters) { - assert_eq!(ma.name, mb.name); - assert_eq!(ma.theme.name, mb.theme.name); - // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. - let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; - assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); - assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); - assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); - assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); - assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); - assert_eq!(ma.background, mb.background, "background of {}", ma.name); - let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); - assert_eq!(names(ma), names(mb)); + same_master(ma, mb); } // Each slide stays on its master's layout. let layout_name = diff --git a/crates/pptx/tests/it/sample.rs b/crates/pptx/tests/it/sample.rs index f42ca230..f7cbba12 100644 --- a/crates/pptx/tests/it/sample.rs +++ b/crates/pptx/tests/it/sample.rs @@ -10,6 +10,30 @@ fn sample() -> Presentation { s.doc().expect("doc").doc.as_ref().clone() } +fn same_shapes(a: &deckcraft_model::Slide, b: &deckcraft_model::Slide) { + assert_eq!(a.shapes.len(), b.shapes.len(), "shape count on slide '{}'", a.title()); + for (sa, sb) in a.shapes.iter().zip(b.shapes.iter()) { + assert_eq!(sa.kind_name(), sb.kind_name(), "{}", sa.name); + assert_eq!(sa.text.as_ref().map(|t| t.text()), sb.text.as_ref().map(|t| t.text()), "{}", sa.name); + if let (Some(x), Some(y)) = (sa.xfrm, sb.xfrm) { + assert!((x.x - y.x).abs() < 0.01 && (x.w - y.w).abs() < 0.01, "{}: {x:?} vs {y:?}", sa.name); + } + if let (ShapeKind::Table(ta), ShapeKind::Table(tb)) = (&sa.kind, &sb.kind) { + assert_eq!(ta.style, tb.style); + assert_eq!(ta.rows.len(), tb.rows.len()); + } + if let (ShapeKind::Chart(ca), ShapeKind::Chart(cb)) = (&sa.kind, &sb.kind) { + assert_eq!(ca.kind, cb.kind); + assert_eq!(ca.categories, cb.categories); + assert_eq!(ca.series.len(), cb.series.len()); + for (x, y) in ca.series.iter().zip(cb.series.iter()) { + assert_eq!(x.name, y.name); + assert_eq!(x.values, y.values); + } + } + } +} + #[test] fn sample_deck_round_trips() { let p = sample(); @@ -25,27 +49,7 @@ fn sample_deck_round_trips() { for (a, b) in p.slides.iter().zip(q.slides.iter()) { assert_eq!(a.title(), b.title()); assert_eq!(a.notes_text(), b.notes_text()); - assert_eq!(a.shapes.len(), b.shapes.len(), "shape count on slide '{}'", a.title()); - for (sa, sb) in a.shapes.iter().zip(b.shapes.iter()) { - assert_eq!(sa.kind_name(), sb.kind_name(), "{}", sa.name); - assert_eq!(sa.text.as_ref().map(|t| t.text()), sb.text.as_ref().map(|t| t.text()), "{}", sa.name); - if let (Some(x), Some(y)) = (sa.xfrm, sb.xfrm) { - assert!((x.x - y.x).abs() < 0.01 && (x.w - y.w).abs() < 0.01, "{}: {x:?} vs {y:?}", sa.name); - } - if let (ShapeKind::Table(ta), ShapeKind::Table(tb)) = (&sa.kind, &sb.kind) { - assert_eq!(ta.style, tb.style); - assert_eq!(ta.rows.len(), tb.rows.len()); - } - if let (ShapeKind::Chart(ca), ShapeKind::Chart(cb)) = (&sa.kind, &sb.kind) { - assert_eq!(ca.kind, cb.kind); - assert_eq!(ca.categories, cb.categories); - assert_eq!(ca.series.len(), cb.series.len()); - for (x, y) in ca.series.iter().zip(cb.series.iter()) { - assert_eq!(x.name, y.name); - assert_eq!(x.values, y.values); - } - } - } + same_shapes(a, b); assert_eq!(a.transition.as_ref().map(|t| (&t.kind, &t.option)), b.transition.as_ref().map(|t| (&t.kind, &t.option))); assert_eq!(a.animations.len(), b.animations.len(), "animations on '{}'", a.title()); for (x, y) in a.animations.iter().zip(b.animations.iter()) {