Files
wordcraft/crates/docx/tests/it/alt_chunks.rs
T
2026-11-09 20:16:05 +01:00

446 lines
24 KiB
Rust

//! 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 wordcraft_doc::{Block, Document, InlineObject, StoryRef};
use wordcraft_docx::{AltChunk, ReadOptions, read, read_with};
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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const DOCX_MAIN: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
fn p(text: &str) -> String {
format!(r#"<w:p><w:r><w:t xml:space="preserve">{text}</w:t></w:r></w:p>"#)
}
/// 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<u8> {
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<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}<w:sectPr/></w:body></w:document>"#);
let mut files: Vec<(String, Vec<u8>)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())];
let mut types = String::from(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>"#,
);
let mut rels = String::new();
if let Some((ext, ct, bytes)) = chunk {
types.push_str(&format!(r#"<Default Extension="{ext}" ContentType="{ct}"/>"#));
rels.push_str(&format!(r#"<Relationship Id="rIdC" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="/word/afchunk.{ext}"/>"#));
files.push((format!("word/afchunk.{ext}"), bytes.to_vec()));
}
if let Some(styles) = styles {
rels.push_str(
r#"<Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#,
);
files.push(("word/styles.xml".into(), format!(r#"<w:styles {NS}>{styles}</w:styles>"#).into_bytes()));
}
if !rels.is_empty() {
files.push((
"word/_rels/document.xml.rels".into(),
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#).into_bytes(),
));
}
types.push_str(r#"<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#);
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#"<w:altChunk r:id="rIdC"/>"#;
fn body_lines(d: &Document) -> Vec<String> {
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"<p>Chunk one</p><p>Chunk two</p>";
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#"<w:bookmarkStart w:id="0" w:name="Here"/>{CHUNK}<w:bookmarkEnd w:id="0"/>{}"#, p("After"));
let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"<p>Chunk</p>")));
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!("<w:tbl><w:tr><w:tc>{}</w:tc></w:tr></w:tbl>", p("Cell"));
let body = format!(r#"{table}<w:bookmarkStart w:id="0" w:name="Here"/>{CHUNK}<w:bookmarkEnd w:id="0"/>{}"#, p("After"));
let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"<p>Chunk</p>")));
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), (wordcraft_doc::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#"<w:p><w:permStart w:id="0" w:ed="them"/><w:r><w:t>Inner</w:t></w:r><w:permEnd w:id="0"/></w:p>"#, None);
// The host's range 0 is open across two chunks whose own ranges are 0 too.
let body = format!(
r#"<w:p><w:permStart w:id="0" w:edGrp="everyone"/><w:r><w:t>Host</w:t></w:r></w:p>{CHUNK}{CHUNK}<w:p><w:r><w:t>After</w:t></w:r><w:permEnd w:id="0"/></w:p>"#
);
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<u32> = 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(&wordcraft_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<u8> {
let rel = |id: &str, ty: &str, target: &str| {
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
};
let rels =
|inner: String| format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{inner}</Relationships>"#);
let chunk_rel = if chunk.is_some() { rel("rIdC", "aFChunk", "/word/afchunk.docx") } else { String::new() };
let doc = format!(r#"<w:document {NS}><w:body>{body}<w:sectPr/></w:body></w:document>"#);
let mut main_rels = chunk_rel.clone();
let mut files: Vec<(String, Vec<u8>)> = 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#"<w:p><w:commentRangeStart w:id="{id}"/><w:r><w:t>{text}</w:t></w:r><w:commentRangeEnd w:id="{id}"/><w:r><w:commentReference w:id="{id}"/></w:r></w:p>"#
)
}
/// 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#"<w:comment w:id="{id}" w:author="{who}">{content}</w:comment>"#)).collect();
("comments", "comments.xml", format!("<w:comments {NS}>{body}</w:comments>"))
}
/// 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<String> {
let mut stories: Vec<&[std::sync::Arc<Block>]> = 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<u8> {
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#"<w:footnotes {NS}><w:footnote w:id="1"><w:altChunk r:id="rIdC"/></w:footnote></w:footnotes>"#);
let body = format!(r#"{}<w:p><w:r><w:footnoteReference w:id="1"/></w:r></w:p>"#, 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#"{}<w:altChunk r:id="rIdC"/>"#, 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#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{id}"/><w:rPr><w:sz w:val="{half_points}"/></w:rPr></w:style>"#)
};
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#"<w:p><w:pPr><w:pStyle w:val="{id}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let inner = package_styled(&(para("A", "ten") + &para("A1", "fourteen")), None, Some(&theirs));
let body = format!(r#"{}<w:altChunk r:id="rIdC"><w:altChunkPr><w:matchSrc/></w:altChunkPr></w:altChunk>"#, 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(&wordcraft_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#"<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="200"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:spacing w:after="248" w:line="248" w:lineRule="exact"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:rPr><w:i/></w:rPr></w:style>"#;
let theirs = r#"<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="24"/></w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/></w:style>"#;
let inner = package_styled(
&format!(r#"{}<w:p><w:pPr><w:pStyle w:val="Subtitle"/></w:pPr><w:r><w:t>Sub</w:t></w:r></w:p>"#, p("Plain")),
None,
Some(theirs),
);
for match_src in [true, false] {
let pr = if match_src { "<w:altChunkPr><w:matchSrc/></w:altChunkPr>" } else { "" };
let body = format!(r#"{}<w:altChunk r:id="rIdC">{pr}</w:altChunk>"#, 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"<p>x</p>")));
let import = |_: &AltChunk| {
let mut d = Document::new();
let key = d.add_media(vec![1, 2, 3], "png");
let mut para = wordcraft_doc::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<String> = 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"<p>x</p>")));
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#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, 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<u8> {
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<u8> {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect();
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p><w:sectPr/></w:body></w:document>"#);
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#"<Relationship Id="rIdP{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/>"#
)
};
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#,
(0..n).map(image).collect::<String>()
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
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, wordcraft_doc::MissingPictureReason::MissingPart), (true, wordcraft_doc::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 = wordcraft_doc::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(), wordcraft_doc::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 = wordcraft_doc::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(), wordcraft_doc::MAX_MISSING_PICTURES);
assert_eq!(d.missing_pictures.total, n as u64);
}