Skip to main content

setzer_model/
book.rs

1//! The semantic document: `Book` → `Section`s → `Block`s → `Inline`s.
2
3use crate::table::Table;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7/// Fields in `book.json` that this build does not know, kept as read and
8/// written back on save (ADR-0003), so a file saved by a newer build loses
9/// nothing when an older one re-saves it. Code never puts known field names
10/// here; leave it empty when building a value.
11pub type Extra = serde_json::Map<String, serde_json::Value>;
12
13/// A whole book as imported and edited by the author.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct Book {
16    pub format_version: u32,
17    pub metadata: Metadata,
18    /// Front matter, body and back matter in reading order.
19    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    /// Body chapters only (what gets numbered).
37    pub fn chapters(&self) -> impl Iterator<Item = &Section> {
38        self.sections
39            .iter()
40            .filter(|s| s.kind == SectionKind::Chapter)
41    }
42
43    /// The number of each section, by index: chapters and parts are
44    /// counted apart, `Auto` counts on from the last one of its kind,
45    /// `Explicit(n)` is `n` and the count goes on from it. Other kinds and
46    /// `Numbering::None` have none. Print, EPUB, pre-flight and the app all
47    /// number from this.
48    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/// Where a top-level block is: `sections[section].blocks[block]`. Edits
80/// (M4 `book_apply`) address sections by index and blocks with this; a
81/// change inside a quote, list or footnote replaces its top-level block.
82/// Positions, not ids stored in the file: ops run one after another on the
83/// book held in Rust, so a position is exact when its op runs.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub struct BlockRef {
86    pub section: usize,
87    pub block: usize,
88}
89
90/// BCP 47 language tag, e.g. `en`, `en-GB`, `de`, `de-AT`.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct Lang(pub String);
93
94impl Lang {
95    /// Primary subtag, lowercased (`de-AT` → `de`).
96    pub fn primary(&self) -> String {
97        self.0
98            .split(['-', '_'])
99            .next()
100            .unwrap_or_default()
101            .to_ascii_lowercase()
102    }
103
104    /// The tag as BCP 47 writes it, without spaces around it and with its
105    /// subtags joined by `-` (`en_GB` → `en-GB`). `None` when it is no
106    /// language tag as Kernsetzer reads one: a primary language subtag of two
107    /// or three letters, then subtags of one to eight letters and digits
108    /// (`de-AT`, `en_GB`, `sr-Latn`). Pre-flight reports any other language
109    /// as `INVALID_LANGUAGE`.
110    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    /// The language the book is set in, print and EPUB alike: this one as
128    /// [`Lang::tag`] writes it, or English (the default) where it is no
129    /// language tag, so that a language typed wrong never stops a render.
130    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    /// Free text for the copyright page, e.g. "© 2026 Jane Doe".
157    #[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    /// Asset id of the front cover image, used for the EPUB cover (M3-08).
162    /// Print covers are made outside Kernsetzer.
163    #[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/// The rest of the copyright page (DE: Impressum); the © line, ISBNs and
170/// edition are in [`Metadata`]. German press law asks for the name and
171/// address of the publisher (`Metadata::publisher`; a self-publisher names
172/// themselves) and of the printer. Values only, printed as written; the
173/// theme adds labels ("Druck:", "Printed by") in the book's language.
174#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
175pub struct Imprint {
176    /// The publisher's postal address; lines separated by `\n`.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub address: Option<String>,
179    /// Email or website.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub contact: Option<String>,
182    /// The printer's name and address, e.g. "Amazon Distribution GmbH,
183    /// Leipzig".
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub printer: Option<String>,
186    /// Cover design, editing, proofreading, image credits, in print order.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub credits: Vec<Credit>,
189    /// E.g. "All rights reserved." or "Alle Rechte vorbehalten."
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub rights: Option<String>,
192    /// Anything else: a fiction disclaimer, the Deutsche Nationalbibliothek
193    /// notice. Paragraphs separated by a blank line.
194    #[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/// One credit line on the copyright page.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct Credit {
207    /// As printed, in the book's language: "Lektorat", "Cover design".
208    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/// What role a section plays. Drives page layout (display pages, running
220/// heads, numbering) and EPUB semantics (`epub:type`).
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
222#[serde(rename_all = "kebab-case")]
223pub enum SectionKind {
224    // Front matter
225    HalfTitle,
226    TitlePage,
227    Copyright,
228    Dedication,
229    Epigraph,
230    TableOfContents,
231    Foreword,
232    Preface,
233    Prologue,
234    // Body
235    Part,
236    Chapter,
237    // Back matter
238    Epilogue,
239    Afterword,
240    Acknowledgments,
241    AboutTheAuthor,
242    AlsoBy,
243    /// Anything else, with the heading as given.
244    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    /// Heading as displayed, without the "Chapter N" label (that is
273    /// generated by the theme from `numbering`).
274    #[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    /// Design choices for this section alone (ADR-0014 § 3, the narrowest
281    /// scope). Reserved in format version 2 and unused until M5-36; values
282    /// are stored as given and checked by `setzer-typeset`, not here.
283    #[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    /// Numbered in sequence with the other chapters.
305    #[default]
306    Auto,
307    /// Not numbered (e.g. an interlude).
308    None,
309    /// Number given explicitly in the manuscript.
310    Explicit(u32),
311}
312
313/// A chapter's or part's number, from [`Book::section_numbers`].
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct SectionNumber {
316    /// The number shown.
317    pub number: u32,
318    /// The number that follows the last one of its kind (what `Auto`
319    /// gives); an `Explicit` number other than this is out of sequence.
320    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/// Every variant carries `extra` (unknown fields, see [`Extra`]); build
331/// blocks with the constructors below and match with `..`.
332#[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    /// A sub-heading inside a section (rare in fiction). `level` counts
341    /// from the section title: 1 is the first level below it (Markdown
342    /// `##`; in .docx the heading style one below the chapter style).
343    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    /// Lines kept as written; stanzas separated by empty `Vec`s.
359    Verse {
360        lines: Vec<Vec<Inline>>,
361        /// The indent level of each line (0 is flush), by position in
362        /// `lines`; empty when no line is indented, and then not written.
363        /// May be shorter or longer than `lines` in a damaged file: read it
364        /// through [`Block::verse_indent`], never by index.
365        #[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        /// Width as a percentage of the text block's width, 10–100 (the app
377        /// offers steps of 5). `None`: automatic, today's fit, or 40 % beside
378        /// the text. Read through [`Block::image_width`].
379        #[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        /// Which side a picture beside the text stands on; read only for
384        /// `Beside`, kept when the placement changes.
385        #[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    /// A table (ADR-0011). Its JSON is flat: the table's own fields and its
397    /// `extra` stand next to `"type": "table"`. Draw it through
398    /// [`Table::layout`], never by indexing `rows`.
399    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    /// Set the indent level of each line of a verse block, by position in
444    /// `lines` (other blocks are returned unchanged). All zeros is stored as
445    /// none, so a verse without indents writes the same JSON as before.
446    /// Levels are stored as given and capped on read.
447    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    /// The indent level of verse line `line`: 0 when the block has no level
459    /// for it (none stored, a shorter list in a damaged file, a block that
460    /// is not verse), at most [`MAX_VERSE_INDENT`]; levels beyond the lines
461    /// are ignored. Always read the levels through this.
462    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    /// Set a picture's width (percent of the text width); other blocks are
488    /// returned unchanged. The value is stored as given and clamped on read.
489    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    /// A picture's width as a percentage of the text block, clamped for a
511    /// hand-edited file: 10–100, and 25–50 beside the text. `None` for
512    /// automatic width and for other blocks.
513    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    /// Unknown fields read with this block.
546    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
561/// The deepest verse indent level the model reads (M5-49); the importers
562/// stop there too. A level is a step in from the verse's own indent; the
563/// themes decide how far.
564pub const MAX_VERSE_INDENT: u8 = 6;
565
566/// Where a picture stands (ADR-0012).
567#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(rename_all = "kebab-case")]
569pub enum ImagePlacement {
570    /// In the flow of the text, centred, where the author put it.
571    #[default]
572    Inline,
573    /// Beside the text, which wraps round it.
574    Beside,
575    /// On a page of its own, inside the margins.
576    Page,
577    /// On a page of its own, to the trimmed edge and into the bleed.
578    Bleed,
579}
580
581impl ImagePlacement {
582    pub fn is_inline(&self) -> bool {
583        *self == ImagePlacement::Inline
584    }
585}
586
587/// The side a `Beside` picture stands on.
588#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
589#[serde(rename_all = "kebab-case")]
590pub enum ImageSide {
591    /// The page's outer edge: right on a recto, left on a verso.
592    #[default]
593    Outside,
594    /// The binding edge.
595    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    /// Plain text content, ignoring formatting and footnotes.
626    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/// Everything about appearance the author chooses. Themes interpret the
646/// parameters; unknown parameters are kept for forward compatibility.
647#[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    /// Design choices for a kind of section (ADR-0014 § 3, between the
654    /// book's `params` and one section's `style`), keyed by [`KIND_CHAPTERS`],
655    /// [`KIND_PARTS`], [`KIND_FRONT_MATTER`] and [`KIND_BACK_MATTER`].
656    /// Reserved in format version 2 and unused until M5-36. Unknown keys are
657    /// kept, since a newer build may add kinds; values are stored as given.
658    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
659    pub kinds: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
660    /// Which rules `params`, `kinds` and the sections' `style` were set under
661    /// (ADR-0014, M5-34). Absent means before M5-34, when a theme ignores a
662    /// value it does not declare, so leftovers from an earlier theme may sit
663    /// in the maps. M5-34 writes 1 on new books and when it normalizes an old
664    /// one. Reserved in format version 2; nothing reads or writes it until
665    /// M5-34.
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub params_version: Option<u32>,
668    /// The custom theme this book's design was taken from (ADR-0015 § Where
669    /// it lives): a link only, since the book keeps its own copy of the
670    /// values and art. Reserved in format version 2; nothing reads or writes
671    /// it until M5-39.
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub custom_theme: Option<CustomThemeLink>,
674    /// Fields written by newer builds, kept on round-trip.
675    #[serde(flatten)]
676    pub extra: Extra,
677}
678
679/// The link from a book to the custom theme it was designed from
680/// (`StyleSettings::custom_theme`, ADR-0015).
681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682pub struct CustomThemeLink {
683    /// The theme's id in the author's library.
684    pub id: String,
685    /// The name the author gave it, shown when the library lacks the theme.
686    pub name: String,
687    /// Hash of the theme file's content when the book last took the design
688    /// from it; a different hash in the library means the theme has changed.
689    pub hash: String,
690    /// Fields written by newer builds, kept on round-trip.
691    #[serde(flatten)]
692    pub extra: Extra,
693}
694
695/// Key in [`StyleSettings::kinds`] for the numbered and unnumbered chapters
696/// (ADR-0014 § 3, decision 4). The mapping from `SectionKind` to these keys
697/// comes with M5-36.
698pub const KIND_CHAPTERS: &str = "chapters";
699/// Key for part pages (ADR-0014 § 3; mapping in M5-36).
700pub const KIND_PARTS: &str = "parts";
701/// Key for the sections before the first chapter: dedication, epigraph,
702/// foreword and the like (ADR-0014 § 3; mapping in M5-36).
703pub const KIND_FRONT_MATTER: &str = "front-matter";
704/// Key for the sections after the last chapter: afterword, acknowledgments,
705/// about the author, also-by (ADR-0014 § 3; mapping in M5-36).
706pub 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        // Lewis Carroll, Alice's Adventures in Wonderland (Gutenberg #11).
765        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        // All zeros is none.
778        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        // A block written before indents existed reads as flush.
785        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        // The lines have levels; the two beyond the last line are ignored.
794        assert_eq!(
795            (0..8).map(|i| b.verse_indent(i)).collect::<Vec<_>>(),
796            [0, 1, 0, 2, 0, 0, 0, 0]
797        );
798        // Fewer levels than lines: the missing ones are 0.
799        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        // Capped on read, kept as stored.
805        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        // Not verse: 0.
809        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("Noch standen treu die "),
837                    Inline::Emphasis {
838                        content: vec![Inline::text("Pferde")],
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), "Noch standen treu die Pferde");
995    }
996}