mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* Add craft-wordmodel: WordCraft's document model without its editing code WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX, RTF, ODT and other text-format crates can read into and write from one model. Module paths, type names and serde attributes are unchanged: an app that re-exports the crate keeps its native file format. Left in the app: range editing (edit.rs), IME text and equation editing. Not model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs. * craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs The DOCX reader and writer walk an equation's runs. The walk and the child accessors it uses were in WordCraft's equation editor (math_edit.rs), which stays in the app; they are structure, not editing. * craft-wordmodel: keep the document operations (edit.rs) with the model The DOCX and RTF readers use them: alternative-format chunks are merged into a document with adopt_blocks, and a reader formats ranges with format_range and inserts objects. They operate on the document, not on a view, so they belong beside it. The tests that drove them come back with them; the paragraph walks stay in walk.rs and edit re-exports them. base64 0.23, as WordCraft and craft-offcrypto use. * craft-docx: WordCraft's crates/docx as it is at wordcraft 07f97737 The source, unchanged, so the next commit shows only what the move changes. This commit does not build on its own (it still names wordcraft-doc and wordcraft-geom, and is not a workspace member yet). * craft-docx: on craft-wordmodel, craft-opc, craft-xml and craft-offcrypto - The package (zip reading, part names, relationships, content types, zip writing) is craft-opc's; the module keeps WordCraft's bookkeeping (passthrough keys, the embedded-part manifest) and its own Rel/Rels shape. - Parts are parsed with craft-xml's canonical names (the Strict drawing names and mc:Choice/@Requires through its hooks), then given the tree shape the reader was written against. - Encryption and Restrict Editing hashes are craft-offcrypto's; this crate maps its errors and the w:documentProtection attributes. - wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom. Tests that covered what now lives in craft-opc and craft-offcrypto are left to those crates; the DIFAT regression is craft-cfb's. Test-only zip use follows zip 9's fallible names. The attribute-cap test times the read only.
447 lines
24 KiB
Rust
447 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 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#"<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), (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#"<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(&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<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(&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#"<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 = 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<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, 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);
|
|
}
|