diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index b8c403b..c592dde 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use deckcraft_geom::{Size, emu_to_pt}; use deckcraft_model::text::TextBody; use deckcraft_model::{ - Background, Comment, CustomShow, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation, + Background, Comment, CustomShow, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, PhType, Presentation, Properties, Section, ShowSettings, Slide, SlideId, defaults, }; @@ -387,6 +387,7 @@ pub fn import(bytes: &[u8]) -> Result { p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default(); } + inherit_placeholder_geometry(&mut p); p.header_footer = header_footer(&p); p.media = std::mem::take(&mut imp.media); p.next_id = imp.next; @@ -419,6 +420,91 @@ pub fn color_map(cm: &El) -> Vec<(String, String)> { cm.attrs.iter().filter(|(k, _)| !k.starts_with("xmlns")).map(|(k, v)| (k.clone(), v.clone())).collect() } +/// Placeholders read without a geometry (an empty custom geometry, see `shapes::sp`) take the +/// geometry of the layout or master placeholder they inherit from, as PowerPoint draws them: a +/// layout's picture placeholder cut into a shape keeps that shape on the slide. Anything left +/// is a rectangle. +fn inherit_placeholder_geometry(p: &mut Presentation) { + let unset = |g: &Geom| matches!(g, Geom::Custom { paths } if paths.is_empty()); + // Masters first, then layouts (from their masters), then slides (from layouts and masters). + for m in &mut p.masters { + let m = Arc::make_mut(m); + walk_shapes_mut(&mut m.shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + let mut layout_geoms: Vec<(LayoutId, usize, Geom)> = vec![]; + for m in &p.masters { + for l in &m.layouts { + let ctx = deckcraft_model::resolve::Ctx::for_layout(p, m, l); + for (i, s) in l.shapes.iter().enumerate() { + if unset(&s.geom) { + let (_, mp) = deckcraft_model::resolve::parents(&ctx, s); + layout_geoms.push((l.id, i, mp.map(|m| m.geom.clone()).filter(|g| !unset(g)).unwrap_or_default())); + } + } + } + } + for m in &mut p.masters { + let m = Arc::make_mut(m); + for l in &mut m.layouts { + for (id, i, g) in layout_geoms.iter().filter(|(id, _, _)| *id == l.id) { + let _ = id; + if let Some(s) = l.shapes.get_mut(*i) { + s.geom = g.clone(); + } + } + } + } + let mut slide_geoms: Vec<(usize, usize, Geom)> = vec![]; + for (si, sl) in p.slides.iter().enumerate() { + let Some(ctx) = deckcraft_model::resolve::Ctx::for_slide(p, sl) else { continue }; + for (i, s) in sl.shapes.iter().enumerate() { + if unset(&s.geom) { + let (lp, mp) = deckcraft_model::resolve::parents(&ctx, s); + let g = lp.map(|l| l.geom.clone()).filter(|g| !unset(g)).or_else(|| mp.map(|m| m.geom.clone()).filter(|g| !unset(g))); + slide_geoms.push((si, i, g.unwrap_or_default())); + } + } + } + for (si, i, g) in slide_geoms { + if let Some(sl) = p.slides.get_mut(si) + && let Some(s) = Arc::make_mut(sl).shapes.get_mut(i) + { + s.geom = g; + } + } + // Placeholders inside groups (rare) and anything still unset: rectangles. + for sl in &mut p.slides { + walk_shapes_mut(&mut Arc::make_mut(sl).shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + for m in &mut p.masters { + for l in &mut Arc::make_mut(m).layouts { + walk_shapes_mut(&mut l.shapes, &mut |s| { + if unset(&s.geom) { + s.geom = Geom::default(); + } + }); + } + } +} + +/// Every shape, groups included. +fn walk_shapes_mut(shapes: &mut [deckcraft_model::Shape], f: &mut dyn FnMut(&mut deckcraft_model::Shape)) { + for s in shapes { + f(s); + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut s.kind { + walk_shapes_mut(children, f); + } + } +} + fn background(imp: &mut Imp, part: &Part, csld: &El) -> Option { let bg = csld.child("bg")?; if let Some(pr) = bg.child("bgPr") { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 2e10e36..c35d266 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -180,6 +180,15 @@ fn preset_geom(pg: &El) -> Geom { Geom::Preset { name, adj } } +/// A placeholder whose `spPr` names no geometry takes its layout's (or master's) placeholder's: +/// marked here with an empty custom geometry, filled in once the layouts are read +/// (`inherit_placeholder_geometry`). +fn mark_inherited_geometry(s: &mut Shape, pr: Option<&El>) { + if s.ph.is_some() && pr.is_none_or(|pr| pr.child("prstGeom").is_none() && pr.child("custGeom").is_none()) { + s.geom = Geom::Custom { paths: vec![] }; + } +} + /// `spPr` → transform, geometry, fill, line, effects. fn sp_pr(imp: &mut Imp, part: &Part, s: &mut Shape, pr: &El) { if let Some(x) = pr.child("xfrm") { @@ -229,6 +238,7 @@ fn sp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { if let Some(pr) = e.child("spPr") { sp_pr(imp, part, &mut s, pr); } + mark_inherited_geometry(&mut s, e.child("spPr")); if e.bool("useBgFill") == Some(true) { s.fill = Some(Fill::Background); } diff --git a/crates/pptx/tests/placeholders.rs b/crates/pptx/tests/placeholders.rs new file mode 100644 index 0000000..b2b4610 --- /dev/null +++ b/crates/pptx/tests/placeholders.rs @@ -0,0 +1,64 @@ +//! Placeholder inheritance in decks written by PowerPoint. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::{Geom, defaults}; + +/// `bytes` with `edit` applied to every slide part. +fn edit_slides(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name.starts_with("ppt/slides/slide") && name.ends_with(".xml") { + d = edit(String::from_utf8(d).expect("utf8")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn a_placeholder_without_geometry_takes_its_layouts() { + // PowerPoint leaves a slide placeholder's spPr empty and draws it with the layout + // placeholder's geometry (a picture placeholder cut into a shape keeps the shape). + let mut p = defaults::new_presentation(None); + let layout = p.slides[0].layout; + let m = std::sync::Arc::make_mut(&mut p.masters[0]); + let l = m.layouts.iter_mut().find(|l| l.id == layout).expect("layout"); + for s in &mut l.shapes { + if s.ph.is_some() { + s.geom = Geom::Preset { name: "ellipse".into(), adj: vec![] }; + } + } + let bytes = deckcraft_pptx::export(&p).expect("export"); + // Strip the slide's own geometry, as PowerPoint writes inheriting placeholders. + let bare = edit_slides(&bytes, |s| { + let mut s = s; + while let Some(i) = s.find("").map(|j| j + i + "".len()) else { break }; + s.replace_range(i..j, ""); + } + s + }); + let q = deckcraft_pptx::import(&bare).expect("import"); + let phs: Vec<_> = q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).collect(); + assert!(!phs.is_empty()); + for s in phs { + assert!(matches!(&s.geom, Geom::Preset { name, .. } if name == "ellipse"), "{:?}", s.geom); + } + // A placeholder with its own geometry keeps it. + let first = p.slides[0].id; + for s in &mut p.slide_mut(first).expect("slide").shapes { + if s.ph.is_some() { + s.geom = Geom::Preset { name: "triangle".into(), adj: vec![] }; + } + } + let q = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import"); + assert!(q.slides[0].shapes.iter().filter(|s| s.ph.is_some()).all(|s| matches!(&s.geom, Geom::Preset { name, .. } if name == "triangle"))); +}