diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 16a069fe..a960df46 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -95,6 +95,9 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { /// `a:blipFill` / `p:blipFill`. pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) { + // An image that lives outside the package is a linked picture: `a:blip` names it through + // `r:link`; `r:embed` may only reference a part inside the package. + let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some()); let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o)); w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" })); let adj = &pf.adjust; @@ -106,7 +109,7 @@ pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFil || adj.clear_color.is_some() || adj.bilevel.is_some() || adj.alpha_inv; - let a = A::new().a("r:embed", &rid); + let a = if linked { A::new().a("r:link", &rid) } else { A::new().a("r:embed", &rid) }; if has_kids { w.open("a:blip", a); if let Some(c) = adj.clear_color { @@ -521,7 +524,12 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option, p: Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") { Some(rid) => { w.open0("a:buBlip"); - w.empty("a:blip", A::new().a("r:embed", rid)); + let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) { + A::new().a("r:link", &rid) + } else { + A::new().a("r:embed", &rid) + }; + w.empty("a:blip", a); w.close("a:buBlip"); } None => w.empty("a:buChar", A::new().a("char", "•")), diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..dafa6c03 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,31 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +#[test] +fn linked_pictures_are_written_with_r_link() { + // A picture whose bytes live outside the package must be referenced with `r:link`; `r:embed` + // may only name a part inside the package (PowerPoint won't load the linked image otherwise). + let mut p = defaults::new_presentation(None); + let media = deckcraft_model::MediaId(5000); + p.media.push(deckcraft_model::MediaItem { + id: media, + name: "linked.png".into(), + content_type: "image/png".into(), + data: Arc::new(Vec::new()), + link: Some("file:///tmp/linked.png".into()), + }); + let mut pic = shape(&mut p, "Linked", 100.0, 100.0, 200.0, 150.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }; + Arc::make_mut(&mut p.slides[0]).shapes.push(pic); + + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").expect("slide"), &mut xml).expect("read"); + assert!(xml.contains(r#"r:link="rId"#), "{xml}"); + assert!(!xml.contains(r#"r:embed="rId"#), "{xml}"); + let mut rels = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/_rels/slide1.xml.rels").expect("rels"), &mut rels).expect("read"); + assert!(rels.contains(r#"Target="file:///tmp/linked.png" TargetMode="External""#), "{rels}"); +}