342 lines
11 KiB
Rust
342 lines
11 KiB
Rust
use std::collections::BTreeSet;
|
|
use std::fs::File;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
|
|
use chrono::Utc;
|
|
use quick_xml::escape::escape;
|
|
use zip::CompressionMethod;
|
|
use zip::write::{SimpleFileOptions, ZipWriter};
|
|
|
|
use crate::error::{EbookmError, Result};
|
|
use crate::pipeline::BuiltEntry;
|
|
|
|
pub fn write_epub(
|
|
manifest: &crate::manifest::Manifest,
|
|
built: &[BuiltEntry],
|
|
output_path: &Path,
|
|
cover_bytes: Option<(String, Vec<u8>)>,
|
|
) -> Result<()> {
|
|
if let Some(parent) = output_path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|source| EbookmError::Io {
|
|
path: parent.display().to_string(),
|
|
source,
|
|
})?;
|
|
}
|
|
|
|
let file = File::create(output_path).map_err(|source| EbookmError::Io {
|
|
path: output_path.display().to_string(),
|
|
source,
|
|
})?;
|
|
let mut zip = ZipWriter::new(file);
|
|
|
|
let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
|
zip.start_file("mimetype", stored)
|
|
.map_err(|error| EbookmError::Epub {
|
|
message: error.to_string(),
|
|
})?;
|
|
zip.write_all(b"application/epub+zip")
|
|
.map_err(|error| EbookmError::Epub {
|
|
message: error.to_string(),
|
|
})?;
|
|
|
|
let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
|
write_file(&mut zip, "META-INF/container.xml", deflated, CONTAINER_XML)?;
|
|
write_file(&mut zip, "OEBPS/styles/book.css", deflated, DEFAULT_STYLES)?;
|
|
|
|
let nav = build_nav(manifest, built);
|
|
let ncx = build_ncx(manifest, built);
|
|
let opf = build_opf(
|
|
manifest,
|
|
built,
|
|
cover_bytes.as_ref().map(|(href, _)| href.as_str()),
|
|
);
|
|
|
|
write_file(&mut zip, "OEBPS/nav.xhtml", deflated, &nav)?;
|
|
write_file(&mut zip, "OEBPS/toc.ncx", deflated, &ncx)?;
|
|
write_file(&mut zip, "OEBPS/content.opf", deflated, &opf)?;
|
|
|
|
if let Some((href, bytes)) = cover_bytes {
|
|
write_bytes(&mut zip, &format!("OEBPS/{href}"), deflated, &bytes)?;
|
|
}
|
|
|
|
let mut seen_assets = BTreeSet::new();
|
|
for entry in built {
|
|
write_file(
|
|
&mut zip,
|
|
&format!("OEBPS/text/{}.xhtml", entry.id),
|
|
deflated,
|
|
&entry.chapter.xhtml,
|
|
)?;
|
|
for asset in &entry.assets {
|
|
if seen_assets.insert(asset.href.clone()) {
|
|
write_bytes(
|
|
&mut zip,
|
|
&format!("OEBPS/{}", asset.href),
|
|
deflated,
|
|
&asset.bytes,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
zip.finish().map_err(|error| EbookmError::Epub {
|
|
message: error.to_string(),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_file(
|
|
zip: &mut ZipWriter<File>,
|
|
path: &str,
|
|
options: SimpleFileOptions,
|
|
contents: &str,
|
|
) -> Result<()> {
|
|
write_bytes(zip, path, options, contents.as_bytes())
|
|
}
|
|
|
|
fn write_bytes(
|
|
zip: &mut ZipWriter<File>,
|
|
path: &str,
|
|
options: SimpleFileOptions,
|
|
contents: &[u8],
|
|
) -> Result<()> {
|
|
zip.start_file(path, options)
|
|
.map_err(|error| EbookmError::Epub {
|
|
message: error.to_string(),
|
|
})?;
|
|
zip.write_all(contents).map_err(|error| EbookmError::Epub {
|
|
message: error.to_string(),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn build_nav(manifest: &crate::manifest::Manifest, built: &[BuiltEntry]) -> String {
|
|
let mut nav_points = String::new();
|
|
for section in &manifest.sections {
|
|
let section_target = section
|
|
.entries
|
|
.iter()
|
|
.find_map(|entry_id| built.iter().find(|candidate| &candidate.id == entry_id))
|
|
.map(|entry| format!("text/{}.xhtml", entry.id));
|
|
nav_points.push_str("<li>");
|
|
if let Some(target) = section_target {
|
|
nav_points.push_str(&format!(
|
|
"<a href=\"{}\">{}</a><ol>",
|
|
escape(&target),
|
|
escape(§ion.title)
|
|
));
|
|
} else {
|
|
nav_points.push_str(&format!("<span>{}</span><ol>", escape(§ion.title)));
|
|
}
|
|
for entry_id in §ion.entries {
|
|
if let Some(entry) = built.iter().find(|candidate| &candidate.id == entry_id) {
|
|
if entry.hidden_from_toc {
|
|
continue;
|
|
}
|
|
nav_points.push_str(&format!(
|
|
"<li><a href=\"text/{}.xhtml\">{}</a></li>",
|
|
escape(&entry.id),
|
|
escape(&entry.chapter.nav_title)
|
|
));
|
|
}
|
|
}
|
|
nav_points.push_str("</ol></li>");
|
|
}
|
|
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
|
<head>
|
|
<title>{}</title>
|
|
<link rel="stylesheet" type="text/css" href="styles/book.css"/>
|
|
</head>
|
|
<body>
|
|
<nav epub:type="toc" id="toc">
|
|
<h1>{}</h1>
|
|
<ol>{}</ol>
|
|
</nav>
|
|
</body>
|
|
</html>"#,
|
|
escape(&manifest.book.title),
|
|
escape(&manifest.book.title),
|
|
nav_points
|
|
)
|
|
}
|
|
|
|
fn build_ncx(manifest: &crate::manifest::Manifest, built: &[BuiltEntry]) -> String {
|
|
let mut play_order = 1usize;
|
|
let mut nav_points = String::new();
|
|
for section in &manifest.sections {
|
|
let section_entries: Vec<_> = section
|
|
.entries
|
|
.iter()
|
|
.filter_map(|entry_id| built.iter().find(|candidate| &candidate.id == entry_id))
|
|
.filter(|entry| !entry.hidden_from_toc)
|
|
.collect();
|
|
|
|
if section_entries.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let section_play_order = play_order;
|
|
play_order += 1;
|
|
|
|
let section_target = section_entries[0]
|
|
.section_anchor
|
|
.as_ref()
|
|
.map(|anchor| format!("text/{}.xhtml#{}", section_entries[0].id, anchor))
|
|
.unwrap_or_else(|| format!("text/{}.xhtml", section_entries[0].id));
|
|
let mut child_points = String::new();
|
|
for entry in §ion_entries {
|
|
child_points.push_str(&format!(
|
|
"<navPoint id=\"nav-{}\" playOrder=\"{}\"><navLabel><text>{}</text></navLabel><content src=\"text/{}.xhtml\"/></navPoint>",
|
|
escape(&xml_id("nav", &entry.id)),
|
|
play_order,
|
|
escape(&entry.chapter.nav_title),
|
|
escape(&entry.id)
|
|
));
|
|
play_order += 1;
|
|
}
|
|
|
|
nav_points.push_str(&format!(
|
|
"<navPoint id=\"{}\" playOrder=\"{}\"><navLabel><text>{}</text></navLabel><content src=\"{}\"/>{}</navPoint>",
|
|
escape(&xml_id("section", §ion.id)),
|
|
section_play_order,
|
|
escape(§ion.title),
|
|
escape(§ion_target),
|
|
child_points
|
|
));
|
|
}
|
|
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
|
<head>
|
|
<meta name="dtb:uid" content="{}"/>
|
|
</head>
|
|
<docTitle><text>{}</text></docTitle>
|
|
<navMap>{}</navMap>
|
|
</ncx>"#,
|
|
escape(&manifest.book.identifier),
|
|
escape(&manifest.book.title),
|
|
nav_points
|
|
)
|
|
}
|
|
|
|
fn build_opf(
|
|
manifest: &crate::manifest::Manifest,
|
|
built: &[BuiltEntry],
|
|
cover_href: Option<&str>,
|
|
) -> String {
|
|
let mut manifest_items = String::from(
|
|
r#"<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
|
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
|
<item id="css" href="styles/book.css" media-type="text/css"/>"#,
|
|
);
|
|
let mut spine_items = String::new();
|
|
|
|
for entry in built {
|
|
manifest_items.push_str(&format!(
|
|
"<item id=\"{}\" href=\"text/{}.xhtml\" media-type=\"application/xhtml+xml\"/>",
|
|
escape(&xml_id("entry", &entry.id)),
|
|
escape(&entry.id)
|
|
));
|
|
spine_items.push_str(&format!(
|
|
"<itemref idref=\"{}\"/>",
|
|
escape(&xml_id("entry", &entry.id))
|
|
));
|
|
for asset in &entry.assets {
|
|
manifest_items.push_str(&format!(
|
|
"<item id=\"{}\" href=\"{}\" media-type=\"{}\"/>",
|
|
escape(&xml_id("asset", &asset.id)),
|
|
escape(&asset.href),
|
|
escape(&asset.media_type)
|
|
));
|
|
}
|
|
}
|
|
|
|
if let Some(cover_href) = cover_href {
|
|
manifest_items.push_str(&format!(
|
|
"<item id=\"cover\" href=\"{}\" media-type=\"{}\" properties=\"cover-image\"/>",
|
|
escape(cover_href),
|
|
escape(&media_type_from_href(cover_href))
|
|
));
|
|
}
|
|
|
|
let author = manifest
|
|
.book
|
|
.author
|
|
.clone()
|
|
.unwrap_or_else(|| "Unknown".to_string());
|
|
let description = manifest.book.description.clone().unwrap_or_default();
|
|
let modified = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid">
|
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
<dc:identifier id="bookid">{}</dc:identifier>
|
|
<dc:title>{}</dc:title>
|
|
<dc:creator>{}</dc:creator>
|
|
<dc:language>{}</dc:language>
|
|
<dc:description>{}</dc:description>
|
|
<meta property="dcterms:modified">{}</meta>
|
|
</metadata>
|
|
<manifest>{}</manifest>
|
|
<spine toc="ncx">{}</spine>
|
|
</package>"#,
|
|
escape(&manifest.book.identifier),
|
|
escape(&manifest.book.title),
|
|
escape(&author),
|
|
escape(&manifest.book.language),
|
|
escape(&description),
|
|
escape(&modified),
|
|
manifest_items,
|
|
spine_items
|
|
)
|
|
}
|
|
|
|
fn xml_id(prefix: &str, value: &str) -> String {
|
|
let mut id = String::with_capacity(prefix.len() + value.len() + 1);
|
|
id.push_str(prefix);
|
|
id.push('-');
|
|
for ch in value.chars() {
|
|
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
|
|
id.push(ch);
|
|
} else {
|
|
id.push('-');
|
|
}
|
|
}
|
|
id
|
|
}
|
|
|
|
fn media_type_from_href(href: &str) -> String {
|
|
match href.rsplit('.').next().map(|ext| ext.to_ascii_lowercase()) {
|
|
Some(extension) => match extension.as_str() {
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"png" => "image/png",
|
|
"gif" => "image/gif",
|
|
"svg" => "image/svg+xml",
|
|
"webp" => "image/webp",
|
|
"avif" => "image/avif",
|
|
_ => "application/octet-stream",
|
|
}
|
|
.to_string(),
|
|
None => "application/octet-stream".to_string(),
|
|
}
|
|
}
|
|
|
|
const CONTAINER_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
<rootfiles>
|
|
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
|
</rootfiles>
|
|
</container>"#;
|
|
|
|
const DEFAULT_STYLES: &str = r#"body { font-family: serif; line-height: 1.5; margin: 5%; }
|
|
h1 { margin-bottom: 0.2em; }
|
|
.chapter-meta { color: #555; font-size: 0.9em; margin-bottom: 1.5em; }
|
|
img { max-width: 100%; height: auto; }
|
|
a { color: #0b4f7a; text-decoration: none; }
|
|
"#;
|