Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_file/src/office/pptx/read.rs

8.8 KiB, 20 runs

created by r1870400018:22878, 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//! Reading a `.pptx` into the neutral deck.
2//!
3//! Simpler than the document side, because a slide carries less. The nesting is
4//! `p:sld > p:cSld > p:spTree > p:sp > p:txBody > a:p > a:r > a:t`, and what separates a title from a
5//! body is the placeholder type on the shape rather than anything about the text.
6//!
7//! # Slide order comes from the presentation, not from the file names
8//!
9//! `ppt/slides/slide10.xml` sorts before `slide2.xml` and is the ninth slide, not the second. The
10//! order lives in `p:sldIdLst`, which names each slide by a relationship id, so that is what is read.
11//! A reader that walked the archive would deal a deck of more than nine slides out of order, and it
12//! would look like an authoring mistake rather than a reading one.
13//!
14//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
15//! Anthropic Claude
16
17use crate::office::deck::{
18 Bullet,
19 Deck,
20 Slide,
21};
22use crate::office::opc::{
23 REL_DOC,
24 REL_SLIDE,
25};
26use crate::zip::Zip;
27
28use oxedyne_fe2o3_core::prelude::*;
29use oxedyne_fe2o3_text::doc::Inline;
30use oxedyne_fe2o3_text::xml::{
31 Elem,
32 Xml,
33};
34
35use std::collections::BTreeMap;
36
37// The most a single part is inflated to. A slide is small; a slide claiming otherwise is not one.
38pub const MAX_PART: u64 = 32 * 1024 * 1024;
39
40/// The leading bytes of an OLE compound file: an encrypted deck, or a `.ppt` from before 2007.
41const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
42
43/// A deck read for reading, and what came with it.
44#[derive(Clone, Debug, Default)]
45pub struct Reading {
46 pub deck: Deck,
47 pub macros: bool, // said, never run
48 // Pictures, charts and other shapes holding no text, counted rather than named by kind: on a
49 // slide the distinction a reader cares about is "there is something here you are not seeing",
50 // and every one of them is a rectangle of pixels.
51 pub shapes: usize,
52 // Slides the presentation names and whose part is missing or unreadable, by number.
53 pub missing: Vec<usize>,
54}
55
56pub fn read(bytes: &[u8]) -> Outcome<Reading> {
57 if bytes.len() >= OLE_MAGIC.len() && bytes[..OLE_MAGIC.len()] == OLE_MAGIC {
58 return Err(err!(
59 "This is an OLE compound file, not a `.pptx`. Either it is encrypted, or it is a \
60 `.ppt` from before 2007 -- a different format entirely, which this does not read.";
61 Invalid, Input, Unimplemented));
62 }
63 let zip = res!(Zip::read(bytes.to_vec()));
64 let mut out = Reading::default();
65 out.macros = zip.names().iter().any(|n| n.ends_with("vbaProject.bin"));
66
67 let root_rels = res!(rels_of(&zip, ""));
68 let main = res!(root_rels.values()
69 .find(|(kind, _)| kind == REL_DOC)
70 .map(|(_, t)| t.clone())
71 .ok_or_else(|| err!(
72 "The package names no presentation part, so this is not a deck. It holds: {}.",
73 zip.names().join(", "); Invalid, Input, Missing)));
74 let pres = res!(Xml::parse(&res!(part_text(&zip, &main))));
75 let rels = res!(rels_of(&zip, &main));
76
77 // The ORDER is here, not in the file names. See the module's own note.
78 let ids = match res!(pres.root()).child("p:sldIdLst") {
79 Some(l) => l.children("p:sldId"),
80 None => Vec::new(),
81 };
82 for (n, id) in ids.iter().enumerate() {
83 let target = id.attr("r:id")
84 .and_then(|i| rels.get(i))
85 .filter(|(kind, _)| kind == REL_SLIDE)
86 .map(|(_, t)| t.clone());
87 let target = match target {
88 Some(t) if zip.has(&t) => t,
89 _ => {
90 out.missing.push(n + 1);
91 continue;
92 }
93 };
94 let part = match part_text(&zip, &target) {
95 Ok(p) => p,
96 Err(_) => {
97 out.missing.push(n + 1);
98 continue;
99 }
100 };
101 let xml = match Xml::parse(&part) {
102 Ok(x) => x,
103 Err(_) => {
104 out.missing.push(n + 1);
105 continue;
106 }
107 };
108 out.deck.slides.push(slide_of(&xml, &mut out.shapes));
109 }
110 Ok(out)
111}
112
113/// One slide: its title, and everything else as bullets.
114fn slide_of(xml: &Xml, shapes: &mut usize) -> Slide {
115 let mut slide = Slide::default();
116 // Every shape anywhere in the tree, so a shape inside a group is read rather than lost. A deck
117 // from a real template nests them two and three deep.
118 for sp in xml.all("p:sp") {
119 let body = match sp.child("p:txBody") {
120 Some(b) => b,
121 None => continue,
122 };
123 let is_title = sp.find(&["p:nvSpPr", "p:nvPr", "p:ph"])
124 .and_then(|ph| ph.attr("type"))
125 .map(|t| t == "title" || t == "ctrTitle")
126 .unwrap_or(false);
127 for p in body.children("a:p") {
128 let content = inlines(xml, p);
129 if content.is_empty() {
130 continue;
131 }
132 // The first title paragraph is the title; a second one is a line of the body, because a
133 // slide has one title and losing the rest would be worse than moving it.
134 if is_title && slide.title.is_none() {
135 slide.title = Some(content);
136 continue;
137 }
138 let level = p.find(&["a:pPr"])
139 .and_then(|pr| pr.attr("lvl"))
140 .and_then(|v| v.parse::<usize>().ok())
141 .unwrap_or(0);
142 slide.bullets.push(Bullet { level, content });
143 }
144 }
145 // A picture or a chart holds no text, so it is counted rather than read.
146 *shapes += xml.all("p:pic").len() + xml.all("p:graphicFrame").len();
147 slide
148}
149
150fn inlines(xml: &Xml, p: &Elem) -> Vec<Inline> {
151 let mut out: Vec<Inline> = Vec::new();
152 for kid in p.elems() {
153 match kid.name.qname.as_str() {
154 // Paragraph properties, not content.
155 "a:pPr" | "a:endParaRPr" => {}
156 "a:br" => out.push(Inline::Break),
157 "a:r" | "a:fld" => {
158 // A field is a run whose text was computed -- a slide number, a date -- and its
159 // cached text is what is on the slide.
160 let text = match kid.child("a:t") {
161 Some(t) => xml.text_of(t),
162 None => continue,
163 };
164 if text.is_empty() {
165 continue;
166 }
167 let pr = kid.child("a:rPr");
168 let bold = pr.and_then(|e| e.attr("b")).map(|v| v == "1").unwrap_or(false);
169 let italic = pr.and_then(|e| e.attr("i")).map(|v| v == "1").unwrap_or(false);
170 let mono = pr.and_then(|e| e.child("a:latin"))
171 .and_then(|e| e.attr("typeface"))
172 .map(|f| {
173 let f = f.to_ascii_lowercase();
174 f.contains("consol") || f.contains("courier") || f.contains("mono")
175 })
176 .unwrap_or(false);
177 let mut item = match mono {
178 true => Inline::Code(text),
179 false => Inline::Text(text),
180 };
181 if italic {
182 item = Inline::Emph { strong: false, content: vec![item] };
183 }
184 if bold {
185 item = Inline::Emph { strong: true, content: vec![item] };
186 }
187 out.push(item);
188 }
189 // Anything else contributes nothing itself and its content is read where it stood.
190 _ => out.extend(inlines(xml, kid)),
191 }
192 }
193 coalesce(out)
194}
195
196/// Joins adjacent inlines that are marked alike, so a phrase split across runs reads as one.
197fn coalesce(items: Vec<Inline>) -> Vec<Inline> {
198 let mut out: Vec<Inline> = Vec::with_capacity(items.len());
199 for item in items {
200 match (out.last_mut(), item) {
201 (Some(Inline::Text(a)), Inline::Text(b)) => a.push_str(&b),
202 (Some(Inline::Code(a)), Inline::Code(b)) => a.push_str(&b),
203 (
204 Some(Inline::Emph { strong: sa, content: ca }),
205 Inline::Emph { strong: sb, content: cb },
206 ) if *sa == sb => {
207 let mut joined = std::mem::take(ca);
208 joined.extend(cb);
209 *ca = coalesce(joined);
210 }
211 (_, item) => out.push(item),
212 }
213 }
214 out
215}
216
217fn part_text(zip: &Zip, name: &str) -> Outcome<String> {
218 let bytes = res!(zip.content_capped(name, MAX_PART));
219 Ok(res!(String::from_utf8(bytes), Decode, String))
220}
221
222/// The directory a part sits in, with its trailing slash.
223fn dir_of(part: &str) -> String {
224 match part.rfind('/') {
225 Some(k) => part[..k + 1].to_string(),
226 None => String::new(),
227 }
228}
229
230/// Where a relationship target actually is within the package.
231///
232/// A slide's rels point at `../slideLayouts/...`, so the `..` has to be resolved rather than left in
233/// the path: a lookup for `ppt/slides/../slideLayouts/x.xml` finds nothing in an archive whose member
234/// is named `ppt/slideLayouts/x.xml`.
235fn resolve(dir: &str, target: &str) -> String {
236 if let Some(rest) = target.strip_prefix('/') {
237 return rest.to_string();
238 }
239 let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect();
240 for step in target.split('/') {
241 match step {
242 "" | "." => {}
243 ".." => { parts.pop(); }
244 s => parts.push(s),
245 }
246 }
247 parts.join("/")
248}
249
250/// The relationships a part owns, by id.
251fn rels_of(zip: &Zip, part: &str) -> Outcome<BTreeMap<String, (String, String)>> {
252 let dir = dir_of(part);
253 let name = &part[dir.len()..];
254 let path = fmt!("{}_rels/{}.rels", dir, name);
255 let mut out = BTreeMap::new();
256 if !zip.has(&path) {
257 return Ok(out);
258 }
259 let xml = res!(Xml::parse(&res!(part_text(zip, &path))));
260 for rel in res!(xml.root()).children("Relationship") {
261 let id = match rel.attr("Id") {
262 Some(id) => id.to_string(),
263 None => continue,
264 };
265 let kind = rel.attr("Type").unwrap_or("").to_string();
266 let target = rel.attr("Target").unwrap_or("").to_string();
267 let target = match rel.attr("TargetMode") {
268 Some("External") => target,
269 _ => resolve(&dir, &target),
270 };
271 out.insert(id, (kind, target));
272 }
273 Ok(out)
274}