oxedyne/fe2o3/fe2o3_file/src/office/pptx/write.rs
11.8 KiB, 44 runs
created by r1870400018:22880, 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 | //! Creating a `.pptx` from the neutral deck. |
| 2 | //! |
| 3 | //! Nine parts for one slide and one more for each slide after it. The nine are the chain a slide |
| 4 | //! hangs from -- presentation, master, layout, theme, and a relationship part for each -- and |
| 5 | //! [`super::parts`] writes the ones that are not content. |
| 6 | //! |
| 7 | //! # What a slide is, once the skeleton is out of the way |
| 8 | //! |
| 9 | //! Two shapes: a title placeholder and a body placeholder. The body's paragraphs carry an indent |
| 10 | //! level and nothing else, so a bullet three deep is `<a:pPr lvl="2">` and the layout decides what |
| 11 | //! that looks like. That is the whole mapping, and it is small because [`crate::office::deck`] is |
| 12 | //! small on purpose. |
| 13 | //! |
| 14 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 15 | //! Anthropic Claude |
| 16 | |
| 17 | use crate::office::deck::Deck; |
| 18 | |
| 19 | #[cfg(feature = "deck-write")] |
| 20 | use crate::office::deck::MAX_LEVEL; |
| 21 | #[cfg(feature = "deck-write")] |
| 22 | use crate::office::opc::{ |
| 23 | CT_LAYOUT, |
| 24 | CT_MASTER, |
| 25 | CT_PRESENTATION, |
| 26 | CT_SLIDE, |
| 27 | CT_THEME, |
| 28 | NS_R, |
| 29 | REL_DOC, |
| 30 | REL_LAYOUT, |
| 31 | REL_MASTER, |
| 32 | REL_SLIDE, |
| 33 | REL_THEME, |
| 34 | Rels, |
| 35 | Types, |
| 36 | }; |
| 37 | #[cfg(feature = "deck-write")] |
| 38 | use crate::office::pptx::{ |
| 39 | MARGIN, |
| 40 | NS_A, |
| 41 | NS_P, |
| 42 | SLIDE_H, |
| 43 | SLIDE_W, |
| 44 | TITLE_H, |
| 45 | parts, |
| 46 | }; |
| 47 | #[cfg(feature = "deck-write")] |
| 48 | use crate::zip::{ |
| 49 | Method, |
| 50 | Zip, |
| 51 | }; |
| 52 | |
| 53 | use oxedyne_fe2o3_core::prelude::*; |
| 54 | #[cfg(feature = "deck-write")] |
| 55 | use oxedyne_fe2o3_text::doc::Inline; |
| 56 | #[cfg(feature = "deck-write")] |
| 57 | use oxedyne_fe2o3_text::xml::write::Out; |
| 58 | |
| 59 | /// What a created deck could not carry. |
| 60 | #[derive(Clone, Debug, Default, PartialEq)] |
| 61 | pub struct Left { |
| 62 | pub images: Vec<String>, // by the source each was written with |
| 63 | // Slides carrying speaker's notes that were not written. A notes slide needs a notes master and a |
| 64 | // notes layout, which is the whole skeleton again for content nobody sees on screen. Counted and |
| 65 | // said rather than written or silently dropped. |
| 66 | pub notes: usize, |
| 67 | } |
| 68 | |
| 69 | impl Left { |
| 70 | |
| 71 | /// Whether everything in the deck reached the file. |
| 72 | pub fn is_empty(&self) -> bool { |
| 73 | self.images.is_empty() && self.notes == 0 |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | /// Writes a deck as the bytes of a `.pptx`, and says what did not fit. |
| 78 | /// |
| 79 | /// Behind the `deck-write` feature, which is on by default. Compiled without it the signature is |
| 80 | /// unchanged and the call returns an error naming the feature, so a caller neither changes nor |
| 81 | /// silently gets an empty file. |
| 82 | #[cfg(feature = "deck-write")] |
| 83 | pub fn write(deck: &Deck) -> Outcome<(Vec<u8>, Left)> { |
| 84 | let mut owned; |
| 85 | // A presentation with no slides is a file PowerPoint opens and shows nothing in, which reads as |
| 86 | // a corrupt deck. One empty slide is honest about being empty. |
| 87 | let deck = match deck.slides.is_empty() { |
| 88 | false => deck, |
| 89 | true => { |
| 90 | owned = Deck::new(); |
| 91 | owned.slides.push(Default::default()); |
| 92 | &owned |
| 93 | } |
| 94 | }; |
| 95 | let mut left = Left::default(); |
| 96 | |
| 97 | let mut types = Types::new(); |
| 98 | types.over("/ppt/presentation.xml", CT_PRESENTATION); |
| 99 | types.over("/ppt/slideMasters/slideMaster1.xml", CT_MASTER); |
| 100 | types.over("/ppt/slideLayouts/slideLayout1.xml", CT_LAYOUT); |
| 101 | types.over("/ppt/theme/theme1.xml", CT_THEME); |
| 102 | |
| 103 | let mut root = Rels::new(); |
| 104 | let _ = root.add(REL_DOC, "ppt/presentation.xml"); |
| 105 | |
| 106 | // The presentation's relationships. The master comes FIRST, because `p:sldMasterIdLst` names it |
| 107 | // by id and a reader that found a slide there instead refuses the file. |
| 108 | let mut pres_rels = Rels::new(); |
| 109 | let master_id = pres_rels.add(REL_MASTER, "slideMasters/slideMaster1.xml"); |
| 110 | let mut slide_ids = Vec::with_capacity(deck.slides.len()); |
| 111 | for i in 0..deck.slides.len() { |
| 112 | slide_ids.push(pres_rels.add(REL_SLIDE, &fmt!("slides/slide{}.xml", i + 1))); |
| 113 | types.over(&fmt!("/ppt/slides/slide{}.xml", i + 1), CT_SLIDE); |
| 114 | } |
| 115 | let _ = pres_rels.add(REL_THEME, "theme/theme1.xml"); |
| 116 | |
| 117 | let mut pres = Out::declared(); |
| 118 | pres.open("p:presentation", &[("xmlns:a", NS_A), ("xmlns:r", NS_R), ("xmlns:p", NS_P)]); |
| 119 | pres.open("p:sldMasterIdLst", &[]); |
| 120 | pres.empty("p:sldMasterId", &[("id", "2147483648"), ("r:id", &master_id)]); |
| 121 | res!(pres.close("p:sldMasterIdLst")); |
| 122 | pres.open("p:sldIdLst", &[]); |
| 123 | for (i, id) in slide_ids.iter().enumerate() { |
| 124 | // Slide ids must be at least 256 and unique. Counting from 256 is what PowerPoint does. |
| 125 | pres.empty("p:sldId", &[("id", &fmt!("{}", 256 + i)), ("r:id", id)]); |
| 126 | } |
| 127 | res!(pres.close("p:sldIdLst")); |
| 128 | pres.empty("p:sldSz", &[("cx", &fmt!("{}", SLIDE_W)), ("cy", &fmt!("{}", SLIDE_H))]); |
| 129 | // The notes page is a different size from the slide, and the element is required even by a deck |
| 130 | // that carries no notes. |
| 131 | pres.empty("p:notesSz", &[("cx", &fmt!("{}", SLIDE_H)), ("cy", &fmt!("{}", SLIDE_W))]); |
| 132 | res!(pres.close("p:presentation")); |
| 133 | |
| 134 | // The master points at its one layout and at the theme; the layout points back at the master. |
| 135 | // A chain with a link missing is a file PowerPoint offers to repair rather than open. |
| 136 | let mut master_rels = Rels::new(); |
| 137 | let _ = master_rels.add(REL_LAYOUT, "../slideLayouts/slideLayout1.xml"); |
| 138 | let _ = master_rels.add(REL_THEME, "../theme/theme1.xml"); |
| 139 | let mut layout_rels = Rels::new(); |
| 140 | let _ = layout_rels.add(REL_MASTER, "../slideMasters/slideMaster1.xml"); |
| 141 | |
| 142 | let mut zip = Zip::new(); |
| 143 | zip.set("[Content_Types].xml", res!(types.write()).into_bytes(), Method::Deflate); |
| 144 | zip.set("_rels/.rels", res!(root.write()).into_bytes(), Method::Deflate); |
| 145 | zip.set("ppt/presentation.xml", res!(pres.finish()).into_bytes(), Method::Deflate); |
| 146 | zip.set("ppt/_rels/presentation.xml.rels", res!(pres_rels.write()).into_bytes(), Method::Deflate); |
| 147 | zip.set("ppt/slideMasters/slideMaster1.xml", res!(parts::master()).into_bytes(), Method::Deflate); |
| 148 | zip.set("ppt/slideMasters/_rels/slideMaster1.xml.rels", |
| 149 | res!(master_rels.write()).into_bytes(), Method::Deflate); |
| 150 | zip.set("ppt/slideLayouts/slideLayout1.xml", res!(parts::layout()).into_bytes(), Method::Deflate); |
| 151 | zip.set("ppt/slideLayouts/_rels/slideLayout1.xml.rels", |
| 152 | res!(layout_rels.write()).into_bytes(), Method::Deflate); |
| 153 | zip.set("ppt/theme/theme1.xml", res!(parts::theme()).into_bytes(), Method::Deflate); |
| 154 | for (i, slide) in deck.slides.iter().enumerate() { |
| 155 | if slide.notes.is_some() { |
| 156 | left.notes += 1; |
| 157 | } |
| 158 | let part = res!(slide_part(slide, &mut left)); |
| 159 | zip.set(&fmt!("ppt/slides/slide{}.xml", i + 1), part.into_bytes(), Method::Deflate); |
| 160 | // Every slide names the layout it hangs from, in its own relationship part. |
| 161 | let mut rels = Rels::new(); |
| 162 | let _ = rels.add(REL_LAYOUT, "../slideLayouts/slideLayout1.xml"); |
| 163 | zip.set(&fmt!("ppt/slides/_rels/slide{}.xml.rels", i + 1), |
| 164 | res!(rels.write()).into_bytes(), Method::Deflate); |
| 165 | } |
| 166 | Ok((res!(zip.write()), left)) |
| 167 | } |
| 168 | |
| 169 | /// The same function, in a build compiled without `deck-write`. |
| 170 | #[cfg(not(feature = "deck-write"))] |
| 171 | pub fn write(_deck: &Deck) -> Outcome<(Vec<u8>, Left)> { |
| 172 | Err(err!("This build cannot write a .pptx. Creating one needs a slide master, a slide layout \ |
| 173 | and a theme, which are generated behind the 'deck-write' feature of \ |
| 174 | oxedyne_fe2o3_file, and that feature is off here. Reading a .pptx is unaffected."; |
| 175 | Unimplemented)) |
| 176 | } |
| 177 | |
| 178 | #[cfg(feature = "deck-write")] |
| 179 | fn slide_part(slide: &crate::office::deck::Slide, left: &mut Left) -> Outcome<String> { |
| 180 | let mut out = Out::declared(); |
| 181 | out.open("p:sld", &[("xmlns:a", NS_A), ("xmlns:r", NS_R), ("xmlns:p", NS_P)]); |
| 182 | out.open("p:cSld", &[]); |
| 183 | out.open("p:spTree", &[]); |
| 184 | out.open("p:nvGrpSpPr", &[]); |
| 185 | out.empty("p:cNvPr", &[("id", "1"), ("name", "")]); |
| 186 | out.empty("p:cNvGrpSpPr", &[]); |
| 187 | out.empty("p:nvPr", &[]); |
| 188 | res!(out.close("p:nvGrpSpPr")); |
| 189 | out.open("p:grpSpPr", &[]); |
| 190 | res!(out.close("p:grpSpPr")); |
| 191 | |
| 192 | res!(shape(&mut out, 2, "Title", "title", None, MARGIN, MARGIN, SLIDE_W - 2 * MARGIN, TITLE_H, |
| 193 | |out, left| { |
| 194 | match &slide.title { |
| 195 | Some(t) => para(out, t, 0, left), |
| 196 | None => { |
| 197 | out.open("a:p", &[]); |
| 198 | res!(out.close("a:p")); |
| 199 | Ok(()) |
| 200 | } |
| 201 | } |
| 202 | }, left)); |
| 203 | |
| 204 | res!(shape(&mut out, 3, "Body", "body", Some("1"), |
| 205 | MARGIN, MARGIN + TITLE_H, SLIDE_W - 2 * MARGIN, SLIDE_H - TITLE_H - 2 * MARGIN, |
| 206 | |out, left| { |
| 207 | if slide.bullets.is_empty() { |
| 208 | out.open("a:p", &[]); |
| 209 | res!(out.close("a:p")); |
| 210 | return Ok(()); |
| 211 | } |
| 212 | for b in &slide.bullets { |
| 213 | res!(para(out, &b.content, b.level.min(MAX_LEVEL), left)); |
| 214 | } |
| 215 | Ok(()) |
| 216 | }, left)); |
| 217 | |
| 218 | res!(out.close("p:spTree")); |
| 219 | res!(out.close("p:cSld")); |
| 220 | out.open("p:clrMapOvr", &[]); |
| 221 | out.empty("a:masterClrMapping", &[]); |
| 222 | res!(out.close("p:clrMapOvr")); |
| 223 | res!(out.close("p:sld")); |
| 224 | out.finish() |
| 225 | } |
| 226 | |
| 227 | /// One placeholder shape on a slide, with its body written by the caller. |
| 228 | #[cfg(feature = "deck-write")] |
| 229 | fn shape<F>( |
| 230 | out: &mut Out, |
| 231 | id: u32, |
| 232 | name: &str, |
| 233 | kind: &str, |
| 234 | idx: Option<&str>, |
| 235 | x: i64, |
| 236 | y: i64, |
| 237 | cx: i64, |
| 238 | cy: i64, |
| 239 | body: F, |
| 240 | left: &mut Left, |
| 241 | ) |
| 242 | -> Outcome<()> |
| 243 | where |
| 244 | F: FnOnce(&mut Out, &mut Left) -> Outcome<()>, |
| 245 | { |
| 246 | out.open("p:sp", &[]); |
| 247 | out.open("p:nvSpPr", &[]); |
| 248 | out.empty("p:cNvPr", &[("id", &fmt!("{}", id)), ("name", name)]); |
| 249 | out.open("p:cNvSpPr", &[]); |
| 250 | out.empty("a:spLocks", &[("noGrp", "1")]); |
| 251 | res!(out.close("p:cNvSpPr")); |
| 252 | out.open("p:nvPr", &[]); |
| 253 | match idx { |
| 254 | Some(i) => out.empty("p:ph", &[("type", kind), ("idx", i)]), |
| 255 | None => out.empty("p:ph", &[("type", kind)]), |
| 256 | } |
| 257 | res!(out.close("p:nvPr")); |
| 258 | res!(out.close("p:nvSpPr")); |
| 259 | out.open("p:spPr", &[]); |
| 260 | out.open("a:xfrm", &[]); |
| 261 | out.empty("a:off", &[("x", &fmt!("{}", x)), ("y", &fmt!("{}", y))]); |
| 262 | out.empty("a:ext", &[("cx", &fmt!("{}", cx)), ("cy", &fmt!("{}", cy))]); |
| 263 | res!(out.close("a:xfrm")); |
| 264 | out.empty("a:prstGeom", &[("prst", "rect")]); |
| 265 | res!(out.close("p:spPr")); |
| 266 | out.open("p:txBody", &[]); |
| 267 | out.empty("a:bodyPr", &[("wrap", "square")]); |
| 268 | out.empty("a:lstStyle", &[]); |
| 269 | res!(body(out, left)); |
| 270 | res!(out.close("p:txBody")); |
| 271 | res!(out.close("p:sp")); |
| 272 | Ok(()) |
| 273 | } |
| 274 | |
| 275 | /// One paragraph of a text body, at an indent level. |
| 276 | #[cfg(feature = "deck-write")] |
| 277 | fn para(out: &mut Out, content: &[Inline], level: usize, left: &mut Left) -> Outcome<()> { |
| 278 | match level { |
| 279 | 0 => out.open("a:p", &[]), |
| 280 | n => { |
| 281 | out.open("a:p", &[]); |
| 282 | out.empty("a:pPr", &[("lvl", &fmt!("{}", n))]); |
| 283 | } |
| 284 | } |
| 285 | res!(runs(out, content, Fmt::default(), left)); |
| 286 | res!(out.close("a:p")); |
| 287 | Ok(()) |
| 288 | } |
| 289 | |
| 290 | /// How a run of text on a slide is marked. |
| 291 | #[cfg(feature = "deck-write")] |
| 292 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 293 | struct Fmt { |
| 294 | bold: bool, |
| 295 | italic: bool, |
| 296 | code: bool, // a span of code, which on a slide is a monospaced run |
| 297 | link: bool, // part of a link |
| 298 | } |
| 299 | |
| 300 | #[cfg(feature = "deck-write")] |
| 301 | fn runs(out: &mut Out, content: &[Inline], fmt: Fmt, left: &mut Left) -> Outcome<()> { |
| 302 | for item in content { |
| 303 | match item { |
| 304 | Inline::Text(t) => res!(run(out, t, fmt)), |
| 305 | Inline::Code(t) => res!(run(out, t, Fmt { code: true, ..fmt })), |
| 306 | Inline::Emph { strong, content } => { |
| 307 | let fmt = match strong { |
| 308 | true => Fmt { bold: true, ..fmt }, |
| 309 | false => Fmt { italic: true, ..fmt }, |
| 310 | }; |
| 311 | res!(runs(out, content, fmt, left)); |
| 312 | } |
| 313 | // A link on a slide is a relationship in the SLIDE's own rels part, and a generated |
| 314 | // deck has no reader that would follow one. The text stands and is marked, which is |
| 315 | // what a person reading the slide gets from it either way. |
| 316 | Inline::Link { content, .. } => { |
| 317 | res!(runs(out, content, Fmt { link: true, ..fmt }, left)) |
| 318 | } |
| 319 | Inline::Image { src, alt } => { |
| 320 | left.images.push(src.clone()); |
| 321 | res!(run(out, alt, Fmt { italic: true, ..fmt })); |
| 322 | } |
| 323 | Inline::Span { content, .. } => res!(runs(out, content, fmt, left)), |
| 324 | // A slide has no soft break worth keeping: the shape wraps. |
| 325 | Inline::Break => res!(run(out, " ", fmt)), |
| 326 | } |
| 327 | } |
| 328 | Ok(()) |
| 329 | } |
| 330 | |
| 331 | #[cfg(feature = "deck-write")] |
| 332 | fn run(out: &mut Out, text: &str, fmt: Fmt) -> Outcome<()> { |
| 333 | if text.is_empty() { |
| 334 | return Ok(()); |
| 335 | } |
| 336 | out.open("a:r", &[]); |
| 337 | let mut attrs: Vec<(&str, &str)> = vec![("lang", "en-AU"), ("dirty", "0")]; |
| 338 | if fmt.bold { |
| 339 | attrs.push(("b", "1")); |
| 340 | } |
| 341 | if fmt.italic { |
| 342 | attrs.push(("i", "1")); |
| 343 | } |
| 344 | if fmt.link { |
| 345 | attrs.push(("u", "sng")); |
| 346 | } |
| 347 | match fmt.code { |
| 348 | false => out.empty("a:rPr", &attrs), |
| 349 | true => { |
| 350 | out.open("a:rPr", &attrs); |
| 351 | out.empty("a:latin", &[("typeface", "Consolas")]); |
| 352 | res!(out.close("a:rPr")); |
| 353 | } |
| 354 | } |
| 355 | out.leaf("a:t", &[], text); |
| 356 | res!(out.close("a:r")); |
| 357 | Ok(()) |
| 358 | } |