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>,
pub render_cache: parking_lot::Mutex<HashMap<usize, String>>,
}
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.clone()) {
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);
}
}
return crate::cbz::CbzBook::from_archive(archive, title_fallback);
}
}
if crate::kfx::KfxBook::is_kfx(bytes) {
return crate::kfx::KfxBook::parse(bytes);
}
if is_mobi_bytes(bytes) {
return crate::mobi::MobiBook::parse(bytes);
}
if bytes.starts_with(b"ITOLITLS") {
return crate::lit::LitBook::parse(bytes);
}
if let Ok(fb2) = crate::fb2::Fb2Book::parse(bytes) {
return Ok(fb2);
}
if let Ok(text) = std::str::from_utf8(bytes) {
let is_md =
text.contains("# ") || text.contains("## ") || title_fallback.ends_with(".md");
return crate::txt::TxtBook::parse(bytes, title_fallback, is_md);
}
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,
render_cache: parking_lot::Mutex::new(HashMap::new()),
})
}
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) -> &ahash::AHashMap<String, ManifestItem> {
&self.opf.manifest
}
pub fn toc(&self) -> &[NavPoint] {
&self.toc
}
pub fn to_rag_chunks(&self, config: &crate::rag::RagChunkConfig) -> Vec<crate::rag::RagChunk> {
crate::rag::RagChunker::chunk_book(self, config)
}
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 detect_language(&self) -> Option<String> {
let meta_lang = self.opf.metadata.language();
if !meta_lang.trim().is_empty() {
return Some(meta_lang.to_string());
}
for sec in &self.sections {
if let Some(lang) = sec.detect_language() {
return Some(lang);
}
}
None
}
pub fn export_zstd_cache(&self) -> Result<Vec<u8>, String> {
let mut sections = self.sections.clone();
for sec in &mut sections {
if sec.processed_html == sec.raw_html {
sec.processed_html.clear();
}
}
let cache = BookCacheState {
opf: self.opf.clone(),
toc: self.toc.clone(),
landmarks: self.landmarks.clone(),
page_list: self.page_list.clone(),
sections,
locations: self.locations.clone(),
layout: self.layout.clone(),
archive_files: self.archive.files().clone(),
annotations: self.annotations.clone(),
media_overlays: self.media_overlays.clone(),
};
let json_bytes = serde_json::to_vec(&cache)
.map_err(|e| format!("Failed to serialize Book state to JSON: {}", e))?;
zstd::encode_all(&json_bytes[..], 3).map_err(|e| format!("Zstd compression failed: {}", e))
}
pub fn from_zstd_cache(zstd_bytes: &[u8]) -> Result<Self, String> {
let json_bytes = zstd::decode_all(zstd_bytes)
.map_err(|e| format!("Zstd decompression failed: {}", e))?;
let mut cache: BookCacheState = serde_json::from_slice(&json_bytes)
.map_err(|e| format!("Failed to deserialize Book state from JSON: {}", e))?;
for sec in &mut cache.sections {
if sec.processed_html.is_empty() {
sec.processed_html = sec.raw_html.clone();
}
}
let mut archive = EpubArchive::empty();
for (path, bytes) in &cache.archive_files {
archive.insert(path, bytes.clone());
}
for sec in &cache.sections {
if !archive.files().contains_key(&sec.full_path) {
archive.insert(&sec.full_path, sec.raw_html.as_bytes().to_vec());
}
}
if archive.contains("META-INF/license.lcpl") {
if let Ok(license_str) = archive.read_string("META-INF/license.lcpl") {
if let Ok(lcp) = crate::lcp::LcpLicense::parse(&license_str) {
if lcp.encryption.is_some() {
return Err("Readium LCP protected eBook requires passphrase validation"
.to_string());
}
}
}
}
Ok(Self {
archive,
opf: cache.opf,
toc: cache.toc,
landmarks: cache.landmarks,
page_list: cache.page_list,
sections: cache.sections,
locations: cache.locations,
annotations: cache.annotations,
layout: cache.layout,
font_deobfuscator: FontDeobfuscator::new(),
before_display_hooks: Vec::new(),
media_overlays: cache.media_overlays,
render_cache: parking_lot::Mutex::new(HashMap::new()),
})
}
pub fn get_tts_tokens(
&self,
index: usize,
) -> Result<Vec<crate::section::TtsWordToken>, String> {
let section = self.get_section(index)?;
Ok(section.tokenize_tts_words())
}
pub fn get_tts_section_html(&self, index: usize) -> Result<String, String> {
let section = self.get_section(index)?;
Ok(section.to_tts_annotated_html())
}
pub fn load_section_lazy(&self, index: usize) -> Result<Section, String> {
let spine_item = self
.opf
.spine
.get(index)
.ok_or_else(|| format!("Spine index out of bounds: {}", index))?;
let man_item =
self.opf.manifest.get(&spine_item.idref).ok_or_else(|| {
format!("Manifest item not found for idref: {}", spine_item.idref)
})?;
let mut section = Section::new(
index,
spine_item.idref.clone(),
man_item.href.clone(),
man_item.full_path.clone(),
&self.archive,
)?;
section.processed_html = crate::section::process_section_resources(
§ion.raw_html,
§ion.full_path,
&self.archive,
);
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_raw(&self, index: usize) -> Option<&Section> {
self.sections.get(index)
}
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 let Some(cached) = self.render_cache.lock().get(&index) {
section.processed_html = cached.clone();
return Ok(section);
}
if section.processed_html == section.raw_html && !section.raw_html.is_empty() {
section.processed_html = crate::section::process_section_resources(
§ion.raw_html,
§ion.full_path,
&self.archive,
);
}
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);
}
self.render_cache
.lock()
.insert(index, section.processed_html.clone());
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 html_source = if section.raw_html.is_empty() {
§ion.processed_html
} else {
§ion.raw_html
};
let trimmed = html_source.trim();
let trimmed_low = trimmed.to_lowercase();
let doc_html = if trimmed_low.starts_with("<!doctype")
|| trimmed_low.starts_with("<?xml")
|| trimmed_low.starts_with("<html")
{
html_source.to_string()
} else {
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,
html_source
)
};
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",
);
render_nav_points_xml(&self.toc, &mut nav_html, &self.sections);
nav_html.push_str("</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 uuid_str = generate_rfc4122_uuid_v4(&self.opf.metadata.title);
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:{}</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"/>
"#,
uuid_str,
xml_escape(&self.opf.metadata.title),
xml_escape(
self.opf
.metadata
.languages
.first()
.map(|s| s.as_str())
.unwrap_or("en")
)
);
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 is_mobi_bytes(bytes: &[u8]) -> bool {
if bytes.len() < 68 {
return false;
}
let type_creator = &bytes[60..68];
type_creator == b"BOOKMOBI"
|| type_creator == b"TEXtRECD"
|| &bytes[60..64] == b"BOOK"
|| &bytes[64..68] == b"MOBI"
}
fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
if s.len() < prefix.len() || !s.is_char_boundary(prefix.len()) {
return false;
}
s[..prefix.len()].eq_ignore_ascii_case(prefix)
}
fn find_ignore_case(s: &str, pat: &str) -> Option<usize> {
if pat.is_empty() {
return Some(0);
}
if s.len() < pat.len() {
return None;
}
for i in 0..=s.len() - pat.len() {
if s.is_char_boundary(i) {
if let Some(sub) = s.get(i..i + pat.len()) {
if sub.eq_ignore_ascii_case(pat) {
return Some(i);
}
}
}
}
None
}
fn extract_first_img_src(html: &str) -> Option<String> {
let mut i = 0;
while i < html.len() {
if starts_with_ignore_case(&html[i..], "<img") {
let rem = &html[i..];
let mut j = 0;
while j < rem.len() {
if starts_with_ignore_case(&rem[j..], "src=\"") {
let val_start = j + 5;
if let Some(end_idx) = rem[val_start..].find('"') {
return Some(rem[val_start..val_start + end_idx].to_string());
}
} else if starts_with_ignore_case(&rem[j..], "src='") {
let val_start = j + 5;
if let Some(end_idx) = rem[val_start..].find('\'') {
return Some(rem[val_start..val_start + end_idx].to_string());
}
}
if let Some(ch) = rem[j..].chars().next() {
j += ch.len_utf8();
} else {
break;
}
}
}
if let Some(ch) = html[i..].chars().next() {
i += ch.len_utf8();
} else {
break;
}
}
None
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn render_nav_points_xml(pts: &[NavPoint], out: &mut String, sections: &[Section]) {
render_nav_points_xml_depth(pts, out, 0, sections);
}
fn render_nav_points_xml_depth(
pts: &[NavPoint],
out: &mut String,
depth: usize,
sections: &[Section],
) {
if depth > 32 || pts.is_empty() {
return;
}
out.push_str("<ol>\n");
for pt in pts {
let remapped_href = remap_toc_href(&pt.href, sections);
out.push_str(&format!(
"<li><a href=\"{}\">{}</a>",
xml_escape(&remapped_href),
xml_escape(&pt.label)
));
if !pt.subitems.is_empty() {
out.push('\n');
render_nav_points_xml_depth(&pt.subitems, out, depth + 1, sections);
}
out.push_str("</li>\n");
}
out.push_str("</ol>\n");
}
fn remap_toc_href(href: &str, sections: &[Section]) -> String {
let parts: Vec<&str> = href.split('#').collect();
let path = parts[0];
let anchor = if parts.len() > 1 {
format!("#{}", parts[1])
} else {
String::new()
};
let norm_path = crate::archive::normalize_path(path);
for sec in sections {
let sec_norm = crate::archive::normalize_path(&sec.full_path);
if sec_norm == norm_path || sec_norm.ends_with(&norm_path) || norm_path.ends_with(&sec_norm)
{
return format!("section_{}.html{}", sec.index, anchor);
}
}
if let Some(sec) = sections.first() {
format!("section_{}.html{}", sec.index, anchor)
} else {
href.to_string()
}
}
fn generate_rfc4122_uuid_v4(seed_text: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
seed_text.hash(&mut hasher);
let h1 = hasher.finish();
let mut hasher2 = DefaultHasher::new();
(h1, seed_text).hash(&mut hasher2);
let h2 = hasher2.finish();
let bytes1 = h1.to_be_bytes();
let bytes2 = h2.to_be_bytes();
let mut b = [0u8; 16];
b[..8].copy_from_slice(&bytes1);
b[8..].copy_from_slice(&bytes2);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
u32::from_be_bytes([b[0], b[1], b[2], b[3]]),
u16::from_be_bytes([b[4], b[5]]),
u16::from_be_bytes([b[6], b[7]]),
u16::from_be_bytes([b[8], b[9]]),
((u64::from_be_bytes([b[10], b[11], b[12], b[13], b[14], b[15], 0, 0])) >> 16)
)
}
fn find_nearest_element_id_anchor(html: &str, char_offset: usize) -> Option<String> {
let max_byte_offset = html
.char_indices()
.nth(char_offset)
.map(|(i, _)| i)
.unwrap_or_else(|| html.len());
let mut search_idx = 0;
let mut last_id: Option<String> = None;
while search_idx < html.len() {
if let Some(idx) = find_ignore_case(&html[search_idx..], " id=\"") {
let abs_idx = search_idx + idx + 5;
if abs_idx > max_byte_offset {
break;
}
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;
}
} else {
break;
}
}
if last_id.is_none() {
if let Some(idx) = find_ignore_case(html, " id=\"") {
let abs_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() {
return Some(id_val.to_string());
}
}
}
}
last_id
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct BookCacheState {
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 layout: RenditionLayout,
pub archive_files: HashMap<String, Vec<u8>>,
pub annotations: AnnotationManager,
pub media_overlays: HashMap<String, crate::media_overlay::MediaOverlayPackage>,
}
#[cfg(feature = "async")]
pub mod async_api {
use super::Book;
pub async fn from_file_async(path: &str) -> Result<Book, String> {
let bytes = tokio::fs::read(path)
.await
.map_err(|e| format!("Async read failed for {}: {}", path, e))?;
let filename = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("eBook");
Book::from_bytes_with_title(&bytes, filename)
}
pub async fn from_bytes_async(bytes: Vec<u8>) -> Result<Book, String> {
tokio::task::spawn_blocking(move || Book::from_bytes(&bytes))
.await
.map_err(|e| format!("Async book parse task failed: {}", e))?
}
pub async fn load_section_lazy_async(
book: std::sync::Arc<Book>,
index: usize,
) -> Result<super::super::section::Section, String> {
tokio::task::spawn_blocking(move || book.load_section_lazy(index))
.await
.map_err(|e| format!("Async section load task failed: {}", e))?
}
}