mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
An alternative format chunk holding a Word document with w:matchSrc keeps its own styles: a style the document already has comes along under a new id, the chunk's document defaults (and Word's, where they say nothing) go into its base styles, and its unstyled paragraphs use its own Normal. Without w:matchSrc the document's styles win, as before. Adopting another document now also renumbers table styles and the revisions of table, row and cell property changes. A document whose chunk shares style names with it (Normal with an exact line height, an italic Subtitle) ran a page longer than in Word: the chunk's tables took the document's styles. It now keeps its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
19 KiB
Rust
366 lines
19 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") + ¶("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 };
|
|
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"]);
|
|
}
|