//! Pictures dropped at open are recorded for strict export (#6005): a missing part, an external //! target and a link-only picture (ECMA-376 Part 1 ยง20.1.8.13 `a:blip` `r:embed`/`r:link`). //! Original synthetic packages; nothing is fetched, the reader has no network code. use craft_wordmodel::{Document, InlineObject, MissingPictureReason}; use std::io::Write; const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:v="urn:schemas-microsoft-com:vml""#; const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; fn png() -> Vec { let mut out = Vec::new(); image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255])) .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) .unwrap(); out } fn blip(attrs: &str) -> String { format!( r#""# ) } /// A package: `body` in `word/document.xml` (whose relationships are `doc_rels`), an optional /// header with `header` as its content (relationships `header_rels`) and `extra` parts. fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&str, Vec)]) -> Vec { let hdr_rel = if header.is_some() { format!(r#""#) } else { String::new() }; let sect = if header.is_some() { r#""# } else { "" }; let doc = format!(r#"{body}{sect}"#); let rels = format!( r#""# ); let drels = format!(r#"{doc_rels}{hdr_rel}"#); let types = r#""#; let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let mut put = |name: &str, bytes: &[u8]| { zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); zip.write_all(bytes).unwrap(); }; put("[Content_Types].xml", types.as_bytes()); put("_rels/.rels", rels.as_bytes()); put("word/document.xml", doc.as_bytes()); put("word/_rels/document.xml.rels", drels.as_bytes()); if let Some((content, hrels)) = header { put("word/header2.xml", format!(r#"{content}"#).as_bytes()); put( "word/_rels/header2.xml.rels", format!(r#"{hrels}"#).as_bytes(), ); } for (n, b) in extra { put(n, b); } zip.finish().unwrap().into_inner() } fn image_rel(id: &str, target: &str, external: bool) -> String { let mode = if external { r#" TargetMode="External""# } else { "" }; format!(r#""#) } fn missing(d: &Document) -> Vec<(String, String, MissingPictureReason)> { d.missing_pictures.list.iter().map(|m| (m.part.clone(), m.story.clone(), m.reason)).collect() } fn pictures(d: &Document) -> usize { d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count() } #[test] fn external_missing_and_link_only_pictures_are_recorded_and_still_dropped() { let body = [blip(r#"r:embed="rIdExt""#), blip(r#"r:embed="rIdGone""#), blip(r#"r:link="rIdLink""#), blip(r#"r:embed="rIdNone""#)].concat(); let rels = [ image_rel("rIdExt", "http://127.0.0.1:9/never-fetched.png", true), image_rel("rIdGone", "media/absent.png", false), image_rel("rIdLink", "file:///C:/pics/linked.png", true), ] .concat(); let d = craft_docx::read(&package(&body, &rels, None, &[])).unwrap(); assert_eq!(pictures(&d), 0, "nothing is drawn for them, as before"); assert_eq!( missing(&d), [ ("http://127.0.0.1:9/never-fetched.png".into(), "word/document.xml".into(), MissingPictureReason::ExternalTarget), ("word/media/absent.png".into(), "word/document.xml".into(), MissingPictureReason::MissingPart), ("file:///C:/pics/linked.png".into(), "word/document.xml".into(), MissingPictureReason::LinkOnly), ("rIdNone".into(), "word/document.xml".into(), MissingPictureReason::MissingPart), ] ); assert!(!d.missing_pictures.truncated); } #[test] fn a_header_pictures_story_is_the_header_part() { let rels = [image_rel("rIdHx", "https://example.invalid/logo.png", true), image_rel("rIdOk", "media/ok.png", false)].concat(); let header = [blip(r#"r:embed="rIdHx""#), blip(r#"r:embed="rIdOk""#)].concat(); let bytes = package("", "", Some((&header, &rels)), &[("word/media/ok.png", png())]); let d = craft_docx::read(&bytes).unwrap(); assert_eq!(missing(&d), [("https://example.invalid/logo.png".into(), "word/header2.xml".into(), MissingPictureReason::ExternalTarget)]); assert_eq!(d.missing_pictures.main, "word/document.xml"); assert!(d.missing_pictures.part_names.values().any(|n| &**n == "word/header2.xml")); assert_eq!(d.missing_pictures.media_parts.get("ok.png").map(String::as_str), Some("word/media/ok.png")); } #[test] fn a_picture_that_is_there_is_not_recorded() { let body = blip(r#"r:embed="rIdOk""#); let d = craft_docx::read(&package(&body, &image_rel("rIdOk", "media/ok.png", false), None, &[("word/media/ok.png", png())])).unwrap(); assert_eq!(pictures(&d), 1); assert!(d.missing_pictures.list.is_empty()); } #[test] fn vml_image_data_links_are_recorded() { let pict = |attrs: &str| format!(r#""#); let body = [pict(r#"r:id="rIdExt""#), pict(r#"r:href="rIdLink""#), pict(r#"r:id="rIdGone""#)].concat(); let rels = [ image_rel("rIdExt", "https://example.invalid/a.png", true), image_rel("rIdLink", "https://example.invalid/b.png", true), image_rel("rIdGone", "media/none.png", false), ] .concat(); let d = craft_docx::read(&package(&body, &rels, None, &[])).unwrap(); let got: Vec<_> = missing(&d).into_iter().map(|(p, _, r)| (p, r)).collect(); assert_eq!( got, [ ("https://example.invalid/a.png".to_string(), MissingPictureReason::ExternalTarget), ("https://example.invalid/b.png".to_string(), MissingPictureReason::LinkOnly), ("word/media/none.png".to_string(), MissingPictureReason::MissingPart), ] ); } #[test] fn the_list_is_capped_and_the_document_is_still_equal() { let n = craft_wordmodel::MAX_MISSING_PICTURES + 5; let body: String = (0..n).map(|i| blip(&format!(r#"r:embed="r{i}""#))).collect(); let rels: String = (0..n).map(|i| image_rel(&format!("r{i}"), &format!("media/gone{i}.png"), false)).collect(); let d = craft_docx::read(&package(&body, &rels, None, &[])).unwrap(); assert_eq!(d.missing_pictures.list.len(), craft_wordmodel::MAX_MISSING_PICTURES); assert!(d.missing_pictures.truncated); // Not part of the document: `==` and the clone ignore it, and a save doesn't carry it. let mut e = d.clone(); e.missing_pictures = Default::default(); assert_eq!(d, e); assert!(!serde_json::to_string(&d).unwrap().contains("gone")); } /// Every note of a part shares the one allocation of its source name, so a file with many notes /// in a part with a long name does not keep a copy of the name per note. #[test] fn the_notes_of_a_part_share_one_copy_of_its_name() { let notes: String = (1..=3).map(|i| format!(r#"n{i}"#)).collect(); let rels = format!(r#""#); let part = format!(r#"{notes}"#); let d = craft_docx::read(&package("x", &rels, None, &[("word/footnotes.xml", part.into_bytes())])).unwrap(); let names: Vec<_> = d.missing_pictures.part_names.values().collect(); assert!(names.len() >= 3, "{names:?}"); assert!(names.iter().all(|n| &***n == "word/footnotes.xml")); assert!(names.windows(2).all(|w| std::sync::Arc::ptr_eq(w[0], w[1])), "each note holds its own copy of the part name"); }