1use 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
12pub const MAX_LIST_NUMBER: u32 = 32_767 + 20_000;
15
16#[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 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
71pub 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
90pub 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#[derive(Clone, Copy, Debug, Default)]
102pub struct ParagraphCascade<'a> {
103 pub primary: Option<&'a TextBody>,
105 pub layout: Option<&'a TextBody>,
107 pub master: Option<&'a TextBody>,
109 pub master_slide: Option<&'a SlideMaster>,
111 pub default_style: &'a [ParagraphProperties],
114 pub default_paragraph: Option<&'a ParagraphProperties>,
116 pub placeholder: Option<&'a Placeholder>,
117 pub style_color: Option<&'a ColorValue>,
119}
120
121impl ParagraphCascade<'_> {
122 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| ¶graph.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
212pub 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#[derive(Clone, Debug, Eq, PartialEq)]
299pub enum ListMarker {
300 Character(String),
302 Number { value: u32, marker: Option<String> },
304}
305
306impl ListMarker {
307 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#[derive(Clone, Debug, Default)]
319pub struct ListCounters {
320 numbers: [u32; 9],
321 starts: [u32; 9],
322}
323
324impl ListCounters {
325 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 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
377pub 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 #[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}