Oregami
Repositories/oxedyne/fe2o3

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
17use crate::office::deck::Deck;
18
19#[cfg(feature = "deck-write")]
20use crate::office::deck::MAX_LEVEL;
21#[cfg(feature = "deck-write")]
22use 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")]
38use 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")]
48use crate::zip::{
49 Method,
50 Zip,
51};
52
53use oxedyne_fe2o3_core::prelude::*;
54#[cfg(feature = "deck-write")]
55use oxedyne_fe2o3_text::doc::Inline;
56#[cfg(feature = "deck-write")]
57use oxedyne_fe2o3_text::xml::write::Out;
58
59/// What a created deck could not carry.
60#[derive(Clone, Debug, Default, PartialEq)]
61pub 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
69impl 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")]
83pub 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"))]
171pub 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")]
179fn 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")]
229fn 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<()>
243where
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")]
277fn 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)]
293struct 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")]
301fn 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")]
332fn 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}