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")]
666 pub params_version: Option<u32>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub custom_theme: Option<CustomThemeLink>,
672 #[serde(flatten)]
674 pub extra: Extra,
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
680pub struct CustomThemeLink {
681 pub id: String,
683 pub name: String,
685 pub hash: String,
688 #[serde(flatten)]
690 pub extra: Extra,
691}
692
693pub const KIND_CHAPTERS: &str = "chapters";
696pub const KIND_PARTS: &str = "parts";
698pub const KIND_FRONT_MATTER: &str = "front-matter";
701pub const KIND_BACK_MATTER: &str = "back-matter";
704
705impl Default for StyleSettings {
706 fn default() -> Self {
707 Self {
708 theme: "classic".into(),
709 trim: crate::presets::TrimSize::US_6X9,
710 params: serde_json::Map::new(),
711 kinds: BTreeMap::new(),
712 params_version: None,
713 custom_theme: None,
714 extra: Extra::new(),
715 }
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 #[test]
724 fn picture_defaults_are_not_written() {
725 let json = serde_json::to_value(Block::image("a.png", "x", None)).unwrap();
726 assert_eq!(
727 json,
728 serde_json::json!({"type": "image", "asset": "a.png", "alt": "x"})
729 );
730 let b = Block::image("a.png", "x", None)
731 .with_width(55)
732 .with_placement(ImagePlacement::Beside)
733 .with_side(ImageSide::Inside);
734 let json = serde_json::to_value(&b).unwrap();
735 assert_eq!(json["width"], 55);
736 assert_eq!(json["placement"], "beside");
737 assert_eq!(json["side"], "inside");
738 assert_eq!(serde_json::from_value::<Block>(json).unwrap(), b);
739 }
740
741 #[test]
742 fn picture_width_is_clamped_on_read() {
743 let w = |width: u8, placement| {
744 Block::image("a", "", None)
745 .with_width(width)
746 .with_placement(placement)
747 .image_width()
748 };
749 assert_eq!(w(0, ImagePlacement::Inline), Some(10));
750 assert_eq!(w(9, ImagePlacement::Page), Some(10));
751 assert_eq!(w(55, ImagePlacement::Inline), Some(55));
752 assert_eq!(w(255, ImagePlacement::Bleed), Some(100));
753 assert_eq!(w(0, ImagePlacement::Beside), Some(25));
754 assert_eq!(w(40, ImagePlacement::Beside), Some(40));
755 assert_eq!(w(100, ImagePlacement::Beside), Some(50));
756 assert_eq!(Block::image("a", "", None).image_width(), None);
757 assert_eq!(Block::scene_break().image_width(), None);
758 }
759
760 fn alice() -> Block {
761 Block::verse(vec![
763 vec![Inline::text("“How doth the little crocodile")],
764 vec![Inline::text("Improve his shining tail,")],
765 vec![],
766 vec![Inline::text("And pour the waters of the Nile")],
767 ])
768 }
769
770 #[test]
771 fn verse_indents_are_not_written_when_there_are_none() {
772 let json = serde_json::to_value(alice()).unwrap();
773 assert!(json.get("indents").is_none());
774 let flush = alice().with_indents(vec![0, 0, 0, 0]);
776 assert_eq!(flush, alice());
777 let b = alice().with_indents(vec![0, 1, 0, 0]);
778 let json = serde_json::to_value(&b).unwrap();
779 assert_eq!(json["indents"], serde_json::json!([0, 1, 0, 0]));
780 assert_eq!(serde_json::from_value::<Block>(json).unwrap(), b);
781 let old = serde_json::to_value(alice()).unwrap();
783 let b: Block = serde_json::from_value(old).unwrap();
784 assert_eq!(b.verse_indent(1), 0);
785 }
786
787 #[test]
788 fn verse_indent_never_panics_on_a_damaged_file() {
789 let b = alice().with_indents(vec![0, 1, 0, 2, 3, 4]);
790 assert_eq!(
792 (0..8).map(|i| b.verse_indent(i)).collect::<Vec<_>>(),
793 [0, 1, 0, 2, 0, 0, 0, 0]
794 );
795 let short = alice().with_indents(vec![0, 1]);
797 assert_eq!(
798 (0..5).map(|i| short.verse_indent(i)).collect::<Vec<_>>(),
799 [0, 1, 0, 0, 0]
800 );
801 let deep = alice().with_indents(vec![0, 200]);
803 assert_eq!(deep.verse_indent(1), MAX_VERSE_INDENT);
804 assert!(matches!(&deep, Block::Verse { indents, .. } if indents[1] == 200));
805 assert_eq!(Block::scene_break().verse_indent(0), 0);
807 assert_eq!(
808 Block::scene_break().with_indents(vec![1]).verse_indent(0),
809 0
810 );
811 }
812
813 #[test]
814 fn picture_builders_ignore_other_blocks() {
815 let b = Block::scene_break()
816 .with_width(50)
817 .with_placement(ImagePlacement::Page)
818 .with_side(ImageSide::Left);
819 assert_eq!(b, Block::scene_break());
820 }
821
822 fn sample() -> Book {
823 let mut b = Book::new(Metadata {
824 title: "Probe".into(),
825 authors: vec!["A. Autorin".into()],
826 lang: Lang("de-AT".into()),
827 ..Default::default()
828 });
829 b.sections.push(Section::chapter(
830 "Anfang",
831 vec![
832 Block::paragraph(vec![
833 Inline::text("Noch standen treu die "),
834 Inline::Emphasis {
835 content: vec![Inline::text("Pferde")],
836 },
837 ]),
838 Block::scene_break(),
839 ],
840 ));
841 b
842 }
843
844 #[test]
845 fn serde_round_trip() {
846 let b = sample();
847 let json = serde_json::to_string_pretty(&b).unwrap();
848 let back: Book = serde_json::from_str(&json).unwrap();
849 assert_eq!(b, back);
850 assert!(json.contains("\"type\": \"scene-break\""));
851 }
852
853 #[test]
854 fn imprint_is_left_out_until_set() {
855 let mut b = sample();
856 assert!(!serde_json::to_string(&b).unwrap().contains("imprint"));
857 b.metadata.imprint.credits.push(Credit {
858 role: "Lektorat".into(),
859 name: "C. Lektorin".into(),
860 });
861 let json = serde_json::to_string(&b).unwrap();
862 assert!(json.contains(r#""imprint":{"credits":[{"role":"Lektorat","#));
863 assert_eq!(serde_json::from_str::<Book>(&json).unwrap(), b);
864 }
865
866 #[test]
867 fn block_refs() {
868 let mut b = sample();
869 let at = BlockRef {
870 section: 0,
871 block: 1,
872 };
873 assert_eq!(b.block(at), Some(&Block::scene_break()));
874 *b.block_mut(at).unwrap() = Block::page_break();
875 assert_eq!(b.sections[0].blocks[1], Block::page_break());
876 assert_eq!(b.block(BlockRef { block: 2, ..at }), None);
877 assert_eq!(b.block(BlockRef { section: 1, ..at }), None);
878 }
879
880 #[test]
881 fn lang_primary() {
882 assert_eq!(Lang("de-AT".into()).primary(), "de");
883 assert_eq!(Lang("EN_gb".into()).primary(), "en");
884 }
885
886 #[test]
887 fn lang_tags() {
888 let tag = |s: &str| Lang(s.into()).tag();
889 for (lang, written) in [
890 ("en", "en"),
891 ("de-AT", "de-AT"),
892 (" en_GB ", "en-GB"),
893 ("sr-Latn", "sr-Latn"),
894 ("gsw", "gsw"),
895 ] {
896 assert_eq!(tag(lang).as_deref(), Some(written), "{lang}");
897 }
898 for lang in [
899 "",
900 "english",
901 "e",
902 "de--AT",
903 "d3",
904 "de AT",
905 "de-AT-x-toolongsubtag",
906 ] {
907 assert_eq!(tag(lang), None, "{lang}");
908 }
909 assert_eq!(Lang("Deutsch".into()).effective(), Lang("en".into()));
910 assert_eq!(Lang(" de_AT".into()).effective(), Lang("de-AT".into()));
911 }
912
913 fn numbered(sections: &[(SectionKind, Numbering)]) -> Vec<Option<(u32, u32)>> {
914 let mut b = Book::new(Metadata::default());
915 for (kind, numbering) in sections {
916 b.sections.push(Section {
917 kind: *kind,
918 numbering: numbering.clone(),
919 ..Section::chapter("", Vec::new())
920 });
921 }
922 b.section_numbers()
923 .into_iter()
924 .map(|n| n.map(|n| (n.number, n.expected)))
925 .collect()
926 }
927
928 #[test]
929 fn chapters_and_parts_are_counted_apart() {
930 use Numbering::{Auto, Explicit};
931 use SectionKind::*;
932 let numbers = numbered(&[
933 (Prologue, Auto),
934 (Part, Auto),
935 (Chapter, Auto),
936 (Chapter, Numbering::None),
937 (Chapter, Explicit(5)),
938 (Part, Auto),
939 (Chapter, Auto),
940 (Chapter, Explicit(2)),
941 (Epilogue, Explicit(9)),
942 (Part, Explicit(1)),
943 (Chapter, Auto),
944 ]);
945 assert_eq!(
946 numbers,
947 [
948 None,
949 Some((1, 1)),
950 Some((1, 1)),
951 None,
952 Some((5, 2)),
953 Some((2, 2)),
954 Some((6, 6)),
955 Some((2, 7)),
956 None,
957 Some((1, 3)),
958 Some((3, 3)),
959 ]
960 );
961 }
962
963 #[test]
964 fn counting_saturates() {
965 use Numbering::{Auto, Explicit};
966 let numbers = numbered(&[
967 (SectionKind::Chapter, Explicit(u32::MAX)),
968 (SectionKind::Chapter, Auto),
969 (SectionKind::Chapter, Explicit(4)),
970 (SectionKind::Part, Explicit(u32::MAX)),
971 (SectionKind::Part, Auto),
972 ]);
973 assert_eq!(
974 numbers,
975 [
976 Some((u32::MAX, 1)),
977 Some((u32::MAX, u32::MAX)),
978 Some((4, u32::MAX)),
979 Some((u32::MAX, 1)),
980 Some((u32::MAX, u32::MAX)),
981 ]
982 );
983 }
984
985 #[test]
986 fn plain_text_flattens() {
987 let b = sample();
988 let Block::Paragraph { content, .. } = &b.sections[0].blocks[0] else {
989 panic!()
990 };
991 assert_eq!(Inline::plain_text(content), "Noch standen treu die Pferde");
992 }
993}