use std::collections::HashMap;
use std::io::{Cursor, Read};
use crate::config::RuntimeConfig;
use crate::parsers::ParseResult;
use crate::parsers::structured::html;
use crate::parsers::text::plain_text::extract_text_templates;
use crate::parsers::traits::empty_mining_result;
use crate::results::{EpubMetadata, MiningResult};
use anyhow::Result;
use log::{debug, warn};
use quick_xml::Reader;
use quick_xml::events::{BytesEnd, BytesStart, Event};
use zip::ZipArchive;
fn get_first_attr_value(e: &BytesStart, names: &[&[u8]]) -> Option<String> {
for name in names {
if let Ok(Some(attr)) = e.try_get_attribute(*name) {
return Some(String::from_utf8_lossy(&attr.value).into_owned());
}
}
None
}
fn start_local_name_eq(e: &BytesStart, name: &[u8]) -> bool {
let local = e.name().local_name();
local.as_ref() == name
}
fn end_local_name_eq(e: &BytesEnd, name: &[u8]) -> bool {
let local = e.name().local_name();
local.as_ref() == name
}
struct EpubPaths;
impl EpubPaths {
const CONTAINER_XML: &'static str = "META-INF/container.xml";
const ENCRYPTION_XML: &'static str = "META-INF/encryption.xml";
const COMMON_OPF_PATHS: [&'static str; 3] =
["OEBPS/content.opf", "content.opf", "EPUB/package.opf"];
}
fn epub_has_encryption(archive: &mut ZipArchive<Cursor<&[u8]>>) -> bool {
archive.by_name(EpubPaths::ENCRYPTION_XML).is_ok()
}
struct EpubElements;
impl EpubElements {
const ROOTFILE: &'static [u8] = b"rootfile";
const FULL_PATH: &'static [u8] = b"full-path";
const FULL_PATH_ALT: &'static [u8] = b"full_path";
const METADATA: &'static [u8] = b"metadata";
const TITLE: &'static [u8] = b"title";
const CREATOR: &'static [u8] = b"creator";
const LANGUAGE: &'static [u8] = b"language";
const IDENTIFIER: &'static [u8] = b"identifier";
const SPINE: &'static [u8] = b"spine";
const ITEMREF: &'static [u8] = b"itemref";
const ITEM: &'static [u8] = b"item";
const ID: &'static [u8] = b"id";
const HREF: &'static [u8] = b"href";
const IDREF: &'static [u8] = b"idref";
}
macro_rules! set_metadata_field {
($local:expr, $value:expr, $metadata:expr, $element:expr => $field:ident) => {
if $local == $element && $metadata.$field.is_none() {
$metadata.$field = Some($value.to_string());
}
};
}
pub fn extract_epub_metadata(
content: &[u8],
stats: &ParseResult,
_config: &RuntimeConfig,
) -> Result<EpubMetadata> {
let metadata = EpubMetadata {
file_size: Some(stats.byte_count),
..Default::default()
};
let mut archive = match ZipArchive::new(Cursor::new(content)) {
Ok(a) => a,
Err(e) => {
warn!("EPUB: failed to open as ZIP: {e}");
return Ok(metadata);
}
};
if epub_has_encryption(&mut archive) {
debug!("EPUB: encryption.xml present, skipping metadata extraction");
return Ok(metadata);
}
let opf_path =
read_container_rootfile(&mut archive).or_else(|| try_common_opf_paths(&mut archive));
let Some(opf_path) = opf_path else {
return Ok(metadata);
};
Ok(parse_opf(&mut archive, &opf_path, metadata))
}
fn read_container_rootfile(archive: &mut ZipArchive<Cursor<&[u8]>>) -> Option<String> {
let mut f = archive.by_name(EpubPaths::CONTAINER_XML).ok()?;
let mut xml = String::new();
f.read_to_string(&mut xml).ok()?;
parse_container_rootfile(&xml)
}
fn try_common_opf_paths(archive: &mut ZipArchive<Cursor<&[u8]>>) -> Option<String> {
for name in EpubPaths::COMMON_OPF_PATHS {
if archive.by_name(name).is_ok() {
return Some(name.to_string());
}
}
None
}
fn parse_container_rootfile(xml: &str) -> Option<String> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Empty(ref e) | Event::Start(ref e)) => {
if start_local_name_eq(e, EpubElements::ROOTFILE)
&& let Some(path) = get_first_attr_value(
e,
&[EpubElements::FULL_PATH, EpubElements::FULL_PATH_ALT],
)
{
return Some(path);
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
None
}
fn parse_opf(
archive: &mut ZipArchive<Cursor<&[u8]>>,
path: &str,
mut metadata: EpubMetadata,
) -> EpubMetadata {
let Ok(mut f) = archive.by_name(path) else {
return metadata;
};
let mut xml = String::new();
if f.read_to_string(&mut xml).is_err() {
return metadata;
}
extract_metadata_from_opf(&xml, &mut metadata);
metadata.chapter_count = Some(count_spine_itemrefs(&xml));
metadata
}
fn extract_metadata_from_opf(xml: &str, m: &mut EpubMetadata) {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut text = String::new();
let mut in_meta = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
in_meta = start_local_name_eq(e, EpubElements::METADATA) || in_meta;
if in_meta {
text.clear();
}
}
Ok(Event::End(ref e)) => {
if end_local_name_eq(e, EpubElements::METADATA) {
in_meta = false;
}
if in_meta {
let v = text.trim();
if !v.is_empty() {
let local_name = e.name().local_name();
let local_slice = local_name.as_ref();
set_metadata_field!(local_slice, v, m, EpubElements::TITLE => title);
set_metadata_field!(local_slice, v, m, EpubElements::CREATOR => author);
set_metadata_field!(local_slice, v, m, EpubElements::LANGUAGE => language);
set_metadata_field!(local_slice, v, m, EpubElements::IDENTIFIER => identifier);
}
text.clear();
}
}
Ok(Event::Text(e)) => {
if in_meta && let Ok(s) = std::str::from_utf8(e.as_ref()) {
text.push_str(s);
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
}
fn count_spine_itemrefs(xml: &str) -> usize {
parse_spine_order_from_opf(xml).len()
}
fn parse_manifest_from_opf(xml: &str) -> HashMap<String, String> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut manifest = HashMap::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Empty(ref e) | Event::Start(ref e)) => {
if !start_local_name_eq(e, EpubElements::ITEM) {
buf.clear();
continue;
}
let id = get_first_attr_value(e, &[EpubElements::ID]);
let href = get_first_attr_value(e, &[EpubElements::HREF]);
if let (Some(id), Some(href)) = (id, href) {
manifest.insert(id, href);
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
manifest
}
fn parse_spine_order_from_opf(xml: &str) -> Vec<String> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut in_spine = false;
let mut order = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Empty(ref e) | Event::Start(ref e)) => {
if start_local_name_eq(e, EpubElements::SPINE) {
in_spine = true;
} else if in_spine
&& start_local_name_eq(e, EpubElements::ITEMREF)
&& let Some(idref) = get_first_attr_value(e, &[EpubElements::IDREF])
{
order.push(idref);
}
}
Ok(Event::End(ref e)) => {
if end_local_name_eq(e, EpubElements::SPINE) {
in_spine = false;
}
}
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buf.clear();
}
order
}
fn resolve_content_path(opf_path: &str, href: &str) -> String {
let opf_dir = opf_path.rfind('/').map_or("", |i| &opf_path[..=i]);
if opf_dir.is_empty() {
href.to_string()
} else {
format!("{opf_dir}{href}")
}
}
fn extract_epub_body_text(content: &[u8]) -> Result<String> {
let mut archive = ZipArchive::new(Cursor::new(content))
.map_err(|e| anyhow::anyhow!("EPUB: failed to open as ZIP: {e}"))?;
if epub_has_encryption(&mut archive) {
debug!("EPUB: encryption.xml present, skipping body text extraction");
return Ok(String::new());
}
let opf_path =
read_container_rootfile(&mut archive).or_else(|| try_common_opf_paths(&mut archive));
let opf_path = opf_path.ok_or_else(|| anyhow::anyhow!("EPUB: no package document found"))?;
let mut opf_xml = String::new();
archive
.by_name(&opf_path)
.map_err(|e| anyhow::anyhow!("EPUB: cannot read OPF: {e}"))?
.read_to_string(&mut opf_xml)
.map_err(|e| anyhow::anyhow!("EPUB: OPF read error: {e}"))?;
let manifest = parse_manifest_from_opf(&opf_xml);
let spine_order = parse_spine_order_from_opf(&opf_xml);
if spine_order.is_empty() {
return Ok(String::new());
}
let mut body_parts = Vec::with_capacity(spine_order.len());
for idref in spine_order {
let href = if let Some(h) = manifest.get(&idref) {
h.as_str()
} else {
debug!("EPUB: spine idref '{idref}' not in manifest, skipping");
continue;
};
let content_path = resolve_content_path(&opf_path, href);
let mut entry = match archive.by_name(&content_path) {
Ok(e) => e,
Err(e) => {
warn!("EPUB: cannot open content document '{content_path}': {e}");
continue;
}
};
let mut html_content = String::new();
if entry.read_to_string(&mut html_content).is_err() {
continue;
}
let text = html::extract_plain_text_from_html(&html_content);
if !text.is_empty() {
body_parts.push(text);
}
}
Ok(body_parts.join("\n\n"))
}
pub fn extract_epub_templates(
content: &[u8],
stats: &ParseResult,
config: &RuntimeConfig,
) -> Result<MiningResult> {
let body_text = match extract_epub_body_text(content) {
Ok(t) => t,
Err(e) => {
warn!("EPUB body text extraction failed: {e}");
return Ok(empty_mining_result(stats));
}
};
if body_text.trim().is_empty() {
return Ok(empty_mining_result(stats));
}
extract_text_templates(&body_text, stats, config)
}