mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 20:47:15 -06:00
Refactor pptx for strict Clippy checks
This commit is contained in:
+240
-168
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user