1use 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
233pub 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
267pub 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
293pub const MAX_LIST_NUMBER: u32 = 32_767 + 20_000;
296
297#[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 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
352pub 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
371pub 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#[derive(Clone, Copy, Debug, Default)]
455pub struct ParagraphCascade<'a> {
456 pub primary: Option<&'a TextBody>,
458 pub layout: Option<&'a TextBody>,
460 pub master: Option<&'a TextBody>,
462 pub master_slide: Option<&'a SlideMaster>,
464 pub default_style: &'a [ParagraphProperties],
467 pub default_paragraph: Option<&'a ParagraphProperties>,
469 pub placeholder: Option<&'a Placeholder>,
470 pub style_color: Option<&'a ColorValue>,
472}
473
474impl ParagraphCascade<'_> {
475 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| ¶graph.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
565pub 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#[derive(Clone, Debug, Eq, PartialEq)]
655pub enum ListMarker {
656 Character(String),
658 Number { value: u32, marker: Option<String> },
660}
661
662impl ListMarker {
663 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#[derive(Clone, Debug, Default)]
675pub struct ListCounters {
676 numbers: [u32; 9],
677 starts: [u32; 9],
678}
679
680impl ListCounters {
681 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 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
733pub 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 #[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}