use crate::html_text::{extract_with_indents, parse_indents, IndentMap, TextSegment};
use log::warn;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum EpubError {
#[error("epub: {0}")]
Other(String),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Chapter {
pub index: usize,
pub title: Option<String>,
pub text: String,
pub segments: Vec<TextSegment>,
pub images: Vec<(String, Vec<u8>)>,
pub epub_path: String,
pub chapter_path: String,
}
impl Chapter {
pub fn from_xhtml(index: usize, title: Option<String>, xhtml: &str) -> Self {
Self::from_xhtml_with_indents(index, title, xhtml, &IndentMap::new())
}
pub fn from_xhtml_with_indents(
index: usize,
title: Option<String>,
xhtml: &str,
indents: &IndentMap,
) -> Self {
let (text, segments) = extract_with_indents(xhtml, indents);
Chapter {
index,
title,
text,
segments,
images: Vec::new(),
epub_path: String::new(),
chapter_path: String::new(),
}
}
pub fn load_images(&mut self) -> &[(String, Vec<u8>)] {
if !self.images.is_empty() || self.epub_path.is_empty() {
return &self.images;
}
let mut doc = match epub::doc::EpubDoc::new(&self.epub_path) {
Ok(d) => d,
Err(e) => {
warn!("load_images: epub open error: {e}");
return &self.images;
}
};
let base_dir = Path::new(&self.chapter_path)
.parent()
.unwrap_or(Path::new(""));
for seg in &self.segments {
if let Some(src) = &seg.src {
let joined = base_dir.join(src);
let full = normalize_zip_path(&joined);
if let Some(data) = doc.get_resource_by_path(Path::new(&full)) {
log::debug!("loaded image {} ({} bytes)", src, data.len());
self.images.push((src.clone(), data));
} else {
log::debug!("image {} not found at {}", src, full);
}
}
}
&self.images
}
pub fn display_title(&self, idx: usize) -> String {
if let Some(t) = self.title.as_deref() {
let t = t.trim();
if !t.is_empty() {
return t.to_string();
}
}
for seg in &self.segments {
let is_heading = seg.tag.len() == 2
&& seg.tag.starts_with('h')
&& seg.tag.as_bytes()[1].is_ascii_digit();
if is_heading {
if let Some(slice) = self.text.get(seg.start..seg.end) {
let t = slice.trim();
if !t.is_empty() {
return t.to_string();
}
}
}
}
if let Some(first) = self.text.lines().find(|l| !l.trim().is_empty()) {
let t = first.trim();
if !t.is_empty() {
return t.to_string();
}
}
format!("Chapter {}", idx + 1)
}
}
#[derive(Debug)]
pub struct EpubBook {
pub title: Option<String>,
pub author: Option<String>,
pub language: Option<String>,
pub chapters: Vec<Chapter>,
}
impl EpubBook {
pub fn cover_bytes(path: impl AsRef<Path>) -> Option<Vec<u8>> {
let mut doc = epub::doc::EpubDoc::new(path.as_ref()).ok()?;
let (data, _mime) = doc.get_cover()?;
Some(data)
}
pub fn open(path: impl AsRef<Path>) -> Result<Self, EpubError> {
let path_str = path.as_ref().to_string_lossy().into_owned();
let mut doc =
epub::doc::EpubDoc::new(path.as_ref()).map_err(|e| EpubError::Other(e.to_string()))?;
let indents = collect_indents(&mut doc);
let mut chapters = Vec::new();
let mut idx = 0usize;
let mut skipped = 0usize;
loop {
if let Some((xhtml, _mime)) = doc.get_current_str() {
let mut ch = Chapter::from_xhtml_with_indents(idx, None, &xhtml, &indents);
ch.epub_path = path_str.clone();
ch.chapter_path = doc
.get_current_path()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
idx += 1;
let has_images = ch.segments.iter().any(|s| s.src.is_some());
if ch.text.trim().is_empty() && !has_images {
skipped += 1;
if !doc.go_next() {
break;
}
continue;
}
chapters.push(ch);
}
if !doc.go_next() {
break;
}
}
let toc_map = build_toc_map(&doc.toc);
for ch in &mut chapters {
if ch.title.is_none() {
let cp = strip_fragment(&ch.chapter_path);
if let Some(label) = toc_map.get(&cp) {
ch.title = Some(label.clone());
}
}
}
let title = doc.get_title();
let author = doc.mdata("creator").map(|m| m.value.clone());
let language = doc.mdata("language").map(|m| m.value.clone());
log::info!(
"epub: {} spine items, {} skipped, {} chapters",
idx,
skipped,
chapters.len()
);
Ok(EpubBook {
title,
author,
language,
chapters,
})
}
}
fn collect_indents(doc: &mut epub::doc::EpubDoc<std::io::BufReader<std::fs::File>>) -> IndentMap {
let css_ids: Vec<String> = doc
.resources
.iter()
.filter(|(_, item)| item.mime.contains("css"))
.map(|(id, _)| id.clone())
.collect();
let mut map = IndentMap::new();
for id in css_ids {
if let Some((bytes, _mime)) = doc.get_resource(&id) {
map.extend(parse_indents(&String::from_utf8_lossy(&bytes)));
}
}
log::info!("epub: {} indent classes from stylesheets", map.len());
map
}
fn build_toc_map(toc: &[epub::doc::NavPoint]) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
let mut flat = Vec::new();
flatten_toc(toc, &mut flat);
for (label, path) in flat {
map.entry(path).or_insert(label);
}
map
}
fn flatten_toc(toc: &[epub::doc::NavPoint], out: &mut Vec<(String, String)>) {
for np in toc {
let raw = np.content.to_string_lossy().into_owned();
let path = strip_fragment(&raw);
out.push((np.label.clone(), path));
if !np.children.is_empty() {
flatten_toc(&np.children, out);
}
}
}
fn strip_fragment(path: &str) -> String {
match path.rsplit_once('#') {
Some((base, _)) => base.to_string(),
None => path.to_string(),
}
}
fn normalize_zip_path(path: &Path) -> String {
let mut parts: Vec<String> = Vec::new();
for comp in path.components() {
match comp {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
parts.pop();
}
std::path::Component::Normal(s) => {
parts.push(s.to_string_lossy().into_owned());
}
_ => {}
}
}
parts.join("/")
}
#[cfg(test)]
mod tests;