1use crate::model::{Content, Shape, SlideContent, TextBody};
5
6#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct TextOptions {
9 pub furniture: bool,
11 pub hidden_shapes: bool,
13 pub hidden_slides: bool,
15 pub notes: bool,
17 pub alt_text: bool,
19 pub comments: bool,
21 pub charts: bool,
23 pub diagrams: bool,
25 pub cell_separator: String,
27}
28
29impl Default for TextOptions {
30 fn default() -> Self {
31 Self {
32 furniture: false,
33 hidden_shapes: false,
34 hidden_slides: true,
35 notes: false,
36 alt_text: false,
37 comments: false,
38 charts: true,
39 diagrams: true,
40 cell_separator: "\t".to_string(),
41 }
42 }
43}
44
45pub fn write_content_text(
52 content: &SlideContent,
53 options: &TextOptions,
54 frames: &mut dyn FnMut(&Shape) -> Option<String>,
55 out: &mut String,
56) {
57 let mut first = true;
58 for shape in content.walk() {
59 if shape.hidden && !options.hidden_shapes {
60 continue;
61 }
62 if !options.furniture
63 && shape
64 .placeholder
65 .as_ref()
66 .is_some_and(|ph| ph.kind.is_furniture())
67 {
68 continue;
69 }
70 let start = out.len();
71 write_shape_text(shape, options, out);
72 if out.len() == start && matches!(shape.content, Content::Chart(_) | Content::Diagram(_)) {
73 if let Some(text) = frames(shape) {
74 out.push_str(&text);
75 }
76 }
77 if out.len() == start && options.alt_text {
78 write_alt_text(shape, out);
79 }
80 if out.len() == start {
81 continue;
82 }
83 if !first {
84 out.insert(start, '\n');
85 }
86 first = false;
87 }
88}
89
90fn write_shape_text(shape: &Shape, options: &TextOptions, out: &mut String) {
91 match &shape.content {
92 Content::Text(body) => write_body(body, out),
93 Content::Table(table) => {
94 let mut first_row = true;
95 for row in &table.rows {
96 let row_start = out.len();
97 let mut first_cell = true;
98 for cell in row.cells.iter().filter(|cell| cell.is_origin()) {
99 if !first_cell {
100 out.push_str(&options.cell_separator);
101 }
102 first_cell = false;
103 write_body(&cell.body, out);
104 }
105 if out.len() == row_start && !first_cell {
106 continue;
107 }
108 if !first_row {
109 out.insert(row_start, '\n');
110 }
111 first_row = false;
112 }
113 }
114 _ => {}
115 }
116}
117
118fn write_alt_text(shape: &Shape, out: &mut String) {
121 if matches!(shape.content, Content::Group(..) | Content::Connector) {
122 return;
123 }
124 let Some(description) = shape.description.as_deref().map(str::trim) else {
125 return;
126 };
127 if description.is_empty() {
128 return;
129 }
130 out.push_str(description);
131}
132
133fn write_body(body: &TextBody, out: &mut String) {
134 if body.is_empty() {
135 return;
136 }
137 body.write_text(out);
138}
139
140#[derive(Clone, Debug, Default, PartialEq, Eq)]
142pub struct ExtractReport {
143 pub failed_slides: Vec<(usize, String)>,
145 pub failed_notes: Vec<(usize, String)>,
147 pub failed_comments: Vec<(usize, String)>,
149 pub failed_frames: Vec<(usize, String)>,
151 pub hidden_slides_skipped: u32,
153 pub unknown_graphics: u32,
155 pub unknown_graphic_uris: Vec<String>,
157 pub unknown_elements: u32,
159}
160
161impl ExtractReport {
162 pub fn is_complete(&self) -> bool {
164 self.failed_slides.is_empty()
165 && self.failed_notes.is_empty()
166 && self.failed_comments.is_empty()
167 && self.failed_frames.is_empty()
168 && self.unknown_graphics == 0
169 }
170
171 pub fn merge(&mut self, index: usize, other: ExtractReport) {
173 let reindex =
174 |items: Vec<(usize, String)>| items.into_iter().map(move |(_, err)| (index, err));
175 self.failed_slides.extend(reindex(other.failed_slides));
176 self.failed_notes.extend(reindex(other.failed_notes));
177 self.failed_comments.extend(reindex(other.failed_comments));
178 self.failed_frames.extend(reindex(other.failed_frames));
179 self.hidden_slides_skipped += other.hidden_slides_skipped;
180 self.unknown_graphics += other.unknown_graphics;
181 for uri in other.unknown_graphic_uris {
182 if !self.unknown_graphic_uris.contains(&uri) {
183 self.unknown_graphic_uris.push(uri);
184 }
185 }
186 self.unknown_elements += other.unknown_elements;
187 }
188
189 pub fn warnings(&self) -> Vec<String> {
191 let mut lines = Vec::new();
192 for (index, err) in &self.failed_slides {
193 lines.push(format!("slide {}: unreadable: {err}", index + 1));
194 }
195 for (index, err) in &self.failed_notes {
196 lines.push(format!("slide {}: notes unreadable: {err}", index + 1));
197 }
198 for (index, err) in &self.failed_comments {
199 lines.push(format!("slide {}: comments unreadable: {err}", index + 1));
200 }
201 for (index, err) in &self.failed_frames {
202 lines.push(format!(
203 "slide {}: chart or diagram unreadable: {err}",
204 index + 1
205 ));
206 }
207 if self.unknown_graphics > 0 {
208 lines.push(format!(
209 "{} graphic frame(s) of unknown type skipped: {}",
210 self.unknown_graphics,
211 self.unknown_graphic_uris.join(", ")
212 ));
213 }
214 lines
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::model::*;
222
223 fn text_shape(
224 id: u32,
225 text: &str,
226 placeholder: Option<PlaceholderKind>,
227 hidden: bool,
228 ) -> Shape {
229 let paragraphs = text
230 .split('\n')
231 .map(|line| Paragraph {
232 level: 0,
233 bullet: Bullet::Inherited,
234 runs: vec![Run::text(line)],
235 })
236 .collect();
237 Shape {
238 id,
239 name: format!("Shape {id}"),
240 hidden,
241 description: None,
242 hyperlink: None,
243 placeholder: placeholder.map(|kind| Placeholder { kind, idx: 0 }),
244 transform: None,
245 text_box: false,
246 content: Content::Text(TextBody { paragraphs }),
247 }
248 }
249
250 fn cell(text: &str) -> Cell {
251 Cell {
252 body: TextBody {
253 paragraphs: vec![Paragraph {
254 level: 0,
255 bullet: Bullet::Inherited,
256 runs: vec![Run::text(text)],
257 }],
258 },
259 grid_span: 1,
260 row_span: 1,
261 h_merge: false,
262 v_merge: false,
263 }
264 }
265
266 #[test]
267 fn shapes_are_separated_furniture_and_hidden_are_skipped_and_groups_recurse() {
268 let table = Table {
269 column_widths: vec![1, 2],
270 rows: vec![
271 Row {
272 height: 1,
273 cells: vec![cell("a"), cell("b")],
274 },
275 Row {
276 height: 1,
277 cells: vec![
278 cell("c"),
279 Cell {
280 h_merge: true,
281 ..cell("ignored")
282 },
283 ],
284 },
285 ],
286 };
287 let content = SlideContent {
288 kind: SlideKind::Slide,
289 name: None,
290 show: true,
291 shapes: vec![
292 text_shape(1, "Title", Some(PlaceholderKind::Title), false),
293 text_shape(2, "", None, false),
294 text_shape(3, "12", Some(PlaceholderKind::SlideNumber), false),
295 text_shape(4, "secret", None, true),
296 Shape {
297 content: Content::Group(
298 vec![text_shape(6, "in group\nsecond", None, false)],
299 None,
300 ),
301 ..text_shape(5, "", None, false)
302 },
303 Shape {
304 content: Content::Table(table),
305 ..text_shape(7, "", None, false)
306 },
307 ],
308 };
309 let mut out = String::new();
310 write_content_text(&content, &TextOptions::default(), &mut |_| None, &mut out);
311 assert_eq!(out, "Title\nin group\nsecond\na\tb\nc");
312 let mut out = String::new();
313 write_content_text(
314 &content,
315 &TextOptions {
316 furniture: true,
317 hidden_shapes: true,
318 cell_separator: " | ".into(),
319 ..TextOptions::default()
320 },
321 &mut |_| None,
322 &mut out,
323 );
324 assert_eq!(out, "Title\n12\nsecret\nin group\nsecond\na | b\nc");
325 }
326
327 #[test]
328 fn reports_list_problems() {
329 let report = ExtractReport {
330 failed_slides: vec![(2, "boom".into())],
331 unknown_graphics: 3,
332 unknown_graphic_uris: vec!["urn:x".into()],
333 ..ExtractReport::default()
334 };
335 assert!(!report.is_complete());
336 assert_eq!(
337 report.warnings(),
338 vec![
339 "slide 3: unreadable: boom",
340 "3 graphic frame(s) of unknown type skipped: urn:x"
341 ]
342 );
343 assert!(ExtractReport {
344 hidden_slides_skipped: 2,
345 unknown_elements: 4,
346 ..ExtractReport::default()
347 }
348 .is_complete());
349 }
350}