1use crate::table::Table;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7pub type Extra = serde_json::Map<String, serde_json::Value>;
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct Book {
16 pub format_version: u32,
17 pub metadata: Metadata,
18 pub sections: Vec<Section>,
20 pub style: StyleSettings,
21 #[serde(flatten)]
22 pub extra: Extra,
23}
24
25impl Book {
26 pub fn new(metadata: Metadata) -> Self {
27 Self {
28 format_version: crate::FORMAT_VERSION,
29 metadata,
30 sections: Vec::new(),
31 style: StyleSettings::default(),
32 extra: Extra::new(),
33 }
34 }
35
36 pub fn chapters(&self) -> impl Iterator<Item = &Section> {
38 self.sections
39 .iter()
40 .filter(|s| s.kind == SectionKind::Chapter)
41 }
42
43 pub fn section_numbers(&self) -> Vec<Option<SectionNumber>> {
49 let (mut chapters, mut parts) = (0u32, 0u32);
50 self.sections
51 .iter()
52 .map(|s| {
53 let count = match s.kind {
54 SectionKind::Chapter => &mut chapters,
55 SectionKind::Part => &mut parts,
56 _ => return None,
57 };
58 let expected = count.saturating_add(1);
59 let number = match s.numbering {
60 Numbering::Auto => expected,
61 Numbering::Explicit(n) => n,
62 Numbering::None => return None,
63 };
64 *count = number;
65 Some(SectionNumber { number, expected })
66 })
67 .collect()
68 }
69
70 pub fn block(&self, at: BlockRef) -> Option<&Block> {
71 self.sections.get(at.section)?.blocks.get(at.block)
72 }
73
74 pub fn block_mut(&mut self, at: BlockRef) -> Option<&mut Block> {
75 self.sections.get_mut(at.section)?.blocks.get_mut(at.block)
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub struct BlockRef {
86 pub section: usize,
87 pub block: usize,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct Lang(pub String);
93
94impl Lang {
95 pub fn primary(&self) -> String {
97 self.0
98 .split(['-', '_'])
99 .next()
100 .unwrap_or_default()
101 .to_ascii_lowercase()
102 }
103
104 pub fn tag(&self) -> Option<String> {
111 let mut parts = self.0.trim().split(['-', '_']);
112 let primary = parts.next().unwrap_or_default();
113 let mut tag = primary.to_string();
114 if !(2..=3).contains(&primary.len()) || !primary.chars().all(|c| c.is_ascii_alphabetic()) {
115 return None;
116 }
117 for p in parts {
118 if !(1..=8).contains(&p.len()) || !p.chars().all(|c| c.is_ascii_alphanumeric()) {
119 return None;
120 }
121 tag.push('-');
122 tag.push_str(p);
123 }
124 Some(tag)
125 }
126
127 pub fn effective(&self) -> Lang {
131 self.tag().map(Lang).unwrap_or_default()
132 }
133}
134
135impl Default for Lang {
136 fn default() -> Self {
137 Lang("en".into())
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
142pub struct Metadata {
143 pub title: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub subtitle: Option<String>,
146 pub authors: Vec<String>,
147 pub lang: Lang,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub series: Option<Series>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub publisher: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub isbn_print: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub isbn_ebook: Option<String>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub copyright: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub edition: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub cover: Option<String>,
165 #[serde(default, skip_serializing_if = "Imprint::is_empty")]
166 pub imprint: Imprint,
167}
168
169#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
175pub struct Imprint {
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub address: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub contact: Option<String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub printer: Option<String>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub credits: Vec<Credit>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub rights: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub notice: Option<String>,
196}
197
198impl Imprint {
199 pub fn is_empty(&self) -> bool {
200 *self == Self::default()
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct Credit {
207 pub role: String,
209 pub name: String,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct Series {
214 pub name: String,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub number: Option<u32>,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
222#[serde(rename_all = "kebab-case")]
223pub enum SectionKind {
224 HalfTitle,
226 TitlePage,
227 Copyright,
228 Dedication,
229 Epigraph,
230 TableOfContents,
231 Foreword,
232 Preface,
233 Prologue,
234 Part,
236 Chapter,
237 Epilogue,
239 Afterword,
240 Acknowledgments,
241 AboutTheAuthor,
242 AlsoBy,
243 Other,
245}
246
247impl SectionKind {
248 pub fn is_front_matter(self) -> bool {
249 use SectionKind::*;
250 matches!(
251 self,
252 HalfTitle
253 | TitlePage
254 | Copyright
255 | Dedication
256 | Epigraph
257 | TableOfContents
258 | Foreword
259 | Preface
260 )
261 }
262
263 pub fn is_back_matter(self) -> bool {
264 use SectionKind::*;
265 matches!(self, Afterword | Acknowledgments | AboutTheAuthor | AlsoBy)
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct Section {
271 pub kind: SectionKind,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub title: Option<String>,
276 #[serde(default)]
277 pub numbering: Numbering,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub epigraph: Option<Epigraph>,
280 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
284 pub style: serde_json::Map<String, serde_json::Value>,
285 pub blocks: Vec<Block>,
286}
287
288impl Section {
289 pub fn chapter(title: impl Into<String>, blocks: Vec<Block>) -> Self {
290 Self {
291 kind: SectionKind::Chapter,
292 title: Some(title.into()),
293 numbering: Numbering::Auto,
294 epigraph: None,
295 style: serde_json::Map::new(),
296 blocks,
297 }
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
302#[serde(rename_all = "kebab-case", tag = "type", content = "value")]
303pub enum Numbering {
304 #[default]
306 Auto,
307 None,
309 Explicit(u32),
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct SectionNumber {
316 pub number: u32,
318 pub expected: u32,
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324pub struct Epigraph {
325 pub text: Vec<Inline>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub attribution: Option<String>,
328}
329
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(rename_all = "kebab-case", tag = "type")]
334pub enum Block {
335 Paragraph {
336 content: Vec<Inline>,
337 #[serde(flatten)]
338 extra: Extra,
339 },
340 Heading {
344 level: u8,
345 content: Vec<Inline>,
346 #[serde(flatten)]
347 extra: Extra,
348 },
349 SceneBreak {
350 #[serde(flatten)]
351 extra: Extra,
352 },
353 BlockQuote {
354 blocks: Vec<Block>,
355 #[serde(flatten)]
356 extra: Extra,
357 },
358 Verse {
360 lines: Vec<Vec<Inline>>,
361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 indents: Vec<u8>,
367 #[serde(flatten)]
368 extra: Extra,
369 },
370 Image {
371 asset: String,
372 #[serde(default)]
373 alt: String,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 caption: Option<Vec<Inline>>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
380 width: Option<u8>,
381 #[serde(default, skip_serializing_if = "ImagePlacement::is_inline")]
382 placement: ImagePlacement,
383 #[serde(default, skip_serializing_if = "ImageSide::is_outside")]
386 side: ImageSide,
387 #[serde(flatten)]
388 extra: Extra,
389 },
390 List {
391 ordered: bool,
392 items: Vec<Vec<Block>>,
393 #[serde(flatten)]
394 extra: Extra,
395 },
396 Table(Table),
400 PageBreak {
401 #[serde(flatten)]
402 extra: Extra,
403 },
404}
405
406impl Block {
407 pub fn paragraph(content: Vec<Inline>) -> Self {
408 Block::Paragraph {
409 content,
410 extra: Extra::new(),
411 }
412 }
413
414 pub fn heading(level: u8, content: Vec<Inline>) -> Self {
415 Block::Heading {
416 level,
417 content,
418 extra: Extra::new(),
419 }
420 }
421
422 pub fn scene_break() -> Self {
423 Block::SceneBreak {
424 extra: Extra::new(),
425 }
426 }
427
428 pub fn block_quote(blocks: Vec<Block>) -> Self {
429 Block::BlockQuote {
430 blocks,
431 extra: Extra::new(),
432 }
433 }
434
435 pub fn verse(lines: Vec<Vec<Inline>>) -> Self {
436 Block::Verse {
437 lines,
438 indents: Vec::new(),
439 extra: Extra::new(),
440 }
441 }
442
443 pub fn with_indents(mut self, levels: Vec<u8>) -> Self {
448 if let Block::Verse { indents, .. } = &mut self {
449 *indents = if levels.iter().all(|&l| l == 0) {
450 Vec::new()
451 } else {
452 levels
453 };
454 }
455 self
456 }
457
458 pub fn verse_indent(&self, line: usize) -> u8 {
463 match self {
464 Block::Verse { lines, indents, .. } if line < lines.len() => indents
465 .get(line)
466 .map_or(0, |&level| level.min(MAX_VERSE_INDENT)),
467 _ => 0,
468 }
469 }
470
471 pub fn image(
472 asset: impl Into<String>,
473 alt: impl Into<String>,
474 caption: Option<Vec<Inline>>,
475 ) -> Self {
476 Block::Image {
477 asset: asset.into(),
478 alt: alt.into(),
479 caption,
480 width: None,
481 placement: ImagePlacement::Inline,
482 side: ImageSide::Outside,
483 extra: Extra::new(),
484 }
485 }
486
487 pub fn with_width(mut self, percent: u8) -> Self {
490 if let Block::Image { width, .. } = &mut self {
491 *width = Some(percent);
492 }
493 self
494 }
495
496 pub fn with_placement(mut self, to: ImagePlacement) -> Self {
497 if let Block::Image { placement, .. } = &mut self {
498 *placement = to;
499 }
500 self
501 }
502
503 pub fn with_side(mut self, to: ImageSide) -> Self {
504 if let Block::Image { side, .. } = &mut self {
505 *side = to;
506 }
507 self
508 }
509
510 pub fn image_width(&self) -> Option<u8> {
514 match self {
515 Block::Image {
516 width: Some(w),
517 placement,
518 ..
519 } => Some(match placement {
520 ImagePlacement::Beside => (*w).clamp(25, 50),
521 _ => (*w).clamp(10, 100),
522 }),
523 _ => None,
524 }
525 }
526
527 pub fn list(ordered: bool, items: Vec<Vec<Block>>) -> Self {
528 Block::List {
529 ordered,
530 items,
531 extra: Extra::new(),
532 }
533 }
534
535 pub fn table(table: Table) -> Self {
536 Block::Table(table)
537 }
538
539 pub fn page_break() -> Self {
540 Block::PageBreak {
541 extra: Extra::new(),
542 }
543 }
544
545 pub fn extra(&self) -> &Extra {
547 match self {
548 Block::Paragraph { extra, .. }
549 | Block::Heading { extra, .. }
550 | Block::SceneBreak { extra }
551 | Block::BlockQuote { extra, .. }
552 | Block::Verse { extra, .. }
553 | Block::Image { extra, .. }
554 | Block::List { extra, .. }
555 | Block::PageBreak { extra } => extra,
556 Block::Table(table) => &table.extra,
557 }
558 }
559}
560
561pub const MAX_VERSE_INDENT: u8 = 6;
565
566#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(rename_all = "kebab-case")]
569pub enum ImagePlacement {
570 #[default]
572 Inline,
573 Beside,
575 Page,
577 Bleed,
579}
580
581impl ImagePlacement {
582 pub fn is_inline(&self) -> bool {
583 *self == ImagePlacement::Inline
584 }
585}
586
587#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
589#[serde(rename_all = "kebab-case")]
590pub enum ImageSide {
591 #[default]
593 Outside,
594 Inside,
596 Left,
597 Right,
598}
599
600impl ImageSide {
601 pub fn is_outside(&self) -> bool {
602 *self == ImageSide::Outside
603 }
604}
605
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
607#[serde(rename_all = "kebab-case", tag = "type")]
608pub enum Inline {
609 Text { text: String },
610 Emphasis { content: Vec<Inline> },
611 Strong { content: Vec<Inline> },
612 SmallCaps { content: Vec<Inline> },
613 Superscript { content: Vec<Inline> },
614 Subscript { content: Vec<Inline> },
615 Link { url: String, content: Vec<Inline> },
616 Footnote { blocks: Vec<Block> },
617 LineBreak,
618}
619
620impl Inline {
621 pub fn text(s: impl Into<String>) -> Self {
622 Inline::Text { text: s.into() }
623 }
624
625 pub fn plain_text(inlines: &[Inline]) -> String {
627 let mut out = String::new();
628 for i in inlines {
629 match i {
630 Inline::Text { text } => out.push_str(text),
631 Inline::Emphasis { content }
632 | Inline::Strong { content }
633 | Inline::SmallCaps { content }
634 | Inline::Superscript { content }
635 | Inline::Subscript { content }
636 | Inline::Link { content, .. } => out.push_str(&Self::plain_text(content)),
637 Inline::LineBreak => out.push(' '),
638 Inline::Footnote { .. } => {}
639 }
640 }
641 out
642 }
643}
644
645#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648pub struct StyleSettings {
649 pub theme: String,
650 pub trim: crate::presets::TrimSize,
651 #[serde(default)]
652 pub params: serde_json::Map<String, serde_json::Value>,
653 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
659 pub kinds: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub params_version: Option<u32>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub custom_theme: Option<CustomThemeLink>,
674 #[serde(flatten)]
676 pub extra: Extra,
677}
678
679#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682pub struct CustomThemeLink {
683 pub id: String,
685 pub name: String,
687 pub hash: String,
690 #[serde(flatten)]
692 pub extra: Extra,
693}
694
695pub const KIND_CHAPTERS: &str = "chapters";
699pub const KIND_PARTS: &str = "parts";
701pub const KIND_FRONT_MATTER: &str = "front-matter";
704pub const KIND_BACK_MATTER: &str = "back-matter";
707
708impl Default for StyleSettings {
709 fn default() -> Self {
710 Self {
711 theme: "classic".into(),
712 trim: crate::presets::TrimSize::US_6X9,
713 params: serde_json::Map::new(),
714 kinds: BTreeMap::new(),
715 params_version: None,
716 custom_theme: None,
717 extra: Extra::new(),
718 }
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn picture_defaults_are_not_written() {
728 let json = serde_json::to_value(Block::image("a.png", "x", None)).unwrap();
729 assert_eq!(
730 json,
731 serde_json::json!({"type": "image", "asset": "a.png", "alt": "x"})
732 );
733 let b = Block::image("a.png", "x", None)
734 .with_width(55)
735 .with_placement(ImagePlacement::Beside)
736 .with_side(ImageSide::Inside);
737 let json = serde_json::to_value(&b).unwrap();
738 assert_eq!(json["width"], 55);
739 assert_eq!(json["placement"], "beside");
740 assert_eq!(json["side"], "inside");
741 assert_eq!(serde_json::from_value::<Block>(json).unwrap(), b);
742 }
743
744 #[test]
745 fn picture_width_is_clamped_on_read() {
746 let w = |width: u8, placement| {
747 Block::image("a", "", None)
748 .with_width(width)
749 .with_placement(placement)
750 .image_width()
751 };
752 assert_eq!(w(0, ImagePlacement::Inline), Some(10));
753 assert_eq!(w(9, ImagePlacement::Page), Some(10));
754 assert_eq!(w(55, ImagePlacement::Inline), Some(55));
755 assert_eq!(w(255, ImagePlacement::Bleed), Some(100));
756 assert_eq!(w(0, ImagePlacement::Beside), Some(25));
757 assert_eq!(w(40, ImagePlacement::Beside), Some(40));
758 assert_eq!(w(100, ImagePlacement::Beside), Some(50));
759 assert_eq!(Block::image("a", "", None).image_width(), None);
760 assert_eq!(Block::scene_break().image_width(), None);
761 }
762
763 fn alice() -> Block {
764 Block::verse(vec![
766 vec![Inline::text("“How doth the little crocodile")],
767 vec![Inline::text("Improve his shining tail,")],
768 vec![],
769 vec![Inline::text("And pour the waters of the Nile")],
770 ])
771 }
772
773 #[test]
774 fn verse_indents_are_not_written_when_there_are_none() {
775 let json = serde_json::to_value(alice()).unwrap();
776 assert!(json.get("indents").is_none());
777 let flush = alice().with_indents(vec![0, 0, 0, 0]);
779 assert_eq!(flush, alice());
780 let b = alice().with_indents(vec![0, 1, 0, 0]);
781 let json = serde_json::to_value(&b).unwrap();
782 assert_eq!(json["indents"], serde_json::json!([0, 1, 0, 0]));
783 assert_eq!(serde_json::from_value::<Block>(json).unwrap(), b);
784 let old = serde_json::to_value(alice()).unwrap();
786 let b: Block = serde_json::from_value(old).unwrap();
787 assert_eq!(b.verse_indent(1), 0);
788 }
789
790 #[test]
791 fn verse_indent_never_panics_on_a_damaged_file() {
792 let b = alice().with_indents(vec![0, 1, 0, 2, 3, 4]);
793 assert_eq!(
795 (0..8).map(|i| b.verse_indent(i)).collect::<Vec<_>>(),
796 [0, 1, 0, 2, 0, 0, 0, 0]
797 );
798 let short = alice().with_indents(vec![0, 1]);
800 assert_eq!(
801 (0..5).map(|i| short.verse_indent(i)).collect::<Vec<_>>(),
802 [0, 1, 0, 0, 0]
803 );
804 let deep = alice().with_indents(vec![0, 200]);
806 assert_eq!(deep.verse_indent(1), MAX_VERSE_INDENT);
807 assert!(matches!(&deep, Block::Verse { indents, .. } if indents[1] == 200));
808 assert_eq!(Block::scene_break().verse_indent(0), 0);
810 assert_eq!(
811 Block::scene_break().with_indents(vec![1]).verse_indent(0),
812 0
813 );
814 }
815
816 #[test]
817 fn picture_builders_ignore_other_blocks() {
818 let b = Block::scene_break()
819 .with_width(50)
820 .with_placement(ImagePlacement::Page)
821 .with_side(ImageSide::Left);
822 assert_eq!(b, Block::scene_break());
823 }
824
825 fn sample() -> Book {
826 let mut b = Book::new(Metadata {
827 title: "Probe".into(),
828 authors: vec!["A. Autorin".into()],
829 lang: Lang("de-AT".into()),
830 ..Default::default()
831 });
832 b.sections.push(Section::chapter(
833 "Anfang",
834 vec![
835 Block::paragraph(vec![
836 Inline::text("Es war "),
837 Inline::Emphasis {
838 content: vec![Inline::text("einmal")],
839 },
840 ]),
841 Block::scene_break(),
842 ],
843 ));
844 b
845 }
846
847 #[test]
848 fn serde_round_trip() {
849 let b = sample();
850 let json = serde_json::to_string_pretty(&b).unwrap();
851 let back: Book = serde_json::from_str(&json).unwrap();
852 assert_eq!(b, back);
853 assert!(json.contains("\"type\": \"scene-break\""));
854 }
855
856 #[test]
857 fn imprint_is_left_out_until_set() {
858 let mut b = sample();
859 assert!(!serde_json::to_string(&b).unwrap().contains("imprint"));
860 b.metadata.imprint.credits.push(Credit {
861 role: "Lektorat".into(),
862 name: "C. Lektorin".into(),
863 });
864 let json = serde_json::to_string(&b).unwrap();
865 assert!(json.contains(r#""imprint":{"credits":[{"role":"Lektorat","#));
866 assert_eq!(serde_json::from_str::<Book>(&json).unwrap(), b);
867 }
868
869 #[test]
870 fn block_refs() {
871 let mut b = sample();
872 let at = BlockRef {
873 section: 0,
874 block: 1,
875 };
876 assert_eq!(b.block(at), Some(&Block::scene_break()));
877 *b.block_mut(at).unwrap() = Block::page_break();
878 assert_eq!(b.sections[0].blocks[1], Block::page_break());
879 assert_eq!(b.block(BlockRef { block: 2, ..at }), None);
880 assert_eq!(b.block(BlockRef { section: 1, ..at }), None);
881 }
882
883 #[test]
884 fn lang_primary() {
885 assert_eq!(Lang("de-AT".into()).primary(), "de");
886 assert_eq!(Lang("EN_gb".into()).primary(), "en");
887 }
888
889 #[test]
890 fn lang_tags() {
891 let tag = |s: &str| Lang(s.into()).tag();
892 for (lang, written) in [
893 ("en", "en"),
894 ("de-AT", "de-AT"),
895 (" en_GB ", "en-GB"),
896 ("sr-Latn", "sr-Latn"),
897 ("gsw", "gsw"),
898 ] {
899 assert_eq!(tag(lang).as_deref(), Some(written), "{lang}");
900 }
901 for lang in [
902 "",
903 "english",
904 "e",
905 "de--AT",
906 "d3",
907 "de AT",
908 "de-AT-x-toolongsubtag",
909 ] {
910 assert_eq!(tag(lang), None, "{lang}");
911 }
912 assert_eq!(Lang("Deutsch".into()).effective(), Lang("en".into()));
913 assert_eq!(Lang(" de_AT".into()).effective(), Lang("de-AT".into()));
914 }
915
916 fn numbered(sections: &[(SectionKind, Numbering)]) -> Vec<Option<(u32, u32)>> {
917 let mut b = Book::new(Metadata::default());
918 for (kind, numbering) in sections {
919 b.sections.push(Section {
920 kind: *kind,
921 numbering: numbering.clone(),
922 ..Section::chapter("", Vec::new())
923 });
924 }
925 b.section_numbers()
926 .into_iter()
927 .map(|n| n.map(|n| (n.number, n.expected)))
928 .collect()
929 }
930
931 #[test]
932 fn chapters_and_parts_are_counted_apart() {
933 use Numbering::{Auto, Explicit};
934 use SectionKind::*;
935 let numbers = numbered(&[
936 (Prologue, Auto),
937 (Part, Auto),
938 (Chapter, Auto),
939 (Chapter, Numbering::None),
940 (Chapter, Explicit(5)),
941 (Part, Auto),
942 (Chapter, Auto),
943 (Chapter, Explicit(2)),
944 (Epilogue, Explicit(9)),
945 (Part, Explicit(1)),
946 (Chapter, Auto),
947 ]);
948 assert_eq!(
949 numbers,
950 [
951 None,
952 Some((1, 1)),
953 Some((1, 1)),
954 None,
955 Some((5, 2)),
956 Some((2, 2)),
957 Some((6, 6)),
958 Some((2, 7)),
959 None,
960 Some((1, 3)),
961 Some((3, 3)),
962 ]
963 );
964 }
965
966 #[test]
967 fn counting_saturates() {
968 use Numbering::{Auto, Explicit};
969 let numbers = numbered(&[
970 (SectionKind::Chapter, Explicit(u32::MAX)),
971 (SectionKind::Chapter, Auto),
972 (SectionKind::Chapter, Explicit(4)),
973 (SectionKind::Part, Explicit(u32::MAX)),
974 (SectionKind::Part, Auto),
975 ]);
976 assert_eq!(
977 numbers,
978 [
979 Some((u32::MAX, 1)),
980 Some((u32::MAX, u32::MAX)),
981 Some((4, u32::MAX)),
982 Some((u32::MAX, 1)),
983 Some((u32::MAX, u32::MAX)),
984 ]
985 );
986 }
987
988 #[test]
989 fn plain_text_flattens() {
990 let b = sample();
991 let Block::Paragraph { content, .. } = &b.sections[0].blocks[0] else {
992 panic!()
993 };
994 assert_eq!(Inline::plain_text(content), "Es war einmal");
995 }
996}