Skip to main content

pptxboss_core/
slide.rs

1//! Parser for slide-family parts (`p:sld`, `p:sldLayout`, `p:sldMaster`,
2//! `p:notes`, `p:notesMaster`, `p:handoutMaster`) into the content model.
3//!
4//! One pass over the XML, no tree. Unknown namespaces and elements are
5//! skipped and counted in the [`SlideReport`]; markup compatibility is
6//! applied on the way (see [`crate::mce`]).
7
8use crate::mce::children;
9use crate::model::*;
10use crate::xml::{unescape_attr, Event, Ns, Reader, Start, XmlError};
11
12type XmlResult<T> = Result<T, XmlError>;
13
14/// What the parser skipped in one part.
15#[derive(Clone, Debug, Default, PartialEq, Eq)]
16pub struct SlideReport {
17    /// `graphicData` URIs the reader does not understand, one entry each.
18    pub unknown_graphics: Vec<String>,
19    /// Elements in namespaces the reader does not understand, outside `extLst`.
20    pub unknown_elements: u32,
21}
22
23impl SlideReport {
24    pub fn is_empty(&self) -> bool {
25        self.unknown_graphics.is_empty() && self.unknown_elements == 0
26    }
27
28    pub fn merge(&mut self, other: &SlideReport) {
29        self.unknown_graphics
30            .extend(other.unknown_graphics.iter().cloned());
31        self.unknown_elements += other.unknown_elements;
32    }
33}
34
35/// Parses one slide-family part.
36pub fn parse_slide(xml: &[u8]) -> XmlResult<(SlideContent, SlideReport)> {
37    let mut reader = Reader::new(xml);
38    let root = loop {
39        match reader.next()? {
40            Event::Start(start) => break start,
41            Event::Eof => {
42                return Err(XmlError {
43                    offset: xml.len(),
44                    msg: "no root element",
45                })
46            }
47            _ => {}
48        }
49    };
50    let kind = match root.name.ns {
51        Ns::Pml => SlideKind::from_root(root.name.local),
52        _ => None,
53    };
54    let Some(kind) = kind else {
55        return Err(XmlError {
56            offset: root.offset,
57            msg: "root element is not a slide, layout, master or notes element",
58        });
59    };
60    let show = attr_bool(&reader, &root, Ns::None, b"show").unwrap_or(true);
61    let mut content = SlideContent {
62        kind,
63        name: None,
64        show,
65        shapes: Vec::new(),
66    };
67    let mut report = SlideReport::default();
68    children(&mut reader, &mut |reader, child| {
69        if !child.name.is(Ns::Pml, b"cSld") {
70            return Ok(());
71        }
72        content.name = reader
73            .attr(&child, Ns::None, b"name")
74            .map(unescape_attr)
75            .filter(|name| !name.is_empty());
76        children(reader, &mut |reader, grandchild| {
77            if !grandchild.name.is(Ns::Pml, b"spTree") {
78                return Ok(());
79            }
80            parse_shape_list(reader, &mut content.shapes, &mut report)
81        })
82    })?;
83    Ok((content, report))
84}
85
86fn parse_shape_list<'a>(
87    reader: &mut Reader<'a>,
88    out: &mut Vec<Shape>,
89    report: &mut SlideReport,
90) -> XmlResult<()> {
91    children(reader, &mut |reader, child| {
92        if child.name.ns != Ns::Pml {
93            report.unknown_elements += 1;
94            return Ok(());
95        }
96        let shape = match child.name.local {
97            b"sp" => parse_sp(reader, report)?,
98            b"pic" => parse_pic(reader, report)?,
99            b"grpSp" => parse_grp_sp(reader, report)?,
100            b"graphicFrame" => parse_graphic_frame(reader, report)?,
101            b"cxnSp" => parse_cxn_sp(reader, report)?,
102            b"contentPart" => {
103                let rel = reader.attr(&child, Ns::Rel, b"id").map(unescape_attr);
104                let mut shape = blank_shape(Content::ContentPart(rel));
105                shape.name = String::from("Content Part");
106                shape
107            }
108            _ => return Ok(()),
109        };
110        out.push(shape);
111        Ok(())
112    })
113}
114
115fn blank_shape(content: Content) -> Shape {
116    Shape {
117        id: 0,
118        name: String::new(),
119        hidden: false,
120        description: None,
121        hyperlink: None,
122        placeholder: None,
123        transform: None,
124        text_box: false,
125        content,
126    }
127}
128
129#[derive(Default)]
130struct NonVisual {
131    id: u32,
132    name: String,
133    hidden: bool,
134    description: Option<String>,
135    hyperlink: Option<String>,
136    placeholder: Option<Placeholder>,
137    text_box: bool,
138    media: Option<String>,
139}
140
141impl NonVisual {
142    fn apply(self, shape: &mut Shape) -> Option<String> {
143        shape.id = self.id;
144        shape.name = self.name;
145        shape.hidden = self.hidden;
146        shape.description = self.description;
147        shape.hyperlink = self.hyperlink;
148        shape.placeholder = self.placeholder;
149        shape.text_box = self.text_box;
150        self.media
151    }
152}
153
154fn parse_non_visual<'a>(reader: &mut Reader<'a>, report: &mut SlideReport) -> XmlResult<NonVisual> {
155    let mut nv = NonVisual::default();
156    children(reader, &mut |reader, child| {
157        if child.name.ns != Ns::Pml {
158            report.unknown_elements += 1;
159            return Ok(());
160        }
161        match child.name.local {
162            b"cNvPr" => {
163                nv.id = attr_u32(reader, &child, Ns::None, b"id").unwrap_or(0);
164                nv.name = reader
165                    .attr(&child, Ns::None, b"name")
166                    .map(unescape_attr)
167                    .unwrap_or_default();
168                nv.hidden = attr_bool(reader, &child, Ns::None, b"hidden").unwrap_or(false);
169                nv.description = reader
170                    .attr(&child, Ns::None, b"descr")
171                    .map(unescape_attr)
172                    .filter(|descr| !descr.is_empty());
173                children(reader, &mut |reader, link| {
174                    if link.name.is(Ns::Dml, b"hlinkClick") {
175                        nv.hyperlink = reader
176                            .attr(&link, Ns::Rel, b"id")
177                            .map(unescape_attr)
178                            .filter(|id| !id.is_empty());
179                    }
180                    Ok(())
181                })
182            }
183            b"cNvSpPr" => {
184                nv.text_box = attr_bool(reader, &child, Ns::None, b"txBox").unwrap_or(false);
185                Ok(())
186            }
187            b"nvPr" => children(reader, &mut |reader, prop| {
188                match (prop.name.ns, prop.name.local) {
189                    (Ns::Pml, b"ph") => {
190                        let kind = reader
191                            .attr(&prop, Ns::None, b"type")
192                            .map(PlaceholderKind::parse)
193                            .unwrap_or_default();
194                        let idx = attr_u32(reader, &prop, Ns::None, b"idx").unwrap_or(0);
195                        nv.placeholder = Some(Placeholder { kind, idx });
196                    }
197                    (Ns::Dml, b"videoFile" | b"audioFile" | b"quickTimeFile") => {
198                        nv.media = reader.attr(&prop, Ns::Rel, b"link").map(unescape_attr);
199                    }
200                    (Ns::Dml, b"wavAudioFile") => {
201                        nv.media = reader.attr(&prop, Ns::Rel, b"embed").map(unescape_attr);
202                    }
203                    _ => {}
204                }
205                Ok(())
206            }),
207            _ => Ok(()),
208        }
209    })?;
210    Ok(nv)
211}
212
213fn parse_sp<'a>(reader: &mut Reader<'a>, report: &mut SlideReport) -> XmlResult<Shape> {
214    let mut shape = blank_shape(Content::Text(TextBody::default()));
215    let mut body = TextBody::default();
216    children(reader, &mut |reader, child| {
217        if child.name.ns != Ns::Pml {
218            report.unknown_elements += 1;
219            return Ok(());
220        }
221        match child.name.local {
222            b"nvSpPr" => {
223                parse_non_visual(reader, report)?.apply(&mut shape);
224            }
225            b"spPr" => shape.transform = parse_sp_pr(reader)?.0,
226            b"txBody" => body = parse_text_body(reader)?,
227            _ => {}
228        }
229        Ok(())
230    })?;
231    shape.content = Content::Text(body);
232    Ok(shape)
233}
234
235fn parse_pic<'a>(reader: &mut Reader<'a>, report: &mut SlideReport) -> XmlResult<Shape> {
236    let mut shape = blank_shape(Content::Connector);
237    let mut picture = Picture::default();
238    children(reader, &mut |reader, child| {
239        if child.name.ns != Ns::Pml {
240            report.unknown_elements += 1;
241            return Ok(());
242        }
243        match child.name.local {
244            b"nvPicPr" => picture.media = parse_non_visual(reader, report)?.apply(&mut shape),
245            b"blipFill" => parse_blip_fill(reader, &mut picture)?,
246            b"spPr" => shape.transform = parse_sp_pr(reader)?.0,
247            _ => {}
248        }
249        Ok(())
250    })?;
251    shape.content = Content::Picture(picture);
252    Ok(shape)
253}
254
255fn parse_blip_fill<'a>(reader: &mut Reader<'a>, picture: &mut Picture) -> XmlResult<()> {
256    children(reader, &mut |reader, child| {
257        if !child.name.is(Ns::Dml, b"blip") {
258            return Ok(());
259        }
260        picture.embed = reader
261            .attr(&child, Ns::Rel, b"embed")
262            .map(unescape_attr)
263            .filter(|id| !id.is_empty());
264        picture.link = reader
265            .attr(&child, Ns::Rel, b"link")
266            .map(unescape_attr)
267            .filter(|id| !id.is_empty());
268        Ok(())
269    })
270}
271
272fn parse_grp_sp<'a>(reader: &mut Reader<'a>, report: &mut SlideReport) -> XmlResult<Shape> {
273    let mut shape = blank_shape(Content::Connector);
274    let mut shapes = Vec::new();
275    let mut child_space = None;
276    children(reader, &mut |reader, child| {
277        if child.name.ns != Ns::Pml {
278            report.unknown_elements += 1;
279            return Ok(());
280        }
281        match child.name.local {
282            b"nvGrpSpPr" => {
283                parse_non_visual(reader, report)?.apply(&mut shape);
284            }
285            b"grpSpPr" => {
286                let (transform, space) = parse_sp_pr(reader)?;
287                shape.transform = transform;
288                child_space = space;
289            }
290            b"sp" => shapes.push(parse_sp(reader, report)?),
291            b"pic" => shapes.push(parse_pic(reader, report)?),
292            b"grpSp" => shapes.push(parse_grp_sp(reader, report)?),
293            b"graphicFrame" => shapes.push(parse_graphic_frame(reader, report)?),
294            b"cxnSp" => shapes.push(parse_cxn_sp(reader, report)?),
295            b"contentPart" => {
296                let rel = reader.attr(&child, Ns::Rel, b"id").map(unescape_attr);
297                shapes.push(blank_shape(Content::ContentPart(rel)));
298            }
299            _ => {}
300        }
301        Ok(())
302    })?;
303    shape.content = Content::Group(shapes, child_space);
304    Ok(shape)
305}
306
307fn parse_cxn_sp<'a>(reader: &mut Reader<'a>, report: &mut SlideReport) -> XmlResult<Shape> {
308    let mut shape = blank_shape(Content::Connector);
309    children(reader, &mut |reader, child| {
310        if child.name.ns != Ns::Pml {
311            report.unknown_elements += 1;
312            return Ok(());
313        }
314        match child.name.local {
315            b"nvCxnSpPr" => {
316                parse_non_visual(reader, report)?.apply(&mut shape);
317            }
318            b"spPr" => shape.transform = parse_sp_pr(reader)?.0,
319            _ => {}
320        }
321        Ok(())
322    })?;
323    Ok(shape)
324}
325
326fn parse_graphic_frame<'a>(reader: &mut Reader<'a>, report: &mut SlideReport) -> XmlResult<Shape> {
327    let mut shape = blank_shape(Content::UnknownGraphic(String::new()));
328    let mut content = None;
329    children(reader, &mut |reader, child| {
330        match (child.name.ns, child.name.local) {
331            (Ns::Pml, b"nvGraphicFramePr") => {
332                parse_non_visual(reader, report)?.apply(&mut shape);
333            }
334            (Ns::Pml, b"xfrm") => shape.transform = parse_xfrm(reader, &child)?.0,
335            (Ns::Dml, b"graphic") => {
336                children(reader, &mut |reader, data| {
337                    if !data.name.is(Ns::Dml, b"graphicData") {
338                        return Ok(());
339                    }
340                    let uri = reader
341                        .attr(&data, Ns::None, b"uri")
342                        .map(unescape_attr)
343                        .unwrap_or_default();
344                    content = Some(parse_graphic_data(reader, uri, report)?);
345                    Ok(())
346                })?;
347            }
348            (Ns::Pml, _) => {}
349            _ => report.unknown_elements += 1,
350        }
351        Ok(())
352    })?;
353    shape.content = content.unwrap_or(Content::UnknownGraphic(String::new()));
354    Ok(shape)
355}
356
357fn parse_graphic_data<'a>(
358    reader: &mut Reader<'a>,
359    uri: String,
360    report: &mut SlideReport,
361) -> XmlResult<Content> {
362    let mut content = None;
363    children(reader, &mut |reader, child| {
364        let parsed = match (child.name.ns, child.name.local) {
365            (Ns::Dml, b"tbl") => Some(Content::Table(parse_table(reader)?)),
366            (Ns::Chart, b"chart") | (Ns::ChartEx, b"chart") => Some(Content::Chart(
367                reader.attr(&child, Ns::Rel, b"id").map(unescape_attr),
368            )),
369            (Ns::Dgm, b"relIds") => Some(Content::Diagram(
370                reader.attr(&child, Ns::Rel, b"dm").map(unescape_attr),
371            )),
372            (Ns::Pml, b"oleObj") => Some(Content::Ole(parse_ole(reader, &child, report)?)),
373            _ => None,
374        };
375        if parsed.is_some() && content.is_none() {
376            content = parsed;
377        }
378        Ok(())
379    })?;
380    Ok(content.unwrap_or_else(|| {
381        report.unknown_graphics.push(uri.clone());
382        Content::UnknownGraphic(uri)
383    }))
384}
385
386fn parse_ole<'a>(
387    reader: &mut Reader<'a>,
388    start: &Start<'a>,
389    report: &mut SlideReport,
390) -> XmlResult<OleObject> {
391    let mut ole = OleObject {
392        prog_id: reader.attr(start, Ns::None, b"progId").map(unescape_attr),
393        rel_id: reader
394            .attr(start, Ns::Rel, b"id")
395            .map(unescape_attr)
396            .filter(|id| !id.is_empty()),
397        preview: None,
398    };
399    children(reader, &mut |reader, child| {
400        if child.name.is(Ns::Pml, b"pic") {
401            let pic = parse_pic(reader, report)?;
402            if let Content::Picture(picture) = pic.content {
403                ole.preview = Some(picture);
404            }
405        }
406        Ok(())
407    })?;
408    Ok(ole)
409}
410
411fn parse_table<'a>(reader: &mut Reader<'a>) -> XmlResult<Table> {
412    let mut table = Table::default();
413    children(
414        reader,
415        &mut |reader, child| match (child.name.ns, child.name.local) {
416            (Ns::Dml, b"tblGrid") => children(reader, &mut |reader, col| {
417                if col.name.is(Ns::Dml, b"gridCol") {
418                    table
419                        .column_widths
420                        .push(attr_coordinate(reader, &col, Ns::None, b"w").unwrap_or(0));
421                }
422                Ok(())
423            }),
424            (Ns::Dml, b"tr") => {
425                let mut row = Row {
426                    height: attr_coordinate(reader, &child, Ns::None, b"h").unwrap_or(0),
427                    cells: Vec::new(),
428                };
429                children(reader, &mut |reader, cell| {
430                    if !cell.name.is(Ns::Dml, b"tc") {
431                        return Ok(());
432                    }
433                    let mut parsed = Cell {
434                        body: TextBody::default(),
435                        grid_span: attr_u32(reader, &cell, Ns::None, b"gridSpan").unwrap_or(1),
436                        row_span: attr_u32(reader, &cell, Ns::None, b"rowSpan").unwrap_or(1),
437                        h_merge: attr_bool(reader, &cell, Ns::None, b"hMerge").unwrap_or(false),
438                        v_merge: attr_bool(reader, &cell, Ns::None, b"vMerge").unwrap_or(false),
439                    };
440                    children(reader, &mut |reader, inner| {
441                        if inner.name.is(Ns::Dml, b"txBody") {
442                            parsed.body = parse_text_body(reader)?;
443                        }
444                        Ok(())
445                    })?;
446                    row.cells.push(parsed);
447                    Ok(())
448                })?;
449                table.rows.push(row);
450                Ok(())
451            }
452            _ => Ok(()),
453        },
454    )?;
455    Ok(table)
456}
457
458/// Parses `p:spPr` or `p:grpSpPr`: only the transform is kept.
459fn parse_sp_pr<'a>(reader: &mut Reader<'a>) -> XmlResult<(Option<Transform>, Option<ChildSpace>)> {
460    let mut result = (None, None);
461    children(reader, &mut |reader, child| {
462        if child.name.is(Ns::Dml, b"xfrm") {
463            result = parse_xfrm(reader, &child)?;
464        }
465        Ok(())
466    })?;
467    Ok(result)
468}
469
470fn parse_xfrm<'a>(
471    reader: &mut Reader<'a>,
472    start: &Start<'a>,
473) -> XmlResult<(Option<Transform>, Option<ChildSpace>)> {
474    let mut transform = Transform {
475        rot: attr_i64(reader, start, Ns::None, b"rot").unwrap_or(0),
476        flip_h: attr_bool(reader, start, Ns::None, b"flipH").unwrap_or(false),
477        flip_v: attr_bool(reader, start, Ns::None, b"flipV").unwrap_or(false),
478        ..Transform::default()
479    };
480    let mut child_space = ChildSpace::default();
481    let mut has_off_or_ext = false;
482    let mut has_child = false;
483    children(reader, &mut |reader, child| {
484        if child.name.ns != Ns::Dml {
485            return Ok(());
486        }
487        match child.name.local {
488            b"off" => {
489                has_off_or_ext = true;
490                transform.x = attr_coordinate(reader, &child, Ns::None, b"x").unwrap_or(0);
491                transform.y = attr_coordinate(reader, &child, Ns::None, b"y").unwrap_or(0);
492            }
493            b"ext" => {
494                has_off_or_ext = true;
495                transform.cx = attr_coordinate(reader, &child, Ns::None, b"cx").unwrap_or(0);
496                transform.cy = attr_coordinate(reader, &child, Ns::None, b"cy").unwrap_or(0);
497            }
498            b"chOff" => {
499                has_child = true;
500                child_space.x = attr_coordinate(reader, &child, Ns::None, b"x").unwrap_or(0);
501                child_space.y = attr_coordinate(reader, &child, Ns::None, b"y").unwrap_or(0);
502            }
503            b"chExt" => {
504                has_child = true;
505                child_space.cx = attr_coordinate(reader, &child, Ns::None, b"cx").unwrap_or(0);
506                child_space.cy = attr_coordinate(reader, &child, Ns::None, b"cy").unwrap_or(0);
507            }
508            _ => {}
509        }
510        Ok(())
511    })?;
512    Ok((
513        has_off_or_ext.then_some(transform),
514        has_child.then_some(child_space),
515    ))
516}
517
518/// Parses `p:txBody` or `a:txBody`.
519pub fn parse_text_body<'a>(reader: &mut Reader<'a>) -> XmlResult<TextBody> {
520    let mut body = TextBody::default();
521    children(reader, &mut |reader, child| {
522        if child.name.is(Ns::Dml, b"p") {
523            body.paragraphs.push(parse_paragraph(reader)?);
524        }
525        Ok(())
526    })?;
527    Ok(body)
528}
529
530fn parse_paragraph<'a>(reader: &mut Reader<'a>) -> XmlResult<Paragraph> {
531    let mut paragraph = Paragraph::default();
532    children(reader, &mut |reader, child| {
533        if child.name.ns != Ns::Dml {
534            return Ok(());
535        }
536        match child.name.local {
537            b"pPr" => {
538                paragraph.level =
539                    attr_u32(reader, &child, Ns::None, b"lvl").map_or(0, |lvl| lvl.min(8) as u8);
540                children(reader, &mut |reader, prop| {
541                    if prop.name.ns != Ns::Dml {
542                        return Ok(());
543                    }
544                    match prop.name.local {
545                        b"buNone" => paragraph.bullet = Bullet::None,
546                        b"buChar" => {
547                            paragraph.bullet = Bullet::Char(
548                                reader
549                                    .attr(&prop, Ns::None, b"char")
550                                    .map(unescape_attr)
551                                    .unwrap_or_default(),
552                            )
553                        }
554                        b"buAutoNum" => {
555                            paragraph.bullet = Bullet::AutoNumber {
556                                scheme: reader
557                                    .attr(&prop, Ns::None, b"type")
558                                    .map(unescape_attr)
559                                    .unwrap_or_default(),
560                                start_at: attr_u32(reader, &prop, Ns::None, b"startAt")
561                                    .unwrap_or(1),
562                            }
563                        }
564                        b"buBlip" => paragraph.bullet = Bullet::Picture,
565                        _ => {}
566                    }
567                    Ok(())
568                })
569            }
570            b"r" => {
571                let mut run = Run {
572                    kind: RunKind::Text,
573                    text: String::new(),
574                    props: RunProps::default(),
575                };
576                parse_run_children(reader, &mut run)?;
577                paragraph.runs.push(run);
578                Ok(())
579            }
580            b"br" => {
581                let mut run = Run {
582                    kind: RunKind::LineBreak,
583                    text: String::new(),
584                    props: RunProps::default(),
585                };
586                parse_run_children(reader, &mut run)?;
587                paragraph.runs.push(run);
588                Ok(())
589            }
590            b"fld" => {
591                let field_type = reader
592                    .attr(&child, Ns::None, b"type")
593                    .map(unescape_attr)
594                    .unwrap_or_default();
595                let mut run = Run {
596                    kind: RunKind::Field(field_type),
597                    text: String::new(),
598                    props: RunProps::default(),
599                };
600                parse_run_children(reader, &mut run)?;
601                paragraph.runs.push(run);
602                Ok(())
603            }
604            _ => Ok(()),
605        }
606    })?;
607    Ok(paragraph)
608}
609
610fn parse_run_children<'a>(reader: &mut Reader<'a>, run: &mut Run) -> XmlResult<()> {
611    children(reader, &mut |reader, child| {
612        if child.name.ns != Ns::Dml {
613            return Ok(());
614        }
615        match child.name.local {
616            b"rPr" => parse_run_props(reader, &child, &mut run.props),
617            b"t" => reader.text_content(&mut run.text),
618            _ => Ok(()),
619        }
620    })
621}
622
623fn parse_run_props<'a>(
624    reader: &mut Reader<'a>,
625    start: &Start<'a>,
626    props: &mut RunProps,
627) -> XmlResult<()> {
628    props.bold = attr_bool(reader, start, Ns::None, b"b");
629    props.italic = attr_bool(reader, start, Ns::None, b"i");
630    props.underline = reader
631        .attr(start, Ns::None, b"u")
632        .map(|value| value != b"none");
633    props.strike = reader
634        .attr(start, Ns::None, b"strike")
635        .map(|value| value != b"noStrike");
636    props.size = attr_u32(reader, start, Ns::None, b"sz");
637    props.lang = reader.attr(start, Ns::None, b"lang").map(unescape_attr);
638    children(reader, &mut |reader, child| {
639        if child.name.ns != Ns::Dml {
640            return Ok(());
641        }
642        match child.name.local {
643            b"hlinkClick" => {
644                props.hyperlink = reader
645                    .attr(&child, Ns::Rel, b"id")
646                    .map(unescape_attr)
647                    .filter(|id| !id.is_empty())
648            }
649            b"latin" => {
650                props.typeface = reader
651                    .attr(&child, Ns::None, b"typeface")
652                    .map(unescape_attr)
653            }
654            _ => {}
655        }
656        Ok(())
657    })
658}
659
660fn attr_bool(reader: &Reader<'_>, start: &Start<'_>, ns: Ns, local: &[u8]) -> Option<bool> {
661    match reader.attr(start, ns, local)? {
662        b"1" | b"true" | b"on" => Some(true),
663        b"0" | b"false" | b"off" => Some(false),
664        _ => None,
665    }
666}
667
668fn attr_u32(reader: &Reader<'_>, start: &Start<'_>, ns: Ns, local: &[u8]) -> Option<u32> {
669    std::str::from_utf8(reader.attr(start, ns, local)?)
670        .ok()?
671        .trim()
672        .parse()
673        .ok()
674}
675
676fn attr_i64(reader: &Reader<'_>, start: &Start<'_>, ns: Ns, local: &[u8]) -> Option<i64> {
677    std::str::from_utf8(reader.attr(start, ns, local)?)
678        .ok()?
679        .trim()
680        .parse()
681        .ok()
682}
683
684fn attr_coordinate(reader: &Reader<'_>, start: &Start<'_>, ns: Ns, local: &[u8]) -> Option<Emu> {
685    parse_coordinate(reader.attr(start, ns, local)?)
686}
687
688/// Parses `ST_Coordinate`: an integer number of EMUs or a number with a
689/// unit suffix (`in`, `cm`, `mm`, `pt`, `pc`, `pi`) per 22.9.2.15.
690pub fn parse_coordinate(value: &[u8]) -> Option<Emu> {
691    let text = std::str::from_utf8(value).ok()?.trim();
692    if let Ok(emu) = text.parse::<i64>() {
693        return Some(emu);
694    }
695    let unit_start = text.rfind(|c: char| c.is_ascii_digit() || c == '.')? + 1;
696    let (number, unit) = text.split_at(unit_start);
697    let per_unit: f64 = match unit {
698        "in" => 914_400.0,
699        "cm" => 360_000.0,
700        "mm" => 36_000.0,
701        "pt" => 12_700.0,
702        "pc" | "pi" => 152_400.0,
703        _ => return None,
704    };
705    let number: f64 = number.parse().ok()?;
706    Some((number * per_unit).round() as i64)
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart""#;
714
715    fn slide(body: &str) -> (SlideContent, SlideReport) {
716        let xml = format!(
717            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sld {NS}><p:cSld name="My Slide"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>{body}</p:spTree></p:cSld></p:sld>"#
718        );
719        parse_slide(xml.as_bytes()).unwrap()
720    }
721
722    #[test]
723    fn a_title_shape_with_runs_breaks_and_fields() {
724        let (content, report) = slide(
725            r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Title 1" descr="alt"><a:hlinkClick r:id="rId9"/></p:cNvPr><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="ctrTitle"/></p:nvPr></p:nvSpPr><p:spPr><a:xfrm rot="5400000" flipH="1"><a:off x="838200" y="365125"/><a:ext cx="10515600" cy="1325563"/></a:xfrm></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:pPr lvl="1"><a:buChar char="•"/></a:pPr><a:r><a:rPr lang="en-US" b="1" sz="4400" u="sng"><a:latin typeface="Arial"/><a:hlinkClick r:id="rId2"/></a:rPr><a:t>Hello, </a:t></a:r><a:br/><a:fld id="{B6F15528-21DE-4FAA-801E-634DDDAF4B2B}" type="slidenum"><a:rPr lang="en-US"/><a:t>3</a:t></a:fld><a:r><a:t>World &amp; more</a:t></a:r><a:endParaRPr lang="en-US"/></a:p><a:p><a:pPr><a:buNone/></a:pPr></a:p></p:txBody></p:sp>"#,
726        );
727        assert!(report.is_empty());
728        assert_eq!(content.kind, SlideKind::Slide);
729        assert_eq!(content.name.as_deref(), Some("My Slide"));
730        assert!(content.show);
731        assert_eq!(content.shapes.len(), 1);
732        let shape = &content.shapes[0];
733        assert_eq!(shape.id, 2);
734        assert_eq!(shape.name, "Title 1");
735        assert_eq!(shape.description.as_deref(), Some("alt"));
736        assert_eq!(shape.hyperlink.as_deref(), Some("rId9"));
737        assert_eq!(
738            shape.placeholder,
739            Some(Placeholder {
740                kind: PlaceholderKind::CenterTitle,
741                idx: 0
742            })
743        );
744        assert!(shape.is_title());
745        assert_eq!(
746            shape.transform,
747            Some(Transform {
748                x: 838200,
749                y: 365125,
750                cx: 10515600,
751                cy: 1325563,
752                rot: 5400000,
753                flip_h: true,
754                flip_v: false
755            })
756        );
757        let body = shape.text_body().unwrap();
758        assert_eq!(body.paragraphs.len(), 2);
759        let first = &body.paragraphs[0];
760        assert_eq!(first.level, 1);
761        assert_eq!(first.bullet, Bullet::Char("•".into()));
762        assert_eq!(first.runs.len(), 4);
763        assert_eq!(first.runs[0].text, "Hello, ");
764        assert_eq!(
765            first.runs[0].props,
766            RunProps {
767                bold: Some(true),
768                italic: None,
769                underline: Some(true),
770                strike: None,
771                size: Some(4400),
772                hyperlink: Some("rId2".into()),
773                lang: Some("en-US".into()),
774                typeface: Some("Arial".into())
775            }
776        );
777        assert_eq!(first.runs[1].kind, RunKind::LineBreak);
778        assert_eq!(first.runs[2].kind, RunKind::Field("slidenum".into()));
779        assert_eq!(first.runs[2].text, "3");
780        assert_eq!(first.text(), "Hello, \n3World & more");
781        assert_eq!(body.paragraphs[1].bullet, Bullet::None);
782        assert!(body.paragraphs[1].is_empty());
783        assert_eq!(body.text(), "Hello, \n3World & more\n");
784        assert_eq!(content.title().as_deref(), Some("Hello, \n3World & more\n"));
785    }
786
787    #[test]
788    fn pictures_groups_tables_charts_and_unknown_graphics() {
789        let (content, report) = slide(
790            r#"<p:pic><p:nvPicPr><p:cNvPr id="4" name="Picture 3" hidden="1"/><p:cNvPicPr/><p:nvPr><a:videoFile r:link="rId7"/></p:nvPr></p:nvPicPr><p:blipFill><a:blip r:embed="rId3"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="1" y="2"/><a:ext cx="3" cy="4"/></a:xfrm></p:spPr></p:pic>
791<p:grpSp><p:nvGrpSpPr><p:cNvPr id="5" name="Group 4"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="10" y="20"/><a:ext cx="30" cy="40"/><a:chOff x="1" y="2"/><a:chExt cx="3" cy="4"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="6" name="In group"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:p><a:r><a:t>inside</a:t></a:r></a:p></p:txBody></p:sp><p:cxnSp><p:nvCxnSpPr><p:cNvPr id="7" name="Connector"/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr><p:spPr/></p:cxnSp></p:grpSp>
792<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="8" name="Table 7"/><p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr><p:ph type="tbl" idx="1"/></p:nvPr></p:nvGraphicFramePr><p:xfrm><a:off x="5" y="6"/><a:ext cx="7" cy="8"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table"><a:tbl><a:tblPr firstRow="1"/><a:tblGrid><a:gridCol w="100"/><a:gridCol w="200"/></a:tblGrid><a:tr h="50"><a:tc gridSpan="2"><a:txBody><a:bodyPr/><a:p><a:r><a:t>Head</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc><a:tc hMerge="1"><a:txBody><a:bodyPr/><a:p/></a:txBody></a:tc></a:tr><a:tr h="60"><a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>a</a:t></a:r></a:p></a:txBody></a:tc><a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>b</a:t></a:r></a:p></a:txBody></a:tc></a:tr></a:tbl></a:graphicData></a:graphic></p:graphicFrame>
793<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="9" name="Chart 8"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1" cy="1"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart r:id="rId4"/></a:graphicData></a:graphic></p:graphicFrame>
794<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="10" name="Mystery"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1" cy="1"/></p:xfrm><a:graphic><a:graphicData uri="urn:mystery"><a:foo/></a:graphicData></a:graphic></p:graphicFrame>"#,
795        );
796        assert_eq!(report.unknown_graphics, vec!["urn:mystery"]);
797        assert_eq!(report.unknown_elements, 0);
798        assert_eq!(content.shapes.len(), 5);
799        let pic = &content.shapes[0];
800        assert!(pic.hidden);
801        assert_eq!(
802            pic.content,
803            Content::Picture(Picture {
804                embed: Some("rId3".into()),
805                link: None,
806                media: Some("rId7".into())
807            })
808        );
809        assert_eq!(
810            pic.transform,
811            Some(Transform {
812                x: 1,
813                y: 2,
814                cx: 3,
815                cy: 4,
816                rot: 0,
817                flip_h: false,
818                flip_v: false
819            })
820        );
821        let Content::Group(children, space) = &content.shapes[1].content else {
822            panic!()
823        };
824        assert_eq!(children.len(), 2);
825        assert!(children[0].text_box);
826        assert_eq!(children[0].text_body().unwrap().text(), "inside");
827        assert_eq!(children[1].content, Content::Connector);
828        assert_eq!(
829            *space,
830            Some(ChildSpace {
831                x: 1,
832                y: 2,
833                cx: 3,
834                cy: 4
835            })
836        );
837        assert_eq!(
838            content.shapes[1].transform,
839            Some(Transform {
840                x: 10,
841                y: 20,
842                cx: 30,
843                cy: 40,
844                rot: 0,
845                flip_h: false,
846                flip_v: false
847            })
848        );
849        let Content::Table(table) = &content.shapes[2].content else {
850            panic!()
851        };
852        assert_eq!(table.column_widths, vec![100, 200]);
853        assert_eq!(table.rows.len(), 2);
854        assert_eq!(table.rows[0].cells[0].grid_span, 2);
855        assert!(table.rows[0].cells[1].h_merge);
856        assert!(!table.rows[0].cells[1].is_origin());
857        assert_eq!(table.rows[1].cells[1].body.text(), "b");
858        assert_eq!(
859            content.shapes[2].placeholder,
860            Some(Placeholder {
861                kind: PlaceholderKind::Table,
862                idx: 1
863            })
864        );
865        assert_eq!(
866            content.shapes[2].transform,
867            Some(Transform {
868                x: 5,
869                y: 6,
870                cx: 7,
871                cy: 8,
872                rot: 0,
873                flip_h: false,
874                flip_v: false
875            })
876        );
877        assert_eq!(
878            content.shapes[3].content,
879            Content::Chart(Some("rId4".into()))
880        );
881        assert_eq!(
882            content.shapes[4].content,
883            Content::UnknownGraphic("urn:mystery".into())
884        );
885        let walked: Vec<u32> = content.walk().map(|shape| shape.id).collect();
886        assert_eq!(walked, [4, 5, 6, 7, 8, 9, 10]);
887        assert!(content.title().is_none());
888    }
889
890    #[test]
891    fn alternate_content_falls_back_and_unknown_namespaces_are_counted() {
892        let (content, report) = slide(
893            r#"<mc:AlternateContent xmlns:p14="urn:p14"><mc:Choice Requires="p14"><p:sp><p:nvSpPr><p:cNvPr id="2" name="New"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:p><a:r><a:t>choice</a:t></a:r></a:p></p:txBody></p:sp></mc:Choice><mc:Fallback><p:sp><p:nvSpPr><p:cNvPr id="2" name="Old"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:p><a:r><a:t>fallback</a:t></a:r></a:p></p:txBody></p:sp></mc:Fallback></mc:AlternateContent><x:thing xmlns:x="urn:x"><x:inner/></x:thing><p:extLst><p:ext uri="{1}"><y:z xmlns:y="urn:y"/></p:ext></p:extLst>"#,
894        );
895        assert_eq!(content.shapes.len(), 1);
896        assert_eq!(content.shapes[0].name, "Old");
897        assert_eq!(content.shapes[0].text_body().unwrap().text(), "fallback");
898        assert_eq!(report.unknown_elements, 1);
899    }
900
901    #[test]
902    fn hidden_slides_layouts_and_notes_roots_parse() {
903        let xml = format!(r#"<p:sld {NS} show="0"><p:cSld><p:spTree/></p:cSld></p:sld>"#);
904        let (content, _) = parse_slide(xml.as_bytes()).unwrap();
905        assert!(!content.show);
906        assert!(content.shapes.is_empty());
907        let xml = format!(
908            r#"<p:notes {NS}><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/><p:cNvSpPr/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:p><a:r><a:t>speaker notes</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:notes>"#
909        );
910        let (content, _) = parse_slide(xml.as_bytes()).unwrap();
911        assert_eq!(content.kind, SlideKind::Notes);
912        assert_eq!(
913            content.shapes[0].placeholder.as_ref().unwrap().kind,
914            PlaceholderKind::Body
915        );
916        let xml = format!(
917            r#"<p:sldLayout {NS} type="title"><p:cSld name="Title Slide"><p:spTree/></p:cSld></p:sldLayout>"#
918        );
919        assert_eq!(
920            parse_slide(xml.as_bytes()).unwrap().0.kind,
921            SlideKind::Layout
922        );
923        let strict = r#"<p:sldMaster xmlns:p="http://purl.oclc.org/ooxml/presentationml/main"><p:cSld><p:spTree/></p:cSld></p:sldMaster>"#;
924        assert_eq!(
925            parse_slide(strict.as_bytes()).unwrap().0.kind,
926            SlideKind::Master
927        );
928        let wrong = format!(r#"<p:presentation {NS}/>"#);
929        assert_eq!(
930            parse_slide(wrong.as_bytes()).unwrap_err().msg,
931            "root element is not a slide, layout, master or notes element"
932        );
933        assert!(parse_slide(b"<p:sld").is_err());
934    }
935
936    #[test]
937    fn coordinates_accept_units() {
938        assert_eq!(parse_coordinate(b"914400"), Some(914400));
939        assert_eq!(parse_coordinate(b"-5"), Some(-5));
940        assert_eq!(parse_coordinate(b"1in"), Some(914400));
941        assert_eq!(parse_coordinate(b"2.54cm"), Some(914400));
942        assert_eq!(parse_coordinate(b"72pt"), Some(914400));
943        assert_eq!(parse_coordinate(b"10mm"), Some(360000));
944        assert_eq!(parse_coordinate(b"abc"), None);
945        assert_eq!(parse_coordinate(b"3px"), None);
946    }
947}