Refactor pptx for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-28 06:02:27 +00:00
parent 3f853db3f6
commit 8e4d0e6b4c
9 changed files with 656 additions and 482 deletions
+240 -168
View File
@@ -413,7 +413,56 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), 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<String>, Vec<(u32, String)>) {
let master_paths: Vec<String> = 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<String>), PptxE
.collect()
})
.unwrap_or_default();
let mut docs: HashMap<String, Doc> = 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<String> = 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<String, Doc>, u32) {
let mut all_parts: Vec<String> = 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<String>), 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<String, Doc> = 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<String, Doc>,
slide_entries: &[(u32, String)],
lost_slides: &mut Vec<String>,
) -> 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<String>), 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<String>), 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<String>), 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<String, Doc>) -> HashMap<String, LayoutId> {
let mut layout_by_part: HashMap<String, LayoutId> = 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<String>), 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<String, Doc>,
slide_ids: &[(u32, String, SlideId)],
layout_by_part: &HashMap<String, LayoutId>,
) -> HashMap<u32, SlideId> {
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<u32, SlideId> = HashMap::new();
let mut notes_by_target: HashMap<String, TextBody> = 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<String, TextBody>) -> Option<TextBody> {
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<u32, SlideId>) {
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<String>), 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<String>), 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.
+192 -155
View File
@@ -129,167 +129,22 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
// (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<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
let primary_val: Option<String> =
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 <c:varyColors> 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::<f64>().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::<f64>().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::<f64>().ok()) {
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
}
// `<c:showVal/>` 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()
};
// `<c:showVal/>` 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::<f64>().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<Chart> {
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<String> {
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<String> {
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<String> = 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<ChartType> {
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<bool> {
// A missing <c:varyColors> 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::<f64>().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::<f64>().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::<f64>().ok()) {
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
}
// `<c:showVal/>` 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<String> {
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<ErrorBars> {
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<bool>), categories: &mut Vec<String>, 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()
};
// `<c:showVal/>` 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::<f64>().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<String>) -> (Option<&'a El>, Option<&'a El>) {
let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r"));
let by_id = |id: &Option<String>| 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::<f64>().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<Chart> {
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<Chart> {
// 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<f64> {
+45 -35
View File
@@ -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<Option<f64>> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() };
+7
View File
@@ -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));
+16
View File
@@ -215,7 +215,14 @@ fn assert_deck(q: &Presentation) {
let inherited = resolve::run(&ctx, body, &paras[1], &paras[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]);
+83 -67
View File
@@ -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::<Vec<_>>(), ["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::<Vec<_>>(), ["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<u8>)], edit: impl Fn(&str, String) -> String) -> Vec<u8> {
let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export");
+27 -22
View File
@@ -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 {
+21 -14
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(names(ma), names(mb));
same_master(ma, mb);
}
// Each slide stays on its master's layout.
let layout_name =
+25 -21
View File
@@ -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()) {