1
0
Fork 0
mirror of https://github.com/lise-henry/crowbook synced 2024-05-28 01:36:17 +02:00
crowbook/src/lib/book.rs
Lise Henry fe8d98ccd7 Replace mustache with upon (wip)
This compiles but probably wrecks everything since the template syntax
is a bit different
2023-08-09 13:30:07 +02:00

1489 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (C) 2016-2023 Élisabeth HENRY.
//
// This file is part of Crowbook.
//
// Crowbook is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published
// by the Free Software Foundation, either version 2.1 of the License, or
// (at your option) any later version.
//
// Crowbook is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Crowbook. If not, see <http://www.gnu.org/licenses/>.
use crate::book_bars::Bars;
use crate::book_renderer::BookRenderer;
use crate::bookoptions::BookOptions;
use crate::chapter::Chapter;
use crate::cleaner::{Cleaner, CleanerParams, Default, French, Off};
use crate::epub::Epub;
use crate::error::{Error, Result, Source};
use crate::html_dir::HtmlDir;
use crate::html_if::HtmlIf;
use crate::html_single::HtmlSingle;
use crate::lang;
use crate::latex::{Latex, Pdf};
use crate::misc;
use crate::number::Number;
use crate::parser::Features;
use crate::parser::Parser;
use crate::resource_handler::ResourceHandler;
use crate::templates::{epub, epub3, highlight, html, html_dir, html_if, html_single, latex};
use crate::text_view::view_as_text;
use crate::token::Token;
use std::borrow::Cow;
use std::cmp::Ordering;
use std::collections::{HashMap, BTreeMap};
use std::fmt;
use std::fs::File;
use std::io::{Read, Write};
use std::iter::IntoIterator;
use std::path::{Path, PathBuf};
use mustache::{Template};
use numerals::roman::Roman;
use rayon::prelude::*;
use yaml_rust::{Yaml, YamlLoader};
/// Type of header (part or chapter)
#[derive(Copy, Clone, Debug)]
pub enum Header {
/// Chapter (default)
Chapter,
/// Part (or "book" or "episode" or whatever)
Part,
}
/// Header data (for chapter or part)
#[derive(Debug, Clone)]
pub struct HeaderData {
/// A string containnig the full text version, for e.g. TOCs
pub text: String,
/// The title of the header, e.g. "Part" or "Chapter" or nothing
pub header: String,
/// The number, formatted in roman or arabic
pub number: String,
/// Only the title
pub title: String,
}
/// The types of bars
#[derive(Copy, Clone)]
pub enum Crowbar {
Main,
Second,
Spinner(usize),
}
/// The state of bars
#[derive(Copy, Clone)]
pub enum CrowbarState {
Running,
Success,
Error,
}
impl fmt::Display for HeaderData {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.text)
}
}
/// A Book.
///
/// Probably the central structure for of Crowbook, as it is the one
/// that calls the other ones.
///
/// It has the tasks of loading a configuration file, loading chapters
/// and using `Parser`to parse them, and then calling various renderers
/// (`HtmlRendrer`, `LatexRenderer`, `EpubRenderer` and/or `OdtRenderer`)
/// to convert the AST into documents.
///
/// # Examples
///
/// ```
/// use crowbook::{Book, Number};
/// // Create a book with some options
/// let mut book = Book::new();
/// book.set_options(&[("author", "Joan Doe"),
/// ("title", "An untitled book"),
/// ("lang", "en")]);
///
/// // Add a chapter to the book
/// book.add_chapter_from_source(Number::Default, "# The beginning#\nBla, bla, bla".as_bytes()).unwrap();
///
/// // Render the book as html to stdout
/// book.render_format_to("html", &mut std::io::stdout()).unwrap();
/// ```
pub struct Book<'a> {
/// Internal structure. You should not accesss this directly except if
/// you are writing a new renderer.
pub chapters: Vec<Chapter>,
/// Options of the book
pub options: BookOptions,
/// Root path of the book
#[doc(hidden)]
pub root: PathBuf,
/// Source for error files
#[doc(hidden)]
pub source: Source,
/// Features used in the book content
#[doc(hidden)]
pub features: Features,
cleaner: Box<dyn Cleaner>,
chapter_template: Option<Template>,
part_template: Option<Template>,
formats: HashMap<&'static str, (String, Box<dyn BookRenderer>)>,
#[doc(hidden)]
pub bars: Bars,
/// Store the templates registry
pub registry: upon::Engine<'a>,
}
impl<'a> Book<'a> {
/// Creates a new, empty `Book`
pub fn new() -> Book<'a> {
let mut book = Book {
source: Source::empty(),
chapters: vec![],
cleaner: Box::new(Off),
root: PathBuf::new(),
options: BookOptions::new(),
chapter_template: None,
part_template: None,
formats: HashMap::new(),
features: Features::new(),
bars: Bars::new(),
registry: upon::Engine::new(),
};
book.add_format(
"html",
lformat!("HTML (standalone page)"),
Box::new(HtmlSingle {}),
)
.add_format(
"html.dir",
lformat!("HTML (multiple pages)"),
Box::new(HtmlDir {}),
)
.add_format("tex", lformat!("LaTeX"), Box::new(Latex {}))
.add_format("pdf", lformat!("PDF"), Box::new(Pdf {}))
.add_format("epub", lformat!("EPUB"), Box::new(Epub {}))
.add_format(
"html.if",
lformat!("HTML (interactive fiction)"),
Box::new(HtmlIf {}),
);
book
}
/// Sets an error message to the progress bar, if it is set
pub fn set_error(&self, msg: &str) {
self.bar_finish(Crowbar::Main, CrowbarState::Error, msg)
}
/// Adds a progress bar where where info should be written.
///
/// See [indicatif doc](https://docs.rs/indicatif) for more information.
pub fn add_progress_bar(&mut self, emoji: bool) {
self.private_add_progress_bar(emoji);
}
/// Register a format that can be rendered.
///
/// The renderer for this format must implement the `BookRenderer` trait.
///
/// # Example
///
/// ```
/// use crowbook::{Result, Book, BookRenderer};
/// use std::io::Write;
/// struct Dummy {}
/// impl BookRenderer for Dummy {
/// fn render(&self, book: &Book, to: &mut Write) -> Result<()> {
/// write!(to, "This does nothing useful").unwrap();
/// Ok(())
/// }
/// }
///
/// let mut book = Book::new();
/// book.add_format("foo",
/// "Some dummy implementation",
/// Box::new(Dummy{}));
/// ```
pub fn add_format<S: Into<String>>(
&mut self,
format: &'static str,
description: S,
renderer: Box<dyn BookRenderer>,
) -> &mut Self {
self.formats.insert(format, (description.into(), renderer));
self
}
/// Sets the options of a `Book`
///
/// # Arguments
/// * `options`: a (possibly empty) list (or other iterator) of (key, value) tuples.
///
/// # Example
///
/// ```
/// use crowbook::Book;
/// let mut book = Book::new();
/// book.set_options(&[("author", "Foo"), ("title", "Bar")]);
/// assert_eq!(book.options.get_str("author").unwrap(), "Foo");
/// assert_eq!(book.options.get_str("title").unwrap(), "Bar");
/// ```
pub fn set_options<'b, I>(&mut self, options: I) -> &mut Self
where
I: IntoIterator<Item = &'b (&'b str, &'b str)>,
{
// set options
for (key, value) in options {
if let Err(err) = self.options.set(key, value) {
error!(
"{}",
lformat!(
"Error initializing book: could not set {key} to {value}: {error}",
key = key,
value = value,
error = err
)
);
}
}
// set cleaner according to lang and autoclean settings
self.update_cleaner();
self
}
/// Loads a book configuration file
///
/// # Argument
/// * `path`: the path of the file to load. The directory of this file is used as
/// a "root" directory for all paths referenced in books, whether chapter files,
/// templates, cover images, and so on.
///
/// # Example
///
/// ```
/// # use crowbook::Book;
/// let mut book = Book::new();
/// let result = book.load_file("some.book");
/// ```
pub fn load_file<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
let filename = format!("{}", path.as_ref().display());
self.source = Source::new(filename.as_str());
self.options.source = Source::new(filename.as_str());
let f = File::open(path.as_ref()).map_err(|_| {
Error::file_not_found(Source::empty(), lformat!("book"), filename.clone())
})?;
// Set book path to book's directory
if let Some(parent) = path.as_ref().parent() {
self.root = parent.to_owned();
self.options.root = self.root.clone();
}
match self.read_config(&f) {
Ok(_) => Ok(()),
Err(err) => {
if err.is_config_parser() && path.as_ref().ends_with(".md") {
let err = Error::default(
Source::empty(),
lformat!(
"could not parse {file} as a book \
file.\nMaybe you meant to run crowbook \
with the --single argument?",
file = misc::normalize(path)
),
);
Err(err)
} else {
Err(err)
}
}
}
}
/// Loads a single markdown file
///
/// This is *not* used to add a chapter to an existing book, but to to load the
/// book configuration file from a single Markdown file.
///
/// Since it is designed for single-chapter short stories, this method also sets
/// the `tex.class` option to `article`.
///
/// # Example
///
/// ```
/// use crowbook::Book;
/// let mut book = Book::new();
/// book.load_markdown_file("foo.md"); // not unwraping since foo.md doesn't exist
/// ```
pub fn load_markdown_file<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
let filename = format!("{}", path.as_ref().display());
self.source = Source::new(filename.as_str());
// Set book path to book's directory
if let Some(parent) = path.as_ref().parent() {
self.root = parent.to_owned();
self.options.root = self.root.clone();
}
self.options.set("tex.class", "article").unwrap();
self.options.set("input.yaml_blocks", "true").unwrap();
// Add the file as chapter with hidden title
// hideous line, but basically transforms foo/bar/baz.md to baz.md
let relative_path = Path::new(path.as_ref().components().last().unwrap().as_os_str());
// Update grammar checker according to options
self.add_chapter(Number::Hidden, &relative_path.to_string_lossy(), false)?;
Ok(())
}
/// Reads a single markdown config from a `Read`able object.
///
/// Similar to `load_markdown_file`, except it reads a source instead of a file.
///
/// # Example
///
/// ```
/// use crowbook::Book;
/// let content = "\
/// ---
/// author: Foo
/// title: Bar
/// ---
///
/// # Book #
///
/// Some content in *markdown*.";
///
/// let mut book = Book::new();
/// book.read_markdown_config(content.as_bytes()).unwrap();
/// assert_eq!(book.options.get_str("title").unwrap(), "Bar");
/// ```
pub fn read_markdown_config<R: Read>(&mut self, source: R) -> Result<()> {
self.options.set("tex.class", "article").unwrap();
self.options.set("input.yaml_blocks", "true").unwrap();
// Update grammar checker according to options
self.add_chapter_from_source(Number::Hidden, source, false)?;
Ok(())
}
/// Sets options from a YAML block
fn set_options_from_yaml(&mut self, yaml: &str) -> Result<&mut Self> {
self.options.source = self.source.clone();
match YamlLoader::load_from_str(yaml) {
Err(err) => {
return Err(Error::config_parser(
&self.source,
lformat!("YAML block was not valid YAML: {error}", error = err),
))
}
Ok(mut docs) => {
if docs.len() == 1 && docs[0].as_hash().is_some() {
if let Yaml::Hash(hash) = docs.pop().unwrap() {
for (key, value) in hash {
if let Err(err) = self.options.set_yaml(key, value) {
error!("{}", err);
};
}
} else {
unreachable!();
}
} else {
return Err(Error::config_parser(
&self.source,
lformat!(
"YAML part of the book is not a \
valid hashmap"
),
));
}
}
}
Ok(self)
}
/// Reads a book configuration from a `Read`able source.
///
/// # Book configuration
///
/// A line with "option: value" sets the option to value
///
/// + chapter_name.md adds the (default numbered) chapter
///
/// - chapter_name.md adds the (unnumbered) chapter
///
/// 3. chapter_name.md adds the (custom numbered) chapter
///
/// # See also
/// * `load_file`
///
/// # Example
///
/// ```
/// use crowbook::Book;
/// let content = "\
/// author: Foo
/// title: Bar
///
/// ! intro.md
/// + chapter_01.md";
///
/// let mut book = Book::new();
/// book.read_config(content.as_bytes()); // no unwraping as `intro.md` and `chapter_01.md` don't exist
/// ```
pub fn read_config<R: Read>(&mut self, mut source: R) -> Result<()> {
fn get_filename<'b>(source: &Source, s: &'b str) -> Result<&'b str> {
let words: Vec<&str> = (s[1..]).split_whitespace().collect();
if words.len() > 1 {
return Err(Error::config_parser(
source,
lformat!(
"chapter filenames must not contain \
whitespace"
),
));
} else if words.is_empty() {
return Err(Error::config_parser(
source,
lformat!("no chapter name specified"),
));
}
Ok(words[0])
}
self.bar_set_message(Crowbar::Main, &lformat!("setting options"));
let mut s = String::new();
source.read_to_string(&mut s).map_err(|err| {
Error::config_parser(
Source::empty(),
lformat!("could not read source: {error}", error = err),
)
})?;
// Parse the YAML block, that is, until first chapter
let mut yaml = String::new();
let mut lines = s.lines().peekable();
let mut line;
let mut line_number = 0;
let mut is_next_line_ok: bool;
while let Some(next_line) = lines.peek() {
if next_line.starts_with(|c| match c {
'-' | '+' | '!' | '@' => true,
_ => c.is_ascii_digit(),
}) {
break;
}
line = lines.next().unwrap();
line_number += 1;
self.source.set_line(line_number);
yaml.push_str(line);
yaml.push('\n');
if line
.trim()
.ends_with(|c| matches!(c, '>' | '|' | ':' | '-'))
{
// line ends with the start of a block indicator
continue;
}
if let Some(next_line) = lines.peek() {
let doc = YamlLoader::load_from_str(next_line);
if let Ok(doc) = doc {
if !doc.is_empty() && doc[0].as_hash().is_some() {
is_next_line_ok = true;
} else {
is_next_line_ok = false;
}
} else {
is_next_line_ok = false;
}
} else {
break;
}
if !is_next_line_ok {
// If next line is not valid yaml, probably means we are in a multistring
continue;
}
let result = self.set_options_from_yaml(&yaml);
match result {
Ok(_) => {
// Fine, we can remove previous lines
yaml = String::new();
}
Err(err) => {
if err.is_book_option() {
// book option error: abort
return Err(err);
} else {
// Other error: we do nothing, hoping it will work
// itself out when more lines are added to yaml
}
}
}
}
self.set_options_from_yaml(&yaml)?;
// Update cleaner according to options (autoclean/lang)
self.update_cleaner();
self.bar_set_message(Crowbar::Main, &lformat!("Parsing chapters"));
// Parse chapters
let lines: Vec<_> = lines.collect();
self.add_second_bar(&lformat!("Processing..."), lines.len() as u64);
for line in lines {
self.inc_second_bar();
line_number += 1;
self.source.set_line(line_number);
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with("--") {
// Subchapter
let mut level = 0;
for b in line.bytes() {
if b == b'-' {
level += 1;
} else {
break;
}
}
assert!(level > 1);
level -= 1;
let file = get_filename(&self.source, &line[level..])?;
self.add_subchapter(level as i32, file)?;
} else if line.starts_with('-') {
// unnumbered chapter
let file = get_filename(&self.source, line)?;
self.add_chapter(Number::Unnumbered, file, false)?;
} else if line.starts_with('+') {
// numbered chapter
let file = get_filename(&self.source, line)?;
self.add_chapter(Number::Default, file, true)?;
} else if line.starts_with('!') {
// hidden chapter
let file = get_filename(&self.source, line)?;
self.add_chapter(Number::Hidden, file, false)?;
} else if line.starts_with(|c: char| c.is_ascii_digit()) {
// chapter with specific number
let parts: Vec<_> = line
.splitn(2, |c: char| c == '.' || c == ':' || c == '+')
.collect();
if parts.len() != 2 {
return Err(Error::config_parser(
&self.source,
lformat!(
"ill-formatted line specifying \
chapter number"
),
));
}
let file = get_filename(&self.source, parts[1])?;
let number = parts[0].parse::<i32>().map_err(|err| {
Error::config_parser(
&self.source,
lformat!("error parsing chapter number: {error}", error = err),
)
})?;
self.add_chapter(Number::Specified(number), file, true)?;
} else if let Some(subline) = line.strip_prefix('@') {
/* Part */
if subline.starts_with(|c: char| c.is_whitespace()) {
let subline = subline.trim();
let ast = Parser::from(self).parse_inline(subline)?;
let ast = vec![Token::Header(1, ast)];
self.chapters
.push(Chapter::new(Number::DefaultPart, String::new(), ast));
} else if subline.starts_with('-') {
/* Unnumbered part */
let file = get_filename(&self.source, subline)?;
self.add_chapter(Number::UnnumberedPart, file, true)?;
} else if subline.starts_with('+') {
/* Numbered part */
let file = get_filename(&self.source, subline)?;
self.add_chapter(Number::DefaultPart, file, true)?;
} else if subline.starts_with(|c: char| c.is_ascii_digit()) {
/* Specified part*/
let parts: Vec<_> = subline
.splitn(2, |c: char| c == '.' || c == ':' || c == '+')
.collect();
if parts.len() != 2 {
return Err(Error::config_parser(
&self.source,
lformat!(
"ill-formatted line specifying \
part number"
),
));
}
let file = get_filename(&self.source, parts[1])?;
let number = parts[0].parse::<i32>().map_err(|err| {
Error::config_parser(
&self.source,
lformat!("error parsing part number: {error}", error = err),
)
})?;
self.add_chapter(Number::SpecifiedPart(number), file, true)?;
} else {
return Err(Error::config_parser(
&self.source,
lformat!("found invalid part definition in the chapter list"),
));
}
} else {
return Err(Error::config_parser(
&self.source,
lformat!(
"found invalid chapter definition in \
the chapter list"
),
));
}
}
self.bar_finish(Crowbar::Second, CrowbarState::Success, "");
self.source.unset_line();
self.set_chapter_template()?;
Ok(())
}
/// Determine whether proofreading is activated or not
fn is_proofread(&self) -> bool {
self.options.get_bool("proofread").unwrap()
&& (self.options.get("output.proofread.html").is_ok()
|| self.options.get("output.proofread.html.dir").is_ok()
|| self.options.get("output.proofread.pdf").is_ok())
}
/// Generates output files acccording to book options.
///
/// # Example
///
/// ```
/// use crowbook::Book;
/// let content = "\
/// ---
/// title: Foo
/// output.tex: /tmp/foo.tex
/// ---
///
/// # Foo
///
/// Bar and baz, too.";
///
/// Book::new()
/// .read_markdown_config(content.as_bytes())
/// .unwrap()
/// .render_all(); // renders foo.tex in /tmp
/// ```
pub fn render_all(&mut self) {
let mut keys: Vec<_> = self
.formats
.keys()
.filter(|fmt| {
if !self.is_proofread() && fmt.contains("proofread") {
return false;
}
self.options.get_path(&format!("output.{fmt}")).is_ok()
})
.map(|s| s.to_string())
.collect();
// Make sure that PDF comes first since running latex takes lots of time
keys.sort_by(|fmt1, fmt2| {
if fmt1.contains("pdf") {
Ordering::Less
} else if fmt2.contains("pdf") {
Ordering::Greater
} else {
Ordering::Equal
}
});
for key in &keys {
self.add_spinner_to_multibar(key);
}
keys.par_iter().enumerate().for_each(|(i, fmt)| {
self.render_format_with_bar(fmt, i);
});
self.bar_finish(Crowbar::Main, CrowbarState::Success, &lformat!("Finished"));
// if handles.is_empty() {
// Logger::display_warning(lformat!("Crowbook generated no file because no output file was \
// specified. Add output.{{format}} to your config file."));
// }
}
/// Renders the book to the given format and reports to progress bar if set
pub fn render_format_with_bar(&self, format: &str, bar: usize) {
let mut key = String::from("output.");
key.push_str(format);
if let Ok(path) = self.options.get_path(&key) {
self.bar_set_message(Crowbar::Spinner(bar), &lformat!("rendering..."));
let result = self.render_format_to_file_with_bar(format, path, bar);
if let Err(err) = result {
self.bar_finish(
Crowbar::Spinner(bar),
CrowbarState::Error,
&format!("{err}"),
);
error!(
"{}",
lformat!(
"Error rendering {name}: {error}",
name = format,
error = err
)
);
}
}
}
pub fn render_format_to_file_with_bar<P: Into<PathBuf>>(
&self,
format: &str,
path: P,
bar: usize,
) -> Result<()> {
debug!(
"{}",
lformat!("Attempting to generate {format}...", format = format)
);
let path = path.into();
match self.formats.get(format) {
Some((description, renderer)) => {
let path = if path.ends_with("auto") {
let file = if let Some(s) = self
.source
.file
.as_ref()
.and_then(|f| Path::new(f).file_stem())
{
s.to_string_lossy().into_owned()
} else {
return Err(Error::default(&self.source, lformat!("output to {format} set to auto but can't find book file name to infer it",
format = description)));
};
let file = renderer.auto_path(&file).map_err(|_| {
Error::default(
&self.source,
lformat!(
"the {format} renderer does not support auto for output path",
format = description
),
)
})?;
path.with_file_name(file)
} else {
path
};
renderer.render_to_file(self, &path)?;
let path = misc::normalize(path);
let msg = lformat!(
"Succesfully generated {format}: {path}",
format = description,
path = &path
);
info!("{}", &msg);
self.bar_finish(
Crowbar::Spinner(bar),
CrowbarState::Success,
&lformat!("generated {path}", path = path),
);
Ok(())
}
None => Err(Error::default(
Source::empty(),
lformat!("unknown format {format}", format = format),
)),
}
}
/// Render book to specified format according to book options, and write the results
/// in the `Write` object.
///
/// This method will fail if the format is not handled by the book, or if there is a
/// problem during rendering, or if the renderer can't render to a byte stream (e.g.
/// multiple files HTML renderer can't, as it must create a directory.)
///
/// # See also
/// * `render_format_to_file`, which creates a new file (that *can* be a directory).
/// * `render_format`, which won't do anything if `output.{format}` isn't specified
/// in the book configuration file.
pub fn render_format_to<T: Write>(&mut self, format: &str, f: &mut T) -> Result<()> {
debug!(
"{}",
lformat!("Attempting to generate {format}...", format = format)
);
let bar = self.add_spinner_to_multibar(format);
match self.formats.get(format) {
Some((description, renderer)) => match renderer.render(self, f) {
Ok(_) => {
self.bar_finish(
Crowbar::Spinner(bar),
CrowbarState::Success,
&lformat!("generated {format}", format = format),
);
self.bar_finish(Crowbar::Main, CrowbarState::Success, &lformat!("Finished"));
info!(
"{}",
lformat!("Succesfully generated {format}", format = description)
);
Ok(())
}
Err(e) => {
self.bar_finish(
Crowbar::Spinner(bar),
CrowbarState::Error,
&lformat!("{error}", error = e),
);
self.bar_finish(Crowbar::Main, CrowbarState::Error, &lformat!("ERROR"));
Err(e)
}
},
None => {
self.bar_finish(
Crowbar::Spinner(bar),
CrowbarState::Error,
&lformat!("unknown format"),
);
Err(Error::default(
Source::empty(),
lformat!("unknown format {format}", format = format),
))
}
}
}
/// Render book to specified format according to book options. Creates a new file
/// and write the result in it.
///
/// This method will fail if the format is not handled by the book, or if there is a
/// problem during rendering.
///
/// # Arguments
///
/// * `format`: the format to render;
/// * `path`: a path to the file that will be created;
/// * `bar`: a Progressbar, or `None`
///
/// # See also
/// * `render_format_to`, which writes in any `Write`able object.
/// * `render_format`, which won't do anything if `output.{format}` isn't specified
/// in the book configuration file.
pub fn render_format_to_file<P: Into<PathBuf>>(&mut self, format: &str, path: P) -> Result<()> {
let bar = self.add_spinner_to_multibar(format);
self.render_format_to_file_with_bar(format, path, bar)?;
self.bar_finish(Crowbar::Main, CrowbarState::Success, &lformat!("Finished"));
Ok(())
}
/// Adds a chapter to the book.
///
/// This method is the backend used both by `add_chapter` and `add_chapter_from_source`.
pub fn add_chapter_from_named_source<R: Read>(
&mut self,
number: Number,
file: &str,
mut source: R,
mut add_title_if_empty: bool,
) -> Result<&mut Self> {
self.bar_set_message(
Crowbar::Main,
&lformat!("Processing {file}...", file = file),
);
let mut content = String::new();
source.read_to_string(&mut content).map_err(|_| {
Error::parser(
&self.source,
lformat!(
"file {file} contains invalid UTF-8",
file = misc::normalize(file)
),
)
})?;
// parse the file
self.bar_set_message(Crowbar::Second, &lformat!("Parsing..."));
let mut parser = Parser::from(self);
parser.set_source_file(file);
let mut yaml_block = String::from("");
let mut tokens = parser.parse(&content, Option::Some(&mut yaml_block))?;
// Parse YAML block
self.parse_yaml(&yaml_block);
self.features = self.features | parser.features();
// transform the AST to make local links and images relative to `book` directory
let offset = if let Some(f) = Path::new(file).parent() {
f
} else {
Path::new("")
};
if offset.starts_with("..") {
debug!(
"{}",
lformat!(
"Warning: book contains chapter '{file}' in a directory above \
the book file, this might cause problems",
file = misc::normalize(file)
)
);
}
// For offset: if nothing is specified, it is the filename's directory
// If base_path.{images/links} is specified, override it for one of them.
// If base_path is specified, override it for both.
let res_base = self.options.get_path("resources.base_path");
let res_base_img = self.options.get_path("resources.base_path.images");
let res_base_lnk = self.options.get_path("resources.base_path.links");
let mut link_offset = offset;
let mut image_offset = offset;
if let Ok(ref path) = res_base {
link_offset = Path::new(path);
image_offset = Path::new(path);
} else {
if let Ok(ref path) = res_base_img {
image_offset = Path::new(path);
}
if let Ok(ref path) = res_base_lnk {
link_offset = Path::new(path);
}
}
// add offset
ResourceHandler::add_offset(link_offset, image_offset, &mut tokens);
// If files_mean_chapters is set, override the default setting
if let Ok(x) = self.options.get_bool("crowbook.files_mean_chapters") {
add_title_if_empty = x;
}
// Add a title if there is none in the chapter (unless this is subchapter)
if add_title_if_empty {
misc::insert_title(&mut tokens);
}
self.bar_set_message(Crowbar::Second, "");
self.chapters.push(Chapter::new(number, file, tokens));
Ok(self)
}
/// Adds a chapter, as a file name, to the book
pub fn add_subchapter(&mut self, level: i32, file: &str) -> Result<&mut Self> {
let number = {
if let Some(chapter) = self.chapters.last() {
chapter.number
} else {
Number::Hidden
}
};
self.add_chapter(number, file, false)?;
// Adjust header levels
{
let last = self.chapters.last_mut().unwrap();
for token in &mut last.content {
if let Token::Header(ref mut n, _) = *token {
let new = *n + level;
if !(0..=6).contains(&new) {
return Err(Error::parser(Source::new(file),
lformat!("this subchapter contains a heading that, when adjusted, is not in the right range ({} instead of [0-6])", new)));
}
*n = new;
}
}
}
Ok(self)
}
/// Adds a chapter, as a file name, to the book
///
/// `Book` will then parse the file and store the AST (i.e., a vector
/// of `Token`s).
///
/// # Arguments
/// * `number`: specifies if the chapter must be numbered, not numbered, or if its title
/// must be hidden. See `Number`.
/// * `file`: path of the file for this chapter
///
/// **Returns** an error if `file` does not exist, could not be read, of if there was
/// some error parsing it.
pub fn add_chapter(
&mut self,
number: Number,
file: &str,
add_title_if_empty: bool,
) -> Result<&mut Self> {
self.bar_set_message(
Crowbar::Main,
&lformat!("Parsing {file}", file = misc::normalize(file)),
);
debug!(
"{}",
lformat!("Parsing chapter: {file}...", file = misc::normalize(file))
);
// try to open file
let path = self.root.join(file);
let f = File::open(&path).map_err(|_| {
Error::file_not_found(
&self.source,
lformat!("book chapter"),
format!("{}", path.display()),
)
})?;
self.add_chapter_from_named_source(number, file, f, add_title_if_empty)
}
/// Adds a chapter to the book from a source (any object implementing `Read`)
///
/// `Book` will then parse the string and store the AST (i.e., a vector
/// of `Token`s).
///
/// # Arguments
/// * `number`: specifies if the chapter must be numbered, not numbered, or if its title
/// must be hidden. See `Number`.
/// * `content`: the content of the chapter.
///
/// **Returns** an error if there was some errror parsing `content`.
pub fn add_chapter_from_source<R: Read>(
&mut self,
number: Number,
source: R,
add_title_if_empty: bool,
) -> Result<&mut Self> {
self.add_chapter_from_named_source(number, "", source, add_title_if_empty)
}
/// Either clean a string or does nothing,
/// according to book `lang` and `autoclean` options
#[doc(hidden)]
pub fn clean<'s, S: Into<Cow<'s, str>>>(&self, text: S) -> Cow<'s, str> {
self.cleaner.clean(text.into())
}
/// Returns a template
///
/// Returns the default one if no option was set, or the one set by the user.
///
/// Returns an error if `template` isn't a valid template name.
#[doc(hidden)]
pub fn get_template(&self, template: &str) -> Result<Cow<'static, str>> {
let option = self.options.get_path(template);
let epub3 = template.starts_with("epub") && self.options.get_i32("epub.version")? == 3;
let fallback = match template {
"epub.css" => epub::CSS,
"epub.titlepage.xhtml" => {
if epub3 {
epub3::TITLE
} else {
epub::TITLE
}
}
"epub.chapter.xhtml" => {
if epub3 {
epub3::TEMPLATE
} else {
epub::TEMPLATE
}
}
"html.css" => html::CSS,
"html.css.colors" => html::CSS_COLORS,
"html.css.print" => html::PRINT_CSS,
"html.standalone.template" => html_single::HTML,
"html.standalone.js" => html_single::JS,
"html.js" => html::JS,
"html.dir.template" => html_dir::TEMPLATE,
"html.highlight.js" => highlight::JS,
"html.highlight.css" => highlight::CSS,
"html.if.js" => html_if::JS,
"html.if.new_game" => html_if::NEW_GAME,
"tex.template" => latex::TEMPLATE,
_ => {
return Err(Error::config_parser(
&self.source,
lformat!("invalid template '{template}'"),
))
}
};
if let Ok(ref s) = option {
let mut f = File::open(s).map_err(|_| {
Error::file_not_found(&self.source, format!("template '{template}'"), s.to_owned())
})?;
let mut res = String::new();
f.read_to_string(&mut res).map_err(|_| {
Error::config_parser(
&self.source,
lformat!("file '{file}' could not be read", file = s),
)
})?;
Ok(Cow::Owned(res))
} else {
Ok(Cow::Borrowed(fallback))
}
}
/// Sets the chapter_template once and for all
fn set_chapter_template(&mut self) -> Result<()> {
self.registry.add_template("rendering.chapter.template",
self.options.get_str("rendering.chapter.template").unwrap().to_owned())
.map_err(|e| Error::template(
&self.source,
lformat!(
"could not compile '{template}': {error}",
template = "rendering.chapter.template",
error = e
))
)?;
Ok(())
}
/// Returns the formatted (roman or arabic) number of chapter
#[doc(hidden)]
pub fn get_header_number(&self, header: Header, n: i32) -> Result<String> {
let boolean = match header {
Header::Part => self
.options
.get_bool("rendering.part.roman_numerals")
.unwrap(),
Header::Chapter => self
.options
.get_bool("rendering.chapter.roman_numerals")
.unwrap(),
};
let number = if boolean {
if n <= 0 {
return Err(Error::render(
Source::empty(),
lformat!(
"can not use roman numerals with zero or negative chapter numbers ({n})",
n = n
),
));
}
format!("{:X}", Roman::from(n as i16))
} else {
format!("{n}")
};
Ok(number)
}
/// Returns the string corresponding to a number, title, and the numbering template for chapter
#[doc(hidden)]
pub fn get_header<F>(
&self,
header: Header,
n: i32,
title: String,
mut f: F,
) -> Result<HeaderData>
where
F: FnMut(&str) -> Result<String>,
{
let header_type = match header {
Header::Part => "part",
Header::Chapter => "chapter",
};
let mut data = self.get_metadata(&mut f)?;
if !title.is_empty() {
data.insert(format!("has_{header_type}_title"), true.into());
}
let number = self.get_header_number(header, n)?;
let header_name = self
.options
.get_str(&format!("rendering.{header_type}"))
.map(|s| s.to_owned())
.unwrap_or_else(|_| lang::get_str(self.options.get_str("lang").unwrap(), header_type));
data.insert(format!("{header_type}_title"), title.clone().into());
data.insert(header_type.into(), header_name.clone().into());
data.insert("number".into(), number.clone().into());
let opt_template = match header {
Header::Part => &self.part_template,
Header::Chapter => &self.chapter_template,
};
let res = self.registry.get_template(&format!("rendering.{header_type}.template"))
.unwrap()
.render(&data)
.to_string()?;
Ok(HeaderData {
text: res,
number,
header: header_name,
title,
})
}
/// Returns the string corresponding to a number, title, and the numbering template for chapter
#[doc(hidden)]
pub fn get_chapter_header<F>(&self, n: i32, title: String, f: F) -> Result<HeaderData>
where
F: FnMut(&str) -> Result<String>,
{
self.get_header(Header::Chapter, n, title, f)
}
/// Returns the string corresponding to a number, title, and the numbering template for part
#[doc(hidden)]
pub fn get_part_header<F>(&self, n: i32, title: String, f: F) -> Result<HeaderData>
where
F: FnMut(&str) -> Result<String>,
{
self.get_header(Header::Part, n, title, f)
}
/// Returns a `Map of Key/Value` (used by `Upon` for templating), to be used (and completed)
/// by renderers. It fills it with the metadata options.
///
/// It also uses the lang/xx.yaml file corresponding to the language and fills
/// `loc_xxx` fiels with it that corresponds to translated versions.
///
/// This method treats the metadata as Markdown and thus calls `f` to render it.
/// This is why we cant really cache this as it will depend on the renderer.
#[doc(hidden)]
pub fn get_metadata<F>(&self, mut f: F) -> Result<BTreeMap<String, upon::Value>>
where
F: FnMut(&str) -> Result<String>,
{
let mut m: BTreeMap<String, upon::Value> = BTreeMap::new();
m.insert("crowbook_version".into(), env!("CARGO_PKG_VERSION").into());
m.insert(format!("lang_{}", self.options.get_str("lang").unwrap()), true.into());
// Add metadata to mapbuilder
for key in self.options.get_metadata() {
if let Ok(s) = self.options.get_str(key) {
let key = key.replace('.', "_");
// Don't render lang as markdown
let content = match key.as_ref() {
"lang" => Ok(s.to_string()),
_ => f(s),
};
let raw = view_as_text(&Parser::from(self).parse(s, None)?);
match content {
Ok(content) => {
if !content.is_empty() {
m.insert(format!("{key}_raw"), raw.into());
m.insert(key.clone(), content.into());
m.insert(format!("has_{key}"), true.into());
} else {
m.insert(format!("has_{key}"), false.into());
}
}
Err(err) => {
return Err(Error::render(
&self.source,
lformat!(
"could not render `{key}` for \
metadata:\n{error}",
key = &key,
error = err
),
));
}
}
} else {
m.insert(format!("has_{key}"), false.into());
}
}
// Add localization strings
let hash = lang::get_hash(self.options.get_str("lang").unwrap());
for (key, value) in hash {
let key = format!("loc_{}", key.as_str().unwrap());
let value = value.as_str().unwrap();
m.insert(key, value.into());
}
Ok(m)
}
/// Calls upon::engine::compile, does NOT registre the complete
pub fn compile_str<'s, O>(&self, template: &'s str, source: O, template_name: &str) -> Result<upon::Template<'_, 's>>
where
O: Into<Source>,
{
let input: String = template.to_owned();
let result = self.registry.compile(template);
match result {
Ok(result) => Ok(result),
Err(err) => Err(Error::template(
source,
lformat!(
"could not compile '{template}': {error}",
template = template_name,
error = err
),
)),
}
}
/// Remove YAML blocks from a string and try to parse them to set options
///
/// YAML blocks start with
/// ---
/// and end either with
/// ---
/// or
/// ...
fn parse_yaml(&mut self, yaml_block: &String) {
// Checks that this is valid YAML
match YamlLoader::load_from_str(yaml_block) {
Ok(docs) => {
// Use this yaml block to set options only if 1) it is valid
// 2) the option is activated
if !docs.is_empty() && docs[0].as_hash().is_some() {
let hash = docs[0].as_hash().unwrap();
for (key, value) in hash {
match self
.options
//todo: remove clone
.set_yaml(key.clone(), value.clone())
{
Ok(opt) => {
if let Some(old_value) = opt {
debug!(
"{}",
lformat!(
"Inline YAML block \
replaced {:?} \
previously set to \
{:?} to {:?}",
key,
old_value,
value
)
);
} else {
debug!(
"{}",
lformat!(
"Inline YAML block \
set {:?} to {:?}",
key,
value
)
);
}
}
Err(e) => {
error!(
"{}",
lformat!(
"Inline YAML block could \
not set {:?} to {:?}: {}",
key,
value,
e
)
)
}
}
}
self.update_cleaner();
} else {
debug!(
"{}",
lformat!(
"Ignoring YAML \
block:
\n{block}",
block = &yaml_block
)
);
}
}
Err(err) => {
error!(
"{}",
lformat!(
"Found something that looked like a \
YAML block:\n{block}",
block = &yaml_block
)
);
error!(
"{}",
lformat!(
"... but it didn't parse correctly as \
YAML('{error}'), so treating it like \
Markdown.",
error = err
)
);
}
}
}
// Update the cleaner according to autoclean and lang options
fn update_cleaner(&mut self) {
let params = CleanerParams {
smart_quotes: self.options.get_bool("input.clean.smart_quotes").unwrap(),
ligature_dashes: self
.options
.get_bool("input.clean.ligature.dashes")
.unwrap(),
ligature_guillemets: self
.options
.get_bool("input.clean.ligature.guillemets")
.unwrap(),
};
if self.options.get_bool("input.clean").unwrap() {
let lang = self.options.get_str("lang").unwrap().to_lowercase();
let cleaner: Box<dyn Cleaner> = if lang.starts_with("fr") {
Box::new(French::new(params))
} else {
Box::new(Default::new(params))
};
self.cleaner = cleaner;
} else {
self.cleaner = Box::new(Off);
}
}
}
impl std::default::Default for Book<'_> {
fn default() -> Self {
Self::new()
}
}