//! Alternative format chunks (`w:altChunk`, ECMA-376 ยง17.17.2.1): content in another format that //! is brought in where the chunk stands. All markup here is synthetic. use std::io::Write; use craft_docx::{AltChunk, ReadOptions, read, read_with}; use craft_wordmodel::{Block, Document, InlineObject, StoryRef}; const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; const ROOT_RELS: &str = r#""#; const DOCX_MAIN: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; fn p(text: &str) -> String { format!(r#"{text}"#) } /// A package whose body is `body`, with the chunk `afchunk.{ext}` (content type `ct`) as `rIdC`. fn package(body: &str, chunk: Option<(&str, &str, &[u8])>) -> Vec { package_styled(body, chunk, None) } /// [`package`] with a styles part holding `styles`. fn package_styled(body: &str, chunk: Option<(&str, &str, &[u8])>, styles: Option<&str>) -> Vec { let doc = format!(r#"{body}"#); let mut files: Vec<(String, Vec)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())]; let mut types = String::from( r#""#, ); let mut rels = String::new(); if let Some((ext, ct, bytes)) = chunk { types.push_str(&format!(r#""#)); rels.push_str(&format!(r#""#)); files.push((format!("word/afchunk.{ext}"), bytes.to_vec())); } if let Some(styles) = styles { rels.push_str( r#""#, ); files.push(("word/styles.xml".into(), format!(r#"{styles}"#).into_bytes())); } if !rels.is_empty() { files.push(( "word/_rels/document.xml.rels".into(), format!(r#"{rels}"#).into_bytes(), )); } types.push_str(r#""#); files.push(("[Content_Types].xml".into(), types.into_bytes())); let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for (name, bytes) in files { zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); zw.write_all(&bytes).unwrap(); } zw.finish().unwrap().into_inner() } const CHUNK: &str = r#""#; fn body_lines(d: &Document) -> Vec { d.plain_text(StoryRef::Body).split('\n').map(str::to_string).collect() } #[test] fn a_chunk_comes_in_through_the_importer_where_it_stands() { let html = b"

Chunk one

Chunk two

"; let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("htm", "application/xhtml+xml", html))); let seen = std::cell::RefCell::new(Vec::new()); let import = |c: &AltChunk| { seen.borrow_mut().push((c.content_type.to_string(), c.part.to_string(), c.bytes.to_vec())); Some(Document::from_text("Chunk one\nChunk two")) }; let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); assert_eq!(body_lines(&d), ["Before", "Chunk one", "Chunk two", "After"]); assert_eq!(*seen.borrow(), [("application/xhtml+xml".to_string(), "word/afchunk.htm".to_string(), html.to_vec())]); // Without an importer the chunk is left out. assert_eq!(body_lines(&read(&bytes).unwrap()), ["Before", "After"]); } #[test] fn a_bookmark_opened_before_a_chunk_at_the_start_of_the_body_is_kept() { let body = format!(r#"{CHUNK}{}"#, p("After")); let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"

Chunk

"))); let import = |_: &AltChunk| Some(Document::from_text("Chunk")); let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); assert_eq!(body_lines(&d), ["Chunk", "After"]); let marks = d.bookmarks(); assert_eq!(marks.len(), 1); assert_eq!((marks[0].0.as_str(), marks[0].1.path.last(), marks[0].1.off), ("Here", 0, 0)); } #[test] fn a_bookmark_opened_between_a_table_and_a_chunk_starts_the_chunk() { let table = format!("{}", p("Cell")); let body = format!(r#"{table}{CHUNK}{}"#, p("After")); let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"

Chunk

"))); let import = |_: &AltChunk| Some(Document::from_text("Chunk")); let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); let marks = d.bookmarks(); assert_eq!(marks.len(), 1); assert_eq!((marks[0].1.path.clone(), marks[0].1.off), (craft_wordmodel::Path::top(1), 0), "not in the table's cell"); } #[test] fn permission_ranges_of_chunks_and_their_host_stay_apart() { let inner = package(r#"Inner"#, None); // The host's range 0 is open across two chunks whose own ranges are 0 too. let body = format!( r#"Host{CHUNK}{CHUNK}After"# ); let d = read(&package(&body, Some(("docx", DOCX_MAIN, &inner)))).unwrap(); assert_eq!(body_lines(&d), ["Host", "Inner", "Inner", "After"]); let check = |d: &Document| { let ranges = d.perm_ranges(); let mut ids: Vec = ranges.iter().map(|r| r.id).collect(); ids.sort_unstable(); ids.dedup(); assert_eq!(ids.len(), 3, "{ranges:?}"); let host = ranges.iter().find(|r| r.group == "everyone").unwrap(); assert_eq!((host.start.path.last(), host.end.path.last()), (0, 3)); assert!(ranges.iter().filter(|r| r.editor == "them").all(|r| r.start.path == r.end.path)); }; check(&d); check(&read(&craft_docx::write(&d).unwrap()).unwrap()); } /// A package whose body is `body`, with story parts (relationship type, part name, XML) and the /// Word document `chunk` as `rIdC` from the main part and from each story part. fn package_with_stories(body: &str, stories: &[(&str, &str, String)], chunk: Option<&[u8]>) -> Vec { let rel = |id: &str, ty: &str, target: &str| { format!(r#""#) }; let rels = |inner: String| format!(r#"{inner}"#); let chunk_rel = if chunk.is_some() { rel("rIdC", "aFChunk", "/word/afchunk.docx") } else { String::new() }; let doc = format!(r#"{body}"#); let mut main_rels = chunk_rel.clone(); let mut files: Vec<(String, Vec)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())]; for (i, (ty, name, xml)) in stories.iter().enumerate() { main_rels.push_str(&rel(&format!("rIdS{i}"), ty, name)); files.push((format!("word/{name}"), xml.clone().into_bytes())); files.push((format!("word/_rels/{name}.rels"), rels(chunk_rel.clone()).into_bytes())); } files.push(("word/_rels/document.xml.rels".into(), rels(main_rels).into_bytes())); if let Some(c) = chunk { files.push(("word/afchunk.docx".into(), c.to_vec())); } let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for (name, bytes) in files { zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); zw.write_all(&bytes).unwrap(); } zw.finish().unwrap().into_inner() } /// A paragraph `text` that comment `id` (in the file) is anchored around. fn commented(id: u32, text: &str) -> String { format!( r#"{text}"# ) } /// A comments part: (file id, author, content). fn comments_part(list: &[(u32, &str, String)]) -> (&'static str, &'static str, String) { let body: String = list.iter().map(|(id, who, content)| format!(r#"{content}"#)).collect(); ("comments", "comments.xml", format!("{body}")) } /// The authors of the comments anchored in the stories whose text contains `text`, and whether /// every comment has text of its own and a distinct id. fn anchored(d: &Document, text: &str) -> Vec { let mut stories: Vec<&[std::sync::Arc]> = vec![&d.body]; stories.extend(d.parts.values().map(|p| p.blocks.as_slice())); let mut out = Vec::new(); for blocks in stories { for b in blocks { let Block::Para(p) = &**b else { continue }; if !p.text.contains(text) { continue; } for o in &p.objects { if let InlineObject::CommentStart { id } = o { out.push(d.comments.get(id).map_or("?".to_string(), |c| c.author.clone())); } } } } out } /// A Word document with one comment by "Them" around "Inner". fn commented_chunk() -> Vec { package_with_stories(&commented(0, "Inner"), &[comments_part(&[(0, "Them", p("Theirs"))])], None) } #[test] fn a_chunk_in_a_footnote_keeps_its_comments_beside_the_documents() { let notes = format!(r#""#); let body = format!(r#"{}"#, commented(0, "Host")); let stories = [("footnotes", "footnotes.xml", notes), comments_part(&[(0, "Us", p("Ours"))])]; let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap(); assert_eq!(d.comments.len(), 2, "{:?}", d.comments); assert_eq!(anchored(&d, "Host"), ["Us"]); assert_eq!(anchored(&d, "Inner"), ["Them"]); } #[test] fn a_chunk_in_a_comment_keeps_its_comments_beside_the_documents() { let inside = format!(r#"{}"#, p("Ours")); let body = format!("{}{}", commented(0, "Host"), commented(1, "Second")); let stories = [comments_part(&[(0, "Us", inside), (1, "Us too", p("Ours too"))])]; let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap(); assert_eq!(d.comments.len(), 3, "{:?}", d.comments); assert_eq!(anchored(&d, "Host"), ["Us"]); assert_eq!(anchored(&d, "Second"), ["Us too"]); assert_eq!(anchored(&d, "Inner"), ["Them"]); } #[test] fn a_wordprocessing_chunk_is_read_without_an_importer_and_flows_in_its_section() { let inner = package(&format!("{}{}", p("Inner one"), p("Inner two")), None); let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("docx", DOCX_MAIN, &inner))); let d = read(&bytes).unwrap(); assert_eq!(body_lines(&d), ["Before", "Inner one", "Inner two", "After"]); // The inner document's own section doesn't come along. assert_eq!(d.sections().len(), 1); } /// Styles a matchSrc chunk brings under new ids get distinct ones: with `A` and `A1` ours and /// `A` to `A10` the chunk's, its `A` and `A1` both renamed don't land on one id (the second then /// lost, its paragraphs taking the first's formatting), and keep their formatting through a save. #[test] fn match_src_gives_each_renamed_style_its_own_id() { let style = |id: &str, half_points: u32| { format!(r#""#) }; let ours: String = ["A", "A1"].iter().map(|id| style(id, 22)).collect(); let mut theirs = style("A", 20) + &style("A1", 28); for n in 2..=10 { theirs += &style(&format!("A{n}"), 16); } let para = |id: &str, text: &str| format!(r#"{text}"#); let inner = package_styled(&(para("A", "ten") + ¶("A1", "fourteen")), None, Some(&theirs)); let body = format!(r#"{}"#, p("Ours")); let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(&ours))).unwrap(); let check = |d: &Document| { let look = |text: &str| { d.body .iter() .find_map(|b| match &**b { Block::Para(p) if p.text == text => { Some((p.props.style.clone(), d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size)) } _ => None, }) .unwrap() }; let ((ten, ten_size), (fourteen, fourteen_size)) = (look("ten"), look("fourteen")); assert_ne!(ten, fourteen); assert_eq!((ten_size, fourteen_size), (10.0, 14.0)); }; check(&d); check(&read(&craft_docx::write(&d).unwrap()).unwrap()); } #[test] fn match_src_keeps_a_word_chunks_own_formatting() { // Both define Normal and Subtitle differently, and the chunk's defaults differ from ours. let ours = r#""#; let theirs = r#""#; let inner = package_styled( &format!(r#"{}Sub"#, p("Plain")), None, Some(theirs), ); for match_src in [true, false] { let pr = if match_src { "" } else { "" }; let body = format!(r#"{}{pr}"#, p("Ours")); let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(ours))).unwrap(); let look = |i: usize| match &*d.body[i] { Block::Para(p) => { let para = d.styles.resolve_para(&p.props); let chr = d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)); (para.space_after, chr.size, chr.italic) } b => panic!("{b:?}"), }; // Ours stays ours either way. assert_eq!(look(0), (12.4, 11.0, false)); if match_src { // The chunk's own Normal (its 12 pt, Word's no space after) and its Subtitle (not italic). assert_eq!(look(1), (0.0, 12.0, false)); assert_eq!(look(2), (0.0, 12.0, false)); } else { // Without it the chunk takes our styles (our Subtitle stands on our defaults). assert_eq!(look(1), (12.4, 11.0, false)); assert_eq!(look(2), (10.0, 11.0, true)); } } } #[test] fn nested_wordprocessing_chunks_stop_at_a_depth() { let mut bytes = package(&p("Deepest"), None); for level in 0..8 { bytes = package(&format!("{}{CHUNK}", p(&format!("Level {level}"))), Some(("docx", DOCX_MAIN, &bytes))); } let lines = body_lines(&read(&bytes).unwrap()); assert_eq!(lines.first().map(String::as_str), Some("Level 7")); assert!(!lines.iter().any(|l| l == "Deepest"), "{lines:?}"); assert!(lines.len() >= 4, "{lines:?}"); } #[test] fn a_chunks_pictures_come_with_it() { let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("htm", "text/html", b"

x

"))); let import = |_: &AltChunk| { let mut d = Document::new(); let key = d.add_media(vec![1, 2, 3], "png"); let mut para = craft_wordmodel::Paragraph::new(); let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None, fx: Default::default(), }; para.insert_object(0, image, &Default::default()).unwrap(); d.body = vec![std::sync::Arc::new(Block::Para(para))]; Some(d) }; let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); let keys: Vec = d .body .iter() .filter_map(|b| match &**b { Block::Para(p) => p.objects.iter().find_map(|o| match o { InlineObject::Image { media, .. } => Some(media.clone()), _ => None, }), _ => None, }) .collect(); assert_eq!(keys.len(), 1); assert_eq!(d.media.get(&keys[0]).map(|m| m.as_slice()), Some(&[1u8, 2, 3][..])); } #[test] fn a_file_brings_in_a_bounded_number_of_chunks() { let bytes = package(&CHUNK.repeat(300), Some(("htm", "text/html", b"

x

"))); let calls = std::cell::Cell::new(0); let import = |_: &AltChunk| { calls.set(calls.get() + 1); Some(Document::from_text("x")) }; let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); assert_eq!(calls.get(), 256); assert_eq!(body_lines(&d).len(), 256); } #[test] fn hostile_chunks_never_panic() { for (ext, ct, bytes) in [("docx", DOCX_MAIN, &b"PK\x03\x04 not a zip"[..]), ("htm", "text/html", b""), ("bin", "", b"\x00\xff")] { let bytes = package(&format!("{CHUNK}{CHUNK}{}", p("x")), Some((ext, ct, bytes))); let import = |_: &AltChunk| None; let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); assert_eq!(body_lines(&d), ["x"]); } // A chunk naming a missing part or another relationship type is left out. let bytes = package(&format!(r#"{}"#, p("y")), None); assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]); } /// A DOCX whose only picture names a part the package lacks (`media/gone.png`), or sits outside it. fn inner_with_dropped_picture(external: bool) -> Vec { inner_with_dropped_pictures(1, external) } /// As [`inner_with_dropped_picture`], with `n` pictures each naming its own missing part. fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec { let drawing = r#""#; let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect(); let doc = format!(r#"{body}"#); let mode = if external { r#" TargetMode="External""# } else { "" }; let image = |i: usize| { let target = if external { format!("http://127.0.0.1:9/never-fetched{i}.png") } else { format!("media/gone{i}.png") }; format!( r#""# ) }; let rels = format!( r#"{}"#, (0..n).map(image).collect::() ); let types = r#""#; let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for (name, bytes) in [ ("[Content_Types].xml", types.as_bytes()), ("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("word/_rels/document.xml.rels", rels.as_bytes()), ] { zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); zw.write_all(bytes).unwrap(); } zw.finish().unwrap().into_inner() } /// A picture the chunk's own reader dropped is the file's too (#6005), named by the chunk part. #[test] fn a_picture_dropped_inside_a_chunk_is_recorded_against_the_chunk() { for (external, reason) in [(false, craft_wordmodel::MissingPictureReason::MissingPart), (true, craft_wordmodel::MissingPictureReason::ExternalTarget)] { let inner = inner_with_dropped_picture(external); let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner))); let d = read(&bytes).unwrap(); let got: Vec<(&str, &str, _)> = d.missing_pictures.list.iter().map(|m| (m.part.as_str(), m.story.as_str(), m.reason)).collect(); assert_eq!(got.len(), 1, "{got:?}"); assert_eq!(got[0].1, "word/afchunk.docx > word/document.xml"); assert_eq!(got[0].2, reason); } } /// A chunk with more dropped pictures than the list holds still counts them all (#6005). #[test] fn a_chunks_total_of_dropped_pictures_counts_past_the_list() { let n = craft_wordmodel::MAX_MISSING_PICTURES + 44; let inner = inner_with_dropped_pictures(n, false); let d = read(&package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap(); assert_eq!(d.missing_pictures.list.len(), craft_wordmodel::MAX_MISSING_PICTURES); assert!(d.missing_pictures.truncated); assert_eq!(d.missing_pictures.total, n as u64); } /// The same chunk imported twice counts its unlisted pictures once, as it dedupes its listed ones. #[test] fn a_chunk_imported_twice_counts_its_dropped_pictures_past_the_list_once() { let n = craft_wordmodel::MAX_MISSING_PICTURES + 44; let inner = inner_with_dropped_pictures(n, false); let d = read(&package(&format!("{}{CHUNK}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap(); assert_eq!(d.missing_pictures.list.len(), craft_wordmodel::MAX_MISSING_PICTURES); assert_eq!(d.missing_pictures.total, n as u64); }