Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_file/tests/office.rs

17.9 KiB, 30 runs

created by r1870400018:22591, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
2//! Anthropic Claude
3
4use oxedyne_fe2o3_file::office::docx;
5use oxedyne_fe2o3_file::office::docx::read::Undrawable;
6use oxedyne_fe2o3_file::office::opc::{
7 CT_DOCUMENT,
8 REL_DOC,
9 REL_HYPERLINK,
10};
11use oxedyne_fe2o3_file::zip::Zip;
12
13use oxedyne_fe2o3_core::{
14 prelude::*,
15 test::test_it,
16};
17use oxedyne_fe2o3_text::doc::{
18 Block,
19 markdown,
20};
21use oxedyne_fe2o3_text::xml::Xml;
22
23// Prose exercising every block the tree has, so a created document is checked against something with
24// more in it than paragraphs.
25const SOURCE: &str = "\
26# A Report On Something
27
28An opening paragraph with **bold words**, some *emphasis*, and `inline code`.
29
30## The second heading
31
32- First bullet
33- Second with a [link](https://example.com/page)
34 - A nested bullet
35
361. Step one
372. Step two
38
39> A quotation.
40
41| Name | Quantity | Price |
42| :--- | ---: | :---: |
43| Widget | 12 | 3.40 |
44
45```rust
46fn main() {}
47```
48
49---
50
51A closing paragraph.
52";
53
54// A `.docx` LibreOffice wrote from an HTML document with known content: two heading levels, a
55// bulleted list, a numbered list, a quotation, a table with a bold first row, a link, and bold and
56// italic runs.
57//
58// The intent is ours and the bytes are somebody else's, which is the only useful shape for a reader
59// test. It has already earned its place: LibreOffice gives its heading 1 style NO outline level and
60// calls its quotation style `BlockQuotation`, and a reader written against Word's spellings alone
61// gets both wrong.
62const RICH: &[u8] = include_bytes!("data/rich.docx");
63
64// A `.docx` LibreOffice wrote holding a picture, which is the one thing in this fixture set that a
65// reading view genuinely cannot draw. The band that says so is the whole point of it.
66const WITHPIC: &[u8] = include_bytes!("data/withpic.docx");
67
68// The parts a `.docx` cannot open without.
69const REQUIRED: [&str; 4] = [
70 "[Content_Types].xml",
71 "_rels/.rels",
72 "word/document.xml",
73 "word/_rels/document.xml.rels",
74];
75
76/// The document under test, and its archive.
77fn made() -> Outcome<(Vec<u8>, Zip)> {
78 let doc = res!(markdown::parse(SOURCE));
79 let (bytes, left) = res!(docx::write(&doc));
80 assert!(left.is_empty(), "this source holds no image, so nothing was left out");
81 let zip = res!(Zip::read(bytes.clone()));
82 Ok((bytes, zip))
83}
84
85pub fn test_office(filter: &'static str) -> Outcome<()> {
86
87 res!(test_it(filter, &["A created .docx is a package, not a heap of XML 000", "all", "office"], || {
88 // The four parts and the two declarations. A document missing any of them is one Word declines
89 // to open, with a message naming neither the part nor the reason.
90 let (_, zip) = res!(made());
91 for part in REQUIRED {
92 assert!(zip.has(part), "the package is missing {}", part);
93 }
94 let types = res!(zip.text("[Content_Types].xml"));
95 assert!(types.contains(CT_DOCUMENT), "the main part's content type is declared");
96 assert!(types.contains("Extension=\"rels\""), "the relationship parts are declared");
97 // The package's own rels must point at the document part, which is how a reader finds it.
98 let root = res!(Xml::parse(&res!(zip.text("_rels/.rels"))));
99 let rels = res!(root.root()).children("Relationship");
100 assert_eq!(rels.len(), 1);
101 assert_eq!(rels[0].attr("Type"), Some(REL_DOC));
102 assert_eq!(rels[0].attr("Target"), Some("word/document.xml"));
103 // And everything the package declares an override for must actually be in the archive: a
104 // content type for a part that is not there is how a package passes a check on its own
105 // manifest and fails in Word.
106 let types = res!(Xml::parse(&types));
107 for over in res!(types.root()).children("Override") {
108 let part = res!(over.attr("PartName").ok_or_else(|| err!(
109 "an override with no PartName"; Invalid, Input, Missing)));
110 let name = part.trim_start_matches('/');
111 assert!(zip.has(name), "'{}' is declared and is not in the archive", name);
112 }
113 Ok(())
114 }));
115
116 res!(test_it(filter, &["The document says what the tree said 001", "all", "office"], || {
117 let (_, zip) = res!(made());
118 let xml = res!(Xml::parse(&res!(zip.text("word/document.xml"))));
119 let root = res!(xml.root());
120 assert_eq!(root.name.qname, "w:document");
121 let body = res!(root.child("w:body").ok_or_else(|| err!("no w:body"; Missing)));
122
123 // A heading is Word's OWN heading, by style, so its navigation pane and a generated contents
124 // page both find it. A bold run at 20 point would look the same and be neither.
125 let styles: Vec<String> = xml.all("w:pStyle").iter()
126 .filter_map(|e| e.attr("w:val").map(|s| s.to_string()))
127 .collect();
128 assert!(styles.contains(&"Heading1".to_string()), "got {:?}", styles);
129 assert!(styles.contains(&"Heading2".to_string()));
130 assert!(styles.contains(&"Quote".to_string()), "a quotation is a Quote");
131 assert!(styles.contains(&"SourceCode".to_string()), "a listing is monospaced by style");
132
133 // The section properties are last in the body, as the schema requires.
134 let last = res!(body.elems().last().ok_or_else(|| err!("an empty body"; Missing)));
135 assert_eq!(last.name.qname, "w:sectPr");
136
137 // Lists are Word's lists: a numbering reference, not a bullet character typed into the text.
138 let nums: Vec<(&str, &str)> = xml.all("w:numPr").iter()
139 .filter_map(|e| {
140 let lvl = e.child("w:ilvl")?.attr("w:val")?;
141 let id = e.child("w:numId")?.attr("w:val")?;
142 Some((id, lvl))
143 })
144 .collect();
145 assert!(nums.contains(&("1", "0")), "a bullet at the top level: {:?}", nums);
146 assert!(nums.contains(&("1", "1")), "a nested bullet is one level deeper: {:?}", nums);
147 assert!(nums.contains(&("2", "0")), "a numbered list is the other definition: {:?}", nums);
148
149 // And the prose is all there, in order.
150 let text: String = xml.all("w:t").iter().map(|e| xml.text_of(e)).collect();
151 for phrase in [
152 "A Report On Something",
153 "An opening paragraph with ",
154 "bold words",
155 "inline code",
156 "First bullet",
157 "A quotation.",
158 "fn main() {}",
159 "A closing paragraph.",
160 ] {
161 assert!(text.contains(phrase), "the document lost {:?}", phrase);
162 }
163 // The spaces between runs survive, which is what `xml:space="preserve"` is for: a sentence
164 // built from three runs otherwise arrives with the words jammed together.
165 assert!(text.contains("with bold words, some emphasis"), "got {:?}", text);
166 Ok(())
167 }));
168
169 res!(test_it(filter, &["A link's target lives in the rels and resolves 002", "all", "office"], || {
170 // The one place a created document refers across parts, and so the one place it can be
171 // internally inconsistent while every part is well formed on its own.
172 let (_, zip) = res!(made());
173 let xml = res!(Xml::parse(&res!(zip.text("word/document.xml"))));
174 let rels = res!(Xml::parse(&res!(zip.text("word/_rels/document.xml.rels"))));
175 let links = xml.all("w:hyperlink");
176 assert_eq!(links.len(), 1);
177 let id = res!(links[0].attr("r:id").ok_or_else(|| err!(
178 "the hyperlink names no relationship"; Invalid, Missing)));
179 let rel = res!(res!(rels.root()).children("Relationship").into_iter()
180 .find(|r| r.attr("Id") == Some(id))
181 .ok_or_else(|| err!("'{}' is named and not declared", id; Invalid, Missing)));
182 assert_eq!(rel.attr("Type"), Some(REL_HYPERLINK));
183 assert_eq!(rel.attr("Target"), Some("https://example.com/page"));
184 assert_eq!(rel.attr("TargetMode"), Some("External"),
185 "a URL outside the package must say so, or Word reads it as a part name");
186 // And every id the body names is declared: an r:id with nothing behind it is a document Word
187 // refuses to open.
188 let declared: Vec<&str> = res!(rels.root()).children("Relationship").iter()
189 .filter_map(|r| r.attr("Id"))
190 .collect();
191 for link in &links {
192 let id = res!(link.attr("r:id").ok_or_else(|| err!("a link with no id"; Missing)));
193 assert!(declared.contains(&id), "'{}' is named and not declared", id);
194 }
195 Ok(())
196 }));
197
198 res!(test_it(filter, &["A table becomes a table 003", "all", "office"], || {
199 let (_, zip) = res!(made());
200 let xml = res!(Xml::parse(&res!(zip.text("word/document.xml"))));
201 let tbls = xml.all("w:tbl");
202 assert_eq!(tbls.len(), 1);
203 let tbl = tbls[0];
204 assert_eq!(tbl.all("w:gridCol").len(), 3, "three columns are declared in the grid");
205 let rows = tbl.children("w:tr");
206 assert_eq!(rows.len(), 2, "a header row and one body row");
207 assert_eq!(rows[0].children("w:tc").len(), 3);
208 assert!(rows[0].find(&["w:trPr", "w:tblHeader"]).is_some(),
209 "the header row repeats onto each page it runs on");
210 // The tree names the sides Start and End because it does not know which way its text runs, and
211 // OOXML has the same two words for the same reason, so nothing here decides what "left" means.
212 let jc: Vec<&str> = tbl.all("w:jc").iter().filter_map(|e| e.attr("w:val")).collect();
213 assert!(jc.contains(&"start"), "got {:?}", jc);
214 assert!(jc.contains(&"end"), "got {:?}", jc);
215 assert!(jc.contains(&"center"), "got {:?}", jc);
216 Ok(())
217 }));
218
219 res!(test_it(filter, &["What could not be carried is said 004", "all", "office"], || {
220 // An image's bytes are somewhere this crate cannot reach: the tree holds where an image IS, and
221 // there is no filesystem here and no network. The alt text stands in its place and the omission
222 // is COUNTED, so a caller can tell the reader rather than the reader having to notice.
223 let doc = res!(markdown::parse("Before ![a diagram](pics/one.png) after.\n"));
224 let (bytes, left) = res!(docx::write(&doc));
225 assert_eq!(left.images, vec!["pics/one.png".to_string()]);
226 assert!(!left.is_empty());
227 let zip = res!(Zip::read(bytes));
228 let xml = res!(Xml::parse(&res!(zip.text("word/document.xml"))));
229 let text: String = xml.all("w:t").iter().map(|e| xml.text_of(e)).collect();
230 assert_eq!(text, "Before a diagram after.", "the alt text stands in for the image");
231 Ok(())
232 }));
233
234 res!(test_it(filter, &["The same tree gives the same bytes 005", "all", "office"], || {
235 // Nothing in a created document comes from the clock or from a counter that survives a call, so
236 // a build that has to be reproducible stays reproducible.
237 let doc = res!(markdown::parse(SOURCE));
238 let (a, _) = res!(docx::write(&doc));
239 let (b, _) = res!(docx::write(&doc));
240 assert_eq!(a, b);
241 // And it survives the archive's own round trip, which is the property an edit will rest on.
242 let zip = res!(Zip::read(a.clone()));
243 assert_eq!(res!(zip.write()), a);
244 Ok(())
245 }));
246
247
248 res!(test_it(filter, &["A foreign document reads back as what it says 006", "all", "office"], || {
249 // Read against a document this crate did not write. Reading back our own output would prove
250 // that the writer and the reader share their assumptions, which is exactly the thing worth
251 // doubting.
252 let r = res!(docx::read(RICH));
253 let blocks = &r.doc.blocks;
254
255 // Headings, by what the style RESOLVES to. LibreOffice gives `Heading1` no outline level at
256 // all, so a reader that asked for one would find no title in this document.
257 let heads: Vec<(u8, String)> = blocks.iter().filter_map(|b| match b {
258 Block::Heading { level, content } => {
259 Some((*level, oxedyne_fe2o3_text::doc::text_of(content)))
260 }
261 _ => None,
262 }).collect();
263 assert_eq!(heads, vec![
264 (1, "Quarterly Review".to_string()),
265 (2, "Findings".to_string()),
266 ], "got {:?}", heads);
267
268 // Both lists, and which is which. That answer comes from word/numbering.xml through two hops:
269 // a paragraph names a w:num, which names an abstract definition, which holds the format.
270 let lists: Vec<(bool, usize)> = blocks.iter().filter_map(|b| match b {
271 Block::List { ordered, items } => Some((*ordered, items.len())),
272 _ => None,
273 }).collect();
274 assert_eq!(lists, vec![(false, 2), (true, 2)], "got {:?}", lists);
275
276 // A quotation, which LibreOffice calls `BlockQuotation` and Word calls `Quote`.
277 assert!(blocks.iter().any(|b| matches!(b, Block::Quote(_))), "the quotation is a quotation");
278
279 // The table, with its bold first row read as the header.
280 let table = res!(blocks.iter().find_map(|b| match b {
281 Block::Table { head, rows, .. } => Some((head, rows)),
282 _ => None,
283 }).ok_or_else(|| err!("no table"; Missing)));
284 let head = res!(table.0.as_ref().ok_or_else(|| err!("the table lost its header"; Missing)));
285 assert_eq!(head.text_of(), "Region Units");
286 assert_eq!(table.1.len(), 2);
287 assert_eq!(table.1[0].text_of(), "North 120");
288
289 // The link, and its target out of the relationships part.
290 let text = markdown::write::render(&r.doc);
291 assert!(text.contains("[a link](https://example.org/detail)"), "got {}", text);
292 // Bold and italic survive as emphasis rather than as font sizes.
293 assert!(text.contains("**bold words**"), "got {}", text);
294 assert!(text.contains("*italic ones*"), "got {}", text);
295
296 // Nothing in this document is undrawable, and it carries no macros.
297 assert!(r.undrawn.is_empty(), "got {:?}", r.undrawn);
298 assert!(r.say_undrawn().is_none());
299 assert!(!r.macros);
300 Ok(())
301 }));
302
303 res!(test_it(filter, &["A picture in a foreign document is counted 012", "all", "office"], || {
304 // The counting, on a real document rather than on a hand-made `Reading`. LibreOffice writes a
305 // picture as `w:drawing`, and the prose around it has to survive it: a reader that stopped at
306 // the drawing would lose the rest of the document and report one image.
307 let r = res!(docx::read(WITHPIC));
308 assert_eq!(r.undrawn, vec![(Undrawable::Image, 1)], "got {:?}", r.undrawn);
309 assert_eq!(res!(r.say_undrawn().ok_or_else(|| err!("no phrase"; Missing))),
310 "1 thing is not drawn: 1 image");
311 let md = markdown::write::render(&r.doc);
312 assert!(md.contains("# Report With A Picture"), "got {}", md);
313 assert!(md.contains("Text before the picture."), "got {}", md);
314 assert!(md.contains("Text after the picture."), "the prose after the drawing survived: {}", md);
315 Ok(())
316 }));
317
318 res!(test_it(filter, &["What cannot be drawn is counted by kind 007", "all", "office"], || {
319 // The band in the panel header says "4 things are not drawn: 3 text boxes, 1 chart". The number
320 // and the kind are both the information: a reader told only that something is missing has been
321 // told that the reader cannot be trusted, and nothing else.
322 assert_eq!(Undrawable::TextBox.say(1), "1 text box");
323 assert_eq!(Undrawable::TextBox.say(3), "3 text boxes");
324 assert_eq!(Undrawable::Chart.say(1), "1 chart");
325 let mut r = docx::read::Reading::default();
326 assert!(r.say_undrawn().is_none(), "a document with everything drawn says nothing");
327 r.undrawn = vec![(Undrawable::Image, 2), (Undrawable::Chart, 1)];
328 assert_eq!(res!(r.say_undrawn().ok_or_else(|| err!("no phrase"; Missing))),
329 "3 things are not drawn: 2 images, 1 chart");
330 r.undrawn = vec![(Undrawable::Chart, 1)];
331 assert_eq!(res!(r.say_undrawn().ok_or_else(|| err!("no phrase"; Missing))),
332 "1 thing is not drawn: 1 chart");
333 Ok(())
334 }));
335
336 res!(test_it(filter, &["A document we wrote reads back as what we put in 008", "all", "office"], || {
337 // Weaker evidence than 006 and worth having for a different reason: it is the ROUND TRIP, and
338 // it covers the constructs the foreign fixture has none of -- a listing, a thematic break, a
339 // code span. It proves the writer and reader agree, and nothing about either being right.
340 let doc = res!(markdown::parse(SOURCE));
341 let (bytes, _) = res!(docx::write(&doc));
342 let back = res!(docx::read(&bytes));
343 let md = markdown::write::render(&back.doc);
344 for phrase in [
345 "# A Report On Something",
346 "## The second heading",
347 "**bold words**",
348 "- First bullet",
349 "1. Step one",
350 "> A quotation.",
351 "[link](https://example.com/page)",
352 "| Widget | 12 | 3.40 |",
353 "fn main() {}",
354 ] {
355 assert!(md.contains(phrase), "the round trip lost {:?}\n---\n{}", phrase, md);
356 }
357 Ok(())
358 }));
359
360 res!(test_it(filter, &["An encrypted document is refused by name 009", "all", "office"], || {
361 // An encrypted Office file is an OLE compound file with the real document inside it. There is
362 // no password here, so it is refused rather than shown as the binary rubble it decodes to.
363 let ole = [
364 0xD0u8, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1,
365 0, 0, 0, 0, 0, 0, 0, 0,
366 ];
367 let e = docx::read(&ole);
368 assert!(e.is_err());
369 // And something that is not a document at all is refused too, rather than read as empty.
370 assert!(docx::read(b"not a document").is_err());
371 Ok(())
372 }));
373
374 res!(test_it(filter, &["A macro project is said rather than run 010", "all", "office"], || {
375 // Never executed, never stripped: the binary travels with the file and the reader is TOLD.
376 // A reader who is not told does not know what they have been sent.
377 let doc = res!(markdown::parse("A document.\n"));
378 let (bytes, _) = res!(docx::write(&doc));
379 let mut zip = res!(Zip::read(bytes));
380 assert!(!res!(docx::read(&res!(zip.write()))).macros);
381 zip.set("word/vbaProject.bin", vec![0xCA, 0xFE], oxedyne_fe2o3_file::zip::Method::Store);
382 let with = res!(zip.write());
383 assert!(res!(docx::read(&with)).macros, "the macro project is seen");
384 // And it is still there afterwards, byte for byte.
385 let back = res!(Zip::read(with));
386 assert_eq!(res!(back.content("word/vbaProject.bin")), vec![0xCA, 0xFE]);
387 Ok(())
388 }));
389
390 res!(test_it(filter, &["Tracked changes are displayed as the document stands 011", "all", "office"], || {
391 // An insertion's text IS in the document, so it is read. A deletion's is not, so it is not.
392 // Display only: nothing here authors w:ins or w:del.
393 let doc = res!(markdown::parse("Placeholder.\n"));
394 let (bytes, _) = res!(docx::write(&doc));
395 let mut zip = res!(Zip::read(bytes));
396 let body = "<w:p><w:ins><w:r><w:t>kept </w:t></w:r></w:ins>\
397 <w:del><w:r><w:delText>gone </w:delText></w:r></w:del>\
398 <w:r><w:t>plain</w:t></w:r></w:p>";
399 let part = fmt!(
400 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
401 <w:document xmlns:w=\"{}\"><w:body>{}</w:body></w:document>",
402 docx::NS_W, body);
403 zip.set("word/document.xml", part.into_bytes(), oxedyne_fe2o3_file::zip::Method::Deflate);
404 let r = res!(docx::read(&res!(zip.write())));
405 let text = markdown::write::render(&r.doc);
406 assert!(text.contains("kept plain"), "got {:?}", text);
407 assert!(!text.contains("gone"), "removed text is not what the document says: {:?}", text);
408 assert_eq!(r.tracked, 1, "and the insertion is counted, so a reader can be told");
409 Ok(())
410 }));
411
412 Ok(())
413}