use crate::table::Table;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub type Extra = serde_json::Map<String, serde_json::Value>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Book {
pub format_version: u32,
pub metadata: Metadata,
pub sections: Vec<Section>,
pub style: StyleSettings,
#[serde(flatten)]
pub extra: Extra,
}
impl Book {
pub fn new(metadata: Metadata) -> Self {
Self {
format_version: crate::FORMAT_VERSION,
metadata,
sections: Vec::new(),
style: StyleSettings::default(),
extra: Extra::new(),
}
}
pub fn chapters(&self) -> impl Iterator<Item = &Section> {
self.sections
.iter()
.filter(|s| s.kind == SectionKind::Chapter)
}
pub fn section_numbers(&self) -> Vec<Option<SectionNumber>> {
let (mut chapters, mut parts) = (0u32, 0u32);
self.sections
.iter()
.map(|s| {
let count = match s.kind {
SectionKind::Chapter => &mut chapters,
SectionKind::Part => &mut parts,
_ => return None,
};
let expected = count.saturating_add(1);
let number = match s.numbering {
Numbering::Auto => expected,
Numbering::Explicit(n) => n,
Numbering::None => return None,
};
*count = number;
Some(SectionNumber { number, expected })
})
.collect()
}
pub fn block(&self, at: BlockRef) -> Option<&Block> {
self.sections.get(at.section)?.blocks.get(at.block)
}
pub fn block_mut(&mut self, at: BlockRef) -> Option<&mut Block> {
self.sections.get_mut(at.section)?.blocks.get_mut(at.block)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BlockRef {
pub section: usize,
pub block: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lang(pub String);
impl Lang {
pub fn primary(&self) -> String {
self.0
.split(['-', '_'])
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}
pub fn tag(&self) -> Option<String> {
let mut parts = self.0.trim().split(['-', '_']);
let primary = parts.next().unwrap_or_default();
let mut tag = primary.to_string();
if !(2..=3).contains(&primary.len()) || !primary.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
for p in parts {
if !(1..=8).contains(&p.len()) || !p.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
tag.push('-');
tag.push_str(p);
}
Some(tag)
}
pub fn effective(&self) -> Lang {
self.tag().map(Lang).unwrap_or_default()
}
}
impl Default for Lang {
fn default() -> Self {
Lang("en".into())
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Metadata {
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtitle: Option<String>,
pub authors: Vec<String>,
pub lang: Lang,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub series: Option<Series>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isbn_print: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isbn_ebook: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copyright: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edition: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cover: Option<String>,
#[serde(default, skip_serializing_if = "Imprint::is_empty")]
pub imprint: Imprint,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Imprint {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub printer: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credits: Vec<Credit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rights: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notice: Option<String>,
}
impl Imprint {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Credit {
pub role: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Series {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SectionKind {
HalfTitle,
TitlePage,
Copyright,
Dedication,
Epigraph,
TableOfContents,
Foreword,
Preface,
Prologue,
Part,
Chapter,
Epilogue,
Afterword,
Acknowledgments,
AboutTheAuthor,
AlsoBy,
Other,
}
impl SectionKind {
pub fn is_front_matter(self) -> bool {
use SectionKind::*;
matches!(
self,
HalfTitle
| TitlePage
| Copyright
| Dedication
| Epigraph
| TableOfContents
| Foreword
| Preface
)
}
pub fn is_back_matter(self) -> bool {
use SectionKind::*;
matches!(self, Afterword | Acknowledgments | AboutTheAuthor | AlsoBy)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Section {
pub kind: SectionKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default)]
pub numbering: Numbering,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epigraph: Option<Epigraph>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub style: serde_json::Map<String, serde_json::Value>,
pub blocks: Vec<Block>,
}
impl Section {
pub fn chapter(title: impl Into<String>, blocks: Vec<Block>) -> Self {
Self {
kind: SectionKind::Chapter,
title: Some(title.into()),
numbering: Numbering::Auto,
epigraph: None,
style: serde_json::Map::new(),
blocks,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "type", content = "value")]
pub enum Numbering {
#[default]
Auto,
None,
Explicit(u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SectionNumber {
pub number: u32,
pub expected: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Epigraph {
pub text: Vec<Inline>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attribution: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "type")]
pub enum Block {
Paragraph {
content: Vec<Inline>,
#[serde(flatten)]
extra: Extra,
},
Heading {
level: u8,
content: Vec<Inline>,
#[serde(flatten)]
extra: Extra,
},
SceneBreak {
#[serde(flatten)]
extra: Extra,
},
BlockQuote {
blocks: Vec<Block>,
#[serde(flatten)]
extra: Extra,
},
Verse {
lines: Vec<Vec<Inline>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
indents: Vec<u8>,
#[serde(flatten)]
extra: Extra,
},
Image {
asset: String,
#[serde(default)]
alt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
caption: Option<Vec<Inline>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
width: Option<u8>,
#[serde(default, skip_serializing_if = "ImagePlacement::is_inline")]
placement: ImagePlacement,
#[serde(default, skip_serializing_if = "ImageSide::is_outside")]
side: ImageSide,
#[serde(flatten)]
extra: Extra,
},
List {
ordered: bool,
items: Vec<Vec<Block>>,
#[serde(flatten)]
extra: Extra,
},
Table(Table),
PageBreak {
#[serde(flatten)]
extra: Extra,
},
}
impl Block {
pub fn paragraph(content: Vec<Inline>) -> Self {
Block::Paragraph {
content,
extra: Extra::new(),
}
}
pub fn heading(level: u8, content: Vec<Inline>) -> Self {
Block::Heading {
level,
content,
extra: Extra::new(),
}
}
pub fn scene_break() -> Self {
Block::SceneBreak {
extra: Extra::new(),
}
}
pub fn block_quote(blocks: Vec<Block>) -> Self {
Block::BlockQuote {
blocks,
extra: Extra::new(),
}
}
pub fn verse(lines: Vec<Vec<Inline>>) -> Self {
Block::Verse {
lines,
indents: Vec::new(),
extra: Extra::new(),
}
}
pub fn with_indents(mut self, levels: Vec<u8>) -> Self {
if let Block::Verse { indents, .. } = &mut self {
*indents = if levels.iter().all(|&l| l == 0) {
Vec::new()
} else {
levels
};
}
self
}
pub fn verse_indent(&self, line: usize) -> u8 {
match self {
Block::Verse { lines, indents, .. } if line < lines.len() => indents
.get(line)
.map_or(0, |&level| level.min(MAX_VERSE_INDENT)),
_ => 0,
}
}
pub fn image(
asset: impl Into<String>,
alt: impl Into<String>,
caption: Option<Vec<Inline>>,
) -> Self {
Block::Image {
asset: asset.into(),
alt: alt.into(),
caption,
width: None,
placement: ImagePlacement::Inline,
side: ImageSide::Outside,
extra: Extra::new(),
}
}
pub fn with_width(mut self, percent: u8) -> Self {
if let Block::Image { width, .. } = &mut self {
*width = Some(percent);
}
self
}
pub fn with_placement(mut self, to: ImagePlacement) -> Self {
if let Block::Image { placement, .. } = &mut self {
*placement = to;
}
self
}
pub fn with_side(mut self, to: ImageSide) -> Self {
if let Block::Image { side, .. } = &mut self {
*side = to;
}
self
}
pub fn image_width(&self) -> Option<u8> {
match self {
Block::Image {
width: Some(w),
placement,
..
} => Some(match placement {
ImagePlacement::Beside => (*w).clamp(25, 50),
_ => (*w).clamp(10, 100),
}),
_ => None,
}
}
pub fn list(ordered: bool, items: Vec<Vec<Block>>) -> Self {
Block::List {
ordered,
items,
extra: Extra::new(),
}
}
pub fn table(table: Table) -> Self {
Block::Table(table)
}
pub fn page_break() -> Self {
Block::PageBreak {
extra: Extra::new(),
}
}
pub fn extra(&self) -> &Extra {
match self {
Block::Paragraph { extra, .. }
| Block::Heading { extra, .. }
| Block::SceneBreak { extra }
| Block::BlockQuote { extra, .. }
| Block::Verse { extra, .. }
| Block::Image { extra, .. }
| Block::List { extra, .. }
| Block::PageBreak { extra } => extra,
Block::Table(table) => &table.extra,
}
}
}
pub const MAX_VERSE_INDENT: u8 = 6;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ImagePlacement {
#[default]
Inline,
Beside,
Page,
Bleed,
}
impl ImagePlacement {
pub fn is_inline(&self) -> bool {
*self == ImagePlacement::Inline
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ImageSide {
#[default]
Outside,
Inside,
Left,
Right,
}
impl ImageSide {
pub fn is_outside(&self) -> bool {
*self == ImageSide::Outside
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "type")]
pub enum Inline {
Text { text: String },
Emphasis { content: Vec<Inline> },
Strong { content: Vec<Inline> },
SmallCaps { content: Vec<Inline> },
Superscript { content: Vec<Inline> },
Subscript { content: Vec<Inline> },
Link { url: String, content: Vec<Inline> },
Footnote { blocks: Vec<Block> },
LineBreak,
}
impl Inline {
pub fn text(s: impl Into<String>) -> Self {
Inline::Text { text: s.into() }
}
pub fn plain_text(inlines: &[Inline]) -> String {
let mut out = String::new();
for i in inlines {
match i {
Inline::Text { text } => out.push_str(text),
Inline::Emphasis { content }
| Inline::Strong { content }
| Inline::SmallCaps { content }
| Inline::Superscript { content }
| Inline::Subscript { content }
| Inline::Link { content, .. } => out.push_str(&Self::plain_text(content)),
Inline::LineBreak => out.push(' '),
Inline::Footnote { .. } => {}
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StyleSettings {
pub theme: String,
pub trim: crate::presets::TrimSize,
#[serde(default)]
pub params: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub kinds: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_theme: Option<CustomThemeLink>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CustomThemeLink {
pub id: String,
pub name: String,
pub hash: String,
#[serde(flatten)]
pub extra: Extra,
}
pub const KIND_CHAPTERS: &str = "chapters";
pub const KIND_PARTS: &str = "parts";
pub const KIND_FRONT_MATTER: &str = "front-matter";
pub const KIND_BACK_MATTER: &str = "back-matter";
impl Default for StyleSettings {
fn default() -> Self {
Self {
theme: "classic".into(),
trim: crate::presets::TrimSize::US_6X9,
params: serde_json::Map::new(),
kinds: BTreeMap::new(),
params_version: None,
custom_theme: None,
extra: Extra::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picture_defaults_are_not_written() {
let json = serde_json::to_value(Block::image("a.png", "x", None)).unwrap();
assert_eq!(
json,
serde_json::json!({"type": "image", "asset": "a.png", "alt": "x"})
);
let b = Block::image("a.png", "x", None)
.with_width(55)
.with_placement(ImagePlacement::Beside)
.with_side(ImageSide::Inside);
let json = serde_json::to_value(&b).unwrap();
assert_eq!(json["width"], 55);
assert_eq!(json["placement"], "beside");
assert_eq!(json["side"], "inside");
assert_eq!(serde_json::from_value::<Block>(json).unwrap(), b);
}
#[test]
fn picture_width_is_clamped_on_read() {
let w = |width: u8, placement| {
Block::image("a", "", None)
.with_width(width)
.with_placement(placement)
.image_width()
};
assert_eq!(w(0, ImagePlacement::Inline), Some(10));
assert_eq!(w(9, ImagePlacement::Page), Some(10));
assert_eq!(w(55, ImagePlacement::Inline), Some(55));
assert_eq!(w(255, ImagePlacement::Bleed), Some(100));
assert_eq!(w(0, ImagePlacement::Beside), Some(25));
assert_eq!(w(40, ImagePlacement::Beside), Some(40));
assert_eq!(w(100, ImagePlacement::Beside), Some(50));
assert_eq!(Block::image("a", "", None).image_width(), None);
assert_eq!(Block::scene_break().image_width(), None);
}
fn alice() -> Block {
Block::verse(vec![
vec![Inline::text("“How doth the little crocodile")],
vec![Inline::text("Improve his shining tail,")],
vec![],
vec![Inline::text("And pour the waters of the Nile")],
])
}
#[test]
fn verse_indents_are_not_written_when_there_are_none() {
let json = serde_json::to_value(alice()).unwrap();
assert!(json.get("indents").is_none());
let flush = alice().with_indents(vec![0, 0, 0, 0]);
assert_eq!(flush, alice());
let b = alice().with_indents(vec![0, 1, 0, 0]);
let json = serde_json::to_value(&b).unwrap();
assert_eq!(json["indents"], serde_json::json!([0, 1, 0, 0]));
assert_eq!(serde_json::from_value::<Block>(json).unwrap(), b);
let old = serde_json::to_value(alice()).unwrap();
let b: Block = serde_json::from_value(old).unwrap();
assert_eq!(b.verse_indent(1), 0);
}
#[test]
fn verse_indent_never_panics_on_a_damaged_file() {
let b = alice().with_indents(vec![0, 1, 0, 2, 3, 4]);
assert_eq!(
(0..8).map(|i| b.verse_indent(i)).collect::<Vec<_>>(),
[0, 1, 0, 2, 0, 0, 0, 0]
);
let short = alice().with_indents(vec![0, 1]);
assert_eq!(
(0..5).map(|i| short.verse_indent(i)).collect::<Vec<_>>(),
[0, 1, 0, 0, 0]
);
let deep = alice().with_indents(vec![0, 200]);
assert_eq!(deep.verse_indent(1), MAX_VERSE_INDENT);
assert!(matches!(&deep, Block::Verse { indents, .. } if indents[1] == 200));
assert_eq!(Block::scene_break().verse_indent(0), 0);
assert_eq!(
Block::scene_break().with_indents(vec![1]).verse_indent(0),
0
);
}
#[test]
fn picture_builders_ignore_other_blocks() {
let b = Block::scene_break()
.with_width(50)
.with_placement(ImagePlacement::Page)
.with_side(ImageSide::Left);
assert_eq!(b, Block::scene_break());
}
fn sample() -> Book {
let mut b = Book::new(Metadata {
title: "Probe".into(),
authors: vec!["A. Autorin".into()],
lang: Lang("de-AT".into()),
..Default::default()
});
b.sections.push(Section::chapter(
"Anfang",
vec![
Block::paragraph(vec![
Inline::text("Noch standen treu die "),
Inline::Emphasis {
content: vec![Inline::text("Pferde")],
},
]),
Block::scene_break(),
],
));
b
}
#[test]
fn serde_round_trip() {
let b = sample();
let json = serde_json::to_string_pretty(&b).unwrap();
let back: Book = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
assert!(json.contains("\"type\": \"scene-break\""));
}
#[test]
fn imprint_is_left_out_until_set() {
let mut b = sample();
assert!(!serde_json::to_string(&b).unwrap().contains("imprint"));
b.metadata.imprint.credits.push(Credit {
role: "Lektorat".into(),
name: "C. Lektorin".into(),
});
let json = serde_json::to_string(&b).unwrap();
assert!(json.contains(r#""imprint":{"credits":[{"role":"Lektorat","#));
assert_eq!(serde_json::from_str::<Book>(&json).unwrap(), b);
}
#[test]
fn block_refs() {
let mut b = sample();
let at = BlockRef {
section: 0,
block: 1,
};
assert_eq!(b.block(at), Some(&Block::scene_break()));
*b.block_mut(at).unwrap() = Block::page_break();
assert_eq!(b.sections[0].blocks[1], Block::page_break());
assert_eq!(b.block(BlockRef { block: 2, ..at }), None);
assert_eq!(b.block(BlockRef { section: 1, ..at }), None);
}
#[test]
fn lang_primary() {
assert_eq!(Lang("de-AT".into()).primary(), "de");
assert_eq!(Lang("EN_gb".into()).primary(), "en");
}
#[test]
fn lang_tags() {
let tag = |s: &str| Lang(s.into()).tag();
for (lang, written) in [
("en", "en"),
("de-AT", "de-AT"),
(" en_GB ", "en-GB"),
("sr-Latn", "sr-Latn"),
("gsw", "gsw"),
] {
assert_eq!(tag(lang).as_deref(), Some(written), "{lang}");
}
for lang in [
"",
"english",
"e",
"de--AT",
"d3",
"de AT",
"de-AT-x-toolongsubtag",
] {
assert_eq!(tag(lang), None, "{lang}");
}
assert_eq!(Lang("Deutsch".into()).effective(), Lang("en".into()));
assert_eq!(Lang(" de_AT".into()).effective(), Lang("de-AT".into()));
}
fn numbered(sections: &[(SectionKind, Numbering)]) -> Vec<Option<(u32, u32)>> {
let mut b = Book::new(Metadata::default());
for (kind, numbering) in sections {
b.sections.push(Section {
kind: *kind,
numbering: numbering.clone(),
..Section::chapter("", Vec::new())
});
}
b.section_numbers()
.into_iter()
.map(|n| n.map(|n| (n.number, n.expected)))
.collect()
}
#[test]
fn chapters_and_parts_are_counted_apart() {
use Numbering::{Auto, Explicit};
use SectionKind::*;
let numbers = numbered(&[
(Prologue, Auto),
(Part, Auto),
(Chapter, Auto),
(Chapter, Numbering::None),
(Chapter, Explicit(5)),
(Part, Auto),
(Chapter, Auto),
(Chapter, Explicit(2)),
(Epilogue, Explicit(9)),
(Part, Explicit(1)),
(Chapter, Auto),
]);
assert_eq!(
numbers,
[
None,
Some((1, 1)),
Some((1, 1)),
None,
Some((5, 2)),
Some((2, 2)),
Some((6, 6)),
Some((2, 7)),
None,
Some((1, 3)),
Some((3, 3)),
]
);
}
#[test]
fn counting_saturates() {
use Numbering::{Auto, Explicit};
let numbers = numbered(&[
(SectionKind::Chapter, Explicit(u32::MAX)),
(SectionKind::Chapter, Auto),
(SectionKind::Chapter, Explicit(4)),
(SectionKind::Part, Explicit(u32::MAX)),
(SectionKind::Part, Auto),
]);
assert_eq!(
numbers,
[
Some((u32::MAX, 1)),
Some((u32::MAX, u32::MAX)),
Some((4, u32::MAX)),
Some((u32::MAX, 1)),
Some((u32::MAX, u32::MAX)),
]
);
}
#[test]
fn plain_text_flattens() {
let b = sample();
let Block::Paragraph { content, .. } = &b.sections[0].blocks[0] else {
panic!()
};
assert_eq!(Inline::plain_text(content), "Noch standen treu die Pferde");
}
}