use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use std::collections::HashMap;
use std::path::PathBuf;
pub fn media(path: &str) -> Vec<PathBuf> {
crate::util::extract_epub_media(path)
}
pub fn to_markdown(path: &str) -> Result<String, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let container = {
let member = zip
.by_name("META-INF/container.xml")
.map_err(|_| "not an epub (no META-INF/container.xml)".to_string())?;
crate::util::read_to_string_capped(member, crate::util::MAX_DECODE_BYTES)?
};
let opf_path = opf_path_from_container(&container)
.ok_or_else(|| "epub container.xml names no rootfile (OPF)".to_string())?;
let opf_xml = {
let member = zip
.by_name(&opf_path)
.map_err(|_| format!("epub OPF missing: {opf_path}"))?;
crate::util::read_to_string_capped(member, crate::util::MAX_DECODE_BYTES)?
};
let hrefs = spine_hrefs(&opf_xml, opf_dir_of(&opf_path));
if hrefs.is_empty() {
return Err("epub has no spine content".to_string());
}
let mut out = String::new();
let mut truncated = false;
for href in &hrefs {
let Ok(member) = zip.by_name(href) else {
continue; };
let Ok(xml) = crate::util::read_to_string_capped(member, crate::util::MAX_DECODE_BYTES)
else {
continue; };
let md = crate::html::markdown_from_str(&xml);
if md.trim().is_empty() {
continue;
}
if !out.is_empty() {
out.push_str("---\n\n");
}
out.push_str(md.trim_end());
out.push_str("\n\n");
if out.len() > crate::util::MAX_DECODE_BYTES {
truncated = true;
break;
}
}
if truncated {
out.push_str("… (truncated)\n");
}
if out.trim().is_empty() {
return Err("epub has no readable text".to_string());
}
Ok(out)
}
fn attr(e: &BytesStart, name: &[u8]) -> Option<String> {
e.attributes()
.flatten()
.find(|a| a.key.as_ref() == name)
.map(|a| String::from_utf8_lossy(&a.value).into_owned())
}
fn opf_path_from_container(xml: &str) -> Option<String> {
let mut r = Reader::from_str(xml);
r.config_mut().trim_text(true);
let mut buf = Vec::new();
loop {
match r.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
if e.local_name().as_ref() == b"rootfile" {
if let Some(p) = attr(&e, b"full-path") {
return Some(p);
}
}
}
Err(_) => break,
_ => {}
}
buf.clear();
}
None
}
fn opf_dir_of(full_path: &str) -> &str {
match full_path.rfind('/') {
Some(i) => &full_path[..i],
None => "",
}
}
fn spine_hrefs(opf_xml: &str, opf_dir: &str) -> Vec<String> {
let mut r = Reader::from_str(opf_xml);
r.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut manifest: HashMap<String, (String, String)> = HashMap::new();
let mut order: Vec<String> = Vec::new();
loop {
match r.read_event_into(&mut buf) {
Ok(Event::Eof) => break,
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => match e.local_name().as_ref() {
b"item" => {
if let (Some(id), Some(href)) = (attr(&e, b"id"), attr(&e, b"href")) {
let mt = attr(&e, b"media-type").unwrap_or_default();
manifest.insert(id, (href, mt));
}
}
b"itemref" => {
if let Some(idref) = attr(&e, b"idref") {
order.push(idref);
}
}
_ => {}
},
Err(_) => break,
_ => {}
}
buf.clear();
}
order
.iter()
.filter_map(|idref| {
let (href, media_type) = manifest.get(idref)?;
is_content(media_type, href).then(|| resolve_href(opf_dir, href))
})
.collect()
}
fn is_content(media_type: &str, href: &str) -> bool {
let mt = media_type.to_ascii_lowercase();
if mt == "application/xhtml+xml" || mt == "text/html" {
return true;
}
let h = href.to_ascii_lowercase();
mt.is_empty() && (h.ends_with(".xhtml") || h.ends_with(".html") || h.ends_with(".htm"))
}
fn resolve_href(opf_dir: &str, href: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
if !opf_dir.is_empty() {
parts.extend(opf_dir.split('/').filter(|s| !s.is_empty()));
}
for seg in href.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn container_yields_opf_full_path() {
let xml = r#"<?xml version="1.0"?>
<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>"#;
assert_eq!(
opf_path_from_container(xml).as_deref(),
Some("OEBPS/content.opf")
);
}
#[test]
fn container_without_rootfile_is_none() {
assert_eq!(opf_path_from_container("<container></container>"), None);
}
#[test]
fn opf_dir_is_the_parent_of_the_package() {
assert_eq!(opf_dir_of("OEBPS/content.opf"), "OEBPS");
assert_eq!(opf_dir_of("content.opf"), "");
assert_eq!(opf_dir_of("a/b/c/pkg.opf"), "a/b/c");
}
const OPF: &str = r#"<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<manifest>
<item id="c1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="c2" href="text/chapter2.xhtml" media-type="application/xhtml+xml"/>
<item id="css" href="style.css" media-type="text/css"/>
<item id="cover" href="images/cover.png" media-type="image/png"/>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
</manifest>
<spine toc="ncx">
<itemref idref="c1"/>
<itemref idref="c2"/>
<itemref idref="css"/>
<itemref idref="missing"/>
</spine>
</package>"#;
#[test]
fn spine_orders_content_and_resolves_relative_to_opf_dir() {
let hrefs = spine_hrefs(OPF, "OEBPS");
assert_eq!(
hrefs,
vec![
"OEBPS/chapter1.xhtml".to_string(),
"OEBPS/text/chapter2.xhtml".to_string(),
]
);
}
#[test]
fn spine_at_root_has_no_directory_prefix() {
let hrefs = spine_hrefs(OPF, "");
assert_eq!(hrefs, vec!["chapter1.xhtml", "text/chapter2.xhtml"]);
}
#[test]
fn href_resolution_collapses_dot_segments() {
assert_eq!(
resolve_href("OEBPS", "chapter1.xhtml"),
"OEBPS/chapter1.xhtml"
);
assert_eq!(
resolve_href("OEBPS", "text/ch.xhtml"),
"OEBPS/text/ch.xhtml"
);
assert_eq!(resolve_href("OEBPS/text", "../ch.xhtml"), "OEBPS/ch.xhtml");
assert_eq!(resolve_href("OEBPS", "./ch.xhtml"), "OEBPS/ch.xhtml");
assert_eq!(resolve_href("", "ch.xhtml"), "ch.xhtml");
}
#[test]
fn content_type_gate_accepts_xhtml_only() {
assert!(is_content("application/xhtml+xml", "a.xhtml"));
assert!(is_content("text/html", "a.html"));
assert!(is_content("", "a.xhtml"));
assert!(!is_content("", "a.css"));
assert!(!is_content("text/css", "a.xhtml"));
assert!(!is_content("image/png", "cover.png"));
}
}