Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_file/src/office/odf/slides.rs

13.6 KiB, 55 runs

created by r1870400018:22910, 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//! `.odp`: an OpenDocument presentation, written from the neutral deck and read back into it.
2//!
3//! # A slide is a page of frames
4//!
5//! `<draw:page>` holds `<draw:frame presentation:class="title">` and
6//! `<draw:frame presentation:class="outline">`, and what makes a frame the title is that attribute
7//! rather than its position. The correspondence with PresentationML is close enough that the reader
8//! here and the one in [`crate::office::pptx`] answer the same questions of a different vocabulary.
9//!
10//! # A known limitation, measured rather than assumed
11//!
12//! The frames written here carry `presentation:class`, and a reader that re-saves the file **drops
13//! it**: LibreOffice turns them back into plain drawing boxes. The words, their order, their nesting
14//! and their positions all survive, and the deck renders correctly to PDF; what is lost is the
15//! outline view, which is the thing that knows a title from a bullet.
16//!
17//! Making them true placeholders needs the master page to carry the placeholder shapes and each
18//! frame to point at one, which is the PresentationML skeleton problem in another vocabulary. It is
19//! not done, it is said, and [`read`] is built so it does not matter to anything reading a deck:
20//! where no frame claims to be the title, the first one is taken as it, which is what a person
21//! looking at the slide sees anyway.
22//!
23//! # Bullet depth is nesting, not an attribute
24//!
25//! PresentationML writes `<a:pPr lvl="2">` on a paragraph. OpenDocument nests a `text:list` inside a
26//! `text:list-item` twice. The neutral deck carries a level either way, so the difference is confined
27//! to these two files -- which is what the neutral model is for.
28//!
29//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
30//! Anthropic Claude
31
32use crate::office::deck::{
33 Bullet,
34 Deck,
35 Slide,
36};
37
38#[cfg(feature = "deck-write")]
39use crate::office::deck::MAX_LEVEL;
40#[cfg(feature = "deck-write")]
41use crate::office::odf::{
42 NS_DRAW,
43 NS_FO,
44 NS_OFFICE,
45 NS_PRES,
46 NS_STYLE,
47 NS_SVG,
48 NS_TEXT,
49 NS_XLINK,
50 pkg,
51};
52#[cfg(feature = "deck-write")]
53use crate::zip::Method;
54use crate::zip::Zip;
55
56use oxedyne_fe2o3_core::prelude::*;
57use oxedyne_fe2o3_text::doc::Inline;
58use oxedyne_fe2o3_text::xml::{
59 Elem,
60 Xml,
61};
62#[cfg(feature = "deck-write")]
63use oxedyne_fe2o3_text::xml::write::Out;
64
65// Declared in the package's first member, which is what names the file.
66pub const MEDIA: &str = "application/vnd.oasis.opendocument.presentation";
67
68pub const MAX_PART: u64 = 64 * 1024 * 1024; // the most one part is inflated to
69
70#[cfg(feature = "deck-write")]
71const SLIDE_W: &str = "28cm"; // a length carries its unit
72#[cfg(feature = "deck-write")]
73const SLIDE_H: &str = "15.75cm";
74
75/// What a created deck could not carry.
76#[derive(Clone, Debug, Default, PartialEq)]
77pub struct Left {
78 pub images: Vec<String>, // those whose bytes could not be reached
79 pub notes: usize, // slides carrying speaker's notes that were not written
80}
81
82impl Left {
83
84 /// Did everything in the deck reach the file?
85 pub fn is_empty(&self) -> bool {
86 self.images.is_empty() && self.notes == 0
87 }
88}
89
90/// Writes a deck as the bytes of an `.odp`, and says what did not fit.
91///
92/// Behind the `deck-write` feature, which is on by default. Compiled without it the signature is
93/// unchanged and the call returns an error naming the feature.
94#[cfg(feature = "deck-write")]
95pub fn write(deck: &Deck) -> Outcome<(Vec<u8>, Left)> {
96 let mut owned;
97 let deck = match deck.slides.is_empty() {
98 false => deck,
99 true => {
100 owned = Deck::new();
101 owned.slides.push(Slide::default());
102 &owned
103 }
104 };
105 let mut left = Left::default();
106 let mut out = Out::declared();
107 out.open("office:document-content", &[
108 ("xmlns:office", NS_OFFICE),
109 ("xmlns:text", NS_TEXT),
110 ("xmlns:draw", NS_DRAW),
111 ("xmlns:presentation", NS_PRES),
112 ("xmlns:style", NS_STYLE),
113 ("xmlns:svg", NS_SVG),
114 ("xmlns:fo", NS_FO),
115 ("xmlns:xlink", NS_XLINK),
116 ("office:version", pkg::VERSION),
117 ]);
118 // The one bulleted list style every outline frame refers to.
119 out.open("office:automatic-styles", &[]);
120 out.open("text:list-style", &[("style:name", "LB")]);
121 for lvl in 1..=9 {
122 out.open("text:list-level-style-bullet", &[
123 ("text:level", &fmt!("{}", lvl)),
124 ("text:bullet-char", "\u{2022}"),
125 ]);
126 res!(out.close("text:list-level-style-bullet"));
127 }
128 res!(out.close("text:list-style"));
129 res!(out.close("office:automatic-styles"));
130 out.open("office:body", &[]);
131 out.open("office:presentation", &[]);
132 for (i, slide) in deck.slides.iter().enumerate() {
133 if slide.notes.is_some() {
134 left.notes += 1;
135 }
136 out.open("draw:page", &[
137 ("draw:name", &fmt!("page{}", i + 1)),
138 ("draw:master-page-name", "Default"),
139 ]);
140 if let Some(title) = &slide.title {
141 out.open("draw:frame", &[
142 ("presentation:class", "title"),
143 ("svg:x", "1cm"), ("svg:y", "1cm"),
144 ("svg:width", "26cm"), ("svg:height", "2.5cm"),
145 ]);
146 out.open("draw:text-box", &[]);
147 out.open("text:p", &[]);
148 res!(inlines(&mut out, title, &mut left));
149 res!(out.close("text:p"));
150 res!(out.close("draw:text-box"));
151 res!(out.close("draw:frame"));
152 }
153 if !slide.bullets.is_empty() {
154 out.open("draw:frame", &[
155 ("presentation:class", "outline"),
156 ("svg:x", "1cm"), ("svg:y", "4cm"),
157 ("svg:width", "26cm"), ("svg:height", "10cm"),
158 ]);
159 out.open("draw:text-box", &[]);
160 res!(bullets(&mut out, &slide.bullets, &mut left));
161 res!(out.close("draw:text-box"));
162 res!(out.close("draw:frame"));
163 }
164 res!(out.close("draw:page"));
165 }
166 res!(out.close("office:presentation"));
167 res!(out.close("office:body"));
168 res!(out.close("office:document-content"));
169
170 let mut zip = pkg::start(MEDIA);
171 zip.set("content.xml", res!(out.finish()).into_bytes(), Method::Deflate);
172 zip.set("styles.xml", res!(pkg::styles_for(MEDIA)).into_bytes(), Method::Deflate);
173 zip.set("meta.xml", res!(pkg::meta()).into_bytes(), Method::Deflate);
174 res!(pkg::finish(&mut zip, MEDIA));
175 let _ = (SLIDE_W, SLIDE_H);
176 Ok((res!(zip.write()), left))
177}
178
179/// The same function, in a build compiled without `deck-write`.
180#[cfg(not(feature = "deck-write"))]
181pub fn write(_deck: &Deck) -> Outcome<(Vec<u8>, Left)> {
182 Err(err!("This build cannot write an .odp. The OpenDocument presentation writer sits behind \
183 the 'deck-write' feature of oxedyne_fe2o3_file, and that feature is off here. Reading \
184 an .odp is unaffected."; Unimplemented))
185}
186
187/// Nests a list inside an item for each level of depth.
188#[cfg(feature = "deck-write")]
189fn bullets(out: &mut Out, items: &[Bullet], left: &mut Left) -> Outcome<()> {
190 res!(at_level(out, items, 0, &mut 0, left));
191 Ok(())
192}
193
194/// Writes the run of bullets at a level, descending where the next one is deeper.
195///
196/// The index is carried by reference because a nested call consumes items the caller must not write
197/// again -- which is the whole difficulty of turning a flat list of levels back into a tree.
198#[cfg(feature = "deck-write")]
199fn at_level(
200 out: &mut Out,
201 items: &[Bullet],
202 level: usize,
203 i: &mut usize,
204 left: &mut Left,
205)
206 -> Outcome<()>
207{
208 match level {
209 0 => out.open("text:list", &[("text:style-name", "LB")]),
210 _ => out.open("text:list", &[]),
211 }
212 while *i < items.len() {
213 let b = &items[*i];
214 let bl = b.level.min(MAX_LEVEL);
215 if bl < level {
216 break;
217 }
218 if bl > level {
219 // A deeper item belongs inside the one before it. An item to hang it from is opened
220 // where there was none, so a list that starts deep is still a list.
221 out.open("text:list-item", &[]);
222 res!(at_level(out, items, level + 1, i, left));
223 res!(out.close("text:list-item"));
224 continue;
225 }
226 *i += 1;
227 out.open("text:list-item", &[]);
228 out.open("text:p", &[]);
229 res!(inlines(out, &b.content, left));
230 res!(out.close("text:p"));
231 // Everything deeper that follows goes inside this item.
232 if items.get(*i).map(|n| n.level.min(MAX_LEVEL) > level).unwrap_or(false) {
233 res!(at_level(out, items, level + 1, i, left));
234 }
235 res!(out.close("text:list-item"));
236 }
237 res!(out.close("text:list"));
238 Ok(())
239}
240
241#[cfg(feature = "deck-write")]
242fn inlines(out: &mut Out, content: &[Inline], left: &mut Left) -> Outcome<()> {
243 for item in content {
244 match item {
245 Inline::Text(t) => out.text(t),
246 Inline::Code(t) => {
247 out.open("text:span", &[("text:style-name", "Source_20_Text")]);
248 out.text(t);
249 res!(out.close("text:span"));
250 }
251 Inline::Emph { strong, content } => {
252 let style = match strong {
253 true => "Strong_20_Emphasis",
254 false => "Emphasis",
255 };
256 out.open("text:span", &[("text:style-name", style)]);
257 res!(inlines(out, content, left));
258 res!(out.close("text:span"));
259 }
260 Inline::Link { to, content } => {
261 out.open("text:a", &[("xlink:href", to), ("xlink:type", "simple")]);
262 res!(inlines(out, content, left));
263 res!(out.close("text:a"));
264 }
265 Inline::Image { src, alt } => {
266 left.images.push(src.clone());
267 out.text(alt);
268 }
269 Inline::Span { content, .. } => res!(inlines(out, content, left)),
270 Inline::Break => out.empty("text:line-break", &[]),
271 }
272 }
273 Ok(())
274}
275
276#[derive(Clone, Debug, Default)]
277pub struct Reading {
278 pub deck: Deck,
279 pub macros: bool, // a macro project is present; said, never run
280 pub images: usize, // pictures held, which this does not draw
281}
282
283pub fn read(bytes: &[u8]) -> Outcome<Reading> {
284 let zip = res!(Zip::read(bytes.to_vec()));
285 let mut out = Reading::default();
286 out.macros = zip.names().iter().any(|n| n.starts_with("Basic/"));
287 let src = res!(String::from_utf8(res!(zip.content_capped("content.xml", MAX_PART))),
288 Decode, String);
289 let xml = res!(Xml::parse(&src));
290 let body = res!(res!(xml.root()).find(&["office:body", "office:presentation"])
291 .ok_or_else(|| err!(
292 "This package has no <office:presentation>, so it is not a deck.";
293 Invalid, Input, Missing)));
294 // The pages are in document order here, which is the one thing this format makes easier than
295 // PresentationML -- there is no id list to go through and so no order to get wrong.
296 for page in body.children("draw:page") {
297 out.deck.slides.push(slide_of(&xml, page, &mut out.images));
298 }
299 Ok(out)
300}
301
302fn slide_of(xml: &Xml, page: &Elem, images: &mut usize) -> Slide {
303 let mut slide = Slide::default();
304 // Where NO frame claims to be the title, the first one is taken as it. A deck whose frames were
305 // written as plain drawing boxes -- which is what a reader saves when a master page is missing --
306 // still has a title on every slide, and a reading view that showed it as a bullet would be
307 // disagreeing with what the person looking at the slide sees.
308 let classed = page.all("draw:frame").iter()
309 .any(|f| matches!(f.attr("presentation:class"), Some("title") | Some("subtitle")));
310 let mut first = true;
311 for frame in page.all("draw:frame") {
312 let class = frame.attr("presentation:class").unwrap_or("");
313 if class == "graphic" || frame.child("draw:image").is_some() {
314 *images += 1;
315 continue;
316 }
317 let boxed = match frame.child("draw:text-box") {
318 Some(b) => b,
319 None => continue,
320 };
321 let is_title = match classed {
322 true => class == "title" || class == "subtitle",
323 false => first,
324 };
325 first = false;
326 let mut lines = Vec::new();
327 gather(xml, boxed, 0, &mut lines, images);
328 for (level, content) in lines {
329 if content.is_empty() {
330 continue;
331 }
332 if is_title && slide.title.is_none() {
333 slide.title = Some(content);
334 continue;
335 }
336 slide.bullets.push(Bullet { level, content });
337 }
338 // Notes live in a `presentation:notes` element beside the frames, not in one.
339 }
340 if let Some(notes) = page.child("presentation:notes") {
341 let text = xml.text_of(notes);
342 let text = text.trim();
343 if !text.is_empty() {
344 slide.notes = Some(text.to_string());
345 }
346 }
347 slide
348}
349
350/// The lines a text box holds, with the depth each sits at.
351fn gather(
352 xml: &Xml,
353 at: &Elem,
354 level: usize,
355 out: &mut Vec<(usize, Vec<Inline>)>,
356 images: &mut usize,
357) {
358 for kid in at.elems() {
359 match kid.name.qname.as_str() {
360 "text:p" => out.push((level, read_inlines(xml, kid, images))),
361 // Depth is nesting here, so each list inside a list is one level further in.
362 "text:list" => gather(xml, kid, level + 1, out, images),
363 "text:list-item" => gather(xml, kid, level, out, images),
364 _ => gather(xml, kid, level, out, images),
365 }
366 }
367}
368
369fn read_inlines(xml: &Xml, at: &Elem, images: &mut usize) -> Vec<Inline> {
370 let mut out = Vec::new();
371 for node in &at.kids {
372 match node {
373 oxedyne_fe2o3_text::xml::Node::Text(span) => {
374 let t = xml.text(span);
375 if !t.is_empty() {
376 out.push(Inline::Text(t));
377 }
378 }
379 oxedyne_fe2o3_text::xml::Node::Elem(e) => match e.name.qname.as_str() {
380 "text:s" => {
381 let n = e.attr("text:c").and_then(|v| v.parse::<usize>().ok()).unwrap_or(1);
382 out.push(Inline::Text(" ".repeat(n.min(256))));
383 }
384 "text:tab" => out.push(Inline::Text("\t".to_string())),
385 "text:line-break" => out.push(Inline::Break),
386 "draw:image" => *images += 1,
387 "text:a" => {
388 let to = e.attr("xlink:href").unwrap_or("").to_string();
389 let content = read_inlines(xml, e, images);
390 if !content.is_empty() {
391 out.push(Inline::Link { to, content });
392 }
393 }
394 "text:span" => {
395 let style = e.attr("text:style-name").unwrap_or("").to_ascii_lowercase();
396 let content = read_inlines(xml, e, images);
397 if content.is_empty() {
398 continue;
399 }
400 if style.contains("strong") || style.contains("bold") {
401 out.push(Inline::Emph { strong: true, content });
402 } else if style.contains("emphasis") || style.contains("italic") {
403 out.push(Inline::Emph { strong: false, content });
404 } else if style.contains("source") || style.contains("teletype") {
405 out.push(Inline::Code(oxedyne_fe2o3_text::doc::text_of(&content)));
406 } else {
407 out.extend(content);
408 }
409 }
410 _ => out.extend(read_inlines(xml, e, images)),
411 },
412 _ => {}
413 }
414 }
415 out
416}