//! An unchanged chart keeps the embedded workbook its `c:externalData` points at (#335). use std::io::{Cursor, Read, Write}; use std::sync::Arc; use deckcraft_geom::Xfrm; use deckcraft_model::chart::{Chart, ChartType}; use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, defaults}; const WORKBOOK: &[u8] = b"chart-workbook-bytes-v1"; const EMBED: &str = "ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx"; fn chart_pptx() -> Vec { let mut p = defaults::new_presentation(None); p.slides.clear(); let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); let mut shape = Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; shape.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))); slide.shapes.push(shape); p.slides.push(Arc::new(slide)); deckcraft_pptx::export(&p).expect("export") } /// Add an embedded workbook and point the chart part at it, the way PowerPoint writes a chart. fn with_workbook(bytes: &[u8]) -> Vec { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); let opts = zip::write::SimpleFileOptions::default(); let mut chart_part = None; for i in 0..zip.len() { let mut f = zip.by_index(i).expect("entry"); let name = f.name().to_string(); let mut data = vec![]; f.read_to_end(&mut data).expect("read"); if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") { let xml = String::from_utf8(data).expect("chart xml"); assert!(xml.contains(""), "{name}"); data = xml .replacen("", r#""#, 1) .into_bytes(); chart_part = Some(name.clone()); } else if name == "[Content_Types].xml" { let xml = String::from_utf8(data).expect("content types"); data = xml .replacen( "", r#""#, 1, ) .into_bytes(); } out.start_file(&name, opts).expect("start"); out.write_all(&data).expect("write"); } let chart_part = chart_part.expect("chart part"); let rels = format!("ppt/charts/_rels/{}.rels", chart_part.rsplit('/').next().unwrap_or("chart1.xml")); let rels_xml = br#" "#; out.start_file(rels, opts).expect("rels"); out.write_all(rels_xml).expect("rels bytes"); out.start_file(EMBED, opts).expect("embed"); out.write_all(WORKBOOK).expect("workbook"); out.finish().expect("finish").into_inner() } fn parts(bytes: &[u8]) -> Vec<(String, Vec)> { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); let mut out = vec![]; for i in 0..zip.len() { let mut f = zip.by_index(i).expect("entry"); let name = f.name().to_string(); let mut data = vec![]; f.read_to_end(&mut data).expect("read"); out.push((name, data)); } out } fn chart_of(bytes: &[u8]) -> deckcraft_model::Chart { let p = deckcraft_pptx::import(bytes).expect("import"); p.slides .iter() .flat_map(|s| s.shapes.iter()) .find_map(|s| match &s.kind { ShapeKind::Chart(c) if s.name == "Pie" => Some((**c).clone()), _ => None, }) .expect("pie chart") } #[test] fn an_unchanged_chart_keeps_its_embedded_workbook() { let src = with_workbook(&chart_pptx()); let chart = chart_of(&src); assert_eq!(chart.kept.parts.iter().filter(|p| p.name == EMBED).count(), 1); assert_eq!(chart.kept.parts.iter().find(|p| p.name == EMBED).map(|p| p.bytes.as_slice()), Some(WORKBOOK)); let saved = deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export"); let saved_parts = parts(&saved); // Kept parts are written under fresh names, so they can't land on a part the writer makes. let workbook = saved_parts.iter().find(|(n, _)| n == "ppt/embeddings/kept1.xlsx").map(|(_, d)| d.as_slice()); assert_eq!(workbook, Some(WORKBOOK), "the embedding part is dropped on save"); let rels = saved_parts .iter() .find(|(n, _)| n.starts_with("ppt/charts/_rels/chart") && n.ends_with(".xml.rels")) .map(|(_, d)| String::from_utf8_lossy(d).into_owned()) .expect("chart relationships"); assert!(rels.contains(r#"Id="rId1""#) && rels.contains("../embeddings/kept1.xlsx"), "{rels}"); assert!(!rels.contains("slide1.xml"), "a chart relationship must not replace the slide: {rels}"); let slide = saved_parts.iter().find(|(n, _)| n == "ppt/slides/slide1.xml").map(|(_, d)| String::from_utf8_lossy(d).into_owned()).expect("slide"); assert!(slide.contains("sld"), "{slide}"); assert!(!slide.as_bytes().windows(WORKBOOK.len()).any(|w| w == WORKBOOK)); let xml = saved_parts .iter() .find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml")) .map(|(_, d)| String::from_utf8_lossy(d).into_owned()) .expect("chart xml"); assert!(xml.contains("externalData"), "{xml}"); } #[test] fn an_edited_chart_does_not_write_a_stale_workbook() { let src = with_workbook(&chart_pptx()); let mut p = deckcraft_pptx::import(&src).expect("import"); let slide = Arc::make_mut(p.slides.get_mut(0).expect("slide")); let chart = slide.shapes.iter_mut().find_map(|s| match &mut s.kind { ShapeKind::Chart(c) if s.name == "Pie" => Some(c), _ => None, }); let chart = chart.expect("pie"); let value = chart.series.get_mut(0).and_then(|s| s.values.get_mut(0)).expect("value"); *value = Some(99.0); let saved = parts(&deckcraft_pptx::export(&p).expect("export")); assert!(saved.iter().all(|(n, _)| !n.starts_with("ppt/embeddings/")), "a stale workbook was written after the chart changed"); let xml = saved .iter() .find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml")) .map(|(_, d)| String::from_utf8_lossy(d).into_owned()) .expect("chart xml"); assert!(!xml.contains("externalData"), "{xml}"); } fn png(rgb: [u8; 3]) -> Vec { let img = image::RgbImage::from_pixel(4, 4, image::Rgb(rgb)); let mut out = Cursor::new(vec![]); image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png"); out.into_inner() } /// Rewrite `src`: `edit` may change any entry (name, bytes → new bytes), then `extra` entries are added. fn rewrite(src: &[u8], edit: impl Fn(&str, Vec) -> Vec, extra: &[(&str, &[u8])]) -> Vec { let mut zip = zip::ZipArchive::new(Cursor::new(src)).expect("zip"); let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); let opts = zip::write::SimpleFileOptions::default(); for i in 0..zip.len() { let mut f = zip.by_index(i).expect("entry"); let name = f.name().to_string(); let mut data = vec![]; f.read_to_end(&mut data).expect("read"); let data = edit(&name, data); out.start_file(&name, opts).expect("start"); out.write_all(&data).expect("write"); } for (name, data) in extra { out.start_file(*name, opts).expect("start"); out.write_all(data).expect("write"); } out.finish().expect("finish").into_inner() } fn unique_names(parts: &[(String, Vec)]) { let mut seen = std::collections::HashSet::new(); for (n, _) in parts { assert!(seen.insert(n.to_ascii_lowercase()), "duplicate zip entry {n}"); } } fn text_of(parts: &[(String, Vec)], name: &str) -> String { parts.iter().find(|(n, _)| n == name).map(|(_, d)| String::from_utf8_lossy(d).into_owned()).unwrap_or_else(|| panic!("no {name}")) } const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; /// A chart whose relationships reach parts named like the ones the writer generates /// (`ppt/media/image1.png`, `ppt/drawings/…`) must not collide with them on save: no duplicate /// zip entries, and the slide's own picture keeps its bytes. #[test] fn kept_chart_parts_never_collide_with_written_parts() { let picture = png([10, 200, 30]); let mut p = defaults::new_presentation(None); p.slides.clear(); let img = p.add_media("image1.png", "image/png", picture.clone()).expect("media"); let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); // The chart comes first, so its kept parts are written before the picture. let mut chart = Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; chart.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))); slide.shapes.push(chart); let mut pic = Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pic".into(), xfrm: Some(Xfrm::new(500.0, 40.0, 80.0, 80.0)), ..Default::default() }; pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: img, ..Default::default() } }; slide.shapes.push(pic); p.slides.push(Arc::new(slide)); let base = deckcraft_pptx::export(&p).expect("export"); assert!(parts(&base).iter().any(|(n, d)| n == "ppt/media/image1.png" && *d == picture)); // The chart's own relationships reach the slide picture's part and a drawing whose // relationships reach it too (and, hostile, the slide). let chart_rels = format!( r#""# ); let drawing_rels = format!( r#""# ); let drawing = br#""#; let src = rewrite( &base, |name, data| { if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") { String::from_utf8(data) .expect("chart") .replacen("", r#""#, 1) .into_bytes() } else { data } }, &[ ("ppt/charts/_rels/chart1.xml.rels", chart_rels.as_bytes()), ("ppt/drawings/drawing1.xml", drawing), ("ppt/drawings/_rels/drawing1.xml.rels", drawing_rels.as_bytes()), (EMBED, WORKBOOK), ], ); let saved = parts(&deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export")); unique_names(&saved); let picture_parts: Vec<&String> = saved.iter().filter(|(_, d)| *d == picture).map(|(n, _)| n).collect(); assert!(picture_parts.iter().any(|n| *n == "ppt/media/image1.png"), "{picture_parts:?}"); let slide_rels = text_of(&saved, "ppt/slides/_rels/slide1.xml.rels"); assert!(slide_rels.contains("../media/image1.png"), "{slide_rels}"); // The chart's relationships point at the renamed parts, which hold the kept bytes. let rels = text_of(&saved, "ppt/charts/_rels/chart1.xml.rels"); assert!(!rels.contains("image1.png") && !rels.contains("drawing1.xml") && !rels.contains("Worksheet1"), "{rels}"); let target = |rels: &str, id: &str| -> String { let at = rels.find(&format!(r#"Id="{id}""#)).unwrap_or_else(|| panic!("{id} in {rels}")); let rest = &rels[at..]; let t = rest.find(r#"Target=""#).expect("target") + 8; rest[t..].split('"').next().unwrap_or("").to_string() }; let resolve = |from_dir: &str, t: &str| format!("ppt/{}", t.trim_start_matches("../").trim_start_matches(from_dir)); let img_part = resolve("", &target(&rels, "rId3")); assert!(img_part.starts_with("ppt/media/kept"), "{img_part}"); assert!(saved.iter().any(|(n, d)| *n == img_part && *d == picture), "{img_part}"); let drawing_part = resolve("", &target(&rels, "rId2")); assert!(saved.iter().any(|(n, d)| *n == drawing_part && d.as_slice() == drawing.as_slice()), "{drawing_part}"); let wb = resolve("", &target(&rels, "rId1")); assert!(saved.iter().any(|(n, d)| *n == wb && d.as_slice() == WORKBOOK), "{wb}"); // The drawing's own relationships follow the rename; the one to the slide is dropped. let file = drawing_part.rsplit('/').next().unwrap_or(""); let drels = text_of(&saved, &format!("ppt/drawings/_rels/{file}.rels")); assert_eq!(resolve("", &target(&drels, "rId1")), img_part, "{drels}"); assert!(!drels.contains("slide"), "{drels}"); // And the result reads back with the same kept parts. let again = deckcraft_pptx::export(&deckcraft_pptx::import(&saved_zip(&saved)).expect("reimport")).expect("export again"); unique_names(&parts(&again)); } fn saved_zip(parts: &[(String, Vec)]) -> Vec { let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); let opts = zip::write::SimpleFileOptions::default(); for (n, d) in parts { out.start_file(n.as_str(), opts).expect("start"); out.write_all(d).expect("write"); } out.finish().expect("finish").into_inner() } /// An unchanged Office 2016 chart keeps its embedded workbook too; its plain-chart fallback /// carries none. #[test] fn an_unchanged_chartex_keeps_its_embedded_workbook() { let mut p = defaults::new_presentation(None); p.slides.clear(); let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); let mut slide = defaults::new_slide(&mut p, layout).expect("slide"); let mut shape = Shape { id: ShapeId(p.alloc_id().expect("id")), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() }; shape.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Waterfall))); slide.shapes.push(shape); p.slides.push(Arc::new(slide)); let base = deckcraft_pptx::export(&p).expect("export"); assert!(parts(&base).iter().any(|(n, _)| n == "ppt/charts/chartEx1.xml")); let rels = format!( r#""# ); let src = rewrite( &base, |name, data| { if name == "ppt/charts/chartEx1.xml" { String::from_utf8(data) .expect("chartex") .replacen("", r#""#, 1) .into_bytes() } else { data } }, &[("ppt/charts/_rels/chartEx1.xml.rels", rels.as_bytes()), (EMBED, WORKBOOK)], ); let chart = chart_of(&src); assert_eq!(chart.kind, ChartType::Waterfall); assert!(chart.kept.parts.iter().any(|p| p.bytes == WORKBOOK)); let saved = parts(&deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export")); unique_names(&saved); let xml = text_of(&saved, "ppt/charts/chartEx1.xml"); assert!(xml.contains("externalData"), "{xml}"); let rels = text_of(&saved, "ppt/charts/_rels/chartEx1.xml.rels"); assert!(rels.contains("../embeddings/kept1.xlsx"), "{rels}"); assert!(saved.iter().any(|(n, d)| n == "ppt/embeddings/kept1.xlsx" && d.as_slice() == WORKBOOK)); assert!(saved.iter().all(|(n, _)| n != "ppt/charts/_rels/chart1.xml.rels"), "the fallback chart has no workbook"); assert!(!text_of(&saved, "ppt/charts/chart1.xml").contains("externalData")); }