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::{
5    ColorValue, TableCellPosition, TableStyleFlags, Theme, resolve_color_value_to_hex_with_theme,
6    resolve_theme_font_ref,
7};
8use pptx_parse::{
9    Bullet, GraphicFrameData, ParagraphProperties, Placeholder, PptxPackage, RunProperties,
10    ShapeNode, Slide, SlideLayout, SlideMaster, TextBody, builtin_table_style,
11};
12use yrs::Transact;
13
14use crate::deck::{map_string, required_map, shape_base, shape_parts, slide_ref};
15use crate::{
16    DeckSession, EditError, EditResult, SHAPES, StorySnapshot, TextRunSnapshot, TextStyle,
17};
18
19impl DeckSession {
20    pub fn story_formatting(
21        &self,
22        slide_id: &str,
23        shape_id: &str,
24        story_id: &str,
25    ) -> EditResult<StorySnapshot> {
26        let txn = self.doc.transact();
27        let slide = slide_ref(&txn, slide_id)?;
28        let source_path = map_string(&slide, &txn, "sourcePartPath");
29        let layout_path = map_string(&slide, &txn, "layoutPartPath");
30        let package = self.package();
31        let theme =
32            pptx_parse::slide_theme(package, source_path.as_deref(), layout_path.as_deref());
33        let parents =
34            SlideParents::resolve(package, source_path.as_deref(), layout_path.as_deref());
35        let shape = shape_parts(&required_map(&txn, SHAPES)?, &txn, shape_id, Some(&theme))?;
36        if !shape.story_ids.iter().any(|id| id == story_id) {
37            return Err(EditError::StoryNotFound(story_id.to_owned()));
38        }
39        let node = parents
40            .slide
41            .and_then(|slide| find_source_node(&slide.shapes, shape.snapshot.source_id));
42        let placeholder = shape.snapshot.placeholder.as_ref();
43        let mut cascade = ParagraphCascade {
44            primary: node.and_then(node_text),
45            layout: parents
46                .layout
47                .and_then(|layout| placeholder_text(&layout.shapes, placeholder)),
48            master: parents
49                .master
50                .and_then(|master| placeholder_text(&master.shapes, placeholder)),
51            master_slide: parents.master,
52            default_style: &package.presentation.default_text_style,
53            default_paragraph: package.presentation.default_text_paragraph.as_deref(),
54            placeholder,
55            style_color: match node {
56                Some(ShapeNode::Shape(shape)) => shape
57                    .style
58                    .as_ref()
59                    .and_then(|style| style.font_color.as_ref()),
60                _ => None,
61            },
62        };
63        let cell = cell_formatting(package, node, &shape.snapshot, story_id);
64        if let Some((text, inherited)) = &cell {
65            cascade = ParagraphCascade {
66                primary: Some(text),
67                master: Some(inherited),
68                ..ParagraphCascade::default()
69            };
70        }
71        let mut story = self.story(story_id)?;
72        for (index, paragraph) in story.paragraphs.iter_mut().enumerate() {
73            let mut properties = cascade.properties(index, paragraph.level, None);
74            paragraph.alignment = paragraph.alignment.clone().or(properties.alignment);
75            if paragraph.runs.is_empty() {
76                let end = [cascade.primary, cascade.layout, cascade.master]
77                    .into_iter()
78                    .flatten()
79                    .find_map(|body| body.paragraphs.get(index)?.end_properties.as_ref());
80                if let Some(end) = end {
81                    merge_run_properties(
82                        properties
83                            .default_run
84                            .get_or_insert_with(RunProperties::default),
85                        end,
86                    );
87                }
88                paragraph.runs.push(TextRunSnapshot {
89                    text: String::new(),
90                    style: TextStyle::default(),
91                });
92            }
93            for run in &mut paragraph.runs {
94                run.style =
95                    inherited_text_style(&run.style, properties.default_run.as_ref(), &theme);
96            }
97        }
98        Ok(story)
99    }
100}
101
102fn inherited_text_style(
103    direct: &TextStyle,
104    fallback: Option<&RunProperties>,
105    theme: &Theme,
106) -> TextStyle {
107    let fallback = fallback.cloned().unwrap_or_default();
108    let family = direct
109        .font_family
110        .as_deref()
111        .or(fallback.font_family.as_deref())
112        .unwrap_or("+mn-lt");
113    TextStyle {
114        bold: Some(direct.bold.or(fallback.bold).unwrap_or(false)),
115        italic: Some(direct.italic.or(fallback.italic).unwrap_or(false)),
116        font_size_pt: Some(
117            direct
118                .font_size_pt
119                .or(fallback.font_size_pt)
120                .unwrap_or(18.0),
121        ),
122        kern_pt: direct.kern_pt.or(fallback.kern_pt),
123        font_family: Some(if family.starts_with('+') {
124            resolve_theme_font_ref(Some(theme), family)
125        } else {
126            family.to_owned()
127        }),
128        color: direct
129            .color
130            .clone()
131            .or_else(|| resolve_color_value_to_hex_with_theme(fallback.color.as_ref(), Some(theme)))
132            .or_else(|| Some("#000000".to_owned())),
133        underline: Some(
134            direct
135                .underline
136                .clone()
137                .or(fallback.underline)
138                .unwrap_or_else(|| "none".to_owned()),
139        ),
140        spacing_pt: direct.spacing_pt.or(fallback.spacing_pt),
141        baseline_pct: direct.baseline_pct.or(fallback.baseline_pct),
142        caps: direct.caps.or(fallback.caps),
143    }
144}
145
146fn find_source_node(nodes: &[ShapeNode], id: u32) -> Option<&ShapeNode> {
147    for node in nodes {
148        if node.id() == id {
149            return Some(node);
150        }
151        if let ShapeNode::Group(group) = node
152            && let Some(found) = find_source_node(&group.children, id)
153        {
154            return Some(found);
155        }
156    }
157    None
158}
159
160fn node_text(node: &ShapeNode) -> Option<&TextBody> {
161    match node {
162        ShapeNode::Shape(shape) => shape.text.as_ref(),
163        _ => None,
164    }
165}
166
167fn placeholder_text<'a>(
168    nodes: &'a [ShapeNode],
169    placeholder: Option<&Placeholder>,
170) -> Option<&'a TextBody> {
171    placeholder
172        .and_then(|target| find_placeholder(nodes, target))
173        .and_then(node_text)
174}
175
176fn cell_formatting<'a>(
177    package: &'a PptxPackage,
178    node: Option<&'a ShapeNode>,
179    shape: &crate::ShapeSnapshot,
180    story_id: &str,
181) -> Option<(&'a TextBody, TextBody)> {
182    let ShapeNode::GraphicFrame(frame) = node? else {
183        return None;
184    };
185    let GraphicFrameData::Table(table) = &frame.data else {
186        return None;
187    };
188    let [row, column] = crate::table::source_cell(shape, story_id)?;
189    let cell = table.rows.get(row)?.cells.get(column)?;
190    let style = table
191        .properties
192        .style_id
193        .as_deref()
194        .and_then(|id| {
195            package
196                .table_styles
197                .style(Some(id))
198                .or_else(|| builtin_table_style(id))
199        })
200        .map(|style| {
201            style.resolve_cell(
202                TableStyleFlags {
203                    first_row: table.properties.first_row,
204                    last_row: table.properties.last_row,
205                    first_column: table.properties.first_col,
206                    last_column: table.properties.last_col,
207                    band_row: table.properties.band_row,
208                },
209                TableCellPosition {
210                    row,
211                    column,
212                    row_count: table.rows.len(),
213                    column_count: table.grid.len().max(
214                        table
215                            .rows
216                            .iter()
217                            .map(|row| row.cells.len())
218                            .max()
219                            .unwrap_or_default(),
220                    ),
221                },
222            )
223        })
224        .unwrap_or_default();
225    Some((
226        &cell.text,
227        TextBody {
228            default_list_style: Some(Box::new(ParagraphProperties {
229                default_run: Some(RunProperties {
230                    bold: style.bold,
231                    italic: style.italic,
232                    color: style.color,
233                    ..RunProperties::default()
234                }),
235                ..ParagraphProperties::default()
236            })),
237            ..TextBody::default()
238        },
239    ))
240}
241
242/// The largest number a list marker shows: the largest `startAt` plus one per paragraph a story
243/// renders.
244pub const MAX_LIST_NUMBER: u32 = 32_767 + 20_000;
245
246/// The source slide, layout and master a slide snapshot inherits from.
247#[derive(Clone, Copy, Debug, Default)]
248pub struct SlideParents<'a> {
249    pub slide: Option<&'a Slide>,
250    pub layout: Option<&'a SlideLayout>,
251    pub master: Option<&'a SlideMaster>,
252}
253
254impl<'a> SlideParents<'a> {
255    /// Resolves a slide's parts from its snapshot's source and layout part paths, falling back to
256    /// the package's first layout and master.
257    pub fn resolve(
258        package: &'a PptxPackage,
259        source_part_path: Option<&str>,
260        layout_part_path: Option<&str>,
261    ) -> Self {
262        let slide = source_part_path
263            .and_then(|path| package.slides.iter().find(|slide| slide.part_path == path));
264        let layout_path =
265            layout_part_path.or_else(|| slide.and_then(|slide| slide.layout_part_path.as_deref()));
266        let layout = layout_path
267            .and_then(|path| {
268                package
269                    .layouts
270                    .iter()
271                    .find(|layout| layout.part_path == path)
272            })
273            .or_else(|| package.layouts.first());
274        let master = layout
275            .and_then(|layout| layout.master_part_path.as_deref())
276            .and_then(|path| {
277                package
278                    .masters
279                    .iter()
280                    .find(|master| master.part_path == path)
281            })
282            .or_else(|| {
283                layout.and_then(|layout| {
284                    package.masters.iter().find(|master| {
285                        master
286                            .layout_part_paths
287                            .iter()
288                            .any(|path| path == &layout.part_path)
289                    })
290                })
291            })
292            .or_else(|| package.masters.first());
293        Self {
294            slide,
295            layout,
296            master,
297        }
298    }
299}
300
301/// The first shape in `nodes`, groups included, whose placeholder matches `target`.
302pub fn find_placeholder<'a>(nodes: &'a [ShapeNode], target: &Placeholder) -> Option<&'a ShapeNode> {
303    for node in nodes {
304        if shape_base(node)
305            .placeholder
306            .as_ref()
307            .is_some_and(|value| value.matches(target))
308        {
309            return Some(node);
310        }
311        if let ShapeNode::Group(group) = node
312            && let Some(found) = find_placeholder(&group.children, target)
313        {
314            return Some(found);
315        }
316    }
317    None
318}
319
320/// A placeholder type with its synonyms folded: `ctrTitle` is `title`, `obj` and an absent type
321/// are `body`.
322pub fn normalize_placeholder_type(value: Option<&str>) -> &str {
323    match value.unwrap_or("body") {
324        "ctrTitle" => "title",
325        "obj" => "body",
326        value => value,
327    }
328}
329
330/// The text bodies a shape's paragraphs inherit from, most specific first.
331#[derive(Clone, Copy, Debug, Default)]
332pub struct ParagraphCascade<'a> {
333    /// The shape's own source text body.
334    pub primary: Option<&'a TextBody>,
335    /// The matching layout placeholder's text body.
336    pub layout: Option<&'a TextBody>,
337    /// The matching master placeholder's text body.
338    pub master: Option<&'a TextBody>,
339    /// The master whose text styles the placeholder type selects.
340    pub master_slide: Option<&'a SlideMaster>,
341    /// `p:defaultTextStyle`, which outranks the master's `otherStyle` for text outside
342    /// placeholders.
343    pub default_style: &'a [ParagraphProperties],
344    /// `p:defaultTextStyle/a:defPPr`, under every level of `default_style`.
345    pub default_paragraph: Option<&'a ParagraphProperties>,
346    pub placeholder: Option<&'a Placeholder>,
347    /// `p:style/a:fontRef` colour.
348    pub style_color: Option<&'a ColorValue>,
349}
350
351impl ParagraphCascade<'_> {
352    /// The effective properties of paragraph `index` at `level`. An `authored` numbered bullet
353    /// replaces the inherited bullet.
354    pub fn properties(
355        &self,
356        index: usize,
357        level: u32,
358        authored: Option<&Bullet>,
359    ) -> ParagraphProperties {
360        let mut properties = self
361            .master_slide
362            .and_then(|master| master_style(master, self.placeholder, level))
363            .cloned()
364            .unwrap_or_default();
365        if self.placeholder.is_none() {
366            let level_style = self
367                .default_style
368                .get(level as usize)
369                .or_else(|| self.default_style.first());
370            for source in self.default_paragraph.into_iter().chain(level_style) {
371                merge_paragraph_properties(&mut properties, source);
372            }
373        }
374        if let Some(color) = self.style_color {
375            properties
376                .default_run
377                .get_or_insert_with(RunProperties::default)
378                .color = Some(color.clone());
379        }
380        for body in [self.master, self.layout, self.primary]
381            .into_iter()
382            .flatten()
383        {
384            if let Some(source) = &body.default_list_style {
385                merge_paragraph_properties(&mut properties, source);
386            }
387            if let Some(source) = body.list_style.get(level as usize) {
388                merge_paragraph_properties(&mut properties, source);
389            }
390            if let Some(source) = body
391                .paragraphs
392                .get(index)
393                .or_else(|| body.paragraphs.get(level as usize))
394                .map(|paragraph| &paragraph.properties)
395            {
396                merge_paragraph_properties(&mut properties, source);
397            }
398        }
399        if let Some(Bullet::AutoNumber { restart, .. }) = &mut properties.bullet {
400            *restart = self
401                .primary
402                .and_then(|body| body.paragraphs.get(index))
403                .is_some_and(|paragraph| {
404                    matches!(
405                        paragraph.properties.bullet,
406                        Some(Bullet::AutoNumber { restart: true, .. })
407                            | Some(Bullet::AutoNumber { start_at: 2.., .. })
408                    )
409                });
410        }
411        if let Some(authored @ Bullet::AutoNumber { .. }) = authored {
412            properties.bullet = Some(authored.clone());
413            if let Some(Bullet::AutoNumber {
414                restart, start_at, ..
415            }) = &mut properties.bullet
416            {
417                *restart |= *start_at != 1;
418            }
419        }
420        properties
421    }
422}
423
424fn master_style<'a>(
425    master: &'a SlideMaster,
426    placeholder: Option<&Placeholder>,
427    level: u32,
428) -> Option<&'a ParagraphProperties> {
429    let styles = match placeholder {
430        Some(placeholder) => {
431            match normalize_placeholder_type(placeholder.placeholder_type.as_deref()) {
432                "title" => &master.text_styles.title,
433                "body" | "subTitle" => &master.text_styles.body,
434                _ => &master.text_styles.other,
435            }
436        }
437        None => &master.text_styles.other,
438    };
439    styles.get(level as usize).or_else(|| styles.first())
440}
441
442/// Overlays every property `source` declares onto `target`.
443pub fn merge_paragraph_properties(target: &mut ParagraphProperties, source: &ParagraphProperties) {
444    if source.alignment.is_some() {
445        target.alignment.clone_from(&source.alignment);
446    }
447    if source.margin_left.is_some() {
448        target.margin_left = source.margin_left;
449    }
450    if source.margin_right.is_some() {
451        target.margin_right = source.margin_right;
452    }
453    if source.indent.is_some() {
454        target.indent = source.indent;
455    }
456    if source.bullet.is_some() {
457        target.bullet.clone_from(&source.bullet);
458    }
459    if source.line_spacing.is_some() {
460        target.line_spacing = source.line_spacing;
461    }
462    if source.space_before.is_some() {
463        target.space_before = source.space_before;
464    }
465    if source.space_after.is_some() {
466        target.space_after = source.space_after;
467    }
468    if source.bullet_font.is_some() {
469        target.bullet_font.clone_from(&source.bullet_font);
470    }
471    if source.bullet_color.is_some() {
472        target.bullet_color.clone_from(&source.bullet_color);
473    }
474    if source.bullet_size.is_some() {
475        target.bullet_size.clone_from(&source.bullet_size);
476    }
477    if source.default_tab_size.is_some() {
478        target.default_tab_size = source.default_tab_size;
479    }
480    if source.tab_stops.is_some() {
481        target.tab_stops.clone_from(&source.tab_stops);
482    }
483    if source.rtl.is_some() {
484        target.rtl = source.rtl;
485    }
486    if let Some(source) = &source.default_run {
487        let target = target
488            .default_run
489            .get_or_insert_with(RunProperties::default);
490        merge_run_properties(target, source);
491    }
492}
493
494fn merge_run_properties(target: &mut RunProperties, source: &RunProperties) {
495    if source.font_size_pt.is_some() {
496        target.font_size_pt = source.font_size_pt;
497    }
498    if source.bold.is_some() {
499        target.bold = source.bold;
500    }
501    if source.italic.is_some() {
502        target.italic = source.italic;
503    }
504    if source.underline.is_some() {
505        target.underline.clone_from(&source.underline);
506    }
507    if source.font_family.is_some() {
508        target.font_family.clone_from(&source.font_family);
509    }
510    if source.color.is_some() {
511        target.color.clone_from(&source.color);
512    }
513    if source.language.is_some() {
514        target.language.clone_from(&source.language);
515    }
516    if source.spacing_pt.is_some() {
517        target.spacing_pt = source.spacing_pt;
518    }
519    if source.baseline_pct.is_some() {
520        target.baseline_pct = source.baseline_pct;
521    }
522    if source.kern_pt.is_some() {
523        target.kern_pt = source.kern_pt;
524    }
525    if source.caps.is_some() {
526        target.caps = source.caps;
527    }
528}
529
530/// A paragraph's list marker, before any symbol-font substitution.
531#[derive(Clone, Debug, Eq, PartialEq)]
532pub enum ListMarker {
533    /// A `buChar` bullet, as authored.
534    Character(String),
535    /// A `buAutoNum` number; `marker` is `None` for a scheme [`format_autonum`] cannot format.
536    Number { value: u32, marker: Option<String> },
537}
538
539impl ListMarker {
540    /// The marker to draw: an unformattable scheme falls back to `"{value}."`.
541    pub fn text(&self) -> String {
542        match self {
543            Self::Character(value) => value.clone(),
544            Self::Number { value, marker } => marker.clone().unwrap_or_else(|| format!("{value}.")),
545        }
546    }
547}
548
549/// Per-level `a:buAutoNum` state of one story: the number last shown and the `startAt` the run
550/// was seeded from.
551#[derive(Clone, Debug, Default)]
552pub struct ListCounters {
553    numbers: [u32; 9],
554    starts: [u32; 9],
555}
556
557impl ListCounters {
558    /// The marker of the story's next paragraph. A paragraph without text is spacing, not an
559    /// item: PowerPoint neither marks it nor counts it towards the next number.
560    pub fn next(
561        &mut self,
562        bullet: Option<&Bullet>,
563        level: u32,
564        has_text: bool,
565    ) -> Option<ListMarker> {
566        if !has_text {
567            return None;
568        }
569        let level = (level as usize).min(self.numbers.len() - 1);
570        self.numbers[level + 1..].fill(0);
571        self.starts[level + 1..].fill(0);
572        match bullet {
573            Some(Bullet::AutoNumber {
574                scheme,
575                start_at,
576                restart,
577            }) => {
578                let start = (*start_at).clamp(1, 32_767);
579                // PowerPoint writes the list's `startAt` on every one of its paragraphs, so
580                // repeating the seed continues the run; only a different declared start opens a
581                // new list.
582                self.numbers[level] = match self.numbers[level] {
583                    0 => start,
584                    _ if *restart && self.starts[level] != start => start,
585                    current => current.saturating_add(1),
586                };
587                if self.numbers[level] == start {
588                    self.starts[level] = start;
589                }
590                let value = self.numbers[level].clamp(1, MAX_LIST_NUMBER);
591                Some(ListMarker::Number {
592                    value,
593                    marker: format_autonum(value, scheme),
594                })
595            }
596            _ => {
597                self.numbers[level] = 0;
598                self.starts[level] = 0;
599                match bullet {
600                    Some(Bullet::Character { value }) if !value.trim().is_empty() => {
601                        Some(ListMarker::Character(value.clone()))
602                    }
603                    _ => None,
604                }
605            }
606        }
607    }
608}
609
610/// Formats a Latin, Roman or decimal `ST_TextAutonumberScheme` marker; `None` for any other
611/// scheme.
612pub fn format_autonum(value: u32, scheme: &str) -> Option<String> {
613    let value = value.clamp(1, MAX_LIST_NUMBER);
614    let (numeral, suffix) = ["ParenBoth", "ParenR", "Period", "Plain"]
615        .into_iter()
616        .find_map(|suffix| Some((scheme.strip_suffix(suffix)?, suffix)))?;
617    let body = match numeral {
618        "alphaLc" => format_alpha(value, false),
619        "alphaUc" => format_alpha(value, true),
620        "romanLc" => format_roman(value, false),
621        "romanUc" => format_roman(value, true),
622        "arabic" => value.to_string(),
623        _ => return None,
624    };
625    Some(match suffix {
626        "ParenBoth" => format!("({body})"),
627        "ParenR" => format!("{body})"),
628        "Plain" => body,
629        _ => format!("{body}."),
630    })
631}
632
633fn format_alpha(value: u32, upper: bool) -> String {
634    let base = if upper { b'A' } else { b'a' };
635    let mut value = value.max(1);
636    let mut out = Vec::new();
637    while value > 0 {
638        let index = (value - 1) % 26;
639        out.push(base + index as u8);
640        value = (value - 1) / 26;
641    }
642    out.reverse();
643    String::from_utf8(out).unwrap_or_default()
644}
645
646fn format_roman(value: u32, upper: bool) -> String {
647    const NUMERALS: [(u32, &str); 13] = [
648        (1000, "m"),
649        (900, "cm"),
650        (500, "d"),
651        (400, "cd"),
652        (100, "c"),
653        (90, "xc"),
654        (50, "l"),
655        (40, "xl"),
656        (10, "x"),
657        (9, "ix"),
658        (5, "v"),
659        (4, "iv"),
660        (1, "i"),
661    ];
662    let mut value = value.max(1);
663    let mut out = String::new();
664    for (amount, numeral) in NUMERALS {
665        while value >= amount {
666            out.push_str(numeral);
667            value -= amount;
668        }
669    }
670    if upper { out.to_uppercase() } else { out }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    fn marker(counters: &mut ListCounters, bullet: Option<&Bullet>, level: u32) -> Option<String> {
678        counters
679            .next(bullet, level, true)
680            .map(|marker| marker.text())
681    }
682
683    fn number(scheme: &str, start_at: u32, restart: bool) -> Bullet {
684        Bullet::AutoNumber {
685            scheme: scheme.to_owned(),
686            start_at,
687            restart,
688        }
689    }
690
691    fn character(value: &str) -> Bullet {
692        Bullet::Character {
693            value: value.to_owned(),
694        }
695    }
696
697    #[test]
698    fn autonumbering_counts_per_level_and_resumes_across_other_levels() {
699        let mut counters = ListCounters::default();
700        let arabic = number("arabicPeriod", 1, false);
701        let dash = character("-");
702        assert_eq!(
703            marker(&mut counters, Some(&arabic), 0).as_deref(),
704            Some("1.")
705        );
706        assert_eq!(marker(&mut counters, Some(&dash), 1).as_deref(), Some("-"));
707        assert_eq!(marker(&mut counters, Some(&dash), 1).as_deref(), Some("-"));
708        assert_eq!(
709            marker(&mut counters, Some(&arabic), 0).as_deref(),
710            Some("2.")
711        );
712        assert_eq!(
713            marker(&mut counters, Some(&arabic), 0).as_deref(),
714            Some("3.")
715        );
716        assert_eq!(
717            marker(&mut counters, Some(&arabic), 1).as_deref(),
718            Some("1.")
719        );
720        assert_eq!(
721            marker(&mut counters, Some(&arabic), 1).as_deref(),
722            Some("2.")
723        );
724        assert_eq!(
725            marker(&mut counters, Some(&arabic), 0).as_deref(),
726            Some("4.")
727        );
728        assert_eq!(
729            marker(&mut counters, Some(&arabic), 1).as_deref(),
730            Some("1.")
731        );
732        assert_eq!(marker(&mut counters, Some(&Bullet::None), 0), None);
733    }
734
735    #[test]
736    fn inherited_autonumber_start_at_applies_only_to_the_first_item() {
737        let mut counters = ListCounters::default();
738        let seven = number("arabicPeriod", 7, false);
739        assert_eq!(
740            marker(&mut counters, Some(&seven), 0).as_deref(),
741            Some("7.")
742        );
743        assert_eq!(
744            marker(&mut counters, Some(&seven), 0).as_deref(),
745            Some("8.")
746        );
747        let mut counters = ListCounters::default();
748        let last_start = number("arabicPeriod", 32_767, false);
749        assert_eq!(
750            marker(&mut counters, Some(&last_start), 0).as_deref(),
751            Some("32767.")
752        );
753        assert_eq!(
754            marker(&mut counters, Some(&last_start), 0).as_deref(),
755            Some("32768.")
756        );
757    }
758
759    #[test]
760    fn autonumber_schemes_format_their_numeral_and_suffix() {
761        assert_eq!(format_autonum(4, "arabicPeriod").as_deref(), Some("4."));
762        assert_eq!(format_autonum(4, "arabicParenR").as_deref(), Some("4)"));
763        assert_eq!(format_autonum(4, "arabicParenBoth").as_deref(), Some("(4)"));
764        assert_eq!(format_autonum(4, "arabicPlain").as_deref(), Some("4"));
765        assert_eq!(format_autonum(1, "alphaLcParenR").as_deref(), Some("a)"));
766        assert_eq!(format_autonum(27, "alphaUcPeriod").as_deref(), Some("AA."));
767        assert_eq!(format_autonum(9, "romanLcPeriod").as_deref(), Some("ix."));
768        assert_eq!(
769            format_autonum(2024, "romanUcPeriod").as_deref(),
770            Some("MMXXIV.")
771        );
772        for scheme in ["somethingElse", "somethingElsePlain", "arabicDbPeriod"] {
773            assert_eq!(format_autonum(3, scheme), None);
774        }
775    }
776
777    #[test]
778    fn unformattable_schemes_fall_back_to_a_decimal_marker() {
779        let mut counters = ListCounters::default();
780        let circled = number("circleNumDbPlain", 3, false);
781        assert_eq!(
782            counters.next(Some(&circled), 0, true),
783            Some(ListMarker::Number {
784                value: 3,
785                marker: None
786            })
787        );
788        assert_eq!(
789            marker(&mut counters, Some(&circled), 0).as_deref(),
790            Some("4.")
791        );
792    }
793
794    #[test]
795    fn paragraphs_without_text_neither_mark_nor_count() {
796        let mut counters = ListCounters::default();
797        let arabic = number("arabicPeriod", 1, false);
798        assert_eq!(
799            marker(&mut counters, Some(&arabic), 0).as_deref(),
800            Some("1.")
801        );
802        assert_eq!(counters.next(Some(&arabic), 0, false), None);
803        assert_eq!(
804            marker(&mut counters, Some(&arabic), 0).as_deref(),
805            Some("2.")
806        );
807    }
808
809    #[test]
810    fn autonumber_sequences_restart_after_plain_paragraphs_and_explicit_starts() {
811        let mut counters = ListCounters::default();
812        let arabic = number("arabicPeriod", 1, false);
813        let restart = number("arabicPeriod", 7, true);
814        assert_eq!(
815            marker(&mut counters, Some(&arabic), 0).as_deref(),
816            Some("1.")
817        );
818        assert_eq!(
819            marker(&mut counters, Some(&restart), 0).as_deref(),
820            Some("7.")
821        );
822        assert_eq!(
823            marker(&mut counters, Some(&arabic), 0).as_deref(),
824            Some("8.")
825        );
826        assert_eq!(marker(&mut counters, None, 0), None);
827        assert_eq!(
828            marker(&mut counters, Some(&arabic), 0).as_deref(),
829            Some("1.")
830        );
831        assert_eq!(
832            marker(&mut counters, Some(&arabic), 1).as_deref(),
833            Some("1.")
834        );
835        assert_eq!(
836            marker(&mut counters, Some(&character("•")), 0).as_deref(),
837            Some("•")
838        );
839        assert_eq!(
840            marker(&mut counters, Some(&arabic), 1).as_deref(),
841            Some("1.")
842        );
843        assert_eq!(
844            marker(&mut counters, Some(&arabic), 0).as_deref(),
845            Some("1.")
846        );
847    }
848
849    /// `pptarena-018-original` slide 11 declares `startAt="4"` on all four of its paragraphs and
850    /// PowerPoint renders 4, 5, 6, 7; `pptarena-034-original` slide 11 declares `startAt="1"` on
851    /// all five and PowerPoint renders a) through e).
852    #[test]
853    fn a_repeated_declared_start_continues_the_list() {
854        let mut counters = ListCounters::default();
855        let arabic = number("arabicPeriod", 4, true);
856        let alpha = number("alphaLcParenR", 1, true);
857        assert_eq!(
858            (0..4)
859                .filter_map(|_| marker(&mut counters, Some(&arabic), 0))
860                .collect::<Vec<_>>(),
861            ["4.", "5.", "6.", "7."]
862        );
863        let mut counters = ListCounters::default();
864        assert_eq!(
865            (0..5)
866                .filter_map(|_| marker(&mut counters, Some(&alpha), 0))
867                .collect::<Vec<_>>(),
868            ["a)", "b)", "c)", "d)", "e)"]
869        );
870    }
871
872    #[test]
873    fn autonumber_roman_markers_bound_untrusted_start_values() {
874        assert_eq!(format_autonum(0, "arabicPeriod").as_deref(), Some("1."));
875        let largest = format_autonum(u32::MAX, "romanUcPeriod").unwrap();
876        assert_eq!(largest.len(), 61);
877        assert_eq!(largest, format!("{}DCCLXVII.", "M".repeat(52)));
878    }
879
880    #[test]
881    fn text_outside_placeholders_takes_the_presentation_default_style() {
882        let sized = |size| ParagraphProperties {
883            default_run: Some(RunProperties {
884                font_size_pt: Some(size),
885                ..RunProperties::default()
886            }),
887            ..ParagraphProperties::default()
888        };
889        let levels = [sized(27.0), sized(20.0)];
890        let under = ParagraphProperties {
891            bullet: Some(character("-")),
892            ..sized(12.0)
893        };
894        let body = Placeholder {
895            placeholder_type: Some("body".to_owned()),
896            index: Some(1),
897            orientation: None,
898            size: None,
899        };
900        let cascade = |placeholder| ParagraphCascade {
901            default_style: &levels,
902            default_paragraph: Some(&under),
903            placeholder,
904            ..ParagraphCascade::default()
905        };
906        let size = |properties: ParagraphProperties| properties.default_run.unwrap().font_size_pt;
907        let plain = cascade(None).properties(0, 1, None);
908        assert_eq!(plain.bullet, Some(character("-")));
909        assert_eq!(size(plain), Some(20.0));
910        assert_eq!(size(cascade(None).properties(0, 4, None)), Some(27.0));
911        let placed = cascade(Some(&body)).properties(0, 1, None);
912        assert_eq!((placed.bullet, placed.default_run), (None, None));
913    }
914}