initial commit

This commit is contained in:
2026-05-25 17:05:15 +02:00
commit 6ebe505a07
25 changed files with 5929 additions and 0 deletions
+298
View File
@@ -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(&section.title)
));
} else {
nav_points.push_str(&format!("<span>{}</span><ol>", escape(&section.title)));
}
for entry_id in &section.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 &section_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(&section.id),
section_play_order,
escape(&section.title),
escape(&section_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; }
"#;