use crate::archive::EpubArchive;
use crate::book::Book;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidationSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub severity: ValidationSeverity,
pub code: String,
pub message: String,
pub location: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationReport {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
pub errors_count: usize,
pub warnings_count: usize,
pub info_count: usize,
}
pub struct EpubValidator;
impl EpubValidator {
pub fn validate(book: &Book) -> ValidationReport {
let mut errors = Vec::new();
let meta = book.metadata();
if meta.title.trim().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Error,
code: "PKG-001".to_string(),
message: "dc:title is missing or empty in OPF metadata".to_string(),
location: Some("metadata.title".to_string()),
});
}
if meta.identifier.as_deref().unwrap_or("").trim().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Warning,
code: "PKG-002".to_string(),
message: "dc:identifier is missing or empty in OPF metadata".to_string(),
location: Some("metadata.identifier".to_string()),
});
}
if meta.language().trim().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Warning,
code: "PKG-003".to_string(),
message: "dc:language is missing or empty in OPF metadata".to_string(),
location: Some("metadata.language".to_string()),
});
}
if meta.creator().trim().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Info,
code: "PKG-004".to_string(),
message: "dc:creator (author) is not specified".to_string(),
location: Some("metadata.creator".to_string()),
});
}
if book.spine().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Error,
code: "RSC-001".to_string(),
message: "Spine contains 0 reading items".to_string(),
location: Some("spine".to_string()),
});
}
let hydrated_sections = book.get_all_sections_hydrated();
if hydrated_sections.is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Error,
code: "RSC-002".to_string(),
message: "Book contains 0 readable content sections".to_string(),
location: Some("sections".to_string()),
});
}
for (idx, section) in hydrated_sections.iter().enumerate() {
if section.href.trim().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Error,
code: "RSC-003".to_string(),
message: format!("Section {} has empty href reference", idx),
location: Some(format!("sections[{}]", idx)),
});
}
if section.char_count == 0 && section.raw_html.trim().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Warning,
code: "RSC-004".to_string(),
message: format!(
"Section {} ('{}') has no extracted text or HTML content",
idx, section.href
),
location: Some(format!("sections[{}]", idx)),
});
}
}
if book.toc().is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Warning,
code: "NAV-001".to_string(),
message: "Table of Contents (NCX / NAV) is empty or missing".to_string(),
location: Some("toc".to_string()),
});
}
let a11y = &meta.accessibility;
if !a11y.is_accessible && a11y.access_modes.is_empty() {
errors.push(ValidationError {
severity: ValidationSeverity::Info,
code: "A11Y-001".to_string(),
message: "No EPUB 3 accessibility metadata (schema:accessMode) declared"
.to_string(),
location: Some("metadata.accessibility".to_string()),
});
}
let errors_count = errors
.iter()
.filter(|e| e.severity == ValidationSeverity::Error)
.count();
let warnings_count = errors
.iter()
.filter(|e| e.severity == ValidationSeverity::Warning)
.count();
let info_count = errors
.iter()
.filter(|e| e.severity == ValidationSeverity::Info)
.count();
ValidationReport {
is_valid: errors_count == 0,
errors,
errors_count,
warnings_count,
info_count,
}
}
}
pub struct UniversalEpub3Exporter;
impl UniversalEpub3Exporter {
pub fn export(book: &Book) -> Result<Vec<u8>, String> {
use std::io::Write;
let mut zip_buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
let options_stored = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("mimetype", options_stored)
.map_err(|e| e.to_string())?;
zip.write_all(b"application/epub+zip")
.map_err(|e| e.to_string())?;
let options_deflate = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("META-INF/container.xml", options_deflate)
.map_err(|e| e.to_string())?;
zip.write_all(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n <rootfiles>\n <rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n </rootfiles>\n</container>")
.map_err(|e| e.to_string())?;
zip.start_file("OEBPS/nav.xhtml", options_deflate)
.map_err(|e| e.to_string())?;
let mut nav_html = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n<head><title>TOC</title></head>\n<body>\n<nav epub:type=\"toc\" id=\"toc\"><h1>Table of Contents</h1><ol>",
);
for (idx, _) in book.spine().iter().enumerate() {
nav_html.push_str(&format!(
"<li><a href=\"sec_{}.xhtml\">Section {}</a></li>",
idx,
idx + 1
));
}
nav_html.push_str("</ol></nav>\n</body>\n</html>");
zip.write_all(nav_html.as_bytes())
.map_err(|e| e.to_string())?;
zip.start_file("OEBPS/content.opf", options_deflate)
.map_err(|e| e.to_string())?;
let meta = book.metadata();
let lang = if meta.language().is_empty() {
"en"
} else {
meta.language()
};
let mut opf_xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"uid\">\n <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n <dc:title>{}</dc:title>\n <dc:identifier id=\"uid\">{}</dc:identifier>\n <dc:language>{}</dc:language>\n",
crate::dom::sanitize_and_repair_xml(&meta.title),
meta.identifier
.as_deref()
.unwrap_or("urn:uuid:ebook-rs-export"),
lang
);
for creator in &meta.creators {
opf_xml.push_str(&format!(
" <dc:creator>{}</dc:creator>\n",
crate::dom::sanitize_and_repair_xml(creator)
));
}
opf_xml.push_str(" </metadata>\n <manifest>\n <item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n");
for (idx, _) in book.spine().iter().enumerate() {
opf_xml.push_str(&format!(" <item id=\"sec_{}\" href=\"sec_{}.xhtml\" media-type=\"application/xhtml+xml\"/>\n", idx, idx));
}
let mut asset_idx = 0;
for path in book.archive.files().keys() {
let path_low = path.to_lowercase();
if path_low.ends_with(".opf")
|| path_low.ends_with(".ncx")
|| path_low == "mimetype"
|| path_low == "meta-inf/container.xml"
|| path_low == "oebps/nav.xhtml"
|| (path_low.contains("sec_") && path_low.ends_with(".xhtml"))
{
continue;
}
let rel_href = if path_low.starts_with("oebps/") {
&path[6..]
} else {
path.as_str()
};
let mime = EpubArchive::get_mime_type(path);
opf_xml.push_str(&format!(
" <item id=\"asset_{}\" href=\"{}\" media-type=\"{}\"/>\n",
asset_idx,
crate::dom::sanitize_and_repair_xml(rel_href),
mime
));
asset_idx += 1;
}
opf_xml.push_str(" </manifest>\n <spine>\n");
for (idx, _) in book.spine().iter().enumerate() {
opf_xml.push_str(&format!(" <itemref idref=\"sec_{}\"/>\n", idx));
}
opf_xml.push_str(" </spine>\n</package>");
zip.write_all(opf_xml.as_bytes())
.map_err(|e| e.to_string())?;
struct ZipEntry {
path: String,
bytes: Vec<u8>,
}
let mut entries = Vec::new();
let hydrated_sections = book.get_all_sections_hydrated();
for (idx, sec) in hydrated_sections.iter().enumerate() {
let html_body = if !sec.raw_html.is_empty() {
&sec.raw_html
} else {
&sec.processed_html
};
let trimmed = html_body.trim();
let doc_xhtml = if trimmed.contains("<html") || trimmed.contains("<body") {
if trimmed.starts_with("<?xml") {
trimmed.to_string()
} else {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n{}",
trimmed
)
}
} else {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head><title>Section {}</title></head>\n<body>{}</body>\n</html>",
idx + 1,
trimmed
)
};
entries.push(ZipEntry {
path: format!("OEBPS/sec_{}.xhtml", idx),
bytes: doc_xhtml.into_bytes(),
});
}
for (path, bytes) in book.archive.files() {
let path_low = path.to_lowercase();
if path_low.ends_with(".opf")
|| path_low.ends_with(".ncx")
|| path_low == "mimetype"
|| path_low == "meta-inf/container.xml"
|| path_low == "oebps/nav.xhtml"
|| (path_low.contains("sec_") && path_low.ends_with(".xhtml"))
{
continue;
}
let zip_path = if path_low.starts_with("oebps/") {
path.clone()
} else {
format!("OEBPS/{}", path)
};
entries.push(ZipEntry {
path: zip_path,
bytes: bytes.clone(),
});
}
for entry in entries {
zip.start_file(&entry.path, options_deflate)
.map_err(|e| e.to_string())?;
zip.write_all(&entry.bytes).map_err(|e| e.to_string())?;
}
zip.finish().map_err(|e| e.to_string())?;
}
Ok(zip_buf)
}
}