oxedyne/fe2o3/fe2o3_file/tests/pptx.rs
9.4 KiB, 14 runs
created by r1870400018:22886, 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 | |
| 4 | use oxedyne_fe2o3_file::office::deck::{ |
| 5 | Deck, |
| 6 | Slide, |
| 7 | }; |
| 8 | use oxedyne_fe2o3_file::office::pptx; |
| 9 | |
| 10 | use oxedyne_fe2o3_core::{ |
| 11 | prelude::*, |
| 12 | test::test_it, |
| 13 | }; |
| 14 | use oxedyne_fe2o3_text::doc::{ |
| 15 | markdown, |
| 16 | text_of, |
| 17 | }; |
| 18 | |
| 19 | // A `.pptx` LibreOffice wrote: it was handed a deck this crate made and asked to save its own, so |
| 20 | // the content is known and every byte of the encoding -- and of the skeleton, which is where all the |
| 21 | // risk lives -- is somebody else's. |
| 22 | // |
| 23 | // Its slide relationship ids run `rId3`, `rId4`, `rId5`, which is the case that catches a reader |
| 24 | // assuming the first relationship is the first slide. |
| 25 | const FOREIGN: &[u8] = include_bytes!("data/foreign.pptx"); |
| 26 | |
| 27 | // Prose with three headings, nested bullets and a listing. |
| 28 | const SOURCE: &str = "\ |
| 29 | # Quarterly Review |
| 30 | |
| 31 | An opening line with **bold words** and *emphasis*. |
| 32 | |
| 33 | - First finding |
| 34 | - Second finding |
| 35 | - A nested point |
| 36 | |
| 37 | ## Numbers |
| 38 | |
| 39 | 1. Revenue rose |
| 40 | 2. Costs held |
| 41 | |
| 42 | ```rust |
| 43 | fn main() {} |
| 44 | ``` |
| 45 | |
| 46 | ## Closing |
| 47 | |
| 48 | A final line. |
| 49 | "; |
| 50 | |
| 51 | // The parts a `.pptx` cannot open without: the chain a slide hangs from. |
| 52 | const REQUIRED: [&str; 7] = [ |
| 53 | "[Content_Types].xml", |
| 54 | "_rels/.rels", |
| 55 | "ppt/presentation.xml", |
| 56 | "ppt/_rels/presentation.xml.rels", |
| 57 | "ppt/slideMasters/slideMaster1.xml", |
| 58 | "ppt/slideLayouts/slideLayout1.xml", |
| 59 | "ppt/theme/theme1.xml", |
| 60 | ]; |
| 61 | |
| 62 | pub fn test_pptx(filter: &'static str) -> Outcome<()> { |
| 63 | |
| 64 | res!(test_it(filter, &["Every heading starts a slide 000", "all", "pptx"], || { |
| 65 | // This split at the SHALLOWEST heading level first, borrowing a rule from `Doc::top_heading` |
| 66 | // that is right for finding a title and wrong for ending a slide: the document below came out |
| 67 | // as ONE slide holding everything, and the author who wrote three headings expected three. |
| 68 | let doc = res!(markdown::parse(SOURCE)); |
| 69 | let deck = Deck::from_doc(&doc); |
| 70 | assert_eq!(deck.slides.len(), 3, "one slide per heading"); |
| 71 | let titles: Vec<String> = deck.slides.iter() |
| 72 | .map(|s| s.title.as_ref().map(|t| text_of(t)).unwrap_or_default()) |
| 73 | .collect(); |
| 74 | assert_eq!(titles, vec!["Quarterly Review", "Numbers", "Closing"]); |
| 75 | // The nesting of a list becomes the indent level of a bullet, and nothing else does. |
| 76 | let levels: Vec<usize> = deck.slides[0].bullets.iter().map(|b| b.level).collect(); |
| 77 | assert_eq!(levels, vec![0, 0, 0, 1], "got {:?}", levels); |
| 78 | // A listing goes on as its lines rather than being dropped: a deck generated from a |
| 79 | // technical document is mostly listings. |
| 80 | assert!(deck.slides[1].text_of().contains("fn main() {}"), "{}", deck.slides[1].text_of()); |
| 81 | Ok(()) |
| 82 | })); |
| 83 | |
| 84 | res!(test_it(filter, &["A created deck carries the chain a slide hangs from 001", "all", "pptx"], || { |
| 85 | // The whole of the risk in this format. A slide points at a layout, a layout at a master, a |
| 86 | // master at a theme, and PowerPoint refuses a file with any link missing -- with a repair |
| 87 | // prompt that names neither the part nor the reason. |
| 88 | let doc = res!(markdown::parse(SOURCE)); |
| 89 | let (bytes, left) = res!(pptx::write(&Deck::from_doc(&doc))); |
| 90 | assert!(left.is_empty(), "this source holds no image and no notes"); |
| 91 | let zip = res!(oxedyne_fe2o3_file::zip::Zip::read(bytes.clone())); |
| 92 | for part in REQUIRED { |
| 93 | assert!(zip.has(part), "the package is missing {}", part); |
| 94 | } |
| 95 | for i in 1..=3 { |
| 96 | assert!(zip.has(&fmt!("ppt/slides/slide{}.xml", i))); |
| 97 | // Each slide names its layout in its OWN rels part. A slide with none is a slide with no |
| 98 | // geometry to inherit. |
| 99 | assert!(zip.has(&fmt!("ppt/slides/_rels/slide{}.xml.rels", i))); |
| 100 | } |
| 101 | // The theme must carry three of each style, whether or not anything uses them. |
| 102 | let theme = res!(zip.text("ppt/theme/theme1.xml")); |
| 103 | for (list, n) in [("a:fillStyleLst", 3), ("a:lnStyleLst", 3), ("a:effectStyleLst", 3)] { |
| 104 | let xml = res!(oxedyne_fe2o3_text::xml::Xml::parse(&theme)); |
| 105 | let found = res!(xml.root()).all(list); |
| 106 | assert_eq!(found.len(), 1, "{} appears once", list); |
| 107 | assert!(found[0].elems().count() >= n, "{} needs {} entries", list, n); |
| 108 | } |
| 109 | // And the master must map scheme colours to roles, or the deck comes out white on white. |
| 110 | let master = res!(zip.text("ppt/slideMasters/slideMaster1.xml")); |
| 111 | assert!(master.contains("p:clrMap"), "the colour map is written explicitly"); |
| 112 | // Written twice, the same bytes. |
| 113 | let (again, _) = res!(pptx::write(&Deck::from_doc(&doc))); |
| 114 | assert_eq!(bytes, again); |
| 115 | // And it survives the archive's round trip. |
| 116 | assert_eq!(res!(zip.write()), bytes); |
| 117 | Ok(()) |
| 118 | })); |
| 119 | |
| 120 | res!(test_it(filter, &["A foreign deck reads back in the right order 002", "all", "pptx"], || { |
| 121 | // Slide ORDER lives in `p:sldIdLst`, not in the file names: `slide10.xml` sorts before |
| 122 | // `slide2.xml` and is the ninth slide. This fixture's slide relationships start at `rId3`, |
| 123 | // because the master and the theme took the first two, so a reader that assumed the first |
| 124 | // relationship was the first slide deals the whole deck wrong. |
| 125 | let r = res!(pptx::read(FOREIGN)); |
| 126 | assert_eq!(r.deck.slides.len(), 3); |
| 127 | let titles: Vec<String> = r.deck.slides.iter() |
| 128 | .map(|s| s.title.as_ref().map(|t| text_of(t)).unwrap_or_default()) |
| 129 | .collect(); |
| 130 | assert_eq!(titles, vec!["Quarterly Review", "Numbers", "Closing"], "got {:?}", titles); |
| 131 | // A title is a title because of the PLACEHOLDER TYPE on its shape, not because of where it |
| 132 | // sits or how large it is. |
| 133 | assert!(r.deck.slides[0].title.is_some()); |
| 134 | let first = r.deck.slides[0].text_of(); |
| 135 | assert!(first.contains("An opening line"), "{}", first); |
| 136 | assert!(first.contains("A nested point"), "{}", first); |
| 137 | assert!(r.deck.slides[1].text_of().contains("Revenue rose")); |
| 138 | assert!(!r.macros); |
| 139 | assert!(r.missing.is_empty()); |
| 140 | Ok(()) |
| 141 | })); |
| 142 | |
| 143 | res!(test_it(filter, &["A deck we wrote reads back as what we put in 003", "all", "pptx"], || { |
| 144 | let doc = res!(markdown::parse(SOURCE)); |
| 145 | let deck = Deck::from_doc(&doc); |
| 146 | let (bytes, _) = res!(pptx::write(&deck)); |
| 147 | let back = res!(pptx::read(&bytes)); |
| 148 | assert_eq!(back.deck.slides.len(), deck.slides.len()); |
| 149 | for (a, b) in deck.slides.iter().zip(&back.deck.slides) { |
| 150 | assert_eq!(text_of(res!(a.title.as_ref().ok_or_else(|| err!("no title"; Missing)))), |
| 151 | text_of(res!(b.title.as_ref().ok_or_else(|| err!("no title back"; Missing))))); |
| 152 | assert_eq!(a.bullets.len(), b.bullets.len(), "slide bullet count"); |
| 153 | let levels_a: Vec<usize> = a.bullets.iter().map(|x| x.level).collect(); |
| 154 | let levels_b: Vec<usize> = b.bullets.iter().map(|x| x.level).collect(); |
| 155 | assert_eq!(levels_a, levels_b, "the indent levels survive"); |
| 156 | } |
| 157 | Ok(()) |
| 158 | })); |
| 159 | |
| 160 | res!(test_it(filter, &["What a deck could not carry is counted 004", "all", "pptx"], || { |
| 161 | // Speaker's notes need a notes master and a notes layout -- the whole skeleton again, for |
| 162 | // content nobody sees on screen. Counted and said rather than written or silently dropped. |
| 163 | let mut deck = Deck::new(); |
| 164 | let mut s = Slide::titled("With notes"); |
| 165 | s.notes = Some("Remember to pause here.".to_string()); |
| 166 | deck.slides.push(s); |
| 167 | let (_, left) = res!(pptx::write(&deck)); |
| 168 | assert_eq!(left.notes, 1); |
| 169 | assert!(!left.is_empty()); |
| 170 | // A deck with no slides gets one empty slide: a presentation holding none is a file that |
| 171 | // opens and shows nothing, which reads as corruption. |
| 172 | let (bytes, _) = res!(pptx::write(&Deck::new())); |
| 173 | let back = res!(pptx::read(&bytes)); |
| 174 | assert_eq!(back.deck.slides.len(), 1); |
| 175 | Ok(()) |
| 176 | })); |
| 177 | |
| 178 | res!(test_it(filter, &["A deck that cannot be read is named 005", "all", "pptx"], || { |
| 179 | let ole = [0xD0u8, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0, 0, 0, 0, 0, 0, 0]; |
| 180 | match pptx::read(&ole) { |
| 181 | Err(e) => { |
| 182 | let s = fmt!("{}", e); |
| 183 | assert!(s.contains("OLE compound file"), "{}", s); |
| 184 | } |
| 185 | Ok(_) => panic!("an OLE file was read"), |
| 186 | } |
| 187 | assert!(pptx::read(b"not a deck").is_err()); |
| 188 | let mut zip = oxedyne_fe2o3_file::zip::Zip::new(); |
| 189 | zip.set("hello.txt", b"hi".to_vec(), oxedyne_fe2o3_file::zip::Method::Store); |
| 190 | assert!(pptx::read(&res!(zip.write())).is_err()); |
| 191 | Ok(()) |
| 192 | })); |
| 193 | |
| 194 | res!(test_it(filter, &["A slide layout wears a type the schema has heard of 006", "all", "pptx"], || { |
| 195 | // `titleAndBody` stood here and is not one of these. The list is `ST_SlideLayoutType` from |
| 196 | // ECMA-376 Part 1 §19.7.15, copied out in full rather than compared against the writer's own |
| 197 | // constant -- a test that read the constant would agree with any value the writer chose, |
| 198 | // including the wrong one it had. LibreOffice converted the deck without complaint. |
| 199 | const LEGAL: [&str; 36] = [ |
| 200 | "title", "tx", "twoColTx", "tbl", "txAndChart", "chartAndTx", "dgm", "chart", |
| 201 | "txAndClipArt", "clipArtAndTx", "titleOnly", "blank", "txAndObj", "objAndTx", |
| 202 | "objOnly", "obj", "txAndMedia", "mediaAndTx", "objOverTx", "txOverObj", |
| 203 | "txAndTwoObj", "twoObjAndTx", "twoObjOverTx", "fourObj", "vertTx", |
| 204 | "clipArtAndVertTx", "vertTitleAndTx", "vertTitleAndTxOverChart", "twoObj", |
| 205 | "objAndTwoObj", "twoObjAndObj", "cust", "secHead", "twoTxTwoObj", "objTx", "picTx", |
| 206 | ]; |
| 207 | let doc = res!(markdown::parse(SOURCE)); |
| 208 | let (bytes, _) = res!(pptx::write(&Deck::from_doc(&doc))); |
| 209 | let zip = res!(oxedyne_fe2o3_file::zip::Zip::read(bytes)); |
| 210 | let xml = res!(zip.text("ppt/slideLayouts/slideLayout1.xml")); |
| 211 | let at = res!(xml.find("type=\"").ok_or_else(|| err!( |
| 212 | "The slide layout carries no type attribute at all."; Test, Missing))) + 6; |
| 213 | let rest = &xml[at..]; |
| 214 | let kind = res!(rest.split('"').next().ok_or_else(|| err!( |
| 215 | "The type attribute is unterminated."; Test, Invalid))); |
| 216 | assert!(LEGAL.contains(&kind), |
| 217 | "the layout's type=\"{}\" is not one of ST_SlideLayoutType's 36 members", kind); |
| 218 | Ok(()) |
| 219 | })); |
| 220 | |
| 221 | Ok(()) |
| 222 | } |