Skip to main content

pptx_edit/
paragraph.rs

1//! Font-independent paragraph semantics shared by rendering and export: the layout and master a
2//! slide inherits from, the paragraph-property cascade, and list markers.
3
4use ooxml_drawingml::ColorValue;
5use pptx_parse::{
6    Bullet, ParagraphProperties, Placeholder, PptxPackage, RunProperties, ShapeNode, Slide,
7    SlideLayout, SlideMaster, TextBody,
8};
9
10use crate::deck::shape_base;
11
12/// The largest number a list marker shows: the largest `startAt` plus one per paragraph a story
13/// renders.
14pub const MAX_LIST_NUMBER: u32 = 32_767 + 20_000;
15
16/// The source slide, layout and master a slide snapshot inherits from.
17#[derive(Clone, Copy, Debug, Default)]
18pub struct SlideParents<'a> {
19    pub slide: Option<&'a Slide>,
20    pub layout: Option<&'a SlideLayout>,
21    pub master: Option<&'a SlideMaster>,
22}
23
24impl<'a> SlideParents<'a> {
25    /// Resolves a slide's parts from its snapshot's source and layout part paths, falling back to
26    /// the package's first layout and master.
27    pub fn resolve(
28        package: &'a PptxPackage,
29        source_part_path: Option<&str>,
30        layout_part_path: Option<&str>,
31    ) -> Self {
32        let slide = source_part_path
33            .and_then(|path| package.slides.iter().find(|slide| slide.part_path == path));
34        let layout_path =
35            layout_part_path.or_else(|| slide.and_then(|slide| slide.layout_part_path.as_deref()));
36        let layout = layout_path
37            .and_then(|path| {
38                package
39                    .layouts
40                    .iter()
41                    .find(|layout| layout.part_path == path)
42            })
43            .or_else(|| package.layouts.first());
44        let master = layout
45            .and_then(|layout| layout.master_part_path.as_deref())
46            .and_then(|path| {
47                package
48                    .masters
49                    .iter()
50                    .find(|master| master.part_path == path)
51            })
52            .or_else(|| {
53                layout.and_then(|layout| {
54                    package.masters.iter().find(|master| {
55                        master
56                            .layout_part_paths
57                            .iter()
58                            .any(|path| path == &layout.part_path)
59                    })
60                })
61            })
62            .or_else(|| package.masters.first());
63        Self {
64            slide,
65            layout,
66            master,
67        }
68    }
69}
70
71/// The first shape in `nodes`, groups included, whose placeholder matches `target`.
72pub fn find_placeholder<'a>(nodes: &'a [ShapeNode], target: &Placeholder) -> Option<&'a ShapeNode> {
73    for node in nodes {
74        if shape_base(node)
75            .placeholder
76            .as_ref()
77            .is_some_and(|value| value.matches(target))
78        {
79            return Some(node);
80        }
81        if let ShapeNode::Group(group) = node
82            && let Some(found) = find_placeholder(&group.children, target)
83        {
84            return Some(found);
85        }
86    }
87    None
88}
89
90/// A placeholder type with its synonyms folded: `ctrTitle` is `title`, `obj` and an absent type
91/// are `body`.
92pub fn normalize_placeholder_type(value: Option<&str>) -> &str {
93    match value.unwrap_or("body") {
94        "ctrTitle" => "title",
95        "obj" => "body",
96        value => value,
97    }
98}
99
100/// The text bodies a shape's paragraphs inherit from, most specific first.
101#[derive(Clone, Copy, Debug, Default)]
102pub struct ParagraphCascade<'a> {
103    /// The shape's own source text body.
104    pub primary: Option<&'a TextBody>,
105    /// The matching layout placeholder's text body.
106    pub layout: Option<&'a TextBody>,
107    /// The matching master placeholder's text body.
108    pub master: Option<&'a TextBody>,
109    /// The master whose text styles the placeholder type selects.
110    pub master_slide: Option<&'a SlideMaster>,
111    /// `p:defaultTextStyle`, which outranks the master's `otherStyle` for text outside
112    /// placeholders.
113    pub default_style: &'a [ParagraphProperties],
114    /// `p:defaultTextStyle/a:defPPr`, under every level of `default_style`.
115    pub default_paragraph: Option<&'a ParagraphProperties>,
116    pub placeholder: Option<&'a Placeholder>,
117    /// `p:style/a:fontRef` colour.
118    pub style_color: Option<&'a ColorValue>,
119}
120
121impl ParagraphCascade<'_> {
122    /// The effective properties of paragraph `index` at `level`. An `authored` numbered bullet
123    /// replaces the inherited bullet.
124    pub fn properties(
125        &self,
126        index: usize,
127        level: u32,
128        authored: Option<&Bullet>,
129    ) -> ParagraphProperties {
130        let mut properties = self
131            .master_slide
132            .and_then(|master| master_style(master, self.placeholder, level))
133            .cloned()
134            .unwrap_or_default();
135        if self.placeholder.is_none() {
136            let level_style = self
137                .default_style
138                .get(level as usize)
139                .or_else(|| self.default_style.first());
140            for source in self.default_paragraph.into_iter().chain(level_style) {
141                merge_paragraph_properties(&mut properties, source);
142            }
143        }
144        if let Some(color) = self.style_color {
145            properties
146                .default_run
147                .get_or_insert_with(RunProperties::default)
148                .color = Some(color.clone());
149        }
150        for body in [self.master, self.layout, self.primary]
151            .into_iter()
152            .flatten()
153        {
154            if let Some(source) = &body.default_list_style {
155                merge_paragraph_properties(&mut properties, source);
156            }
157            if let Some(source) = body.list_style.get(level as usize) {
158                merge_paragraph_properties(&mut properties, source);
159            }
160            if let Some(source) = body
161                .paragraphs
162                .get(index)
163                .or_else(|| body.paragraphs.get(level as usize))
164                .map(|paragraph| &paragraph.properties)
165            {
166                merge_paragraph_properties(&mut properties, source);
167            }
168        }
169        if let Some(Bullet::AutoNumber { restart, .. }) = &mut properties.bullet {
170            *restart = self
171                .primary
172                .and_then(|body| body.paragraphs.get(index))
173                .is_some_and(|paragraph| {
174                    matches!(
175                        paragraph.properties.bullet,
176                        Some(Bullet::AutoNumber { restart: true, .. })
177                            | Some(Bullet::AutoNumber { start_at: 2.., .. })
178                    )
179                });
180        }
181        if let Some(authored @ Bullet::AutoNumber { .. }) = authored {
182            properties.bullet = Some(authored.clone());
183            if let Some(Bullet::AutoNumber {
184                restart, start_at, ..
185            }) = &mut properties.bullet
186            {
187                *restart |= *start_at != 1;
188            }
189        }
190        properties
191    }
192}
193
194fn master_style<'a>(
195    master: &'a SlideMaster,
196    placeholder: Option<&Placeholder>,
197    level: u32,
198) -> Option<&'a ParagraphProperties> {
199    let styles = match placeholder {
200        Some(placeholder) => {
201            match normalize_placeholder_type(placeholder.placeholder_type.as_deref()) {
202                "title" => &master.text_styles.title,
203                "body" | "subTitle" => &master.text_styles.body,
204                _ => &master.text_styles.other,
205            }
206        }
207        None => &master.text_styles.other,
208    };
209    styles.get(level as usize).or_else(|| styles.first())
210}
211
212/// Overlays every property `source` declares onto `target`.
213pub fn merge_paragraph_properties(target: &mut ParagraphProperties, source: &ParagraphProperties) {
214    if source.alignment.is_some() {
215        target.alignment.clone_from(&source.alignment);
216    }
217    if source.margin_left.is_some() {
218        target.margin_left = source.margin_left;
219    }
220    if source.margin_right.is_some() {
221        target.margin_right = source.margin_right;
222    }
223    if source.indent.is_some() {
224        target.indent = source.indent;
225    }
226    if source.bullet.is_some() {
227        target.bullet.clone_from(&source.bullet);
228    }
229    if source.line_spacing.is_some() {
230        target.line_spacing = source.line_spacing;
231    }
232    if source.space_before.is_some() {
233        target.space_before = source.space_before;
234    }
235    if source.space_after.is_some() {
236        target.space_after = source.space_after;
237    }
238    if source.bullet_font.is_some() {
239        target.bullet_font.clone_from(&source.bullet_font);
240    }
241    if source.bullet_color.is_some() {
242        target.bullet_color.clone_from(&source.bullet_color);
243    }
244    if source.bullet_size.is_some() {
245        target.bullet_size.clone_from(&source.bullet_size);
246    }
247    if source.default_tab_size.is_some() {
248        target.default_tab_size = source.default_tab_size;
249    }
250    if source.tab_stops.is_some() {
251        target.tab_stops.clone_from(&source.tab_stops);
252    }
253    if source.rtl.is_some() {
254        target.rtl = source.rtl;
255    }
256    if let Some(source) = &source.default_run {
257        let target = target
258            .default_run
259            .get_or_insert_with(RunProperties::default);
260        merge_run_properties(target, source);
261    }
262}
263
264fn merge_run_properties(target: &mut RunProperties, source: &RunProperties) {
265    if source.font_size_pt.is_some() {
266        target.font_size_pt = source.font_size_pt;
267    }
268    if source.bold.is_some() {
269        target.bold = source.bold;
270    }
271    if source.italic.is_some() {
272        target.italic = source.italic;
273    }
274    if source.underline.is_some() {
275        target.underline.clone_from(&source.underline);
276    }
277    if source.font_family.is_some() {
278        target.font_family.clone_from(&source.font_family);
279    }
280    if source.color.is_some() {
281        target.color.clone_from(&source.color);
282    }
283    if source.language.is_some() {
284        target.language.clone_from(&source.language);
285    }
286    if source.spacing_pt.is_some() {
287        target.spacing_pt = source.spacing_pt;
288    }
289    if source.baseline_pct.is_some() {
290        target.baseline_pct = source.baseline_pct;
291    }
292    if source.caps.is_some() {
293        target.caps = source.caps;
294    }
295}
296
297/// A paragraph's list marker, before any symbol-font substitution.
298#[derive(Clone, Debug, Eq, PartialEq)]
299pub enum ListMarker {
300    /// A `buChar` bullet, as authored.
301    Character(String),
302    /// A `buAutoNum` number; `marker` is `None` for a scheme [`format_autonum`] cannot format.
303    Number { value: u32, marker: Option<String> },
304}
305
306impl ListMarker {
307    /// The marker to draw: an unformattable scheme falls back to `"{value}."`.
308    pub fn text(&self) -> String {
309        match self {
310            Self::Character(value) => value.clone(),
311            Self::Number { value, marker } => marker.clone().unwrap_or_else(|| format!("{value}.")),
312        }
313    }
314}
315
316/// Per-level `a:buAutoNum` state of one story: the number last shown and the `startAt` the run
317/// was seeded from.
318#[derive(Clone, Debug, Default)]
319pub struct ListCounters {
320    numbers: [u32; 9],
321    starts: [u32; 9],
322}
323
324impl ListCounters {
325    /// The marker of the story's next paragraph. A paragraph without text is spacing, not an
326    /// item: PowerPoint neither marks it nor counts it towards the next number.
327    pub fn next(
328        &mut self,
329        bullet: Option<&Bullet>,
330        level: u32,
331        has_text: bool,
332    ) -> Option<ListMarker> {
333        if !has_text {
334            return None;
335        }
336        let level = (level as usize).min(self.numbers.len() - 1);
337        self.numbers[level + 1..].fill(0);
338        self.starts[level + 1..].fill(0);
339        match bullet {
340            Some(Bullet::AutoNumber {
341                scheme,
342                start_at,
343                restart,
344            }) => {
345                let start = (*start_at).clamp(1, 32_767);
346                // PowerPoint writes the list's `startAt` on every one of its paragraphs, so
347                // repeating the seed continues the run; only a different declared start opens a
348                // new list.
349                self.numbers[level] = match self.numbers[level] {
350                    0 => start,
351                    _ if *restart && self.starts[level] != start => start,
352                    current => current.saturating_add(1),
353                };
354                if self.numbers[level] == start {
355                    self.starts[level] = start;
356                }
357                let value = self.numbers[level].clamp(1, MAX_LIST_NUMBER);
358                Some(ListMarker::Number {
359                    value,
360                    marker: format_autonum(value, scheme),
361                })
362            }
363            _ => {
364                self.numbers[level] = 0;
365                self.starts[level] = 0;
366                match bullet {
367                    Some(Bullet::Character { value }) if !value.trim().is_empty() => {
368                        Some(ListMarker::Character(value.clone()))
369                    }
370                    _ => None,
371                }
372            }
373        }
374    }
375}
376
377/// Formats a Latin, Roman or decimal `ST_TextAutonumberScheme` marker; `None` for any other
378/// scheme.
379pub fn format_autonum(value: u32, scheme: &str) -> Option<String> {
380    let value = value.clamp(1, MAX_LIST_NUMBER);
381    let (numeral, suffix) = ["ParenBoth", "ParenR", "Period", "Plain"]
382        .into_iter()
383        .find_map(|suffix| Some((scheme.strip_suffix(suffix)?, suffix)))?;
384    let body = match numeral {
385        "alphaLc" => format_alpha(value, false),
386        "alphaUc" => format_alpha(value, true),
387        "romanLc" => format_roman(value, false),
388        "romanUc" => format_roman(value, true),
389        "arabic" => value.to_string(),
390        _ => return None,
391    };
392    Some(match suffix {
393        "ParenBoth" => format!("({body})"),
394        "ParenR" => format!("{body})"),
395        "Plain" => body,
396        _ => format!("{body}."),
397    })
398}
399
400fn format_alpha(value: u32, upper: bool) -> String {
401    let base = if upper { b'A' } else { b'a' };
402    let mut value = value.max(1);
403    let mut out = Vec::new();
404    while value > 0 {
405        let index = (value - 1) % 26;
406        out.push(base + index as u8);
407        value = (value - 1) / 26;
408    }
409    out.reverse();
410    String::from_utf8(out).unwrap_or_default()
411}
412
413fn format_roman(value: u32, upper: bool) -> String {
414    const NUMERALS: [(u32, &str); 13] = [
415        (1000, "m"),
416        (900, "cm"),
417        (500, "d"),
418        (400, "cd"),
419        (100, "c"),
420        (90, "xc"),
421        (50, "l"),
422        (40, "xl"),
423        (10, "x"),
424        (9, "ix"),
425        (5, "v"),
426        (4, "iv"),
427        (1, "i"),
428    ];
429    let mut value = value.max(1);
430    let mut out = String::new();
431    for (amount, numeral) in NUMERALS {
432        while value >= amount {
433            out.push_str(numeral);
434            value -= amount;
435        }
436    }
437    if upper { out.to_uppercase() } else { out }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    fn marker(counters: &mut ListCounters, bullet: Option<&Bullet>, level: u32) -> Option<String> {
445        counters
446            .next(bullet, level, true)
447            .map(|marker| marker.text())
448    }
449
450    fn number(scheme: &str, start_at: u32, restart: bool) -> Bullet {
451        Bullet::AutoNumber {
452            scheme: scheme.to_owned(),
453            start_at,
454            restart,
455        }
456    }
457
458    fn character(value: &str) -> Bullet {
459        Bullet::Character {
460            value: value.to_owned(),
461        }
462    }
463
464    #[test]
465    fn autonumbering_counts_per_level_and_resumes_across_other_levels() {
466        let mut counters = ListCounters::default();
467        let arabic = number("arabicPeriod", 1, false);
468        let dash = character("-");
469        assert_eq!(
470            marker(&mut counters, Some(&arabic), 0).as_deref(),
471            Some("1.")
472        );
473        assert_eq!(marker(&mut counters, Some(&dash), 1).as_deref(), Some("-"));
474        assert_eq!(marker(&mut counters, Some(&dash), 1).as_deref(), Some("-"));
475        assert_eq!(
476            marker(&mut counters, Some(&arabic), 0).as_deref(),
477            Some("2.")
478        );
479        assert_eq!(
480            marker(&mut counters, Some(&arabic), 0).as_deref(),
481            Some("3.")
482        );
483        assert_eq!(
484            marker(&mut counters, Some(&arabic), 1).as_deref(),
485            Some("1.")
486        );
487        assert_eq!(
488            marker(&mut counters, Some(&arabic), 1).as_deref(),
489            Some("2.")
490        );
491        assert_eq!(
492            marker(&mut counters, Some(&arabic), 0).as_deref(),
493            Some("4.")
494        );
495        assert_eq!(
496            marker(&mut counters, Some(&arabic), 1).as_deref(),
497            Some("1.")
498        );
499        assert_eq!(marker(&mut counters, Some(&Bullet::None), 0), None);
500    }
501
502    #[test]
503    fn inherited_autonumber_start_at_applies_only_to_the_first_item() {
504        let mut counters = ListCounters::default();
505        let seven = number("arabicPeriod", 7, false);
506        assert_eq!(
507            marker(&mut counters, Some(&seven), 0).as_deref(),
508            Some("7.")
509        );
510        assert_eq!(
511            marker(&mut counters, Some(&seven), 0).as_deref(),
512            Some("8.")
513        );
514        let mut counters = ListCounters::default();
515        let last_start = number("arabicPeriod", 32_767, false);
516        assert_eq!(
517            marker(&mut counters, Some(&last_start), 0).as_deref(),
518            Some("32767.")
519        );
520        assert_eq!(
521            marker(&mut counters, Some(&last_start), 0).as_deref(),
522            Some("32768.")
523        );
524    }
525
526    #[test]
527    fn autonumber_schemes_format_their_numeral_and_suffix() {
528        assert_eq!(format_autonum(4, "arabicPeriod").as_deref(), Some("4."));
529        assert_eq!(format_autonum(4, "arabicParenR").as_deref(), Some("4)"));
530        assert_eq!(format_autonum(4, "arabicParenBoth").as_deref(), Some("(4)"));
531        assert_eq!(format_autonum(4, "arabicPlain").as_deref(), Some("4"));
532        assert_eq!(format_autonum(1, "alphaLcParenR").as_deref(), Some("a)"));
533        assert_eq!(format_autonum(27, "alphaUcPeriod").as_deref(), Some("AA."));
534        assert_eq!(format_autonum(9, "romanLcPeriod").as_deref(), Some("ix."));
535        assert_eq!(
536            format_autonum(2024, "romanUcPeriod").as_deref(),
537            Some("MMXXIV.")
538        );
539        for scheme in ["somethingElse", "somethingElsePlain", "arabicDbPeriod"] {
540            assert_eq!(format_autonum(3, scheme), None);
541        }
542    }
543
544    #[test]
545    fn unformattable_schemes_fall_back_to_a_decimal_marker() {
546        let mut counters = ListCounters::default();
547        let circled = number("circleNumDbPlain", 3, false);
548        assert_eq!(
549            counters.next(Some(&circled), 0, true),
550            Some(ListMarker::Number {
551                value: 3,
552                marker: None
553            })
554        );
555        assert_eq!(
556            marker(&mut counters, Some(&circled), 0).as_deref(),
557            Some("4.")
558        );
559    }
560
561    #[test]
562    fn paragraphs_without_text_neither_mark_nor_count() {
563        let mut counters = ListCounters::default();
564        let arabic = number("arabicPeriod", 1, false);
565        assert_eq!(
566            marker(&mut counters, Some(&arabic), 0).as_deref(),
567            Some("1.")
568        );
569        assert_eq!(counters.next(Some(&arabic), 0, false), None);
570        assert_eq!(
571            marker(&mut counters, Some(&arabic), 0).as_deref(),
572            Some("2.")
573        );
574    }
575
576    #[test]
577    fn autonumber_sequences_restart_after_plain_paragraphs_and_explicit_starts() {
578        let mut counters = ListCounters::default();
579        let arabic = number("arabicPeriod", 1, false);
580        let restart = number("arabicPeriod", 7, true);
581        assert_eq!(
582            marker(&mut counters, Some(&arabic), 0).as_deref(),
583            Some("1.")
584        );
585        assert_eq!(
586            marker(&mut counters, Some(&restart), 0).as_deref(),
587            Some("7.")
588        );
589        assert_eq!(
590            marker(&mut counters, Some(&arabic), 0).as_deref(),
591            Some("8.")
592        );
593        assert_eq!(marker(&mut counters, None, 0), None);
594        assert_eq!(
595            marker(&mut counters, Some(&arabic), 0).as_deref(),
596            Some("1.")
597        );
598        assert_eq!(
599            marker(&mut counters, Some(&arabic), 1).as_deref(),
600            Some("1.")
601        );
602        assert_eq!(
603            marker(&mut counters, Some(&character("•")), 0).as_deref(),
604            Some("•")
605        );
606        assert_eq!(
607            marker(&mut counters, Some(&arabic), 1).as_deref(),
608            Some("1.")
609        );
610        assert_eq!(
611            marker(&mut counters, Some(&arabic), 0).as_deref(),
612            Some("1.")
613        );
614    }
615
616    /// `pptarena-018-original` slide 11 declares `startAt="4"` on all four of its paragraphs and
617    /// PowerPoint renders 4, 5, 6, 7; `pptarena-034-original` slide 11 declares `startAt="1"` on
618    /// all five and PowerPoint renders a) through e).
619    #[test]
620    fn a_repeated_declared_start_continues_the_list() {
621        let mut counters = ListCounters::default();
622        let arabic = number("arabicPeriod", 4, true);
623        let alpha = number("alphaLcParenR", 1, true);
624        assert_eq!(
625            (0..4)
626                .filter_map(|_| marker(&mut counters, Some(&arabic), 0))
627                .collect::<Vec<_>>(),
628            ["4.", "5.", "6.", "7."]
629        );
630        let mut counters = ListCounters::default();
631        assert_eq!(
632            (0..5)
633                .filter_map(|_| marker(&mut counters, Some(&alpha), 0))
634                .collect::<Vec<_>>(),
635            ["a)", "b)", "c)", "d)", "e)"]
636        );
637    }
638
639    #[test]
640    fn autonumber_roman_markers_bound_untrusted_start_values() {
641        assert_eq!(format_autonum(0, "arabicPeriod").as_deref(), Some("1."));
642        let largest = format_autonum(u32::MAX, "romanUcPeriod").unwrap();
643        assert_eq!(largest.len(), 61);
644        assert_eq!(largest, format!("{}DCCLXVII.", "M".repeat(52)));
645    }
646
647    #[test]
648    fn text_outside_placeholders_takes_the_presentation_default_style() {
649        let sized = |size| ParagraphProperties {
650            default_run: Some(RunProperties {
651                font_size_pt: Some(size),
652                ..RunProperties::default()
653            }),
654            ..ParagraphProperties::default()
655        };
656        let levels = [sized(27.0), sized(20.0)];
657        let under = ParagraphProperties {
658            bullet: Some(character("-")),
659            ..sized(12.0)
660        };
661        let body = Placeholder {
662            placeholder_type: Some("body".to_owned()),
663            index: Some(1),
664            orientation: None,
665            size: None,
666        };
667        let cascade = |placeholder| ParagraphCascade {
668            default_style: &levels,
669            default_paragraph: Some(&under),
670            placeholder,
671            ..ParagraphCascade::default()
672        };
673        let size = |properties: ParagraphProperties| properties.default_run.unwrap().font_size_pt;
674        let plain = cascade(None).properties(0, 1, None);
675        assert_eq!(plain.bullet, Some(character("-")));
676        assert_eq!(size(plain), Some(20.0));
677        assert_eq!(size(cascade(None).properties(0, 4, None)), Some(27.0));
678        let placed = cascade(Some(&body)).properties(0, 1, None);
679        assert_eq!((placed.bullet, placed.default_run), (None, None));
680    }
681}