use crate::html_text::{
extract_with_indents, extract_with_style, parse_book_style, BookStyle, IndentMap, LinkRun,
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 from_xhtml_with_style(
index: usize,
title: Option<String>,
xhtml: &str,
style: &BookStyle,
) -> Self {
let (text, segments) = extract_with_style(xhtml, style);
Chapter {
index,
title,
text,
segments,
images: Vec::new(),
epub_path: String::new(),
chapter_path: String::new(),
}
}
pub fn links(&self) -> impl Iterator<Item = &LinkRun> {
self.segments.iter().flat_map(|s| s.links.iter())
}
pub fn load_images(&mut self) -> &[(String, Vec<u8>)] {
if !self.images.is_empty() {
return &self.images;
}
let base_dir = Path::new(&self.chapter_path)
.parent()
.unwrap_or(Path::new(""))
.to_path_buf();
let mut doc: Option<epub::doc::EpubDoc<std::io::BufReader<std::fs::File>>> = None;
let mut tried_open = false;
for seg in &self.segments {
if let (Some(src), Some(markup)) = (&seg.src, &seg.svg) {
self.images.push((src.clone(), markup.as_bytes().to_vec()));
continue;
}
let Some(src) = &seg.src else { continue };
if !tried_open {
tried_open = true;
if self.epub_path.is_empty() {
warn!("load_images: no archive for {src}");
} else {
match epub::doc::EpubDoc::new(&self.epub_path) {
Ok(d) => doc = Some(d),
Err(e) => warn!("load_images: epub open error: {e}"),
}
}
}
let Some(doc) = doc.as_mut() else { continue };
let joined = base_dir.join(percent_decode(src));
let full = normalize_zip_path(&joined);
if let Some(data) = doc.get_resource_by_path(Path::new(&full)) {
self.images.push((src.clone(), data));
} else {
warn!("load_images: {full} not found in archive");
}
}
&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();
}
}
}
}
for first in self.text.lines().filter(|l| !l.trim().is_empty()) {
let t = first.trim();
if is_useful_title(t) {
return t.to_string();
}
}
format!("Chapter {}", idx + 1)
}
}
fn is_useful_title(s: &str) -> bool {
if s.chars()
.all(|c| c.is_ascii_digit() || c.is_ascii_whitespace())
{
return false;
}
let lower = s.to_ascii_lowercase();
!matches!(
lower.as_str(),
"copyright"
| "contents"
| "table of contents"
| "all rights reserved"
| "cover"
| "title page"
| "dedication"
| "about the author"
| "index"
)
}
#[derive(Debug)]
pub struct EpubBook {
pub title: Option<String>,
pub author: Option<String>,
pub language: Option<String>,
pub chapters: Vec<Chapter>,
pub toc_tree: Vec<TocEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TocEntry {
pub label: String,
pub depth: usize,
pub chapter: Option<usize>,
pub anchor: Option<String>,
pub children: Vec<TocEntry>,
}
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 style = collect_book_style(&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_style(idx, None, &xhtml, &style);
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_tree = build_toc_tree(&doc.toc, &chapters);
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,
toc_tree,
})
}
}
fn collect_book_style(
doc: &mut epub::doc::EpubDoc<std::io::BufReader<std::fs::File>>,
) -> BookStyle {
let css_ids: Vec<String> = doc
.resources
.iter()
.filter(|(_, item)| item.mime.contains("css"))
.map(|(id, _)| id.clone())
.collect();
let mut style = BookStyle::new();
for id in css_ids {
if let Some((bytes, _mime)) = doc.get_resource(&id) {
style.extend(parse_book_style(&String::from_utf8_lossy(&bytes)));
}
}
log::info!(
"epub: {} indent classes, {} unmarked-list classes from stylesheets",
style.indents.len(),
style.no_marker.len()
);
style
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkTarget {
pub chapter: usize,
pub anchor: Option<String>,
}
pub fn resolve_link(chapters: &[Chapter], from_chapter: usize, href: &str) -> Option<LinkTarget> {
let href = href.trim();
if href.is_empty() || is_external_href(href) {
return None;
}
if let Some(frag) = href.strip_prefix('#') {
return Some(LinkTarget {
chapter: from_chapter,
anchor: Some(percent_decode(frag)),
});
}
let (path_part, anchor) = match href.split_once('#') {
Some((p, a)) => (p, Some(percent_decode(a))),
None => (href, None),
};
let base = chapters
.get(from_chapter)
.map(|c| c.chapter_path.as_str())
.unwrap_or("");
let base_dir = Path::new(base).parent().unwrap_or(Path::new(""));
let target = normalize_zip_path(&base_dir.join(percent_decode(path_part)));
let chapter = chapters
.iter()
.position(|c| normalize_zip_path(Path::new(&c.chapter_path)) == target)?;
Some(LinkTarget { chapter, anchor })
}
pub fn anchor_offset(chapters: &[Chapter], target: &LinkTarget) -> usize {
let Some(chapter) = chapters.get(target.chapter) else {
return 0;
};
let Some(anchor) = target.anchor.as_deref() else {
return 0;
};
chapter
.segments
.iter()
.find(|s| s.id.as_deref() == Some(anchor))
.map(|s| s.start)
.unwrap_or(0)
}
impl EpubBook {
pub fn resolve_link(&self, from_chapter: usize, href: &str) -> Option<LinkTarget> {
resolve_link(&self.chapters, from_chapter, href)
}
pub fn anchor_offset(&self, target: &LinkTarget) -> usize {
anchor_offset(&self.chapters, target)
}
}
fn is_external_href(href: &str) -> bool {
let lower = href.to_ascii_lowercase();
["http://", "https://", "mailto:", "tel:", "data:", "ftp://"]
.iter()
.any(|s| lower.starts_with(s))
}
pub(crate) fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(v) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(v);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn build_toc_tree(toc: &[epub::doc::NavPoint], chapters: &[Chapter]) -> Vec<TocEntry> {
fn walk(np: &epub::doc::NavPoint, depth: usize, chapters: &[Chapter]) -> Option<TocEntry> {
let raw = np.content.to_string_lossy().into_owned();
let (path, anchor) = match raw.rsplit_once('#') {
Some((base, frag)) if !frag.is_empty() => {
(base.to_string(), Some(percent_decode(frag)))
}
_ => (strip_fragment(&raw), None),
};
let chapter = chapters
.iter()
.position(|c| normalize_zip_path(Path::new(&c.chapter_path)) == path);
let children: Vec<TocEntry> = np
.children
.iter()
.filter_map(|c| walk(c, depth + 1, chapters))
.collect();
if chapter.is_none() && children.is_empty() {
return None;
}
Some(TocEntry {
label: np.label.clone(),
depth,
chapter,
anchor,
children,
})
}
toc.iter()
.filter_map(|np| walk(np, 0, chapters))
.collect()
}
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;