initial commit
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
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 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(&entry.id),
|
||||
play_order,
|
||||
escape(&entry.chapter.nav_title),
|
||||
escape(&entry.id)
|
||||
));
|
||||
play_order += 1;
|
||||
}
|
||||
|
||||
nav_points.push_str(&format!(
|
||||
"<navPoint id=\"section-{}\" playOrder=\"{}\"><navLabel><text>{}</text></navLabel><content src=\"text/{}.xhtml\"/>{}</navPoint>",
|
||||
escape(§ion.id),
|
||||
section_play_order,
|
||||
escape(§ion.title),
|
||||
escape(§ion_entries[0].id),
|
||||
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(&entry.id),
|
||||
escape(&entry.id)
|
||||
));
|
||||
spine_items.push_str(&format!("<itemref idref=\"{}\"/>", escape(&entry.id)));
|
||||
for asset in &entry.assets {
|
||||
manifest_items.push_str(&format!(
|
||||
"<item id=\"{}\" href=\"{}\" media-type=\"{}\"/>",
|
||||
escape(&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=\"image/jpeg\" properties=\"cover-image\"/>",
|
||||
escape(cover_href)
|
||||
));
|
||||
}
|
||||
|
||||
let author = manifest
|
||||
.book
|
||||
.author
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let description = manifest.book.description.clone().unwrap_or_default();
|
||||
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>
|
||||
</metadata>
|
||||
<manifest>{}</manifest>
|
||||
<spine toc="ncx">{}</spine>
|
||||
</package>"#,
|
||||
escape(&manifest.book.identifier),
|
||||
escape(&manifest.book.title),
|
||||
escape(&author),
|
||||
escape(&manifest.book.language),
|
||||
escape(&description),
|
||||
manifest_items,
|
||||
spine_items
|
||||
)
|
||||
}
|
||||
|
||||
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; }
|
||||
"#;
|
||||
Reference in New Issue
Block a user