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, ResolvedCellStyle, TableCellPosition, TableStyleFlags, Theme,
6    resolve_color_value_to_hex_with_theme, resolve_theme_font_ref,
7};
8use pptx_parse::{
9    Bullet, GraphicFrameData, ParagraphProperties, Placeholder, PptxPackage, Presentation,
10    RunProperties, 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, parents.master, 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    master: Option<&SlideMaster>,
179    node: Option<&'a ShapeNode>,
180    shape: &crate::ShapeSnapshot,
181    story_id: &str,
182) -> Option<(&'a TextBody, TextBody)> {
183    let ShapeNode::GraphicFrame(frame) = node? else {
184        return None;
185    };
186    let GraphicFrameData::Table(table) = &frame.data else {
187        return None;
188    };
189    let [row, column] = crate::table::source_cell(shape, story_id)?;
190    let cell = table.rows.get(row)?.cells.get(column)?;
191    let style = table
192        .properties
193        .style_id
194        .as_deref()
195        .and_then(|id| {
196            package
197                .table_styles
198                .style(Some(id))
199                .or_else(|| builtin_table_style(id))
200        })
201        .map(|style| {
202            style.resolve_cell(
203                TableStyleFlags {
204                    first_row: table.properties.first_row,
205                    last_row: table.properties.last_row,
206                    first_column: table.properties.first_col,
207                    last_column: table.properties.last_col,
208                    band_row: table.properties.band_row,
209                },
210                TableCellPosition {
211                    row,
212                    column,
213                    row_count: table.rows.len(),
214                    column_count: table.grid.len().max(
215                        table
216                            .rows
217                            .iter()
218                            .map(|row| row.cells.len())
219                            .max()
220                            .unwrap_or_default(),
221                    ),
222                },
223            )
224        })
225        .unwrap_or_default();
226    let master_levels = master
227        .map(|master| package.master_other_style_levels(master))
228        .unwrap_or([false; 9]);
229    let text_levels = table_text_levels(master, &master_levels, &package.presentation);
230    Some((&cell.text, table_style_text_body(&style, &text_levels)))
231}
232
233/// The paragraph levels table text inherits outside the table style: each level the master's
234/// `otherStyle` authors, else `p:defaultTextStyle` over its `a:defPPr`.
235pub fn table_text_levels(
236    master: Option<&SlideMaster>,
237    master_levels: &[bool; 9],
238    presentation: &Presentation,
239) -> Vec<ParagraphProperties> {
240    master_levels
241        .iter()
242        .enumerate()
243        .map(|(index, present)| {
244            let mut level = ParagraphProperties::default();
245            if *present {
246                if let Some(source) = master.and_then(|master| master.text_styles.other.get(index))
247                {
248                    merge_paragraph_properties(&mut level, source);
249                }
250            } else {
251                if let Some(source) = presentation.default_text_paragraph.as_deref() {
252                    merge_paragraph_properties(&mut level, source);
253                }
254                if let Some(source) = presentation
255                    .default_text_style
256                    .get(index)
257                    .or_else(|| presentation.default_text_style.first())
258                {
259                    merge_paragraph_properties(&mut level, source);
260                }
261            }
262            level
263        })
264        .collect()
265}
266
267/// Table text defaults from the master and table style, shaped as a body the cascade inherits
268/// from.
269pub fn table_style_text_body(
270    style: &ResolvedCellStyle,
271    text_levels: &[ParagraphProperties],
272) -> TextBody {
273    let properties = ParagraphProperties {
274        default_run: Some(RunProperties {
275            bold: style.bold,
276            italic: style.italic,
277            color: style.color.clone(),
278            ..RunProperties::default()
279        }),
280        ..ParagraphProperties::default()
281    };
282    let mut list_style = text_levels.to_vec();
283    for level in &mut list_style {
284        merge_paragraph_properties(level, &properties);
285    }
286    TextBody {
287        default_list_style: Some(Box::new(properties)),
288        list_style,
289        ..TextBody::default()
290    }
291}
292
293/// The largest number a list marker shows: the largest `startAt` plus one per paragraph a story
294/// renders.
295pub const MAX_LIST_NUMBER: u32 = 32_767 + 20_000;
296
297/// The source slide, layout and master a slide snapshot inherits from.
298#[derive(Clone, Copy, Debug, Default)]
299pub struct SlideParents<'a> {
300    pub slide: Option<&'a Slide>,
301    pub layout: Option<&'a SlideLayout>,
302    pub master: Option<&'a SlideMaster>,
303}
304
305impl<'a> SlideParents<'a> {
306    /// Resolves a slide's parts from its snapshot's source and layout part paths, falling back to
307    /// the package's first layout and master.
308    pub fn resolve(
309        package: &'a PptxPackage,
310        source_part_path: Option<&str>,
311        layout_part_path: Option<&str>,
312    ) -> Self {
313        let slide = source_part_path
314            .and_then(|path| package.slides.iter().find(|slide| slide.part_path == path));
315        let layout_path =
316            layout_part_path.or_else(|| slide.and_then(|slide| slide.layout_part_path.as_deref()));
317        let layout = layout_path
318            .and_then(|path| {
319                package
320                    .layouts
321                    .iter()
322                    .find(|layout| layout.part_path == path)
323            })
324            .or_else(|| package.layouts.first());
325        let master = layout
326            .and_then(|layout| layout.master_part_path.as_deref())
327            .and_then(|path| {
328                package
329                    .masters
330                    .iter()
331                    .find(|master| master.part_path == path)
332            })
333            .or_else(|| {
334                layout.and_then(|layout| {
335                    package.masters.iter().find(|master| {
336                        master
337                            .layout_part_paths
338                            .iter()
339                            .any(|path| path == &layout.part_path)
340                    })
341                })
342            })
343            .or_else(|| package.masters.first());
344        Self {
345            slide,
346            layout,
347            master,
348        }
349    }
350}
351
352/// The first shape in `nodes`, groups included, whose placeholder matches `target`.
353pub fn find_placeholder<'a>(nodes: &'a [ShapeNode], target: &Placeholder) -> Option<&'a ShapeNode> {
354    for node in nodes {
355        if shape_base(node)
356            .placeholder
357            .as_ref()
358            .is_some_and(|value| value.matches(target))
359        {
360            return Some(node);
361        }
362        if let ShapeNode::Group(group) = node
363            && let Some(found) = find_placeholder(&group.children, target)
364        {
365            return Some(found);
366        }
367    }
368    None
369}
370
371/// A placeholder type with its synonyms folded: `ctrTitle` is `title`, `obj` and an absent type
372/// are `body`.
373pub fn normalize_placeholder_type(value: Option<&str>) -> &str {
374    match value.unwrap_or("body") {
375        "ctrTitle" => "title",
376        "obj" => "body",
377        value => value,
378    }
379}
380
381pub(crate) fn is_title_placeholder(placeholder: Option<&Placeholder>) -> bool {
382    placeholder.is_some_and(|value| {
383        normalize_placeholder_type(value.placeholder_type.as_deref()) == "title"
384    })
385}
386
387pub(crate) fn slide_hidden(package: &PptxPackage, source_path: Option<&str>) -> Option<bool> {
388    match source_path {
389        None => Some(false),
390        Some(path) => package
391            .slides
392            .iter()
393            .find(|slide| slide.part_path == path)
394            .and_then(|slide| slide.hidden),
395    }
396}
397
398pub(crate) fn shape_cascade<'a>(
399    parents: &SlideParents<'a>,
400    presentation: &'a pptx_parse::Presentation,
401    placeholder: Option<&'a Placeholder>,
402    primary: Option<&'a TextBody>,
403) -> ParagraphCascade<'a> {
404    let inherited = |nodes: &'a [ShapeNode]| {
405        placeholder
406            .and_then(|value| find_placeholder(nodes, value))
407            .and_then(node_text)
408    };
409    ParagraphCascade {
410        primary,
411        layout: parents.layout.and_then(|layout| inherited(&layout.shapes)),
412        master: parents.master.and_then(|master| inherited(&master.shapes)),
413        master_slide: parents.master,
414        default_style: &presentation.default_text_style,
415        default_paragraph: presentation.default_text_paragraph.as_deref(),
416        placeholder,
417        style_color: None,
418    }
419}
420
421pub(crate) fn effective_list(
422    counters: &mut ListCounters,
423    properties: &ParagraphProperties,
424    level: u32,
425    has_text: bool,
426) -> Option<crate::structured::ExportList> {
427    use crate::structured::ExportList;
428    match counters.next(properties.bullet.as_ref(), level, has_text)? {
429        ListMarker::Character(character) => Some(ExportList::Bullet {
430            character,
431            font: match &properties.bullet_font {
432                Some(pptx_parse::BulletFont::Typeface(font)) => Some(font.clone()),
433                _ => None,
434            },
435        }),
436        ListMarker::Number { value, marker } => {
437            let (scheme, start_at) = match &properties.bullet {
438                Some(Bullet::AutoNumber {
439                    scheme, start_at, ..
440                }) => (scheme.clone(), *start_at),
441                _ => (String::new(), 1),
442            };
443            Some(ExportList::Number {
444                scheme,
445                start_at,
446                value,
447                marker,
448            })
449        }
450    }
451}
452
453/// The text bodies a shape's paragraphs inherit from, most specific first.
454#[derive(Clone, Copy, Debug, Default)]
455pub struct ParagraphCascade<'a> {
456    /// The shape's own source text body.
457    pub primary: Option<&'a TextBody>,
458    /// The matching layout placeholder's text body.
459    pub layout: Option<&'a TextBody>,
460    /// The matching master placeholder's text body.
461    pub master: Option<&'a TextBody>,
462    /// The master whose text styles the placeholder type selects.
463    pub master_slide: Option<&'a SlideMaster>,
464    /// `p:defaultTextStyle`, which outranks the master's `otherStyle` for text outside
465    /// placeholders.
466    pub default_style: &'a [ParagraphProperties],
467    /// `p:defaultTextStyle/a:defPPr`, under every level of `default_style`.
468    pub default_paragraph: Option<&'a ParagraphProperties>,
469    pub placeholder: Option<&'a Placeholder>,
470    /// `p:style/a:fontRef` colour.
471    pub style_color: Option<&'a ColorValue>,
472}
473
474impl ParagraphCascade<'_> {
475    /// The effective properties of paragraph `index` at `level`. An `authored` numbered bullet
476    /// replaces the inherited bullet.
477    pub fn properties(
478        &self,
479        index: usize,
480        level: u32,
481        authored: Option<&Bullet>,
482    ) -> ParagraphProperties {
483        let mut properties = self
484            .master_slide
485            .and_then(|master| master_style(master, self.placeholder, level))
486            .cloned()
487            .unwrap_or_default();
488        if self.placeholder.is_none() {
489            let level_style = self
490                .default_style
491                .get(level as usize)
492                .or_else(|| self.default_style.first());
493            for source in self.default_paragraph.into_iter().chain(level_style) {
494                merge_paragraph_properties(&mut properties, source);
495            }
496        }
497        if let Some(color) = self.style_color {
498            properties
499                .default_run
500                .get_or_insert_with(RunProperties::default)
501                .color = Some(color.clone());
502        }
503        for body in [self.master, self.layout, self.primary]
504            .into_iter()
505            .flatten()
506        {
507            if let Some(source) = &body.default_list_style {
508                merge_paragraph_properties(&mut properties, source);
509            }
510            if let Some(source) = body.list_style.get(level as usize) {
511                merge_paragraph_properties(&mut properties, source);
512            }
513            if let Some(source) = body
514                .paragraphs
515                .get(index)
516                .or_else(|| body.paragraphs.get(level as usize))
517                .map(|paragraph| &paragraph.properties)
518            {
519                merge_paragraph_properties(&mut properties, source);
520            }
521        }
522        if let Some(Bullet::AutoNumber { restart, .. }) = &mut properties.bullet {
523            *restart = self
524                .primary
525                .and_then(|body| body.paragraphs.get(index))
526                .is_some_and(|paragraph| {
527                    matches!(
528                        paragraph.properties.bullet,
529                        Some(Bullet::AutoNumber { restart: true, .. })
530                            | Some(Bullet::AutoNumber { start_at: 2.., .. })
531                    )
532                });
533        }
534        if let Some(authored @ Bullet::AutoNumber { .. }) = authored {
535            properties.bullet = Some(authored.clone());
536            if let Some(Bullet::AutoNumber {
537                restart, start_at, ..
538            }) = &mut properties.bullet
539            {
540                *restart |= *start_at != 1;
541            }
542        }
543        properties
544    }
545}
546
547fn master_style<'a>(
548    master: &'a SlideMaster,
549    placeholder: Option<&Placeholder>,
550    level: u32,
551) -> Option<&'a ParagraphProperties> {
552    let styles = match placeholder {
553        Some(placeholder) => {
554            match normalize_placeholder_type(placeholder.placeholder_type.as_deref()) {
555                "title" => &master.text_styles.title,
556                "body" | "subTitle" => &master.text_styles.body,
557                _ => &master.text_styles.other,
558            }
559        }
560        None => &master.text_styles.other,
561    };
562    styles.get(level as usize).or_else(|| styles.first())
563}
564
565/// Overlays every property `source` declares onto `target`.
566pub fn merge_paragraph_properties(target: &mut ParagraphProperties, source: &ParagraphProperties) {
567    if source.alignment.is_some() {
568        target.alignment.clone_from(&source.alignment);
569    }
570    if source.margin_left.is_some() {
571        target.margin_left = source.margin_left;
572    }
573    if source.margin_right.is_some() {
574        target.margin_right = source.margin_right;
575    }
576    if source.indent.is_some() {
577        target.indent = source.indent;
578    }
579    if source.bullet.is_some() {
580        target.bullet.clone_from(&source.bullet);
581    }
582    if source.line_spacing.is_some() {
583        target.line_spacing = source.line_spacing;
584    }
585    if source.space_before.is_some() {
586        target.space_before = source.space_before;
587    }
588    if source.space_after.is_some() {
589        target.space_after = source.space_after;
590    }
591    if source.bullet_font.is_some() {
592        target.bullet_font.clone_from(&source.bullet_font);
593    }
594    if source.bullet_color.is_some() {
595        target.bullet_color.clone_from(&source.bullet_color);
596    }
597    if source.bullet_size.is_some() {
598        target.bullet_size.clone_from(&source.bullet_size);
599    }
600    if source.default_tab_size.is_some() {
601        target.default_tab_size = source.default_tab_size;
602    }
603    if source.tab_stops.is_some() {
604        target.tab_stops.clone_from(&source.tab_stops);
605    }
606    if source.rtl.is_some() {
607        target.rtl = source.rtl;
608    }
609    if let Some(source) = &source.default_run {
610        let target = target
611            .default_run
612            .get_or_insert_with(RunProperties::default);
613        merge_run_properties(target, source);
614    }
615}
616
617fn merge_run_properties(target: &mut RunProperties, source: &RunProperties) {
618    if source.font_size_pt.is_some() {
619        target.font_size_pt = source.font_size_pt;
620    }
621    if source.bold.is_some() {
622        target.bold = source.bold;
623    }
624    if source.italic.is_some() {
625        target.italic = source.italic;
626    }
627    if source.underline.is_some() {
628        target.underline.clone_from(&source.underline);
629    }
630    if source.font_family.is_some() {
631        target.font_family.clone_from(&source.font_family);
632    }
633    if source.color.is_some() {
634        target.color.clone_from(&source.color);
635    }
636    if source.language.is_some() {
637        target.language.clone_from(&source.language);
638    }
639    if source.spacing_pt.is_some() {
640        target.spacing_pt = source.spacing_pt;
641    }
642    if source.baseline_pct.is_some() {
643        target.baseline_pct = source.baseline_pct;
644    }
645    if source.kern_pt.is_some() {
646        target.kern_pt = source.kern_pt;
647    }
648    if source.caps.is_some() {
649        target.caps = source.caps;
650    }
651}
652
653/// A paragraph's list marker, before any symbol-font substitution.
654#[derive(Clone, Debug, Eq, PartialEq)]
655pub enum ListMarker {
656    /// A `buChar` bullet, as authored.
657    Character(String),
658    /// A `buAutoNum` number; `marker` is `None` for a scheme [`format_autonum`] cannot format.
659    Number { value: u32, marker: Option<String> },
660}
661
662impl ListMarker {
663    /// The marker to draw: an unformattable scheme falls back to `"{value}."`.
664    pub fn text(&self) -> String {
665        match self {
666            Self::Character(value) => value.clone(),
667            Self::Number { value, marker } => marker.clone().unwrap_or_else(|| format!("{value}.")),
668        }
669    }
670}
671
672/// Per-level `a:buAutoNum` state of one story: the number last shown and the `startAt` the run
673/// was seeded from.
674#[derive(Clone, Debug, Default)]
675pub struct ListCounters {
676    numbers: [u32; 9],
677    starts: [u32; 9],
678}
679
680impl ListCounters {
681    /// The marker of the story's next paragraph. A paragraph without text is spacing, not an
682    /// item: PowerPoint neither marks it nor counts it towards the next number.
683    pub fn next(
684        &mut self,
685        bullet: Option<&Bullet>,
686        level: u32,
687        has_text: bool,
688    ) -> Option<ListMarker> {
689        if !has_text {
690            return None;
691        }
692        let level = (level as usize).min(self.numbers.len() - 1);
693        self.numbers[level + 1..].fill(0);
694        self.starts[level + 1..].fill(0);
695        match bullet {
696            Some(Bullet::AutoNumber {
697                scheme,
698                start_at,
699                restart,
700            }) => {
701                let start = (*start_at).clamp(1, 32_767);
702                // PowerPoint writes the list's `startAt` on every one of its paragraphs, so
703                // repeating the seed continues the run; only a different declared start opens a
704                // new list.
705                self.numbers[level] = match self.numbers[level] {
706                    0 => start,
707                    _ if *restart && self.starts[level] != start => start,
708                    current => current.saturating_add(1),
709                };
710                if self.numbers[level] == start {
711                    self.starts[level] = start;
712                }
713                let value = self.numbers[level].clamp(1, MAX_LIST_NUMBER);
714                Some(ListMarker::Number {
715                    value,
716                    marker: format_autonum(value, scheme),
717                })
718            }
719            _ => {
720                self.numbers[level] = 0;
721                self.starts[level] = 0;
722                match bullet {
723                    Some(Bullet::Character { value }) if !value.trim().is_empty() => {
724                        Some(ListMarker::Character(value.clone()))
725                    }
726                    _ => None,
727                }
728            }
729        }
730    }
731}
732
733/// Formats a Latin, Roman or decimal `ST_TextAutonumberScheme` marker; `None` for any other
734/// scheme.
735pub fn format_autonum(value: u32, scheme: &str) -> Option<String> {
736    let value = value.clamp(1, MAX_LIST_NUMBER);
737    let (numeral, suffix) = ["ParenBoth", "ParenR", "Period", "Plain"]
738        .into_iter()
739        .find_map(|suffix| Some((scheme.strip_suffix(suffix)?, suffix)))?;
740    let body = match numeral {
741        "alphaLc" => format_alpha(value, false),
742        "alphaUc" => format_alpha(value, true),
743        "romanLc" => format_roman(value, false),
744        "romanUc" => format_roman(value, true),
745        "arabic" => value.to_string(),
746        _ => return None,
747    };
748    Some(match suffix {
749        "ParenBoth" => format!("({body})"),
750        "ParenR" => format!("{body})"),
751        "Plain" => body,
752        _ => format!("{body}."),
753    })
754}
755
756fn format_alpha(value: u32, upper: bool) -> String {
757    let base = if upper { b'A' } else { b'a' };
758    let mut value = value.max(1);
759    let mut out = Vec::new();
760    while value > 0 {
761        let index = (value - 1) % 26;
762        out.push(base + index as u8);
763        value = (value - 1) / 26;
764    }
765    out.reverse();
766    String::from_utf8(out).unwrap_or_default()
767}
768
769fn format_roman(value: u32, upper: bool) -> String {
770    const NUMERALS: [(u32, &str); 13] = [
771        (1000, "m"),
772        (900, "cm"),
773        (500, "d"),
774        (400, "cd"),
775        (100, "c"),
776        (90, "xc"),
777        (50, "l"),
778        (40, "xl"),
779        (10, "x"),
780        (9, "ix"),
781        (5, "v"),
782        (4, "iv"),
783        (1, "i"),
784    ];
785    let mut value = value.max(1);
786    let mut out = String::new();
787    for (amount, numeral) in NUMERALS {
788        while value >= amount {
789            out.push_str(numeral);
790            value -= amount;
791        }
792    }
793    if upper { out.to_uppercase() } else { out }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    fn marker(counters: &mut ListCounters, bullet: Option<&Bullet>, level: u32) -> Option<String> {
801        counters
802            .next(bullet, level, true)
803            .map(|marker| marker.text())
804    }
805
806    fn number(scheme: &str, start_at: u32, restart: bool) -> Bullet {
807        Bullet::AutoNumber {
808            scheme: scheme.to_owned(),
809            start_at,
810            restart,
811        }
812    }
813
814    fn character(value: &str) -> Bullet {
815        Bullet::Character {
816            value: value.to_owned(),
817        }
818    }
819
820    #[test]
821    fn autonumbering_counts_per_level_and_resumes_across_other_levels() {
822        let mut counters = ListCounters::default();
823        let arabic = number("arabicPeriod", 1, false);
824        let dash = character("-");
825        assert_eq!(
826            marker(&mut counters, Some(&arabic), 0).as_deref(),
827            Some("1.")
828        );
829        assert_eq!(marker(&mut counters, Some(&dash), 1).as_deref(), Some("-"));
830        assert_eq!(marker(&mut counters, Some(&dash), 1).as_deref(), Some("-"));
831        assert_eq!(
832            marker(&mut counters, Some(&arabic), 0).as_deref(),
833            Some("2.")
834        );
835        assert_eq!(
836            marker(&mut counters, Some(&arabic), 0).as_deref(),
837            Some("3.")
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), 1).as_deref(),
845            Some("2.")
846        );
847        assert_eq!(
848            marker(&mut counters, Some(&arabic), 0).as_deref(),
849            Some("4.")
850        );
851        assert_eq!(
852            marker(&mut counters, Some(&arabic), 1).as_deref(),
853            Some("1.")
854        );
855        assert_eq!(marker(&mut counters, Some(&Bullet::None), 0), None);
856    }
857
858    #[test]
859    fn inherited_autonumber_start_at_applies_only_to_the_first_item() {
860        let mut counters = ListCounters::default();
861        let seven = number("arabicPeriod", 7, false);
862        assert_eq!(
863            marker(&mut counters, Some(&seven), 0).as_deref(),
864            Some("7.")
865        );
866        assert_eq!(
867            marker(&mut counters, Some(&seven), 0).as_deref(),
868            Some("8.")
869        );
870        let mut counters = ListCounters::default();
871        let last_start = number("arabicPeriod", 32_767, false);
872        assert_eq!(
873            marker(&mut counters, Some(&last_start), 0).as_deref(),
874            Some("32767.")
875        );
876        assert_eq!(
877            marker(&mut counters, Some(&last_start), 0).as_deref(),
878            Some("32768.")
879        );
880    }
881
882    #[test]
883    fn autonumber_schemes_format_their_numeral_and_suffix() {
884        assert_eq!(format_autonum(4, "arabicPeriod").as_deref(), Some("4."));
885        assert_eq!(format_autonum(4, "arabicParenR").as_deref(), Some("4)"));
886        assert_eq!(format_autonum(4, "arabicParenBoth").as_deref(), Some("(4)"));
887        assert_eq!(format_autonum(4, "arabicPlain").as_deref(), Some("4"));
888        assert_eq!(format_autonum(1, "alphaLcParenR").as_deref(), Some("a)"));
889        assert_eq!(format_autonum(27, "alphaUcPeriod").as_deref(), Some("AA."));
890        assert_eq!(format_autonum(9, "romanLcPeriod").as_deref(), Some("ix."));
891        assert_eq!(
892            format_autonum(2024, "romanUcPeriod").as_deref(),
893            Some("MMXXIV.")
894        );
895        for scheme in ["somethingElse", "somethingElsePlain", "arabicDbPeriod"] {
896            assert_eq!(format_autonum(3, scheme), None);
897        }
898    }
899
900    #[test]
901    fn unformattable_schemes_fall_back_to_a_decimal_marker() {
902        let mut counters = ListCounters::default();
903        let circled = number("circleNumDbPlain", 3, false);
904        assert_eq!(
905            counters.next(Some(&circled), 0, true),
906            Some(ListMarker::Number {
907                value: 3,
908                marker: None
909            })
910        );
911        assert_eq!(
912            marker(&mut counters, Some(&circled), 0).as_deref(),
913            Some("4.")
914        );
915    }
916
917    #[test]
918    fn paragraphs_without_text_neither_mark_nor_count() {
919        let mut counters = ListCounters::default();
920        let arabic = number("arabicPeriod", 1, false);
921        assert_eq!(
922            marker(&mut counters, Some(&arabic), 0).as_deref(),
923            Some("1.")
924        );
925        assert_eq!(counters.next(Some(&arabic), 0, false), None);
926        assert_eq!(
927            marker(&mut counters, Some(&arabic), 0).as_deref(),
928            Some("2.")
929        );
930    }
931
932    #[test]
933    fn autonumber_sequences_restart_after_plain_paragraphs_and_explicit_starts() {
934        let mut counters = ListCounters::default();
935        let arabic = number("arabicPeriod", 1, false);
936        let restart = number("arabicPeriod", 7, true);
937        assert_eq!(
938            marker(&mut counters, Some(&arabic), 0).as_deref(),
939            Some("1.")
940        );
941        assert_eq!(
942            marker(&mut counters, Some(&restart), 0).as_deref(),
943            Some("7.")
944        );
945        assert_eq!(
946            marker(&mut counters, Some(&arabic), 0).as_deref(),
947            Some("8.")
948        );
949        assert_eq!(marker(&mut counters, None, 0), None);
950        assert_eq!(
951            marker(&mut counters, Some(&arabic), 0).as_deref(),
952            Some("1.")
953        );
954        assert_eq!(
955            marker(&mut counters, Some(&arabic), 1).as_deref(),
956            Some("1.")
957        );
958        assert_eq!(
959            marker(&mut counters, Some(&character("•")), 0).as_deref(),
960            Some("•")
961        );
962        assert_eq!(
963            marker(&mut counters, Some(&arabic), 1).as_deref(),
964            Some("1.")
965        );
966        assert_eq!(
967            marker(&mut counters, Some(&arabic), 0).as_deref(),
968            Some("1.")
969        );
970    }
971
972    /// `pptarena-018-original` slide 11 declares `startAt="4"` on all four of its paragraphs and
973    /// PowerPoint renders 4, 5, 6, 7; `pptarena-034-original` slide 11 declares `startAt="1"` on
974    /// all five and PowerPoint renders a) through e).
975    #[test]
976    fn a_repeated_declared_start_continues_the_list() {
977        let mut counters = ListCounters::default();
978        let arabic = number("arabicPeriod", 4, true);
979        let alpha = number("alphaLcParenR", 1, true);
980        assert_eq!(
981            (0..4)
982                .filter_map(|_| marker(&mut counters, Some(&arabic), 0))
983                .collect::<Vec<_>>(),
984            ["4.", "5.", "6.", "7."]
985        );
986        let mut counters = ListCounters::default();
987        assert_eq!(
988            (0..5)
989                .filter_map(|_| marker(&mut counters, Some(&alpha), 0))
990                .collect::<Vec<_>>(),
991            ["a)", "b)", "c)", "d)", "e)"]
992        );
993    }
994
995    #[test]
996    fn autonumber_roman_markers_bound_untrusted_start_values() {
997        assert_eq!(format_autonum(0, "arabicPeriod").as_deref(), Some("1."));
998        let largest = format_autonum(u32::MAX, "romanUcPeriod").unwrap();
999        assert_eq!(largest.len(), 61);
1000        assert_eq!(largest, format!("{}DCCLXVII.", "M".repeat(52)));
1001    }
1002
1003    #[test]
1004    fn text_outside_placeholders_takes_the_presentation_default_style() {
1005        let sized = |size| ParagraphProperties {
1006            default_run: Some(RunProperties {
1007                font_size_pt: Some(size),
1008                ..RunProperties::default()
1009            }),
1010            ..ParagraphProperties::default()
1011        };
1012        let levels = [sized(27.0), sized(20.0)];
1013        let under = ParagraphProperties {
1014            bullet: Some(character("-")),
1015            ..sized(12.0)
1016        };
1017        let body = Placeholder {
1018            placeholder_type: Some("body".to_owned()),
1019            index: Some(1),
1020            orientation: None,
1021            size: None,
1022        };
1023        let cascade = |placeholder| ParagraphCascade {
1024            default_style: &levels,
1025            default_paragraph: Some(&under),
1026            placeholder,
1027            ..ParagraphCascade::default()
1028        };
1029        let size = |properties: ParagraphProperties| properties.default_run.unwrap().font_size_pt;
1030        let plain = cascade(None).properties(0, 1, None);
1031        assert_eq!(plain.bullet, Some(character("-")));
1032        assert_eq!(size(plain), Some(20.0));
1033        assert_eq!(size(cascade(None).properties(0, 4, None)), Some(27.0));
1034        let placed = cascade(Some(&body)).properties(0, 1, None);
1035        assert_eq!((placed.bullet, placed.default_run), (None, None));
1036    }
1037}