mirror of
https://github.com/lise-henry/crowbook
synced 2024-05-25 02:46:08 +02:00
550 lines
22 KiB
Rust
550 lines
22 KiB
Rust
// Copyright (C) 2016-2019 É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.
|
|
//
|
|
// Caribon 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 ba copy of the GNU Lesser General Public License
|
|
// along with Crowbook. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
use crate::error::{Error, Result, Source};
|
|
use crate::token::Token;
|
|
use crate::html::HtmlRenderer;
|
|
use crate::book::{Book, compile_str};
|
|
use crate::book::Header;
|
|
use crate::templates::epub::*;
|
|
use crate::templates::epub3;
|
|
use crate::resource_handler;
|
|
use crate::renderer::Renderer;
|
|
use crate::parser::Parser;
|
|
use crate::lang;
|
|
use crate::book_renderer::BookRenderer;
|
|
use crate::text_view::view_as_text;
|
|
|
|
use mustache::Template;
|
|
use crowbook_text_processing::escape;
|
|
use epub_builder::{EpubBuilder, EpubVersion, EpubContent, ZipCommand, ZipLibrary, ZipCommandOrLibrary, ReferenceType};
|
|
|
|
use std::io::Write;
|
|
use std::convert::{AsRef, AsMut};
|
|
use std::fs;
|
|
use std::fs::File;
|
|
use std::path::Path;
|
|
use std::borrow::Cow;
|
|
use std::mem;
|
|
use mime_guess;
|
|
|
|
/// Renderer for Epub
|
|
///
|
|
/// Uses part of the HTML renderer
|
|
pub struct EpubRenderer<'a> {
|
|
toc: Vec<String>,
|
|
html: HtmlRenderer<'a>,
|
|
chapter_title: String,
|
|
chapter_title_raw: String,
|
|
}
|
|
|
|
impl<'a> EpubRenderer<'a> {
|
|
/// Creates a new Epub renderer
|
|
pub fn new(book: &'a Book) -> Result<EpubRenderer<'a>> {
|
|
let mut html = HtmlRenderer::new(book,
|
|
book.options
|
|
.get_str("epub.highlight.theme")
|
|
.unwrap_or_else(|_| book.options.get_str("rendering.highlight.theme").unwrap()))?;
|
|
html.handler.set_images_mapping(true);
|
|
html.handler.set_base64(false);
|
|
Ok(EpubRenderer {
|
|
html: html,
|
|
toc: vec![],
|
|
chapter_title: String::new(),
|
|
chapter_title_raw: String::new(),
|
|
})
|
|
}
|
|
|
|
/// Render a book
|
|
pub fn render_book(&mut self, to: &mut dyn Write) -> Result<String> {
|
|
// Initialize the EPUB builder
|
|
let mut zip = ZipCommand::new_in(self.html.book.options.get_path("crowbook.temp_dir")?)?;
|
|
zip.command(self.html.book.options.get_str("crowbook.zip.command")
|
|
.unwrap());
|
|
let wrapper = if zip.test().is_ok() {
|
|
ZipCommandOrLibrary::Command(zip)
|
|
} else {
|
|
warn!("{}", lformat!("Could not run zip command, falling back to zip library"));
|
|
ZipCommandOrLibrary::Library(ZipLibrary::new()?)
|
|
};
|
|
let mut maker = EpubBuilder::new(wrapper)?;
|
|
if self.html.book.options.get_i32("epub.version").unwrap() == 3 {
|
|
maker.epub_version(EpubVersion::V30);
|
|
}
|
|
|
|
let lang = self.html.book.options.get_str("lang").unwrap();
|
|
let toc_extras = self.html.book.options.get_bool("epub.toc.extras").unwrap();
|
|
maker.metadata("lang", lang)?;
|
|
maker.metadata("author", escape::html(self.html.book.options.get_str("author").unwrap()))?;
|
|
maker.metadata("title", escape::html(self.html.book.options.get_str("title").unwrap()))?;
|
|
maker.metadata("generator", "crowbook")?;
|
|
maker.metadata("toc_name", lang::get_str(lang,
|
|
"toc"))?;
|
|
if let Ok(subject) = self.html.book.options.get_str("subject") {
|
|
maker.metadata("subject", subject)?;
|
|
}
|
|
if let Ok(description) = self.html.book.options.get_str("description") {
|
|
maker.metadata("description", description)?;
|
|
}
|
|
if let Ok(license) = self.html.book.options.get_str("license") {
|
|
maker.metadata("license", license)?;
|
|
}
|
|
|
|
// if self.html.book.options.get_bool("epub.toc.extras").unwrap() == true {
|
|
// if self.html.book.options.get("cover").is_ok() {
|
|
// self.html.toc.add(1,
|
|
// String::from("cover.xhtml"),
|
|
// lang::get_str(lang, "cover"));
|
|
// }
|
|
// self.html.toc.add(1,
|
|
// String::from("title_page.xhtml"),
|
|
// lang::get_str(lang, "title"));
|
|
|
|
// }
|
|
|
|
|
|
// /* If toc will be rendered inline, add it... to the toc (yeah it's meta) */
|
|
// if self.html.book.options.get_bool("rendering.inline_toc").unwrap() == true {
|
|
// self.html.toc.add(1,
|
|
// String::from("toc.xhtml"),
|
|
// lang::get_str(self.html.book.options.get_str("lang").unwrap(),
|
|
// "toc"));
|
|
// }
|
|
|
|
|
|
for (i, chapter) in self.html.book.chapters.iter().enumerate() {
|
|
self.html.handler.add_link(chapter.filename.as_str(), filenamer(i));
|
|
}
|
|
|
|
// Write cover.xhtml (if needs be)
|
|
if self.html.book.options.get_path("cover").is_ok() {
|
|
let cover = self.render_cover()?;
|
|
let mut content = EpubContent::new("cover.xhtml", cover.as_bytes())
|
|
.reftype(ReferenceType::Cover);
|
|
if toc_extras {
|
|
content = content.title(lang::get_str(lang, "cover"));
|
|
}
|
|
maker.add_content(content)?;
|
|
}
|
|
|
|
// Write titlepage
|
|
{
|
|
let title_page = self.render_titlepage()?;
|
|
let mut content = EpubContent::new("title_page.xhtml", title_page.as_bytes())
|
|
.reftype(ReferenceType::TitlePage);
|
|
if toc_extras {
|
|
content = content.title(lang::get_str(lang, "title"));
|
|
}
|
|
maker.add_content(content)?;
|
|
}
|
|
|
|
if self.html.book.options.get_bool("rendering.inline_toc").unwrap() {
|
|
maker.inline_toc();
|
|
}
|
|
|
|
|
|
// Write chapters
|
|
let template_chapter =
|
|
compile_str(self.html.book.get_template("epub.chapter.xhtml")?.as_ref(),
|
|
&self.html.book.source,
|
|
"epub.chapter.xhtml")?;
|
|
let mut rendered = vec![];
|
|
for (i, chapter) in self.html.book.chapters.iter().enumerate() {
|
|
let n = chapter.number;
|
|
let v = &chapter.content;
|
|
self.html.chapter_config(i, n, filenamer(i));
|
|
let this_chapter = self.render_chapter(v, &template_chapter)?;
|
|
rendered.push(this_chapter);
|
|
}
|
|
|
|
for (i, (rendered_chapter, raw_title)) in rendered.into_iter().enumerate() {
|
|
let mut content = EpubContent::new(filenamer(i), rendered_chapter.as_bytes());
|
|
if i == 0 {
|
|
content = content.reftype(ReferenceType::Text);
|
|
}
|
|
|
|
// horrible hack to add subtoc of this chapter to epub's toc
|
|
// todo: find cleaner way
|
|
for element in &self.html.toc.elements {
|
|
if element.url.contains(&filenamer(i)) {
|
|
content = content.title(escape::html(raw_title));
|
|
content.toc.children = element.children.clone();
|
|
break;
|
|
}
|
|
}
|
|
maker.add_content(content)?;
|
|
}
|
|
self.html.source = Source::empty();
|
|
|
|
// Render the CSS file and write it
|
|
let template_css =
|
|
compile_str(self.html.book.get_template("epub.css").unwrap().as_ref(),
|
|
&self.html.book.source,
|
|
"epub.css")?;
|
|
let mut data = self.html
|
|
.book
|
|
.get_metadata(|s| self.render_vec(&Parser::new().parse_inline(s)?))?
|
|
.insert_bool(self.html.book.options.get_str("lang").unwrap(), true);
|
|
if let Ok(epub_css_add) = self.html.book.options.get_str("epub.css.add") {
|
|
data = data.insert_str("additional_code", epub_css_add);
|
|
}
|
|
let data = data.build();
|
|
let mut res: Vec<u8> = vec![];
|
|
template_css.render_data(&mut res, &data)?;
|
|
let css = String::from_utf8_lossy(&res);
|
|
maker.stylesheet(css.as_bytes())?;
|
|
|
|
// Write all images (including cover)
|
|
let cover = self.html.book.options.get_path("cover");
|
|
for (source, dest) in self.html.handler.images_mapping() {
|
|
let f = fs::canonicalize(source)
|
|
.and_then(|f| File::open(f))
|
|
.map_err(|_| {
|
|
Error::file_not_found(&self.html.source,
|
|
lformat!("image or cover"),
|
|
source.to_owned())
|
|
})?;
|
|
if cover.as_ref() == Ok(source) {
|
|
// Treat cover specially so it is properly tagged
|
|
maker.add_cover_image(dest, &f, self.get_format(dest))?;
|
|
} else {
|
|
maker.add_resource(dest, &f, self.get_format(dest))?;
|
|
}
|
|
}
|
|
|
|
// Write additional resources
|
|
if let Ok(list) = self.html.book.options.get_str_vec("resources.files") {
|
|
let base_path_files =
|
|
self.html.book.options.get_path("resources.base_path.files").unwrap();
|
|
let list = resource_handler::get_files(list, &base_path_files)?;
|
|
let data_path = Path::new(self.html.book.options.get_relative_path("resources.out_path")?);
|
|
for path in list {
|
|
let abs_path = Path::new(&base_path_files).join(&path);
|
|
let f = fs::canonicalize(&abs_path)
|
|
.and_then(|f| File::open(f))
|
|
.map_err(|_| {
|
|
Error::file_not_found(&self.html.book.source,
|
|
lformat!("additional resource from resources.files"),
|
|
abs_path.to_string_lossy().into_owned())
|
|
})?;
|
|
maker.add_resource(data_path.join(&path), &f, self.get_format(path.as_ref()))?;
|
|
}
|
|
}
|
|
|
|
maker.generate(to)?;
|
|
|
|
Ok(String::new())
|
|
}
|
|
|
|
/// Render the titlepgae
|
|
fn render_titlepage(&mut self) -> Result<String> {
|
|
let epub3 = self.html.book.options.get_i32("epub.version").unwrap() == 3;
|
|
let template = compile_str(if epub3 { epub3::TITLE } else { TITLE },
|
|
&self.html.book.source,
|
|
"title page")?;
|
|
let data = self.html
|
|
.book
|
|
.get_metadata(|s| self.render_vec(&Parser::new().parse_inline(s)?))?
|
|
.build();
|
|
let mut res: Vec<u8> = vec![];
|
|
template.render_data(&mut res, &data)?;
|
|
match String::from_utf8(res) {
|
|
Err(_) => panic!("generated HTML in titlepage was not utf-8 valid"),
|
|
Ok(res) => Ok(res),
|
|
}
|
|
}
|
|
|
|
/// Render cover.xhtml
|
|
fn render_cover(&mut self) -> Result<String> {
|
|
if let Ok(cover) = self.html.book.options.get_path("cover") {
|
|
// Check that cover can be found
|
|
if fs::metadata(&cover).is_err() {
|
|
return Err(Error::file_not_found(&self.html.book.source, lformat!("cover"), cover));
|
|
|
|
}
|
|
let epub3 = self.html.book.options.get_i32("epub.version").unwrap() == 3;
|
|
let template = compile_str(if epub3 { epub3::COVER } else { COVER },
|
|
&self.html.book.source,
|
|
"cover.xhtml")?;
|
|
let data = self.html
|
|
.book
|
|
.get_metadata(|s| self.render_vec(&Parser::new().parse_inline(s)?))?
|
|
.insert_str("cover",
|
|
self.html
|
|
.handler
|
|
.map_image(&self.html.source, Cow::Owned(cover))?
|
|
.into_owned())
|
|
.build();
|
|
let mut res: Vec<u8> = vec![];
|
|
template.render_data(&mut res, &data)?;
|
|
match String::from_utf8(res) {
|
|
Err(_) => panic!(lformat!("generated HTML for cover.xhtml was not utf-8 valid")),
|
|
Ok(res) => Ok(res),
|
|
}
|
|
} else {
|
|
panic!(lformat!("Why is this method called if cover is None???"));
|
|
}
|
|
}
|
|
|
|
|
|
/// Render a chapter
|
|
///
|
|
/// Return chapter content and raw title
|
|
pub fn render_chapter(&mut self, v: &[Token], template: &Template) -> Result<(String, String)> {
|
|
let mut content = String::new();
|
|
|
|
for token in v {
|
|
content.push_str(&self.render_token(token)?);
|
|
self.html.render_side_notes(&mut content);
|
|
}
|
|
self.html.render_end_notes(&mut content);
|
|
|
|
if self.chapter_title.is_empty() && self.html.current_numbering >= 1 {
|
|
let number;
|
|
let header;
|
|
if self.html.current_part {
|
|
number = self.html.current_chapter[0] + 1;
|
|
header = Header::Part;
|
|
} else {
|
|
number = self.html.current_chapter[1] + 1;
|
|
header = Header::Chapter;
|
|
}
|
|
|
|
self.chapter_title = self.html
|
|
.book
|
|
.get_header(header, number, "".to_owned(), |s| {
|
|
self.render_vec(&Parser::new().parse_inline(s)?)
|
|
})?
|
|
.text;
|
|
self.chapter_title_raw = self.html
|
|
.book
|
|
.get_header(header, number, "".to_owned(), |s| {
|
|
Ok(view_as_text(&Parser::new().parse_inline(s)?))
|
|
})?
|
|
.text;
|
|
}
|
|
self.toc.push(self.chapter_title.clone());
|
|
|
|
let data = self.html
|
|
.book
|
|
.get_metadata(|s| self.render_vec(&Parser::new().parse_inline(s)?))?
|
|
.insert_str("content", content)
|
|
.insert_str("chapter_title_raw",
|
|
self.chapter_title_raw.clone())
|
|
.insert_str("chapter_title",
|
|
mem::replace(&mut self.chapter_title, String::new()))
|
|
.build();
|
|
self.chapter_title = String::new();
|
|
let mut res: Vec<u8> = vec![];
|
|
template.render_data(&mut res, &data)?;
|
|
match String::from_utf8(res) {
|
|
Err(_) => panic!(lformat!("generated HTML was not utf-8 valid")),
|
|
Ok(res) => Ok((res, mem::replace(&mut self.chapter_title_raw, String::new())))
|
|
}
|
|
}
|
|
|
|
/// Renders the header section of the book, finding the title of the chapter
|
|
fn find_title(&mut self, vec: &[Token]) -> Result<()> {
|
|
if self.html.current_hide || self.html.current_numbering == 0 {
|
|
if self.chapter_title.is_empty() {
|
|
self.chapter_title = self.html.render_vec(vec)?;
|
|
self.chapter_title_raw = view_as_text(vec);
|
|
} else {
|
|
warn!("{}", lformat!("EPUB ({source}): detected two chapter titles inside the \
|
|
same markdown file, in a file where chapter titles are \
|
|
not even rendered.",
|
|
source = self.html.source));
|
|
}
|
|
} else {
|
|
let header;
|
|
let number;
|
|
if self.html.current_part {
|
|
header = Header::Part;
|
|
number = self.html.current_chapter[0] + 1;
|
|
} else {
|
|
header = Header::Chapter;
|
|
number = self.html.current_chapter[1] + 1;
|
|
};
|
|
let res = self.html.book.get_header(header,
|
|
number,
|
|
self.html.render_vec(vec)?,
|
|
|s| {
|
|
self.render_vec(&(Parser::new()
|
|
.parse_inline(s)?))
|
|
});
|
|
let s = res?;
|
|
if self.chapter_title.is_empty() {
|
|
self.chapter_title = s.text;
|
|
self.chapter_title_raw = self.html
|
|
.book
|
|
.get_header(header,
|
|
number,
|
|
view_as_text(vec),
|
|
|s| {
|
|
Ok(view_as_text(&(Parser::new()
|
|
.parse_inline(s)?)))
|
|
})?
|
|
.text;
|
|
} else {
|
|
warn!("{}", lformat!("EPUB ({source}): detected two chapters inside the same \
|
|
markdown file.",
|
|
source = self.html.source));
|
|
warn!("{}", lformat!("EPUB ({source}): conflict between: {title1} and {title2}",
|
|
source = self.html.source,
|
|
title1 = self.chapter_title,
|
|
title2 = s));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Get the format of a file, based on its extension
|
|
fn get_format(&self, s: &str) -> String {
|
|
let opt = mime_guess::from_path(s).first();
|
|
match opt {
|
|
Some(s) => s.to_string(),
|
|
None => {
|
|
error!("{}", lformat!("EPUB: could not guess the format of {file} based on \
|
|
extension. Assuming png.",
|
|
file = s));
|
|
String::from("png")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Renders a token
|
|
///
|
|
/// Used by render_token implementation of Renderer trait. Separate function
|
|
/// because we need to be able to call it from other renderers.
|
|
///
|
|
/// See http://lise-henry.github.io/articles/rust_inheritance.html
|
|
#[doc(hidden)]
|
|
pub fn static_render_token<T>(this: &mut T, token: &Token) -> Result<String>
|
|
where T: AsMut<EpubRenderer<'a>>+AsRef<EpubRenderer<'a>> +
|
|
AsMut<HtmlRenderer<'a>>+AsRef<HtmlRenderer<'a>> + Renderer
|
|
{
|
|
match *token {
|
|
Token::Str(ref text) => {
|
|
let html: &mut HtmlRenderer = this.as_mut();
|
|
let content = if html.verbatim {
|
|
Cow::Borrowed(text.as_ref())
|
|
} else {
|
|
escape::html(html.book.clean(text.as_str()))
|
|
};
|
|
let mut content = if html.first_letter {
|
|
html.first_letter = false;
|
|
if html.book.options.get_bool("rendering.initials").unwrap() {
|
|
// Use initial
|
|
let mut chars = content.chars();
|
|
let initial = chars.next()
|
|
.ok_or_else(|| Error::parser(&html.book.source,
|
|
lformat!("empty str token, could not find \
|
|
initial")))?;
|
|
let mut new_content = if initial.is_alphanumeric() {
|
|
format!("<span class = \"initial\">{}</span>", initial)
|
|
} else {
|
|
format!("{}", initial)
|
|
};
|
|
for c in chars {
|
|
new_content.push(c);
|
|
}
|
|
Cow::Owned(new_content)
|
|
} else {
|
|
content
|
|
}
|
|
} else {
|
|
content
|
|
};
|
|
|
|
if html.book.options.get_bool("epub.escape_nb_spaces").unwrap() {
|
|
content = escape::nb_spaces_html(content);
|
|
}
|
|
Ok(content.into_owned())
|
|
},
|
|
Token::Header(1, ref vec) => {
|
|
{
|
|
let epub: &mut EpubRenderer = this.as_mut();
|
|
epub.find_title(vec)?;
|
|
}
|
|
HtmlRenderer::static_render_token(this, token)
|
|
}
|
|
Token::FootnoteReference(ref reference) => {
|
|
let epub3 = (this.as_ref() as &HtmlRenderer)
|
|
.book
|
|
.options
|
|
.get_i32("epub.version")
|
|
.unwrap() == 3;
|
|
|
|
Ok(format!("<a {} href = \"#note-dest-{}\"><sup id = \
|
|
\"note-source-{}\">[{}]</sup></a>",
|
|
if epub3 { "epub:type = \"noteref\"" } else { "" },
|
|
reference,
|
|
reference,
|
|
reference))
|
|
}
|
|
Token::FootnoteDefinition(ref reference, ref vec) => {
|
|
let epub3 = (this.as_ref() as &HtmlRenderer)
|
|
.book
|
|
.options
|
|
.get_i32("epub.version")
|
|
.unwrap() == 3;
|
|
let inner_content = this.render_vec(vec)?;
|
|
let html: &mut HtmlRenderer = this.as_mut();
|
|
let note_number = format!("<p class = \"note-number\">
|
|
<a href = \"#note-source-{}\">[{}]</a>
|
|
</p>\n",
|
|
reference,
|
|
reference);
|
|
let inner = if epub3 {
|
|
format!("<aside epub:type = \"footnote\" id = \"note-dest-{}\">{}</aside>",
|
|
reference,
|
|
inner_content)
|
|
} else {
|
|
format!("<a id = \"note-dest-{}\" />{}", reference, inner_content)
|
|
};
|
|
html.add_footnote(note_number, inner);
|
|
|
|
Ok(String::new())
|
|
}
|
|
_ => HtmlRenderer::static_render_token(this, token),
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// Generate a file name given an int
|
|
fn filenamer(i: usize) -> String {
|
|
format!("chapter_{:03}.xhtml", i)
|
|
}
|
|
|
|
|
|
derive_html!{EpubRenderer<'a>, EpubRenderer::static_render_token}
|
|
|
|
pub struct Epub {}
|
|
|
|
impl BookRenderer for Epub {
|
|
fn auto_path(&self, book_name: &str) -> Result<String> {
|
|
Ok(format!("{}.epub", book_name))
|
|
}
|
|
|
|
fn render(&self, book: &Book, to: &mut dyn Write) -> Result<()> {
|
|
EpubRenderer::new(book)?
|
|
.render_book(to)?;
|
|
Ok(())
|
|
}
|
|
}
|