Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_file/tests/odf.rs

13.6 KiB, 16 runs

created by r1870400018:22922, 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::deck::Deck;
5use oxedyne_fe2o3_file::office::odf;
6use oxedyne_fe2o3_file::office::sheet::{
7 Book,
8 Cell,
9 Ref,
10 Sheet,
11 Value,
12};
13use oxedyne_fe2o3_file::zip::{
14 Method,
15 Zip,
16};
17
18use oxedyne_fe2o3_core::{
19 prelude::*,
20 test::test_it,
21};
22use oxedyne_fe2o3_stds::media::Media;
23use oxedyne_fe2o3_text::doc::{
24 Block,
25 markdown,
26 text_of,
27};
28
29// Three files LibreOffice wrote: it was handed each of ours and asked to save its own, so the
30// content is known and every byte of the encoding is somebody else's.
31const ODT: &[u8] = include_bytes!("data/foreign.odt"); // the prose
32const ODS: &[u8] = include_bytes!("data/foreign.ods"); // the spreadsheet
33const ODP: &[u8] = include_bytes!("data/foreign.odp"); // the presentation
34
35// Prose with one of everything.
36const SOURCE: &str = "\
37# A Report
38
39An opening paragraph with **bold** and *emphasis*.
40
41## The second heading
42
43- First bullet
44- Second with a [link](https://example.com/page)
45 - A nested bullet
46
47> A quotation.
48
49```rust
50fn main() {
51 println!(\"indented\");
52}
53```
54
55A closing paragraph.
56";
57
58// A workbook holding one of everything that separates a spreadsheet from a table.
59fn book() -> Book {
60 let mut s = Sheet::new("Sales");
61 s.rows.push(vec![Cell::text("Region"), Cell::text("Units"), Cell::text("Total")]);
62 s.rows.push(vec![
63 Cell::text("North"),
64 Cell::number(120.0),
65 Cell::formula("B2*3.4", Value::Number(408.0)),
66 ]);
67 s.rows.push(Vec::new());
68 s.rows.push(vec![Cell {
69 value: Value::Date("2026-03-14".to_string()),
70 formula: None,
71 }]);
72 Book { sheets: vec![s] }
73}
74
75pub fn test_odf(filter: &'static str) -> Outcome<()> {
76
77 res!(test_it(filter, &["mimetype is first and stored, in all three 000", "all", "odf"], || {
78 // THE one mechanical rule of this format. It is what lets a reader name the file from its
79 // opening bytes without inflating anything, and a package that breaks it is one every reader
80 // calls a plain ZIP -- silently, opening as an archive rather than refusing.
81 let doc = res!(markdown::parse(SOURCE));
82 let (odt, _) = res!(odf::text::write(&doc));
83 let ods = res!(odf::sheet::write(&book()));
84 let (odp, _) = res!(odf::slides::write(&Deck::from_doc(&doc)));
85 for (what, bytes, media, kind) in [
86 ("odt", &odt, odf::text::MEDIA, Media::Odt),
87 ("ods", &ods, odf::sheet::MEDIA, Media::Ods),
88 ("odp", &odp, odf::slides::MEDIA, Media::Odp),
89 ] {
90 let zip = res!(Zip::read(bytes.clone()));
91 assert_eq!(zip.names().first(), Some(&"mimetype"), "{}: mimetype must be FIRST", what);
92 // AND THAT LAST CHECK ALONE IS VACUOUS, so it is not left alone. `mimetype` is the first
93 // member these writers add, so it comes out first whether `set_first` or `set` was
94 // called -- swapping one for the other leaves the assertion green. What has teeth is
95 // the property itself, checked below on a package where something else went in first.
96 let m = res!(zip.member("mimetype").ok_or_else(|| err!("no mimetype"; Missing)));
97 assert_eq!(m.method, Method::Store, "{}: mimetype must be STORED", what);
98 assert_eq!(res!(zip.text("mimetype")), media);
99 // And so the format is readable from the leading bytes, which is the whole point of the
100 // rule: `Media::sniff` reads the member rather than trusting the file's name.
101 assert_eq!(Media::sniff(&bytes[..256.min(bytes.len())]), kind,
102 "{} was not named from its own bytes", what);
103 // A file somebody renamed is still what it is.
104 assert_eq!(oxedyne_fe2o3_stds::media::identify("holiday.zip", bytes).media, kind);
105 }
106 // The property with teeth: `set_first` puts a member at the HEAD of an archive that already
107 // has members, which is what the package writer is relying on and what a plain `set` would
108 // not do. Written as its own check because the three above cannot tell the two apart.
109 let mut z = Zip::new();
110 z.set("content.xml", b"<x/>".to_vec(), Method::Deflate);
111 z.set("styles.xml", b"<y/>".to_vec(), Method::Deflate);
112 z.set_first("mimetype", odf::text::MEDIA.as_bytes().to_vec(), Method::Store);
113 assert_eq!(z.names(), vec!["mimetype", "content.xml", "styles.xml"]);
114 let out = res!(z.write());
115 assert_eq!(Media::sniff(&out[..128.min(out.len())]), Media::Odt,
116 "and so the format is readable from the opening bytes");
117 Ok(())
118 }));
119
120 res!(test_it(filter, &["A foreign .odt reads back as what it says 001", "all", "odf"], || {
121 let r = res!(odf::text::read(ODT));
122 let md = oxedyne_fe2o3_text::doc::markdown::write::render(&r.doc);
123 // A heading says its own level here: no style to resolve, no built-in name to recognise.
124 assert!(md.contains("# A Report On Something"), "{}", md);
125 assert!(md.contains("## The second heading"), "{}", md);
126 assert!(md.contains("- First bullet"), "{}", md);
127 assert!(md.contains("[link to somewhere](https://example.com/page)"), "{}", md);
128 assert!(md.contains("**bold words**"), "{}", md);
129 assert!(md.contains("| Widget | 12 | 3.40 |"), "the table came through: {}", md);
130 // A listing keeps its indentation, which needs `<text:s/>` for the LEADING spaces: a reader
131 // drops a leading literal space, so four became three until that was fixed.
132 assert!(md.contains(" println!"), "the indentation survived:\n{}", md);
133 assert!(r.doc.blocks.iter().any(|b| matches!(b, Block::Quote(_))), "the quotation is one");
134 assert!(!r.macros);
135 Ok(())
136 }));
137
138 res!(test_it(filter, &["A foreign .ods reads back, values and all 002", "all", "odf"], || {
139 let r = res!(odf::sheet::read(ODS));
140 let s = res!(r.book.sheets.first().ok_or_else(|| err!("no sheet"; Missing)));
141 assert_eq!(s.at(&res!(Ref::parse("A1"))).value, Value::Text("Region".to_string()));
142 assert_eq!(s.at(&res!(Ref::parse("B2"))).value, Value::Number(120.0));
143 // The stored value, and the formula beside it. Never recalculated.
144 let total = s.at(&res!(Ref::parse("D2")));
145 assert_eq!(total.value, Value::Number(408.0));
146 assert!(total.formula.is_some(), "the formula came back");
147 assert_eq!(s.at(&res!(Ref::parse("E2"))).value, Value::Date("2026-03-14".to_string()));
148 assert_eq!(s.at(&res!(Ref::parse("F2"))).value, Value::Bool(true));
149 // The gap: row 4 is empty and row 5 skips to D.
150 assert!(s.at(&res!(Ref::parse("A4"))).is_empty());
151 assert_eq!(s.at(&res!(Ref::parse("A5"))).value, Value::Text("Total".to_string()));
152 assert!(s.at(&res!(Ref::parse("B5"))).is_empty());
153 assert_eq!(s.at(&res!(Ref::parse("D5"))).value, Value::Number(1343.0));
154 assert!(r.formulas >= 3, "formulas counted: {}", r.formulas);
155 Ok(())
156 }));
157
158 res!(test_it(filter, &["A formula is written in OpenFormula, not A1 003", "all", "odf"], || {
159 // A reference must be bracketed. Written in the A1 syntax a `.xlsx` uses, LibreOffice cannot
160 // parse it, RECALCULATES the cell, and writes `Err:510` over the stored value -- so a wrong
161 // formula does not merely fail to work, it destroys the number that was there.
162 use oxedyne_fe2o3_file::office::odf::sheet::openformula as of;
163 assert_eq!(of("B2*C2"), "[.B2]*[.C2]");
164 assert_eq!(of("SUM(D2:D3)"), "SUM([.D2:.D3])");
165 assert_eq!(of("A1+B2*2"), "[.A1]+[.B2]*2");
166 // A function name is followed by a bracket and is not a cell, however much it looks like one.
167 assert_eq!(of("LOG10(A1)"), "LOG10([.A1])");
168 // Text passes through untouched, or a quoted `A1` becomes a reference.
169 assert_eq!(of("IF(A1>0,\"A1\",\"B2\")"), "IF([.A1]>0,\"A1\",\"B2\")");
170 assert_eq!(of("2+2"), "2+2");
171 // And the whole of it survives a trip through LibreOffice, which is what test 002 checks on a
172 // file that has been through one.
173 Ok(())
174 }));
175
176 res!(test_it(filter, &["A foreign .odp reads back in order 004", "all", "odf"], || {
177 let r = res!(odf::slides::read(ODP));
178 assert!(r.deck.slides.len() >= 2, "got {} slides", r.deck.slides.len());
179 let titles: Vec<String> = r.deck.slides.iter()
180 .map(|s| s.title.as_ref().map(|t| text_of(t)).unwrap_or_default())
181 .collect();
182 // The fixture is LibreOffice's re-save, which DROPS `presentation:class`. Where no frame
183 // claims to be the title the first one is taken as it, which is what a person looking at the
184 // slide sees -- so a re-saved deck still reads with its titles.
185 assert_eq!(titles[0], "A Report On Something", "got {:?}", titles);
186 assert!(r.deck.slides[1].text_of().contains("First bullet"));
187 Ok(())
188 }));
189
190 res!(test_it(filter, &["What we write reads back as what we put in 005", "all", "odf"], || {
191 let doc = res!(markdown::parse(SOURCE));
192 let (odt, left) = res!(odf::text::write(&doc));
193 assert!(left.is_empty());
194 let back = res!(odf::text::read(&odt));
195 let md = oxedyne_fe2o3_text::doc::markdown::write::render(&back.doc);
196 for phrase in ["# A Report", "## The second heading", "**bold**", "- First bullet",
197 "> A quotation.", "[link](https://example.com/page)", " println!"] {
198 assert!(md.contains(phrase), "the round trip lost {:?}\n{}", phrase, md);
199 }
200 let ods = res!(odf::sheet::write(&book()));
201 let sback = res!(odf::sheet::read(&ods));
202 let s = res!(sback.book.sheets.first().ok_or_else(|| err!("no sheet"; Missing)));
203 assert_eq!(s.at(&res!(Ref::parse("C2"))).value, Value::Number(408.0));
204 assert_eq!(s.at(&res!(Ref::parse("A4"))).value, Value::Date("2026-03-14".to_string()));
205 // Written twice, the same bytes: nothing here comes from the clock.
206 assert_eq!(ods, res!(odf::sheet::write(&book())));
207 let (odp, _) = res!(odf::slides::write(&Deck::from_doc(&doc)));
208 let dback = res!(odf::slides::read(&odp));
209 assert_eq!(dback.deck.slides.len(), Deck::from_doc(&doc).slides.len());
210 Ok(())
211 }));
212
213 res!(test_it(filter, &["A package that is not one is refused 006", "all", "odf"], || {
214 assert!(odf::text::read(b"not a package").is_err());
215 // A ZIP with no content.xml is not an OpenDocument file.
216 let mut zip = Zip::new();
217 zip.set("hello.txt", b"hi".to_vec(), Method::Store);
218 let bytes = res!(zip.write());
219 assert!(odf::text::read(&bytes).is_err());
220 assert!(odf::sheet::read(&bytes).is_err());
221 assert!(odf::slides::read(&bytes).is_err());
222 // And a text document is not a spreadsheet, however well formed it is.
223 let doc = res!(markdown::parse(SOURCE));
224 let (odt, _) = res!(odf::text::write(&doc));
225 assert!(odf::sheet::read(&odt).is_err(), "an .odt is not an .ods");
226 Ok(())
227 }));
228
229 res!(test_it(filter, &["meta.xml carries no attribute the grammar forbids 010", "all", "odf"], || {
230 // `office:mimetype` was on `office:document-meta` in all three writers, because all three go
231 // through one `pkg::meta`. The OASIS grammar defines that attribute in `office-document-attrs`
232 // and the ONLY element referring to those is `office:document`, the root of the flat
233 // single-file form -- which has no `mimetype` member to carry the fact instead. A package does
234 // have one, so the attribute is not merely forbidden here, it is redundant.
235 let doc = res!(markdown::parse(SOURCE));
236 let (odt, _) = res!(odf::text::write(&doc));
237 let ods = res!(odf::sheet::write(&book()));
238 let (odp, _) = res!(odf::slides::write(&Deck::from_doc(&doc)));
239 for (what, bytes, media) in [
240 ("odt", &odt, odf::text::MEDIA),
241 ("ods", &ods, odf::sheet::MEDIA),
242 ("odp", &odp, odf::slides::MEDIA),
243 ] {
244 let zip = res!(Zip::read(bytes.clone()));
245 let meta = res!(zip.text("meta.xml"));
246 assert!(!meta.contains("office:mimetype"),
247 "{}: meta.xml still carries office:mimetype: {}", what, meta);
248 // `office:version` is required on it and must not be lost with the other one.
249 assert!(meta.contains("office:version"), "{}: meta.xml lost its version", what);
250 // And the package still says what it is, in the member that is allowed to.
251 assert_eq!(res!(zip.text("mimetype")), media,
252 "{}: the package no longer declares its own type", what);
253 }
254 Ok(())
255 }));
256
257 res!(test_it(filter, &["styles.xml puts its children in the order required 011", "all", "odf"], || {
258 // `office:document-styles` is a SEQUENCE, not a set: font-face-decls, then styles, then
259 // automatic-styles, then master-styles. The presentation branch opened automatic-styles first,
260 // so an `.odp` came out with two of them the wrong way round. `.odt` and `.ods` take neither
261 // branch and were always in order, which is why only the deck was wrong -- so all three are
262 // checked here rather than the one that broke.
263 const ORDER: [&str; 4] = ["office:font-face-decls", "office:styles",
264 "office:automatic-styles", "office:master-styles"];
265 let doc = res!(markdown::parse(SOURCE));
266 let (odt, _) = res!(odf::text::write(&doc));
267 let ods = res!(odf::sheet::write(&book()));
268 let (odp, _) = res!(odf::slides::write(&Deck::from_doc(&doc)));
269 for (what, bytes) in [("odt", &odt), ("ods", &ods), ("odp", &odp)] {
270 let zip = res!(Zip::read(bytes.clone()));
271 let styles = res!(zip.text("styles.xml"));
272 // Only the ones this document actually has, in the order it has them: the sequence permits
273 // each to be absent, so a missing element is not a fault and an out-of-order pair is.
274 let mut seen: Vec<(usize, &str)> = Vec::new();
275 for name in ORDER {
276 if let Some(at) = styles.find(&fmt!("<{}", name)) {
277 seen.push((at, name));
278 }
279 }
280 let mut sorted = seen.clone();
281 sorted.sort_by_key(|(at, _)| *at);
282 assert_eq!(sorted, seen,
283 "{}: styles.xml has its children out of order -- found {:?}, the sequence wants {:?}",
284 what, sorted.iter().map(|(_, n)| *n).collect::<Vec<_>>(),
285 seen.iter().map(|(_, n)| *n).collect::<Vec<_>>());
286 }
287 // And the deck is the one that has to have all three, since its master page is the whole reason
288 // the branch exists: a check that passed by finding only `office:styles` would prove nothing.
289 let zip = res!(Zip::read(odp.clone()));
290 let styles = res!(zip.text("styles.xml"));
291 for name in ["office:styles", "office:automatic-styles", "office:master-styles"] {
292 assert!(styles.contains(&fmt!("<{}", name)), "the .odp lost its {}", name);
293 }
294 Ok(())
295 }));
296
297 Ok(())
298}