Skip to main content

pptxboss_core/
text.rs

1//! Plain-text extraction from slide content, and the report that says
2//! what it left out.
3
4use crate::model::{Content, Shape, SlideContent, TextBody};
5
6/// What goes into extracted text.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct TextOptions {
9    /// Include date, footer, header and slide number placeholders.
10    pub furniture: bool,
11    /// Include shapes marked hidden.
12    pub hidden_shapes: bool,
13    /// Include slides marked hidden (`show="0"`).
14    pub hidden_slides: bool,
15    /// Include speaker notes after each slide's text.
16    pub notes: bool,
17    /// Include the alternative text (`cNvPr/@descr`) of shapes that have no text of their own.
18    pub alt_text: bool,
19    /// Include the slide's comments after its text and notes.
20    pub comments: bool,
21    /// Include chart titles, series names, categories and values, as table rows.
22    pub charts: bool,
23    /// Include the text of diagrams (SmartArt), one node per line.
24    pub diagrams: bool,
25    /// Separator between table cells of one row.
26    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
45/// Appends the text of `content` to `out`: shapes in z-order, one shape
46/// per line block, paragraphs separated by newlines, table rows one per
47/// line with cells separated by the configured separator. Shapes without
48/// text contribute nothing. Graphic frames whose text lives in another
49/// part (charts, diagrams) ask `frames` for it. No text is inherited from
50/// layouts or masters.
51pub 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
118/// The alternative text of a shape that carries content but no text of its
119/// own: pictures, charts, diagrams, embedded objects, empty text shapes.
120fn 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/// What extraction skipped or could not read.
141#[derive(Clone, Debug, Default, PartialEq, Eq)]
142pub struct ExtractReport {
143    /// Slides whose part could not be read or parsed, with the error text.
144    pub failed_slides: Vec<(usize, String)>,
145    /// Notes parts that could not be read or parsed.
146    pub failed_notes: Vec<(usize, String)>,
147    /// Comments parts that could not be read or parsed.
148    pub failed_comments: Vec<(usize, String)>,
149    /// Chart or diagram parts that could not be read or parsed.
150    pub failed_frames: Vec<(usize, String)>,
151    /// Hidden slides left out because the options excluded them.
152    pub hidden_slides_skipped: u32,
153    /// Graphic frames whose content type the reader does not understand.
154    pub unknown_graphics: u32,
155    /// The distinct `graphicData` URIs behind `unknown_graphics`.
156    pub unknown_graphic_uris: Vec<String>,
157    /// Elements in unknown namespaces skipped inside shape trees.
158    pub unknown_elements: u32,
159}
160
161impl ExtractReport {
162    /// True when nothing was dropped for a reason other than the options.
163    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    /// Folds a per-slide report for slide `index` into this deck-level one.
172    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    /// One line per problem, suitable for a warning stream.
190    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}