Skip to main content

pptxboss_core/
document.rs

1//! The lenient document layer: a presentation located through the package
2//! relationships, its slides in `sldIdLst` order, and their content.
3//!
4//! A `Document` is single-threaded (it caches parsed parts behind
5//! `RefCell`). [`Document::seed`] hands out a `Send + Sync` handle from
6//! which any thread can build its own `Document` over the same archive,
7//! which is how [`Document::map_slides`] spreads work across cores.
8
9use std::path::Path;
10use std::rc::Rc;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::sync::{Arc, OnceLock};
13
14use crate::cfb::{self, Compound};
15use crate::chart::{parse_chart, ChartData};
16use crate::comments::{parse_authors, parse_comments, Comment, CommentAuthor};
17use crate::diagram::{parse_diagram, DiagramData};
18use crate::error::{Error, Result};
19use crate::model::{Content, PlaceholderKind, SlideContent, TextBody};
20use crate::opc::{Relationships, TargetMode};
21use crate::package::{Package, PackageSeed};
22use crate::pml::{content_type, RelKind};
23use crate::ppt::{self, LegacyDeck};
24use crate::presentation::Presentation;
25use crate::properties::{AppProperties, CoreProperties};
26use crate::slide::{parse_slide, SlideReport};
27use crate::text::{write_content_text, ExtractReport, TextOptions};
28use crate::zip::{FileSource, Source};
29
30/// How the presentation part was found.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Located {
33    /// A legacy binary presentation: the `PowerPoint Document` stream.
34    LegacyStream,
35    /// Through the `officeDocument` relationship of the package (13.3.6).
36    Relationship,
37    /// By scanning content types for a presentation main type.
38    ContentType,
39    /// At `/ppt/presentation.xml` with no declaration pointing there.
40    ConventionalPath,
41}
42
43/// What the document layer had to work around.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct DocumentDefects {
46    pub located: Located,
47    /// `sldId` entries whose relationship did not resolve to a part.
48    pub unresolved_slides: Vec<(usize, String)>,
49    /// The slide list was rebuilt from `slide` relationships because `sldIdLst` was empty or absent.
50    pub slides_recovered_from_rels: bool,
51}
52
53/// A slide as listed by the presentation.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct SlideRef {
56    /// The Slide part name.
57    pub part: String,
58    /// `sldId/@id`.
59    pub id: Option<u32>,
60    /// The relationship id from the presentation part.
61    pub rel_id: String,
62}
63
64struct Shared {
65    presentation_part: String,
66    presentation: Presentation,
67    slides: Vec<SlideRef>,
68    defects: DocumentDefects,
69    /// The package behind a PresentationML deck; None for a legacy binary deck.
70    package: Option<PackageSeed>,
71    /// The parsed legacy deck; None for a package.
72    legacy: Option<Arc<LegacyDeck>>,
73    /// Comment authors of both flavours, read on first use.
74    authors: OnceLock<Vec<CommentAuthor>>,
75}
76
77/// A section of the deck with the slides it holds, resolved to slide indexes.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct SlideSection {
80    pub name: String,
81    /// Zero-based slide indexes in section order; ids that match no slide are dropped.
82    pub slides: Vec<usize>,
83}
84
85/// An embedded object (`p:oleObj`) on a slide with its package part resolved.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct ObjectRef {
88    /// `cNvPr/@id` of the graphic frame.
89    pub shape_id: u32,
90    /// `@progId`, e.g. `Excel.Sheet.12`.
91    pub prog_id: Option<String>,
92    pub rel_id: Option<String>,
93    /// The embedding part, when the relationship is internal and resolves.
94    pub part: Option<String>,
95    pub content_type: Option<String>,
96    /// The external target for linked objects.
97    pub external: Option<String>,
98}
99
100/// A thread-safe handle from which a [`Document`] is rebuilt; see [`Document::from_seed`].
101#[derive(Clone)]
102pub struct DocumentSeed {
103    shared: Arc<Shared>,
104    threads: usize,
105}
106
107impl DocumentSeed {
108    /// The worker limit for [`Document::map_slides`]; 0 means every core.
109    pub fn threads(&self) -> usize {
110        self.threads
111    }
112}
113
114/// An open presentation.
115pub struct Document {
116    package: Option<Package>,
117    shared: Arc<Shared>,
118    threads: usize,
119}
120
121/// The default worker limit: `PPTXBOSS_THREADS` when set to a number, else 0 (every core).
122fn threads_from_env() -> usize {
123    std::env::var("PPTXBOSS_THREADS")
124        .ok()
125        .and_then(|value| value.trim().parse().ok())
126        .unwrap_or(0)
127}
128
129impl Document {
130    /// Opens a `.pptx` file with positioned reads.
131    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
132        let source = FileSource::open(path)?;
133        Self::from_source(Arc::new(source))
134    }
135
136    /// Opens a deck over any positioned source: a package, or a compound
137    /// file holding a legacy binary presentation. Encrypted files are refused.
138    pub fn from_source(source: Arc<dyn Source>) -> Result<Self> {
139        let mut head = [0u8; 8];
140        if source.len() >= 8 {
141            source.read_at(0, &mut head)?;
142        }
143        if cfb::is_compound(&head) || head.starts_with(&[0xd0, 0xcf, 0x11, 0xe0]) {
144            let compound = Compound::open_source(source)?;
145            if compound.has_stream("EncryptionInfo") || compound.has_stream("EncryptedPackage") {
146                return Err(Error::Encrypted("package encrypted with a password".into()));
147            }
148            if ppt::is_presentation(&compound) {
149                return Ok(Self::from_legacy(ppt::open_compound(&compound)?));
150            }
151            return Err(Error::NoPresentationStream);
152        }
153        Self::from_package(Package::from_source(source)?)
154    }
155
156    /// A document over a parsed legacy deck; slides map onto the same model.
157    pub fn from_legacy(deck: Arc<LegacyDeck>) -> Self {
158        let slide_ids = deck.slide_ids();
159        let presentation = Presentation {
160            slides: slide_ids
161                .iter()
162                .map(|id| crate::presentation::SlideId {
163                    id: Some(*id),
164                    rel_id: String::new(),
165                    offset: 0,
166                })
167                .collect(),
168            slide_size: deck
169                .slide_size
170                .map(|(cx, cy)| crate::presentation::SlideSize {
171                    cx,
172                    cy,
173                    kind: deck.slide_size_kind.map(str::to_string),
174                }),
175            notes_size: deck.notes_size,
176            first_slide_num: deck.first_slide_number,
177            root_ok: true,
178            ..Presentation::default()
179        };
180        let slides = slide_ids
181            .iter()
182            .enumerate()
183            .map(|(index, id)| SlideRef {
184                part: format!("PowerPoint Document/slide{}", index + 1),
185                id: Some(*id),
186                rel_id: String::new(),
187            })
188            .collect();
189        let shared = Arc::new(Shared {
190            presentation_part: "PowerPoint Document".to_string(),
191            presentation,
192            slides,
193            defects: DocumentDefects {
194                located: Located::LegacyStream,
195                unresolved_slides: Vec::new(),
196                slides_recovered_from_rels: deck.slides_recovered_by_scan,
197            },
198            package: None,
199            legacy: Some(deck),
200            authors: OnceLock::new(),
201        });
202        Self {
203            package: None,
204            shared,
205            threads: threads_from_env(),
206        }
207    }
208
209    /// Opens a presentation held in memory.
210    pub fn load(bytes: Vec<u8>) -> Result<Self> {
211        Self::from_source(Arc::new(bytes))
212    }
213
214    pub fn from_package(package: Package) -> Result<Self> {
215        let (presentation_part, located) = locate_presentation(&package)?;
216        let xml = package.read_part(&presentation_part)?;
217        let presentation = Presentation::parse(&xml).map_err(|err| Error::Xml {
218            part: presentation_part.clone(),
219            offset: err.offset,
220            msg: err.msg.to_string(),
221        })?;
222        let rels = package.rels(&presentation_part)?;
223        let mut defects = DocumentDefects {
224            located,
225            unresolved_slides: Vec::new(),
226            slides_recovered_from_rels: false,
227        };
228        let mut slides = Vec::with_capacity(presentation.slides.len());
229        for (index, slide_id) in presentation.slides.iter().enumerate() {
230            match rels
231                .target_of(&slide_id.rel_id)
232                .filter(|part| package.has_part(part))
233            {
234                Some(part) => slides.push(SlideRef {
235                    part,
236                    id: slide_id.id,
237                    rel_id: slide_id.rel_id.clone(),
238                }),
239                None => defects
240                    .unresolved_slides
241                    .push((index, slide_id.rel_id.clone())),
242            }
243        }
244        if slides.is_empty() && presentation.slides.is_empty() {
245            for rel in rels.iter().filter(|rel| {
246                rel.mode == TargetMode::Internal && RelKind::of(&rel.rel_type) == RelKind::Slide
247            }) {
248                if let Some(part) = rels.resolve(rel).filter(|part| package.has_part(part)) {
249                    slides.push(SlideRef {
250                        part,
251                        id: None,
252                        rel_id: rel.id.clone(),
253                    });
254                }
255            }
256            defects.slides_recovered_from_rels = !slides.is_empty();
257        }
258        let shared = Arc::new(Shared {
259            presentation_part,
260            presentation,
261            slides,
262            defects,
263            package: Some(package.seed()),
264            legacy: None,
265            authors: OnceLock::new(),
266        });
267        Ok(Self {
268            package: Some(package),
269            shared,
270            threads: threads_from_env(),
271        })
272    }
273
274    /// The Core Properties part (`docProps/core.xml`), when the package has one.
275    pub fn core_properties(&self) -> Result<Option<CoreProperties>> {
276        let Some(part) = self.metadata_part(RelKind::CoreProperties, "/docProps/core.xml")? else {
277            return Ok(None);
278        };
279        let xml = self.pkg()?.read_part(&part)?;
280        CoreProperties::parse(&xml)
281            .map(Some)
282            .map_err(|err| xml_error(&part, err))
283    }
284
285    /// The Extended Properties part (`docProps/app.xml`), when the package has one.
286    pub fn app_properties(&self) -> Result<Option<AppProperties>> {
287        let Some(part) = self.metadata_part(RelKind::ExtendedProperties, "/docProps/app.xml")?
288        else {
289            return Ok(None);
290        };
291        let xml = self.pkg()?.read_part(&part)?;
292        AppProperties::parse(&xml)
293            .map(Some)
294            .map_err(|err| xml_error(&part, err))
295    }
296
297    /// A package-level metadata part: by relationship kind, else at its conventional name.
298    fn metadata_part(&self, kind: RelKind, conventional: &str) -> Result<Option<String>> {
299        let Some(package) = self.package.as_ref() else {
300            return Ok(None);
301        };
302        let rels = package.package_rels()?;
303        let by_rel = rels
304            .iter()
305            .filter(|rel| RelKind::of(&rel.rel_type) == kind)
306            .find_map(|rel| rels.resolve(rel))
307            .filter(|part| package.has_part(part));
308        if by_rel.is_some() {
309            return Ok(by_rel);
310        }
311        Ok(package
312            .has_part(conventional)
313            .then(|| conventional.to_string()))
314    }
315
316    /// The deck's sections (PowerPoint 2010 `p14:sectionLst`) with their
317    /// slides as zero-based indexes; empty when the deck has none.
318    pub fn sections(&self) -> Vec<SlideSection> {
319        self.shared
320            .presentation
321            .sections
322            .iter()
323            .map(|section| SlideSection {
324                name: section.name.clone(),
325                slides: section
326                    .slide_ids
327                    .iter()
328                    .filter_map(|id| {
329                        self.shared
330                            .slides
331                            .iter()
332                            .position(|slide| slide.id == Some(*id))
333                    })
334                    .collect(),
335            })
336            .collect()
337    }
338
339    /// Every comment author the presentation part links to, both flavours;
340    /// unreadable authors parts count as empty.
341    pub fn comment_authors(&self) -> &[CommentAuthor] {
342        self.shared.authors.get_or_init(|| {
343            let Some(package) = self.package.as_ref() else {
344                return Vec::new();
345            };
346            let Ok(rels) = package.rels(&self.shared.presentation_part) else {
347                return Vec::new();
348            };
349            let mut authors = Vec::new();
350            for rel in rels.iter() {
351                let kind = RelKind::of(&rel.rel_type);
352                if kind != RelKind::CommentAuthors && kind != RelKind::Authors {
353                    continue;
354                }
355                let Some(part) = rels.resolve(rel) else {
356                    continue;
357                };
358                let Ok(xml) = package.read_part(&part) else {
359                    continue;
360                };
361                if let Ok(mut parsed) = parse_authors(&xml) {
362                    authors.append(&mut parsed);
363                }
364            }
365            authors
366        })
367    }
368
369    /// The worker limit for [`Document::map_slides`]; 0 means every core.
370    pub fn threads(&self) -> usize {
371        self.threads
372    }
373
374    /// Limits [`Document::map_slides`] to `threads` workers; 0 restores every
375    /// core for a package and the calling thread for a legacy deck, whose
376    /// slides are too cheap to spread.
377    pub fn set_threads(&mut self, threads: usize) {
378        self.threads = threads;
379    }
380
381    /// Builder form of [`Document::set_threads`].
382    pub fn with_threads(mut self, threads: usize) -> Self {
383        self.threads = threads;
384        self
385    }
386
387    /// A handle that can cross threads.
388    pub fn seed(&self) -> DocumentSeed {
389        DocumentSeed {
390            shared: Arc::clone(&self.shared),
391            threads: self.threads,
392        }
393    }
394
395    /// A document over the same archive and presentation, with its own caches.
396    pub fn from_seed(seed: DocumentSeed) -> Self {
397        Self {
398            package: seed.shared.package.clone().map(Package::from_seed),
399            shared: seed.shared,
400            threads: seed.threads,
401        }
402    }
403
404    /// The package behind a PresentationML deck; None for a legacy binary deck.
405    pub fn package(&self) -> Option<&Package> {
406        self.package.as_ref()
407    }
408
409    /// The parsed legacy deck behind a `.ppt` file; None for a package.
410    pub fn legacy(&self) -> Option<&Arc<LegacyDeck>> {
411        self.shared.legacy.as_ref()
412    }
413
414    /// True for a legacy binary presentation.
415    pub fn is_legacy(&self) -> bool {
416        self.shared.legacy.is_some()
417    }
418
419    fn pkg(&self) -> Result<&Package> {
420        self.package.as_ref().ok_or_else(|| {
421            Error::Unsupported("a legacy binary presentation has no package parts".into())
422        })
423    }
424
425    pub fn presentation(&self) -> &Presentation {
426        &self.shared.presentation
427    }
428
429    /// The Presentation part name, usually `/ppt/presentation.xml`.
430    pub fn presentation_part(&self) -> &str {
431        &self.shared.presentation_part
432    }
433
434    pub fn defects(&self) -> &DocumentDefects {
435        &self.shared.defects
436    }
437
438    /// Slides in presentation order.
439    pub fn slide_refs(&self) -> &[SlideRef] {
440        &self.shared.slides
441    }
442
443    pub fn slide_count(&self) -> usize {
444        self.shared.slides.len()
445    }
446
447    /// Parses slide `index` (zero-based, presentation order).
448    pub fn slide(&self, index: usize) -> Result<Slide<'_>> {
449        let slide_ref = self
450            .shared
451            .slides
452            .get(index)
453            .ok_or(Error::SlideNotFound(index))?;
454        if let Some(deck) = &self.shared.legacy {
455            let slide = deck.slide(index)?;
456            return Ok(Slide {
457                doc: self,
458                index,
459                part: slide_ref.part.clone(),
460                content: slide.content,
461                report: SlideReport::default(),
462            });
463        }
464        let xml = self.pkg()?.read_part(&slide_ref.part)?;
465        let (content, report) = parse_slide(&xml).map_err(|err| Error::Xml {
466            part: slide_ref.part.clone(),
467            offset: err.offset,
468            msg: err.msg.to_string(),
469        })?;
470        Ok(Slide {
471            doc: self,
472            index,
473            part: slide_ref.part.clone(),
474            content,
475            report,
476        })
477    }
478
479    /// Every slide in order; a slide that fails to parse yields its error and iteration continues.
480    pub fn slides(&self) -> impl Iterator<Item = Result<Slide<'_>>> + '_ {
481        (0..self.slide_count()).map(move |index| self.slide(index))
482    }
483
484    /// Reads and parses any slide-family part by name (layouts, masters, notes).
485    pub fn slide_part(&self, part: &str) -> Result<(SlideContent, SlideReport)> {
486        let xml = self.pkg()?.read_part(part)?;
487        parse_slide(&xml).map_err(|err| Error::Xml {
488            part: part.to_string(),
489            offset: err.offset,
490            msg: err.msg.to_string(),
491        })
492    }
493
494    /// Workers for `count` slides under the configured limit.
495    fn worker_count(&self, count: usize) -> usize {
496        if self.threads == 0 && self.is_legacy() {
497            return 1;
498        }
499        let limit = match self.threads {
500            0 => std::thread::available_parallelism().map_or(1, |n| n.get()),
501            limit => limit,
502        };
503        limit.min(count)
504    }
505
506    /// Applies `f` to every slide, spreading slides across the available
507    /// cores (see [`Document::set_threads`]). Results come back in slide
508    /// order. Each worker thread builds its own `Document` from a seed, so
509    /// nothing is shared but the archive.
510    pub fn map_slides<T, F>(&self, f: F) -> Vec<T>
511    where
512        T: Send,
513        F: Fn(Result<Slide<'_>>) -> T + Sync,
514    {
515        self.map_slides_at(&self.all_slides(), f)
516    }
517
518    /// Every slide index, in presentation order.
519    pub fn all_slides(&self) -> Vec<usize> {
520        (0..self.slide_count()).collect()
521    }
522
523    /// Applies `f` to the slides at `indices` (zero-based), in the written
524    /// order, with the same worker spread as [`Document::map_slides`]. An
525    /// index past the last slide reaches `f` as an error.
526    pub fn map_slides_at<T, F>(&self, indices: &[usize], f: F) -> Vec<T>
527    where
528        T: Send,
529        F: Fn(Result<Slide<'_>>) -> T + Sync,
530    {
531        let count = indices.len();
532        let workers = self.worker_count(count);
533        if workers <= 1 {
534            return indices.iter().map(|&index| f(self.slide(index))).collect();
535        }
536        let seed = self.seed();
537        let next = AtomicUsize::new(0);
538        let mut results: Vec<(usize, T)> = std::thread::scope(|scope| {
539            let handles: Vec<_> = (0..workers)
540                .map(|_| {
541                    scope.spawn(|| {
542                        let doc = Document::from_seed(seed.clone());
543                        let mut mine = Vec::new();
544                        loop {
545                            let position = next.fetch_add(1, Ordering::Relaxed);
546                            if position >= count {
547                                return mine;
548                            }
549                            mine.push((position, f(doc.slide(indices[position]))));
550                        }
551                    })
552                })
553                .collect();
554            handles
555                .into_iter()
556                .flat_map(|handle| match handle.join() {
557                    Ok(mine) => mine,
558                    Err(payload) => std::panic::resume_unwind(payload),
559                })
560                .collect()
561        });
562        results.sort_by_key(|(position, _)| *position);
563        results.into_iter().map(|(_, value)| value).collect()
564    }
565
566    /// The text of every slide, in order, plus what was left out.
567    pub fn slide_texts(&self, options: &TextOptions) -> (Vec<String>, ExtractReport) {
568        self.slide_texts_at(&self.all_slides(), options)
569    }
570
571    /// The text of the slides at `indices` (zero-based), in the written
572    /// order, plus what was left out; failures are reported under the real
573    /// slide number.
574    pub fn slide_texts_at(
575        &self,
576        indices: &[usize],
577        options: &TextOptions,
578    ) -> (Vec<String>, ExtractReport) {
579        let results = self.map_slides_at(indices, |slide| match slide {
580            Ok(slide) => {
581                let mut report = ExtractReport::default();
582                let text = slide.text_reporting(options, &mut report);
583                (text, report)
584            }
585            Err(err) => {
586                let mut report = ExtractReport::default();
587                report.failed_slides.push((0, err.to_string()));
588                (String::new(), report)
589            }
590        });
591        let mut report = ExtractReport::default();
592        let mut texts = Vec::with_capacity(results.len());
593        for (&index, (text, slide_report)) in indices.iter().zip(results) {
594            report.merge(index, slide_report);
595            texts.push(text);
596        }
597        (texts, report)
598    }
599
600    /// The whole deck as text: slides separated by a blank line.
601    pub fn text(&self) -> String {
602        self.text_reporting(&TextOptions::default()).0
603    }
604
605    pub fn text_reporting(&self, options: &TextOptions) -> (String, ExtractReport) {
606        let (texts, report) = self.slide_texts(options);
607        let mut out = String::with_capacity(texts.iter().map(|text| text.len() + 2).sum());
608        let mut first = true;
609        for text in texts.iter().filter(|text| !text.is_empty()) {
610            if !first {
611                out.push_str("\n\n");
612            }
613            first = false;
614            out.push_str(text);
615        }
616        (out, report)
617    }
618}
619
620/// One line for a comment: `[comment] Author: text`, replies indented.
621fn write_comment_line(comment: &Comment, out: &mut String) {
622    if comment.reply {
623        out.push_str("  ");
624    }
625    out.push_str(match comment.reply {
626        true => "[reply] ",
627        false => "[comment] ",
628    });
629    if let Some(author) = &comment.author {
630        out.push_str(author);
631        out.push_str(": ");
632    }
633    out.push_str(comment.text.trim());
634}
635
636fn xml_error(part: &str, err: crate::xml::XmlError) -> Error {
637    Error::Xml {
638        part: part.to_string(),
639        offset: err.offset,
640        msg: err.msg.to_string(),
641    }
642}
643
644fn locate_presentation(package: &Package) -> Result<(String, Located)> {
645    let rels = package.package_rels()?;
646    let by_rel = rels
647        .iter()
648        .filter(|rel| RelKind::of(&rel.rel_type) == RelKind::OfficeDocument)
649        .filter_map(|rel| rels.resolve(rel))
650        .find(|part| package.has_part(part));
651    if let Some(part) = by_rel {
652        return Ok((part, Located::Relationship));
653    }
654    let by_type = package
655        .parts()
656        .iter()
657        .find(|part| {
658            package
659                .content_type_of(&part.name)
660                .is_some_and(content_type::is_presentation_main)
661        })
662        .map(|part| part.name.clone());
663    if let Some(part) = by_type {
664        return Ok((part, Located::ContentType));
665    }
666    if package.has_part("/ppt/presentation.xml") {
667        return Ok((
668            "/ppt/presentation.xml".to_string(),
669            Located::ConventionalPath,
670        ));
671    }
672    Err(Error::NotAPresentation)
673}
674
675/// An image referenced from a slide.
676#[derive(Clone, Debug, PartialEq, Eq)]
677pub struct ImageRef {
678    /// `cNvPr/@id` of the picture shape.
679    pub shape_id: u32,
680    pub rel_id: String,
681    /// The image part, when the relationship is internal and resolves.
682    pub part: Option<String>,
683    /// The image part's declared content type.
684    pub content_type: Option<String>,
685    /// The external target for linked images.
686    pub external: Option<String>,
687}
688
689/// One parsed slide, bound to its document.
690pub struct Slide<'d> {
691    doc: &'d Document,
692    pub index: usize,
693    /// The Slide part name.
694    pub part: String,
695    pub content: SlideContent,
696    pub report: SlideReport,
697}
698
699impl<'d> Slide<'d> {
700    /// One-based position in the deck.
701    pub fn number(&self) -> usize {
702        self.index + 1
703    }
704
705    pub fn document(&self) -> &'d Document {
706        self.doc
707    }
708
709    /// True for slides marked hidden.
710    pub fn is_hidden(&self) -> bool {
711        !self.content.show
712    }
713
714    /// The slide's relationships.
715    pub fn rels(&self) -> Result<Rc<Relationships>> {
716        match self.doc.package.as_ref() {
717            Some(package) => package.rels(&self.part),
718            None => Ok(Rc::new(Relationships::empty(&self.part))),
719        }
720    }
721
722    /// The first title placeholder's text.
723    pub fn title(&self) -> Option<String> {
724        self.content.title()
725    }
726
727    /// The slide text with default options and no notes.
728    pub fn text(&self) -> String {
729        let mut report = ExtractReport::default();
730        self.text_reporting(&TextOptions::default(), &mut report)
731    }
732
733    /// Folds what the slide parser could not understand into `report`.
734    pub(crate) fn merge_parse_report(&self, report: &mut ExtractReport) {
735        report.unknown_graphics += self.report.unknown_graphics.len() as u32;
736        for uri in &self.report.unknown_graphics {
737            if !report.unknown_graphic_uris.contains(uri) {
738                report.unknown_graphic_uris.push(uri.clone());
739            }
740        }
741        report.unknown_elements += self.report.unknown_elements;
742    }
743
744    /// The text of a chart or diagram frame, when the options ask for it;
745    /// unreadable parts are recorded in `report`.
746    fn frame_text(
747        &self,
748        shape: &crate::model::Shape,
749        options: &TextOptions,
750        report: &mut ExtractReport,
751    ) -> Option<String> {
752        let result = match &shape.content {
753            Content::Chart(Some(rel_id)) if options.charts => self.chart(rel_id).map(|chart| {
754                let mut out = String::new();
755                chart.write_text(&options.cell_separator, &mut out);
756                out
757            }),
758            Content::Diagram(Some(rel_id)) if options.diagrams => {
759                self.diagram(rel_id).map(|diagram| {
760                    let mut out = String::new();
761                    diagram.write_text(&mut out);
762                    out
763                })
764            }
765            _ => return None,
766        };
767        match result {
768            Ok(text) => Some(text).filter(|text| !text.is_empty()),
769            Err(err) => {
770                report.failed_frames.push((self.index, err.to_string()));
771                None
772            }
773        }
774    }
775
776    /// The chart part behind relationship `rel_id`, parsed.
777    pub fn chart(&self, rel_id: &str) -> Result<ChartData> {
778        let part = self.frame_part(rel_id)?;
779        let xml = self.doc.pkg()?.read_part(&part)?;
780        parse_chart(&xml).map_err(|err| xml_error(&part, err))
781    }
782
783    /// The diagram data part behind relationship `rel_id`, parsed.
784    pub fn diagram(&self, rel_id: &str) -> Result<DiagramData> {
785        let part = self.frame_part(rel_id)?;
786        let xml = self.doc.pkg()?.read_part(&part)?;
787        parse_diagram(&xml).map_err(|err| xml_error(&part, err))
788    }
789
790    fn frame_part(&self, rel_id: &str) -> Result<String> {
791        let rels = self.rels()?;
792        let package = self.doc.pkg()?;
793        rels.target_of(rel_id)
794            .filter(|part| package.has_part(part))
795            .ok_or_else(|| Error::MissingRelationship {
796                part: self.part.clone(),
797                id: rel_id.to_string(),
798            })
799    }
800
801    /// Every chart on the slide with the id of its graphic frame, in z-order.
802    pub fn charts(&self) -> Result<Vec<(u32, ChartData)>> {
803        let mut charts = Vec::new();
804        for shape in self.content.walk() {
805            if let Content::Chart(Some(rel_id)) = &shape.content {
806                charts.push((shape.id, self.chart(rel_id)?));
807            }
808        }
809        Ok(charts)
810    }
811
812    /// Every diagram on the slide with the id of its graphic frame, in z-order.
813    pub fn diagrams(&self) -> Result<Vec<(u32, DiagramData)>> {
814        let mut diagrams = Vec::new();
815        for shape in self.content.walk() {
816            if let Content::Diagram(Some(rel_id)) = &shape.content {
817                diagrams.push((shape.id, self.diagram(rel_id)?));
818            }
819        }
820        Ok(diagrams)
821    }
822
823    /// The slide text with `options`, recording problems in `report`.
824    pub fn text_reporting(&self, options: &TextOptions, report: &mut ExtractReport) -> String {
825        self.merge_parse_report(report);
826        if self.is_hidden() && !options.hidden_slides {
827            report.hidden_slides_skipped += 1;
828            return String::new();
829        }
830        let mut out = String::new();
831        write_content_text(
832            &self.content,
833            options,
834            &mut |shape| self.frame_text(shape, options, report),
835            &mut out,
836        );
837        if options.notes {
838            match self.notes() {
839                Ok(Some(notes)) if !notes.is_empty() => {
840                    if !out.is_empty() {
841                        out.push('\n');
842                    }
843                    notes.write_text(&mut out);
844                }
845                Ok(_) => {}
846                Err(err) => report.failed_notes.push((self.index, err.to_string())),
847            }
848        }
849        if options.comments {
850            match self.comments() {
851                Ok(comments) => {
852                    for comment in comments {
853                        if !out.is_empty() {
854                            out.push('\n');
855                        }
856                        write_comment_line(&comment, &mut out);
857                    }
858                }
859                Err(err) => report.failed_comments.push((self.index, err.to_string())),
860            }
861        }
862        out
863    }
864
865    /// The part name of the first relationship of `kind`, when internal.
866    fn related_part(&self, kind: RelKind) -> Result<Option<String>> {
867        let rels = self.rels()?;
868        let found = rels
869            .iter()
870            .filter(|rel| RelKind::of(&rel.rel_type) == kind)
871            .find_map(|rel| rels.resolve(rel));
872        Ok(found)
873    }
874
875    /// The Notes Slide part, if the slide has one.
876    pub fn notes_part(&self) -> Result<Option<String>> {
877        if let Some(deck) = self.doc.legacy() {
878            return Ok(deck
879                .has_notes(self.index)
880                .then(|| format!("PowerPoint Document/notes{}", self.index + 1)));
881        }
882        self.related_part(RelKind::NotesSlide)
883    }
884
885    /// The comments part of either flavour, if the slide has one.
886    pub fn comments_part(&self) -> Result<Option<String>> {
887        match self.related_part(RelKind::ModernComments)? {
888            Some(part) => Ok(Some(part)),
889            None => self.related_part(RelKind::Comments),
890        }
891    }
892
893    /// The slide's comments in document order, replies after their parent.
894    pub fn comments(&self) -> Result<Vec<Comment>> {
895        let Some(part) = self.comments_part()? else {
896            return Ok(Vec::new());
897        };
898        let xml = self.doc.pkg()?.read_part(&part)?;
899        parse_comments(&xml, self.doc.comment_authors()).map_err(|err| xml_error(&part, err))
900    }
901
902    /// Every embedded object on the slide with its part resolved.
903    pub fn objects(&self) -> Result<Vec<ObjectRef>> {
904        let rels = self.rels()?;
905        let package = self.doc.package.as_ref();
906        let mut objects = Vec::new();
907        for shape in self.content.walk() {
908            let Content::Ole(ole) = &shape.content else {
909                continue;
910            };
911            let rel = ole.rel_id.as_deref().and_then(|id| rels.get(id));
912            let part = rel
913                .and_then(|rel| rels.resolve(rel))
914                .filter(|part| package.is_some_and(|package| package.has_part(part)));
915            let content_type = part
916                .as_deref()
917                .and_then(|part| package.and_then(|package| package.content_type_of(part)))
918                .map(str::to_string);
919            objects.push(ObjectRef {
920                shape_id: shape.id,
921                prog_id: ole.prog_id.clone(),
922                rel_id: ole.rel_id.clone(),
923                part,
924                content_type,
925                external: rel
926                    .filter(|rel| rel.mode == TargetMode::External)
927                    .map(|rel| rel.target.clone()),
928            });
929        }
930        Ok(objects)
931    }
932
933    /// The bytes of an embedded object's part.
934    pub fn object_bytes(&self, object: &ObjectRef) -> Result<Vec<u8>> {
935        let part = object
936            .part
937            .as_deref()
938            .ok_or_else(|| Error::MissingRelationship {
939                part: self.part.clone(),
940                id: object.rel_id.clone().unwrap_or_default(),
941            })?;
942        let mut out = Vec::new();
943        self.doc.pkg()?.read_part_into(part, &mut out)?;
944        Ok(out)
945    }
946
947    /// The Slide Layout part.
948    pub fn layout_part(&self) -> Result<Option<String>> {
949        self.related_part(RelKind::SlideLayout)
950    }
951
952    /// The speaker notes: the body placeholder of the notes slide, or,
953    /// when there is none, every text shape on it other than the slide
954    /// image and furniture.
955    pub fn notes(&self) -> Result<Option<TextBody>> {
956        if let Some(deck) = self.doc.legacy() {
957            return deck.notes(self.index);
958        }
959        let Some(part) = self.notes_part()? else {
960            return Ok(None);
961        };
962        let (content, _) = self.doc.slide_part(&part)?;
963        let body_placeholder = content
964            .walk()
965            .find(|shape| {
966                shape
967                    .placeholder
968                    .as_ref()
969                    .is_some_and(|ph| ph.kind == PlaceholderKind::Body)
970            })
971            .and_then(|shape| shape.text_body().cloned());
972        if let Some(body) = body_placeholder {
973            return Ok(Some(body));
974        }
975        let mut merged = TextBody::default();
976        for shape in content.walk() {
977            let skip = shape
978                .placeholder
979                .as_ref()
980                .is_some_and(|ph| ph.kind == PlaceholderKind::SlideImage || ph.kind.is_furniture());
981            if skip {
982                continue;
983            }
984            if let Some(body) = shape.text_body() {
985                merged.paragraphs.extend(body.paragraphs.iter().cloned());
986            }
987        }
988        Ok(Some(merged))
989    }
990
991    /// The speaker notes as plain text, if any.
992    pub fn notes_text(&self) -> Result<Option<String>> {
993        Ok(self
994            .notes()?
995            .filter(|body| !body.is_empty())
996            .map(|body| body.text()))
997    }
998
999    /// Every picture on the slide with its image part resolved.
1000    pub fn images(&self) -> Result<Vec<ImageRef>> {
1001        if let Some(deck) = self.doc.legacy() {
1002            return Ok(self.legacy_images(deck));
1003        }
1004        let package = self.doc.pkg()?;
1005        let rels = self.rels()?;
1006        let mut images = Vec::new();
1007        for shape in self.content.walk() {
1008            let picture = match &shape.content {
1009                Content::Picture(picture) => picture,
1010                Content::Ole(ole) => match &ole.preview {
1011                    Some(picture) => picture,
1012                    None => continue,
1013                },
1014                _ => continue,
1015            };
1016            let Some(rel_id) = picture.embed.as_ref().or(picture.link.as_ref()) else {
1017                continue;
1018            };
1019            let rel = rels.get(rel_id);
1020            let part = rel
1021                .and_then(|rel| rels.resolve(rel))
1022                .filter(|part| package.has_part(part));
1023            let content_type = part
1024                .as_deref()
1025                .and_then(|part| package.content_type_of(part))
1026                .map(str::to_string);
1027            let external = rel
1028                .filter(|rel| rel.mode == TargetMode::External)
1029                .map(|rel| rel.target.clone());
1030            images.push(ImageRef {
1031                shape_id: shape.id,
1032                rel_id: rel_id.clone(),
1033                part,
1034                content_type,
1035                external,
1036            });
1037        }
1038        Ok(images)
1039    }
1040
1041    /// Pictures of a legacy slide: blip indexes stand in for relationship ids.
1042    fn legacy_images(&self, deck: &LegacyDeck) -> Vec<ImageRef> {
1043        let mut images = Vec::new();
1044        for shape in self.content.walk() {
1045            let picture = match &shape.content {
1046                Content::Picture(picture) => picture,
1047                Content::Ole(ole) => match &ole.preview {
1048                    Some(picture) => picture,
1049                    None => continue,
1050                },
1051                _ => continue,
1052            };
1053            let Some(index) = picture.embed.as_deref() else {
1054                continue;
1055            };
1056            let content_type = index
1057                .parse::<usize>()
1058                .ok()
1059                .and_then(|index| deck.picture_content_type(index))
1060                .map(str::to_string);
1061            images.push(ImageRef {
1062                shape_id: shape.id,
1063                rel_id: index.to_string(),
1064                part: content_type.is_some().then(|| format!("Pictures/{index}")),
1065                content_type,
1066                external: None,
1067            });
1068        }
1069        images
1070    }
1071
1072    /// The bytes of an image part referenced from this slide.
1073    pub fn image_bytes(&self, image: &ImageRef) -> Result<Vec<u8>> {
1074        if let Some(deck) = self.doc.legacy() {
1075            let picture = image
1076                .rel_id
1077                .parse::<usize>()
1078                .ok()
1079                .and_then(|index| deck.picture(index).ok().flatten());
1080            return picture.map(|picture| picture.bytes).ok_or_else(|| {
1081                Error::MissingRelationship {
1082                    part: self.part.clone(),
1083                    id: image.rel_id.clone(),
1084                }
1085            });
1086        }
1087        let part = image
1088            .part
1089            .as_deref()
1090            .ok_or_else(|| Error::MissingRelationship {
1091                part: self.part.clone(),
1092                id: image.rel_id.clone(),
1093            })?;
1094        let mut out = Vec::new();
1095        self.doc.pkg()?.read_part_into(part, &mut out)?;
1096        Ok(out)
1097    }
1098
1099    /// The target of a hyperlink relationship: an external URL, or an internal part name.
1100    pub fn hyperlink_target(&self, rel_id: &str) -> Result<Option<String>> {
1101        let rels = self.rels()?;
1102        Ok(rels.get(rel_id).map(|rel| match rel.mode {
1103            TargetMode::External => rel.target.clone(),
1104            TargetMode::Internal => rels.resolve(rel).unwrap_or_default(),
1105        }))
1106    }
1107}