1use 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
242pub const MAX_LIST_NUMBER: u32 = 32_767 + 20_000;
245
246#[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 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
301pub 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
320pub 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#[derive(Clone, Copy, Debug, Default)]
332pub struct ParagraphCascade<'a> {
333 pub primary: Option<&'a TextBody>,
335 pub layout: Option<&'a TextBody>,
337 pub master: Option<&'a TextBody>,
339 pub master_slide: Option<&'a SlideMaster>,
341 pub default_style: &'a [ParagraphProperties],
344 pub default_paragraph: Option<&'a ParagraphProperties>,
346 pub placeholder: Option<&'a Placeholder>,
347 pub style_color: Option<&'a ColorValue>,
349}
350
351impl ParagraphCascade<'_> {
352 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| ¶graph.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
442pub 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#[derive(Clone, Debug, Eq, PartialEq)]
532pub enum ListMarker {
533 Character(String),
535 Number { value: u32, marker: Option<String> },
537}
538
539impl ListMarker {
540 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#[derive(Clone, Debug, Default)]
552pub struct ListCounters {
553 numbers: [u32; 9],
554 starts: [u32; 9],
555}
556
557impl ListCounters {
558 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 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
610pub 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 #[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}