diff --git a/crates/pptx/src/write.rs b/crates/pptx/src/write.rs index e4c0858c9..2a5a2115b 100644 --- a/crates/pptx/src/write.rs +++ b/crates/pptx/src/write.rs @@ -794,11 +794,11 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) - } w.close("p:sldLayoutIdLst"); w.open0("p:txStyles"); - let (dt, db, dother) = defaults::master_styles(); - let pick = |s: &deckcraft_model::text::ListStyle, d: deckcraft_model::text::ListStyle| if s.is_empty() { d } else { s.clone() }; - dml::list_style(&mut w, x, o, "p:titleStyle", &pick(&m.title_style, dt)); - dml::list_style(&mut w, x, o, "p:bodyStyle", &pick(&m.body_style, db)); - dml::list_style(&mut w, x, o, "p:otherStyle", &pick(&m.other_style, dother)); + // An imported empty body style has no bullets; saving must not author defaults into it. + // New decks already carry their populated master styles in the model. + dml::list_style(&mut w, x, o, "p:titleStyle", &m.title_style); + dml::list_style(&mut w, x, o, "p:bodyStyle", &m.body_style); + dml::list_style(&mut w, x, o, "p:otherStyle", &m.other_style); w.close("p:txStyles"); ext_list(&mut w, ext_children(m.raw_ext.as_deref()).as_deref()); w.close("p:sldMaster"); diff --git a/crates/pptx/tests/it/main.rs b/crates/pptx/tests/it/main.rs index 3f2026c66..e8e294cf2 100644 --- a/crates/pptx/tests/it/main.rs +++ b/crates/pptx/tests/it/main.rs @@ -14,6 +14,7 @@ mod fills; mod kept_parts; mod locks; mod malformed; +mod master_text_styles; mod math_ns; mod media_timing; mod notes; diff --git a/crates/pptx/tests/it/master_text_styles.rs b/crates/pptx/tests/it/master_text_styles.rs new file mode 100644 index 000000000..a539a1dc0 --- /dev/null +++ b/crates/pptx/tests/it/master_text_styles.rs @@ -0,0 +1,110 @@ +//! Imported empty master styles must not become the defaults used to author new decks. +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_geom::Xfrm; +use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::text::{Align, Bullet, ParaProps, TextBody}; +use deckcraft_model::{PhType, Placeholder, Presentation, Shape, ShapeId, defaults}; + +fn subtitle(text: &str) -> Shape { + let mut body = TextBody::from_text(text); + body.paragraphs[0].props.align = Some(Align::Center); + Shape { + id: ShapeId(102), + name: "Centered subtitle".into(), + ph: Some(Placeholder { kind: PhType::SubTitle, idx: 1, ..Default::default() }), + xfrm: Some(Xfrm::new(120.0, 120.0, 720.0, 90.0)), + text: Some(body), + ..Default::default() + } +} + +/// Author an input with empty title/other styles and the specified body style, without asking +/// the serializer under test to supply its own master text styles in the input. +fn imported(body_style: &str) -> Presentation { + let mut p = defaults::new_presentation(None); + let m = Arc::make_mut(&mut p.masters[0]); + m.shapes.clear(); + for l in &mut m.layouts { + l.shapes = vec![subtitle("")]; + } + Arc::make_mut(&mut p.slides[0]).shapes = vec![subtitle("Centered subtitle")]; + let bytes = deckcraft_pptx::export(&p).expect("base package"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut part = z.by_index(i).expect("part"); + let name = part.name().to_string(); + let mut data = Vec::new(); + part.read_to_end(&mut data).expect("read"); + if name.starts_with("ppt/slideMasters/slideMaster") && name.ends_with(".xml") { + let mut xml = String::from_utf8(data).expect("generated XML"); + let start = xml.find("").expect("styles start"); + let end = xml.find("").expect("styles end") + "".len(); + xml.replace_range(start..end, &format!("{body_style}")); + data = xml.into_bytes(); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import fixture") +} + +fn resolved_subtitle(p: &Presentation) -> ParaProps { + let slide = &p.slides[0]; + let shape = slide.shapes.iter().find(|s| s.name == "Centered subtitle").expect("subtitle"); + assert_eq!(shape.ph_type(), Some(PhType::SubTitle)); + assert_eq!(shape.xfrm, Some(Xfrm::new(120.0, 120.0, 720.0, 90.0))); + let text = shape.text.as_ref().expect("text"); + assert_eq!(text.text(), "Centered subtitle"); + resolve::para(&Ctx::for_slide(p, slide).expect("context"), shape, &text.paragraphs[0]) +} + +fn saved(p: &Presentation) -> Presentation { + deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("reopen") +} + +#[test] +fn an_imported_empty_master_keeps_its_unbulleted_subtitle_after_two_saves() { + let mut p = imported(""); + let before = resolved_subtitle(&p); + assert_eq!(before.bullet, None); + assert_eq!(before.align, Some(Align::Center)); + for _ in 0..2 { + p = saved(&p); + assert_eq!(resolved_subtitle(&p).bullet, None, "saving must not add a subtitle bullet"); + let master = &p.masters[0]; + assert!(master.title_style.is_empty(), "do not invent an imported title style"); + assert!(master.body_style.is_empty(), "do not invent bullets in an imported body style"); + assert!(master.other_style.is_empty(), "do not invent an imported other-text style"); + assert_eq!(resolved_subtitle(&p), before); + } +} + +#[test] +fn an_explicit_unbulleted_body_style_keeps_its_properties() { + let mut p = imported(r#""#); + let expected = p.masters[0].body_style.clone(); + assert_eq!(resolved_subtitle(&p).bullet, Some(Bullet::None)); + for _ in 0..2 { + p = saved(&p); + assert_eq!(p.masters[0].body_style, expected); + assert_eq!(resolved_subtitle(&p).bullet, Some(Bullet::None)); + } +} + +#[test] +fn newly_authored_presentations_keep_their_populated_master_defaults() { + let mut p = defaults::new_presentation(None); + let m = &p.masters[0]; + let expected = (m.title_style.clone(), m.body_style.clone(), m.other_style.clone()); + assert!(!expected.0.is_empty() && !expected.1.is_empty() && !expected.2.is_empty()); + assert_eq!(expected.1.levels.len(), 9); + assert!(matches!(expected.1.level(0).expect("body level").para.bullet, Some(Bullet::Char { .. }))); + for _ in 0..2 { + p = saved(&p); + let m = &p.masters[0]; + assert_eq!((&m.title_style, &m.body_style, &m.other_style), (&expected.0, &expected.1, &expected.2)); + } +}