//! 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);
}