use crate::Cfi;
use crate::annotations::AnnotationManager;
use crate::archive::EpubArchive;
use crate::deobfuscate::FontDeobfuscator;
use crate::layout::RenditionLayout;
use crate::locations::Locations;
use crate::metadata::{ManifestItem, Metadata, SpineItem};
use crate::nav::{Landmark, NavPoint, PageListItem, parse_landmarks, parse_nav_xhtml, parse_ncx};
use crate::opf::parse_opf;
use crate::search::{SearchEngine, SearchResult};
use crate::section::Section;
use std::collections::HashMap;
use std::sync::Arc;
pub type BeforeDisplayHook = Arc<dyn Fn(&mut String, &str) + Send + Sync>;
pub struct Book {
pub archive: EpubArchive,
pub opf: crate::opf::OpfPackage,
pub toc: Vec<NavPoint>,
pub landmarks: Vec<Landmark>,
pub page_list: Vec<PageListItem>,
pub sections: Vec<Section>,
pub locations: Locations,
pub annotations: AnnotationManager,
pub layout: RenditionLayout,
pub font_deobfuscator: FontDeobfuscator,
pub before_display_hooks: Vec<BeforeDisplayHook>,
pub media_overlays: HashMap<String, crate::media_overlay::MediaOverlayPackage>,
}
impl Book {
pub fn from_file(path: &str) -> Result<Self, String> {
let bytes = std::fs::read(path)
.map_err(|e| format!("Failed to read ebook file {}: {}", path, e))?;
let filename = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Comic Book");
Self::from_bytes_with_title(&bytes, filename)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
Self::from_bytes_with_title(bytes, "eBook")
}
pub fn from_bytes_with_title(bytes: &[u8], title_fallback: &str) -> Result<Self, String> {
if bytes.starts_with(b"Rar!\x1a\x07\x00")
|| bytes.starts_with(b"Rar!\x1a\x07\x01\x00")
|| bytes.starts_with(b"Rar!\x1a\x07")
{
return Err("CBR (RAR format) is not supported in pure-Rust mode (RARv4/RARv5 detected). Please convert the file to CBZ (ZIP format).".to_string());
}
if bytes.starts_with(b"%PDF-") {
return crate::pdf::PdfBook::parse(bytes, title_fallback);
}
if bytes.starts_with(b"PK\x03\x04") {
if let Ok(archive) = EpubArchive::from_bytes(bytes) {
if archive.contains("META-INF/container.xml") {
if let Ok(book) = Self::from_archive(archive) {
return Ok(book);
}
} else if archive.contains("content.xml") || archive.contains("meta.xml") {
if let Ok(odt) = crate::odt::OdtBook::parse(bytes, title_fallback) {
return Ok(odt);
}
}
}
crate::cbz::CbzBook::parse(bytes, title_fallback)
} else if let Ok(mobi) = crate::mobi::MobiBook::parse(bytes) {
Ok(mobi)
} else if let Ok(fb2) = crate::fb2::Fb2Book::parse(bytes) {
Ok(fb2)
} else if let Ok(lit) = crate::lit::LitBook::parse(bytes) {
Ok(lit)
} else if let Ok(odt) = crate::odt::OdtBook::parse(bytes, title_fallback) {
Ok(odt)
} else if let Ok(cbz) = crate::cbz::CbzBook::parse(bytes, title_fallback) {
Ok(cbz)
} else if let Ok(text) = std::str::from_utf8(bytes) {
let is_md =
text.contains("# ") || text.contains("## ") || title_fallback.ends_with(".md");
crate::txt::TxtBook::parse(bytes, title_fallback, is_md)
} else {
Err("Unsupported or corrupted eBook format".to_string())
}
}
fn from_archive(archive: EpubArchive) -> Result<Self, String> {
if archive.contains("META-INF/rights.xml") || archive.contains("license.lcpl") {
return Err("DRM protected eBook (ADEPT/LCP). Decryption keys are required to read encrypted content.".to_string());
}
let opf_path = archive.get_opf_path()?;
let opf_xml = archive.read_string(&opf_path)?;
let opf = parse_opf(&opf_xml, &opf_path)?;
let mut toc = Vec::new();
let mut landmarks = Vec::new();
let mut page_list = Vec::new();
if let Some(ncx_item) = opf.manifest.values().find(|i| {
i.media_type == "application/x-dtbncx+xml" || i.href.ends_with(".ncx") || i.id == "ncx"
}) {
if let Ok(ncx_xml) = archive.read_string(&ncx_item.full_path) {
if let Ok(points) = parse_ncx(&ncx_xml, &ncx_item.full_path) {
toc = points;
}
}
}
if let Some(nav_item) = opf.manifest.values().find(|i| {
i.properties.contains(&"nav".to_string())
|| i.href.contains("nav.xhtml")
|| i.href.contains("nav.html")
}) {
if let Ok(nav_html) = archive.read_string(&nav_item.full_path) {
if let Ok(points) = parse_nav_xhtml(&nav_html, &nav_item.full_path) {
if toc.is_empty() || !points.is_empty() {
toc = points;
}
}
landmarks = parse_landmarks(&nav_html);
page_list = crate::nav::parse_page_list(&nav_html);
}
}
let font_deobfuscator = if let Ok(xml) = archive.read_string("META-INF/encryption.xml") {
FontDeobfuscator::parse_encryption_xml(&xml)
} else {
FontDeobfuscator::parse_encryption_xml("")
};
let mut sections = Vec::new();
let mut locations = Locations::default();
for (idx, spine_item) in opf.spine.iter().enumerate() {
if let Some(man_item) = opf.manifest.get(&spine_item.idref) {
match Section::new(
idx,
spine_item.idref.clone(),
man_item.href.clone(),
man_item.full_path.clone(),
&archive,
) {
Ok(section) => {
locations.add_spine_section(section.index, §ion.plain_text);
sections.push(section);
}
Err(err) => {
eprintln!(
"Warning: Failed to load section {} ({}): {}",
idx, man_item.full_path, err
);
}
}
}
}
locations.finalize();
let mut media_overlays = HashMap::new();
for item in opf.manifest.values() {
if item.media_type == "application/smil+xml" || item.href.ends_with(".smil") {
if let Ok(smil_xml) = archive.read_string(&item.full_path) {
if let Ok(pkg) = crate::media_overlay::MediaOverlayPackage::parse_smil(
&smil_xml,
&item.full_path,
) {
media_overlays.insert(item.full_path.clone(), pkg);
}
}
}
}
Ok(Self {
archive,
opf,
toc,
landmarks,
page_list,
sections,
locations,
annotations: AnnotationManager::new(),
layout: RenditionLayout::default(),
font_deobfuscator,
before_display_hooks: Vec::new(),
media_overlays,
})
}
pub fn register_before_display_hook<F>(&mut self, hook: F)
where
F: Fn(&mut String, &str) + Send + Sync + 'static,
{
self.before_display_hooks.push(Arc::new(hook));
}
pub fn metadata(&self) -> &Metadata {
&self.opf.metadata
}
pub fn spine(&self) -> &[SpineItem] {
&self.opf.spine
}
pub fn manifest(&self) -> &HashMap<String, ManifestItem> {
&self.opf.manifest
}
pub fn toc(&self) -> &[NavPoint] {
&self.toc
}
pub fn landmarks(&self) -> &[Landmark] {
&self.landmarks
}
pub fn page_list(&self) -> &[PageListItem] {
&self.page_list
}
pub fn cover_image(&self) -> Option<(Vec<u8>, &'static str)> {
let target_href = self.opf.metadata.cover_href.clone().or_else(|| {
self.opf
.metadata
.cover_id
.as_ref()
.and_then(|id| self.opf.manifest.get(id).map(|item| item.full_path.clone()))
});
if let Some(ref href) = target_href {
let mime = EpubArchive::get_mime_type(href);
if mime.starts_with("image/") {
if let Ok(bytes) = self.archive.read_bytes(href) {
return Some((bytes, mime));
}
} else if mime == "application/xhtml+xml" {
if let Ok(html) = self.archive.read_string(href) {
let base_dir = if let Some(idx) = href.rfind('/') {
&href[..idx]
} else {
""
};
if let Some(img_src) = extract_first_img_src(&html) {
let img_path = crate::archive::resolve_relative_path(base_dir, &img_src);
if let Ok(bytes) = self.archive.read_bytes(&img_path) {
let img_mime = EpubArchive::get_mime_type(&img_path);
return Some((bytes, img_mime));
}
}
}
}
}
None
}
pub fn get_resource_bytes(&self, path: &str) -> Result<(Vec<u8>, &'static str), String> {
let clean_path = path.strip_prefix("resource/").unwrap_or(path);
let bytes = self
.archive
.read_bytes(clean_path)
.map_err(|e| format!("Resource not found in archive: {} ({})", clean_path, e))?;
let mime = EpubArchive::get_mime_type(clean_path);
Ok((bytes, mime))
}
pub fn get_section(&self, index: usize) -> Result<Section, String> {
let mut section = self
.sections
.get(index)
.cloned()
.ok_or_else(|| format!("Section index out of bounds: {}", index))?;
if self.opf.metadata.direction == crate::metadata::PageProgressionDirection::Rtl {
if !section.processed_html.contains("dir=\"rtl\"")
&& !section.processed_html.contains("dir='rtl'")
{
section.processed_html =
section.processed_html.replace("<html", "<html dir=\"rtl\"");
if !section.processed_html.contains("dir=\"rtl\"") {
section.processed_html =
section.processed_html.replace("<body", "<body dir=\"rtl\"");
}
}
}
if !self.layout.allow_scripted_content {
section.strip_script_content();
}
for hook in &self.before_display_hooks {
hook(&mut section.processed_html, §ion.full_path);
}
Ok(section)
}
pub fn get_section_by_href(&self, href: &str) -> Result<Section, String> {
let clean = href.trim();
let target = clean.split('#').next().unwrap_or(clean);
for section in &self.sections {
if section.href == target
|| section.full_path == target
|| section.href.ends_with(&format!("/{}", target))
|| section.full_path.ends_with(&format!("/{}", target))
{
return self.get_section(section.index);
}
}
Err(format!("Section not found for href: {}", href))
}
pub fn get_section_by_cfi(&self, cfi_str: &str) -> Result<Section, String> {
let cfi = Cfi::parse(cfi_str)?;
let spine_idx = cfi.spine_index();
self.get_section(spine_idx)
}
pub fn search(&self, query: &str) -> Vec<SearchResult> {
SearchEngine::search(&self.sections, query, false)
}
pub fn generate_locations(&mut self, chunk_size: usize) {
let mut new_locations = Locations::new(chunk_size);
for section in &self.sections {
new_locations.add_spine_section(section.index, §ion.plain_text);
}
new_locations.finalize();
self.locations = new_locations;
}
pub fn to_readium_locator(
&self,
spine_index: usize,
char_offset: usize,
) -> Result<crate::locations::ReadiumLocator, String> {
let section = self.get_section(spine_index)?;
let cfi = Cfi::from_spine_index(spine_index, None, char_offset).to_string();
let section_char_count = section.char_count.max(1);
let progression = (char_offset as f64 / section_char_count as f64).clamp(0.0, 1.0);
let loc_entry = self
.locations
.location_from_char_offset(spine_index, char_offset);
let loc_idx = loc_entry.map(|e| e.location).unwrap_or(1);
let total_progression = self.locations.percentage_from_location(loc_idx);
let fragment = find_nearest_element_id_anchor(§ion.raw_html, char_offset);
Ok(crate::locations::ReadiumLocator {
href: section.href.clone(),
type_: "application/xhtml+xml".to_string(),
title: Some(format!("Section {}", spine_index + 1)),
locations: crate::locations::LocatorLocations {
cfi: Some(cfi),
fragment,
position: Some(loc_idx),
progression,
total_progression,
},
text: Some(serde_json::json!({
"highlight": section.plain_text.chars().skip(char_offset).take(100).collect::<String>()
})),
})
}
pub fn search_regex(&self, pattern: &str) -> Result<Vec<crate::search::SearchResult>, String> {
crate::search::SearchEngine::search_regex(&self.sections, pattern)
}
pub fn validate(&self) -> crate::validator::ValidationReport {
crate::validator::EpubValidator::validate(self)
}
pub fn fingerprint(&self) -> crate::fingerprint::BookFingerprint {
crate::fingerprint::FingerprintGenerator::generate(self)
}
pub fn to_bibtex(&self) -> String {
crate::citation::CitationExporter::to_bibtex(self.metadata())
}
pub fn to_apa(&self) -> String {
crate::citation::CitationExporter::to_apa(self.metadata())
}
pub fn to_mla(&self) -> String {
crate::citation::CitationExporter::to_mla(self.metadata())
}
pub fn to_chicago(&self) -> String {
crate::citation::CitationExporter::to_chicago(self.metadata())
}
pub fn extract_code_blocks(&self) -> Vec<crate::treesitter::ExtractedCodeBlock> {
crate::treesitter::TreeSitterEngine::extract_code_blocks(self)
}
pub fn search_toc(&self, query: &str) -> Vec<crate::nav::TocSearchResult> {
crate::nav::NavPoint::search(&self.toc, query)
}
pub fn flatten_toc(&self) -> Vec<crate::nav::NavPointFlat> {
crate::nav::NavPoint::flatten(&self.toc)
}
pub fn get_synthetic_spread(
&self,
left_spine_index: usize,
right_spine_index: Option<usize>,
) -> Result<crate::layout::SyntheticSpread, String> {
let left_section = self.get_section(left_spine_index)?;
let right_section = match right_spine_index {
Some(idx) => Some(self.get_section(idx)?),
None => None,
};
let width = left_section.viewport_width.unwrap_or(600.0);
let height = left_section.viewport_height.unwrap_or(800.0);
let mut html = String::new();
html.push_str("<div class=\"epub-fxl-spread-container\" style=\"display:flex; flex-direction:row; justify-content:center; align-items:center; width:100%; height:100vh; background-color:#0f1319;\">");
html.push_str(&format!(
"<div class=\"epub-fxl-page page-left\" style=\"width:{:.1}px; height:{:.1}px; overflow:hidden; box-shadow: -4px 0 16px rgba(0,0,0,0.5);\">",
width, height
));
html.push_str(&left_section.processed_html);
html.push_str("</div>");
if let Some(right_sec) = right_section {
let r_width = right_sec.viewport_width.unwrap_or(width);
let r_height = right_sec.viewport_height.unwrap_or(height);
html.push_str(&format!(
"<div class=\"epub-fxl-page page-right\" style=\"width:{:.1}px; height:{:.1}px; overflow:hidden; box-shadow: 4px 0 16px rgba(0,0,0,0.5);\">",
r_width, r_height
));
html.push_str(&right_sec.processed_html);
html.push_str("</div>");
}
html.push_str("</div>");
Ok(crate::layout::SyntheticSpread {
left_index: left_spine_index,
right_index: right_spine_index,
combined_html: html,
width,
height,
})
}
#[cfg(feature = "mmap")]
pub fn from_mmap<P: AsRef<std::path::Path>>(path: P) -> Result<Self, String> {
let file = std::fs::File::open(path.as_ref())
.map_err(|e| format!("Failed to open file for mmap: {}", e))?;
let mmap = unsafe {
memmap2::Mmap::map(&file).map_err(|e| format!("Failed to memory-map file: {}", e))?
};
Self::from_bytes(&mmap)
}
pub fn export_epub3_bytes(&self) -> Result<Vec<u8>, String> {
use std::io::Write;
use zip::write::FileOptions;
let mut zip_buf = Vec::new();
{
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf));
let stored_options = FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored)
.unix_permissions(0o644);
zip.start_file("mimetype", stored_options)
.map_err(|e| format!("Failed to write mimetype: {}", e))?;
zip.write_all(b"application/epub+zip")
.map_err(|e| format!("Failed to write mimetype content: {}", e))?;
let deflated_options = FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
zip.start_file("META-INF/container.xml", deflated_options)
.map_err(|e| format!("Failed to write container.xml: {}", e))?;
let container_xml = 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>"#;
zip.write_all(container_xml.as_bytes())
.map_err(|e| format!("Failed to write container.xml content: {}", e))?;
for section in &self.sections {
let sec_path = format!("OEBPS/section_{}.html", section.index);
zip.start_file(&sec_path, deflated_options)
.map_err(|e| format!("Failed to write {}: {}", sec_path, e))?;
let doc_html = format!(
"<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head><title>Section {}</title></head>\n<body>\n{}\n</body>\n</html>",
section.index + 1,
section.processed_html
);
zip.write_all(doc_html.as_bytes())
.map_err(|e| format!("Failed to write {} content: {}", sec_path, e))?;
}
zip.start_file("OEBPS/nav.xhtml", deflated_options)
.map_err(|e| format!("Failed to write nav.xhtml: {}", e))?;
let mut nav_html = String::from(
"<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n<head><title>Navigation</title></head>\n<body>\n<nav epub:type=\"toc\" id=\"toc\">\n<ol>\n",
);
for pt in &self.toc {
nav_html.push_str(&format!(
"<li><a href=\"{}\">{}</a></li>\n",
pt.href, pt.label
));
}
nav_html.push_str("</ol>\n</nav>\n</body>\n</html>");
zip.write_all(nav_html.as_bytes())
.map_err(|e| format!("Failed to write nav.xhtml content: {}", e))?;
zip.start_file("OEBPS/content.opf", deflated_options)
.map_err(|e| format!("Failed to write content.opf: {}", e))?;
let mut opf_xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="pub-id">urn:uuid:ebook-rs-export-{}</dc:identifier>
<dc:title>{}</dc:title>
<dc:language>{}</dc:language>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
"#,
self.opf
.metadata
.title
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>(),
self.opf.metadata.title,
self.opf
.metadata
.languages
.first()
.cloned()
.unwrap_or_else(|| "en".to_string())
);
for section in &self.sections {
opf_xml.push_str(&format!(
" <item id=\"sec_{}\" href=\"section_{}.html\" media-type=\"application/xhtml+xml\"/>\n",
section.index, section.index
));
}
opf_xml.push_str(" </manifest>\n <spine>\n");
for section in &self.sections {
opf_xml.push_str(&format!(" <itemref idref=\"sec_{}\"/>\n", section.index));
}
opf_xml.push_str(" </spine>\n</package>");
zip.write_all(opf_xml.as_bytes())
.map_err(|e| format!("Failed to write content.opf content: {}", e))?;
zip.finish()
.map_err(|e| format!("Failed to finalize EPUB ZIP archive: {}", e))?;
}
Ok(zip_buf)
}
}
fn extract_first_img_src(html: &str) -> Option<String> {
let lower = html.to_lowercase();
if let Some(img_idx) = lower.find("<img") {
let rem = &html[img_idx..];
let lower_rem = &lower[img_idx..];
if let Some(src_idx) = lower_rem.find("src=\"") {
let val_start = src_idx + 5;
if let Some(end_idx) = rem[val_start..].find('"') {
return Some(rem[val_start..val_start + end_idx].to_string());
}
}
}
None
}
fn find_nearest_element_id_anchor(html: &str, _char_offset: usize) -> Option<String> {
let lower = html.to_lowercase();
let mut search_idx = 0;
let mut last_id: Option<String> = None;
while let Some(idx) = lower[search_idx..].find(" id=\"") {
let abs_idx = search_idx + idx + 5;
if let Some(end_quote) = html[abs_idx..].find('"') {
let id_val = &html[abs_idx..abs_idx + end_quote];
if !id_val.trim().is_empty() {
last_id = Some(id_val.to_string());
}
search_idx = abs_idx + end_quote + 1;
} else {
break;
}
}
last_id
}