Skip to main content

docparser_epub/
lib.rs

1//! `docparser-epub`:EPUB2/EPUB3 后端。
2//!
3//! 结构(对齐 Python `book.read_epub`):
4//! `META-INF/container.xml` → rootfile(OPF 路径)→ `metadata` / `manifest` / `spine`,
5//! 章节标题优先取 EPUB3 `nav`,其次 EPUB2 `toc.ncx`,取不到回落到文档内首个标题;
6//! 封面取 `properties="cover-image"`,其次 `<meta name="cover">`。
7//!
8//! 正文:每个 spine 文档用 `docparser_core::plugins::html` 解析成**结构化块**
9//! (标题/段落/列表/引用/代码/表格),再按文档内标题切节(层级自适应 h2→h3→h4,
10//! 单节超过 [`BIG_SECTION_CHARS`] 才往深一层切)——与 Python 的分章口径一致,
11//! 区别是这里保留块结构而不是压平成一段文本。
12//!
13//! 不做的事:DRM(`META-INF/encryption.xml`)不解密,加密书会在读取正文时报 `corrupt`;
14//! 图片/字体等资源不解包(只记录封面路径)。
15
16use std::collections::BTreeMap;
17use std::io::Read;
18use std::sync::Arc;
19
20use quick_xml::events::{BytesEnd, BytesStart, Event};
21use quick_xml::Reader;
22
23use docparser_core::error::{DocError, Result};
24use docparser_core::model::{Block, BlockKind, Document, Page};
25use docparser_core::plugin::{FormatPlugin, ParseCapability, RawDoc, RawPage, RawTextItem};
26use docparser_core::plugins::html::parse_blocks;
27use docparser_core::registry::Registry;
28
29/// 单节超过该字符数就尝试按更深层标题再切(与 Python `BIG_SECTION_CHARS` 一致)。
30pub const BIG_SECTION_CHARS: usize = 8000;
31const SECTION_LEVELS: [u8; 3] = [2, 3, 4];
32
33/// 书籍元数据(取不到的字段为空串 / None)。
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct EpubMeta {
36    pub title: String,
37    pub authors: Vec<String>,
38    pub language: String,
39    pub publisher: String,
40    /// 从 `dc:date` 里取的四位年份。
41    pub year: String,
42    /// 封面在包内的路径(POSIX,相对包根)。
43    pub cover_href: Option<String>,
44}
45
46/// 一章(= 一个 spine 文档,或文档内按标题切出的一节)。
47#[derive(Debug, Clone, Default, PartialEq)]
48pub struct EpubChapter {
49    pub title: String,
50    pub blocks: Vec<Block>,
51    /// `blocks` 的非空 content 以空行连接(与 Python 章文本口径一致)。
52    pub text: String,
53}
54
55/// 整本书的解析结果。
56#[derive(Debug, Clone, Default)]
57pub struct EpubBook {
58    pub meta: EpubMeta,
59    pub chapters: Vec<EpubChapter>,
60}
61
62// ── XML 小工具 ───────────────────────────────────────────────────────
63
64fn local_name(e: &BytesStart<'_>) -> String {
65    e.local_name().as_ref().to_string()
66}
67
68fn end_name(e: &BytesEnd<'_>) -> String {
69    e.local_name().as_ref().to_string()
70}
71
72fn attrs(e: &BytesStart<'_>) -> BTreeMap<String, String> {
73    let mut out = BTreeMap::new();
74    for a in e.attributes().flatten() {
75        let key = a.key.local_name().as_ref().to_string();
76        let val = quick_xml::escape::unescape(&a.value)
77            .map(|v| v.into_owned())
78            .unwrap_or_else(|_| a.value.to_string());
79        out.insert(key, val);
80    }
81    out
82}
83
84/// quick-xml 0.42:`&amp;` / `&#65;` 是独立的 `Event::GeneralRef`,按 0.37 语义就地解码。
85fn push_ref(buf: &mut String, e: &quick_xml::events::BytesRef<'_>) {
86    match e.resolve_char_ref() {
87        Ok(Some(ch)) => buf.push(ch),
88        _ => {
89            let entity = format!("&{};", e.xml10_content());
90            if let Ok(t) = quick_xml::escape::unescape(&entity) {
91                buf.push_str(&t);
92            }
93        }
94    }
95}
96
97/// Text 事件取内容并解实体(0.38+ 不再自动 unescape)。
98fn text_of(e: &quick_xml::events::BytesText<'_>) -> Option<String> {
99    let raw = e.xml10_content();
100    quick_xml::escape::unescape(&raw)
101        .ok()
102        .map(|v| v.into_owned())
103}
104
105fn xml_err(what: &str, e: impl std::fmt::Display) -> DocError {
106    DocError::corrupt(format!("{what} 解析失败: {e}"))
107}
108
109// ── 容器 / OPF ──────────────────────────────────────────────────────
110
111#[derive(Debug, Clone, Default)]
112struct Item {
113    id: String,
114    href: String,
115    props: String,
116}
117
118#[derive(Debug, Clone, Default)]
119struct Opf {
120    title: String,
121    authors: Vec<String>,
122    language: String,
123    publisher: String,
124    date: String,
125    items: Vec<Item>,
126    spine: Vec<String>,
127    toc: Option<String>,
128    cover_id: Option<String>,
129}
130
131/// `container.xml` → OPF 包内路径。
132fn find_rootfile(xml: &str) -> Result<Option<String>> {
133    let mut reader = Reader::from_str(xml);
134    reader.config_mut().trim_text(true);
135    let mut buf = Vec::new();
136    loop {
137        match reader.read_event_into(&mut buf) {
138            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
139                if local_name(&e) == "rootfile" {
140                    if let Some(p) = attrs(&e).get("full-path") {
141                        return Ok(Some(p.clone()));
142                    }
143                }
144            }
145            Ok(Event::Eof) => break,
146            Err(e) => return Err(xml_err("container.xml", e)),
147            _ => {}
148        }
149        buf.clear();
150    }
151    Ok(None)
152}
153
154#[derive(Clone, Copy, PartialEq, Eq)]
155enum Field {
156    Title,
157    Creator,
158    Language,
159    Publisher,
160    Date,
161}
162
163/// OPF 单元素处理:元数据字段登记 + `manifest` / `spine` / 封面收集。
164///
165/// `is_empty`(自闭合元素)不登记文本字段——没有正文可收。
166fn on_opf_element(
167    opf: &mut Opf,
168    e: &BytesStart<'_>,
169    stack: &[String],
170    field: &mut Option<Field>,
171    acc: &mut String,
172    is_empty: bool,
173) {
174    let name = local_name(e);
175    let a = attrs(e);
176    let in_metadata = stack.iter().any(|s| s == "metadata");
177    if in_metadata && field.is_none() && !is_empty {
178        let f = match name.as_str() {
179            "title" => Some(Field::Title),
180            "creator" => Some(Field::Creator),
181            "language" => Some(Field::Language),
182            "publisher" => Some(Field::Publisher),
183            "date" => Some(Field::Date),
184            _ => None,
185        };
186        if f.is_some() {
187            *field = f;
188            acc.clear();
189        }
190    }
191    match name.as_str() {
192        "item" => opf.items.push(Item {
193            id: a.get("id").cloned().unwrap_or_default(),
194            href: a.get("href").cloned().unwrap_or_default(),
195            props: a.get("properties").cloned().unwrap_or_default(),
196        }),
197        "itemref" => {
198            if let Some(id) = a.get("idref") {
199                opf.spine.push(id.clone());
200            }
201        }
202        "spine" => {
203            if let Some(t) = a.get("toc") {
204                opf.toc = Some(t.clone());
205            }
206        }
207        "meta" if a.get("name").map(|n| n == "cover").unwrap_or(false) => {
208            opf.cover_id = a.get("content").cloned();
209        }
210        _ => {}
211    }
212}
213
214fn parse_opf(xml: &str) -> Result<Opf> {
215    let mut opf = Opf::default();
216    let mut reader = Reader::from_str(xml);
217    reader.config_mut().trim_text(true);
218    let mut buf = Vec::new();
219    let mut stack: Vec<String> = Vec::new();
220    let mut field: Option<Field> = None;
221    let mut acc = String::new();
222
223    loop {
224        match reader.read_event_into(&mut buf) {
225            Ok(Event::Start(e)) => {
226                on_opf_element(&mut opf, &e, &stack, &mut field, &mut acc, false);
227                stack.push(local_name(&e));
228            }
229            Ok(Event::Empty(e)) => {
230                on_opf_element(&mut opf, &e, &stack, &mut field, &mut acc, true);
231            }
232            Ok(Event::Text(t)) => {
233                if field.is_some() {
234                    if let Some(v) = text_of(&t) {
235                        acc.push_str(&v);
236                    }
237                }
238            }
239            Ok(Event::GeneralRef(ref e)) => {
240                if field.is_some() {
241                    push_ref(&mut acc, e);
242                }
243            }
244            Ok(Event::End(e)) => {
245                let name = end_name(&e);
246                if let Some(f) = field {
247                    let matched = matches!(
248                        (f, name.as_str()),
249                        (Field::Title, "title")
250                            | (Field::Creator, "creator")
251                            | (Field::Language, "language")
252                            | (Field::Publisher, "publisher")
253                            | (Field::Date, "date")
254                    );
255                    if matched {
256                        let v = acc.trim().to_string();
257                        match f {
258                            Field::Title => {
259                                if opf.title.is_empty() {
260                                    opf.title = v;
261                                }
262                            }
263                            Field::Creator => {
264                                if !v.is_empty() {
265                                    opf.authors.push(v);
266                                }
267                            }
268                            Field::Language => {
269                                if opf.language.is_empty() {
270                                    opf.language = v;
271                                }
272                            }
273                            Field::Publisher => {
274                                if opf.publisher.is_empty() {
275                                    opf.publisher = v;
276                                }
277                            }
278                            Field::Date => {
279                                if opf.date.is_empty() {
280                                    opf.date = v;
281                                }
282                            }
283                        }
284                        field = None;
285                        acc.clear();
286                    }
287                }
288                if stack.last().map(|s| s == &name).unwrap_or(false) {
289                    stack.pop();
290                }
291            }
292            Ok(Event::Eof) => break,
293            Err(e) => return Err(xml_err("OPF", e)),
294            _ => {}
295        }
296        buf.clear();
297    }
298    Ok(opf)
299}
300
301/// EPUB3 nav.xhtml → {href(去 fragment): 标题}。
302fn nav_titles(xml: &str) -> BTreeMap<String, String> {
303    let mut out = BTreeMap::new();
304    let mut reader = Reader::from_str(xml);
305    reader.config_mut().trim_text(true);
306    let mut buf = Vec::new();
307    let mut href: Option<String> = None;
308    let mut acc = String::new();
309
310    loop {
311        match reader.read_event_into(&mut buf) {
312            Ok(Event::Start(e)) => {
313                if local_name(&e) == "a" {
314                    href = attrs(&e).get("href").cloned();
315                    acc.clear();
316                }
317            }
318            Ok(Event::Text(t)) => {
319                if href.is_some() {
320                    if let Some(v) = text_of(&t) {
321                        acc.push_str(&v);
322                    }
323                }
324            }
325            Ok(Event::GeneralRef(ref e)) => {
326                if href.is_some() {
327                    push_ref(&mut acc, e);
328                }
329            }
330            Ok(Event::End(e)) => {
331                if end_name(&e) == "a" {
332                    if let Some(h) = href.take() {
333                        let h = h.split('#').next().unwrap_or("").to_string();
334                        let title = acc.trim().to_string();
335                        if !h.is_empty() && !title.is_empty() {
336                            out.entry(h).or_insert(title);
337                        }
338                    }
339                    acc.clear();
340                }
341            }
342            Ok(Event::Eof) | Err(_) => break,
343            _ => {}
344        }
345        buf.clear();
346    }
347    out
348}
349
350/// EPUB2 toc.ncx → {href(去 fragment): 标题}(`navLabel/text` + `content@src` 配对)。
351fn ncx_titles(xml: &str) -> BTreeMap<String, String> {
352    let mut out = BTreeMap::new();
353    let mut reader = Reader::from_str(xml);
354    reader.config_mut().trim_text(true);
355    let mut buf = Vec::new();
356    let mut last_label = String::new();
357    let mut in_text = false;
358
359    loop {
360        match reader.read_event_into(&mut buf) {
361            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
362                let name = local_name(&e);
363                match name.as_str() {
364                    "text" => {
365                        in_text = true;
366                        last_label.clear();
367                    }
368                    "content" => {
369                        if let Some(src) = attrs(&e).get("src") {
370                            let h = src.split('#').next().unwrap_or("").to_string();
371                            if !h.is_empty() && !last_label.is_empty() {
372                                out.entry(h).or_insert_with(|| last_label.clone());
373                            }
374                        }
375                    }
376                    _ => {}
377                }
378            }
379            Ok(Event::Text(t)) => {
380                if in_text {
381                    if let Some(v) = text_of(&t) {
382                        last_label.push_str(&v);
383                    }
384                }
385            }
386            Ok(Event::GeneralRef(ref e)) => {
387                if in_text {
388                    push_ref(&mut last_label, e);
389                }
390            }
391            Ok(Event::End(e)) => {
392                if end_name(&e) == "text" {
393                    last_label = last_label.trim().to_string();
394                    in_text = false;
395                }
396            }
397            Ok(Event::Eof) | Err(_) => break,
398            _ => {}
399        }
400        buf.clear();
401    }
402    out
403}
404
405// ── 路径 / ZIP ──────────────────────────────────────────────────────
406
407/// manifest href(可含 `./` `../`)→ 包内 POSIX 路径。
408fn resolve(base: &str, href: &str) -> String {
409    let href = href.split('#').next().unwrap_or("");
410    let joined = if base.is_empty() {
411        href.to_string()
412    } else {
413        format!("{base}/{href}")
414    };
415    let mut parts: Vec<&str> = Vec::new();
416    for seg in joined.split('/') {
417        match seg {
418            "" | "." => {}
419            ".." => {
420                parts.pop();
421            }
422            s => parts.push(s),
423        }
424    }
425    parts.join("/")
426}
427
428fn open_zip(path: &str) -> Result<zip::ZipArchive<std::fs::File>> {
429    let f =
430        std::fs::File::open(path).map_err(|e| DocError::corrupt(format!("EPUB 打不开: {e}")))?;
431    zip::ZipArchive::new(f).map_err(|e| DocError::corrupt(format!("EPUB 打不开: {e}")))
432}
433
434fn read_entry(zip: &mut zip::ZipArchive<std::fs::File>, name: &str) -> Result<Option<String>> {
435    match zip.by_name(name) {
436        Ok(mut f) => {
437            let mut buf = Vec::new();
438            f.read_to_end(&mut buf)
439                .map_err(|e| DocError::corrupt(format!("读取 {name} 失败: {e}")))?;
440            // EPUB 正文常含非法字节:解码失败按替换字符处理,不整体失败
441            Ok(Some(String::from_utf8_lossy(&buf).into_owned()))
442        }
443        Err(zip::result::ZipError::FileNotFound) => Ok(None),
444        Err(e) => Err(DocError::corrupt(format!("ZIP 读取 {name} 失败: {e}"))),
445    }
446}
447
448fn year_of(date: &str) -> String {
449    let bytes: Vec<char> = date.chars().collect();
450    for w in bytes.windows(4) {
451        if w.iter().all(|c| c.is_ascii_digit()) {
452            return w.iter().collect();
453        }
454    }
455    String::new()
456}
457
458// ── 分章 ────────────────────────────────────────────────────────────
459
460/// 按标题切节:返回 (标题, 该节块下标)。
461fn cut_indices(blocks: &[Block], max_level: u8) -> Vec<(String, Vec<usize>)> {
462    let mut out: Vec<(String, Vec<usize>)> = Vec::new();
463    let mut title = String::new();
464    let mut cur: Vec<usize> = Vec::new();
465    for (i, b) in blocks.iter().enumerate() {
466        if b.kind == BlockKind::Heading && b.level.unwrap_or(1) <= max_level {
467            if !cur.is_empty() {
468                out.push((std::mem::take(&mut title), std::mem::take(&mut cur)));
469            }
470            title = b.content.trim().to_string();
471            continue;
472        }
473        if !b.content.is_empty() {
474            cur.push(i);
475        }
476    }
477    if !cur.is_empty() {
478        out.push((title, cur));
479    }
480    out.retain(|(_, idx)| !idx.is_empty());
481    out
482}
483
484/// 层级自适应切节:单节仍过大就试更深一层(h2 → h3 → h4)。
485fn sections(blocks: &[Block]) -> Vec<(String, Vec<usize>)> {
486    let mut best = Vec::new();
487    for max_level in SECTION_LEVELS {
488        best = cut_indices(blocks, max_level);
489        let max_chars = best
490            .iter()
491            .map(|(_, idx)| {
492                idx.iter()
493                    .map(|&i| blocks[i].content.chars().count())
494                    .sum::<usize>()
495            })
496            .max()
497            .unwrap_or(0);
498        if best.is_empty() || max_chars <= BIG_SECTION_CHARS {
499            break;
500        }
501    }
502    best
503}
504
505fn page_text(blocks: &[Block]) -> String {
506    blocks
507        .iter()
508        .filter(|b| !b.content.is_empty())
509        .map(|b| b.content.as_str())
510        .collect::<Vec<_>>()
511        .join("\n\n")
512}
513
514fn build_page(title: &str, blocks: Vec<Block>) -> Page {
515    let text = page_text(&blocks);
516    Page {
517        name: if title.trim().is_empty() {
518            None
519        } else {
520            Some(title.trim().to_string())
521        },
522        blocks,
523        text,
524        ..Default::default()
525    }
526}
527
528// ── 能力 / 插件 ─────────────────────────────────────────────────────
529
530/// 原始读取:把 spine 顺序的 XHTML 原文放进 `RawPage.items[0]`,书籍元数据进 `RawDoc.meta`。
531pub struct EpubRawBackend;
532
533impl ParseCapability for EpubRawBackend {
534    fn id(&self) -> &str {
535        "epub.read"
536    }
537    fn formats(&self) -> &[&str] {
538        &["epub"]
539    }
540    fn parse(&self, path: &str) -> Result<RawDoc> {
541        let mut zip = open_zip(path)?;
542
543        let container = read_entry(&mut zip, "META-INF/container.xml")?
544            .ok_or_else(|| DocError::corrupt("EPUB 缺 META-INF/container.xml"))?;
545        let opf_path = find_rootfile(&container)?
546            .ok_or_else(|| DocError::corrupt("container.xml 缺 rootfile"))?;
547        let opf_xml = read_entry(&mut zip, &opf_path)?
548            .ok_or_else(|| DocError::corrupt(format!("EPUB 缺 OPF: {opf_path}")))?;
549        let opf = parse_opf(&opf_xml)?;
550        let opf_dir = opf_path
551            .rsplit_once('/')
552            .map(|(d, _)| d.to_string())
553            .unwrap_or_default();
554
555        // 章节标题:EPUB3 nav 优先,其次 NCX
556        let mut titles = BTreeMap::new();
557        if let Some(nav) = opf
558            .items
559            .iter()
560            .find(|it| it.props.split_whitespace().any(|p| p == "nav"))
561            .map(|it| resolve(&opf_dir, &it.href))
562        {
563            if let Some(xml) = read_entry(&mut zip, &nav)? {
564                titles = nav_titles(&xml);
565            }
566        }
567        if titles.is_empty() {
568            let ncx_path = opf
569                .toc
570                .as_ref()
571                .and_then(|id| {
572                    opf.items
573                        .iter()
574                        .find(|it| &it.id == id)
575                        .map(|it| resolve(&opf_dir, &it.href))
576                })
577                .or_else(|| {
578                    opf.items
579                        .iter()
580                        .find(|it| it.href.ends_with(".ncx"))
581                        .map(|it| resolve(&opf_dir, &it.href))
582                });
583            if let Some(p) = ncx_path {
584                if let Some(xml) = read_entry(&mut zip, &p)? {
585                    titles = ncx_titles(&xml);
586                }
587            }
588        }
589
590        // 封面:properties="cover-image" 优先,其次 <meta name="cover">
591        let cover = opf
592            .items
593            .iter()
594            .find(|it| it.props.split_whitespace().any(|p| p == "cover-image"))
595            .map(|it| resolve(&opf_dir, &it.href))
596            .or_else(|| {
597                opf.cover_id.as_ref().and_then(|id| {
598                    opf.items
599                        .iter()
600                        .find(|it| &it.id == id)
601                        .map(|it| resolve(&opf_dir, &it.href))
602                })
603            });
604
605        let mut pages = Vec::new();
606        for idref in &opf.spine {
607            let Some(item) = opf.items.iter().find(|it| &it.id == idref) else {
608                continue;
609            };
610            let full = resolve(&opf_dir, &item.href);
611            if full.ends_with(".ncx") {
612                continue;
613            }
614            let Some(xhtml) = read_entry(&mut zip, &full)? else {
615                continue;
616            };
617            let title = titles
618                .get(&item.href)
619                .or_else(|| titles.get(&full))
620                .cloned()
621                .unwrap_or_default();
622            pages.push(RawPage {
623                name: if title.is_empty() { None } else { Some(title) },
624                items: vec![RawTextItem {
625                    text: xhtml,
626                    bbox: None,
627                }],
628                ..Default::default()
629            });
630        }
631
632        let mut meta: std::collections::HashMap<String, String> = std::collections::HashMap::new();
633        if !opf.title.is_empty() {
634            meta.insert("epub.title".to_string(), opf.title.clone());
635        }
636        if !opf.authors.is_empty() {
637            meta.insert("epub.authors".to_string(), opf.authors.join("; "));
638        }
639        if !opf.language.is_empty() {
640            meta.insert("epub.language".to_string(), opf.language.clone());
641        }
642        if !opf.publisher.is_empty() {
643            meta.insert("epub.publisher".to_string(), opf.publisher.clone());
644        }
645        let year = year_of(&opf.date);
646        if !year.is_empty() {
647            meta.insert("epub.year".to_string(), year);
648        }
649        if let Some(c) = &cover {
650            meta.insert("epub.cover_href".to_string(), c.clone());
651        }
652
653        Ok(RawDoc {
654            pages,
655            meta,
656            needs_ocr: false,
657        })
658    }
659}
660
661/// EPUB 插件:XHTML → 结构化块 → 按标题分章。
662pub struct EpubPlugin;
663
664impl FormatPlugin for EpubPlugin {
665    fn id(&self) -> &str {
666        "epub"
667    }
668    fn formats(&self) -> &[&str] {
669        &["epub"]
670    }
671    fn default_parse_chain(&self) -> Vec<&'static str> {
672        vec!["epub.read"]
673    }
674    fn default_process_chain(&self) -> Vec<&'static str> {
675        vec![]
676    }
677    fn assemble(&self, raw: &RawDoc) -> Document {
678        let mut pages: Vec<Page> = Vec::new();
679
680        for (idx, rp) in raw.pages.iter().enumerate() {
681            let Some(xhtml) = rp.items.first().map(|i| i.text.as_str()) else {
682                continue;
683            };
684            let blocks = parse_blocks(xhtml);
685            if blocks.iter().all(|b| b.content.is_empty()) {
686                continue; // 纯图/空白页(封面页等)不成为章
687            }
688
689            let secs = sections(&blocks);
690            if secs.len() > 1 {
691                for (title, idxs) in secs {
692                    let sec_blocks: Vec<Block> = idxs.iter().map(|&i| blocks[i].clone()).collect();
693                    pages.push(build_page(&title, sec_blocks));
694                }
695                continue;
696            }
697
698            // 单节:保留完整块(含首个标题),标题回落到 toc → 文档内首个标题 → 第 N 章
699            let first_heading = blocks
700                .iter()
701                .find(|b| b.kind == BlockKind::Heading && !b.content.trim().is_empty())
702                .map(|b| b.content.trim().to_string());
703            let title = rp
704                .name
705                .clone()
706                .filter(|s| !s.trim().is_empty())
707                .or(first_heading)
708                .unwrap_or_else(|| format!("第{}章", idx + 1));
709            pages.push(build_page(&title, blocks));
710        }
711
712        let mut doc = Document {
713            pages,
714            ..Default::default()
715        };
716        for key in [
717            "epub.title",
718            "epub.authors",
719            "epub.language",
720            "epub.publisher",
721            "epub.year",
722            "epub.cover_href",
723        ] {
724            if let Some(v) = raw.meta.get(key) {
725                doc.meta.extra.insert(key.to_string(), v.clone());
726            }
727        }
728        doc
729    }
730}
731
732/// 注册 EPUB 后端与插件。
733pub fn register_defaults(reg: &mut Registry) {
734    let _ = reg.register_parse(Arc::new(EpubRawBackend), None);
735    reg.register_plugin(Arc::new(EpubPlugin));
736}
737
738// ── 便捷 API ────────────────────────────────────────────────────────
739
740/// 一次读完:元数据 + 分章(含结构化块)。
741pub fn read_epub(path: &str) -> Result<EpubBook> {
742    let raw = EpubRawBackend.parse(path)?;
743    let doc = EpubPlugin.assemble(&raw);
744    let get = |k: &str| raw.meta.get(k).cloned().unwrap_or_default();
745    let meta = EpubMeta {
746        title: get("epub.title"),
747        authors: raw
748            .meta
749            .get("epub.authors")
750            .map(|a| a.split("; ").map(|s| s.to_string()).collect())
751            .unwrap_or_default(),
752        language: get("epub.language"),
753        publisher: get("epub.publisher"),
754        year: get("epub.year"),
755        cover_href: raw.meta.get("epub.cover_href").cloned(),
756    };
757    let chapters = doc
758        .pages
759        .iter()
760        .map(|p| EpubChapter {
761            title: p.name.clone().unwrap_or_default(),
762            blocks: p.blocks.clone(),
763            text: p.text.clone(),
764        })
765        .collect();
766    Ok(EpubBook { meta, chapters })
767}
768
769/// 只取封面路径与书名(不解析正文),供书库/缩略图场景使用。
770pub fn epub_cover_href(path: &str) -> (Option<String>, String) {
771    match EpubRawBackend.parse(path) {
772        Ok(raw) => (
773            raw.meta.get("epub.cover_href").cloned(),
774            raw.meta.get("epub.title").cloned().unwrap_or_default(),
775        ),
776        Err(_) => (None, String::new()),
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783    use std::io::Write;
784
785    const CONTAINER: &str = r#"<?xml version="1.0"?>
786<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
787  <rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
788</container>"#;
789
790    const OPF: &str = r#"<?xml version="1.0"?>
791<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
792  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
793    <dc:title>测试书</dc:title>
794    <dc:creator>作者甲</dc:creator>
795    <dc:creator>作者乙</dc:creator>
796    <dc:language>zh</dc:language>
797    <dc:publisher>某社</dc:publisher>
798    <dc:date>2021-05-01</dc:date>
799  </metadata>
800  <manifest>
801    <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
802    <item id="ch1" href="text/ch1.xhtml" media-type="application/xhtml+xml"/>
803    <item id="ch2" href="text/ch2.xhtml" media-type="application/xhtml+xml"/>
804    <item id="cover" href="img/cover.jpg" media-type="image/jpeg" properties="cover-image"/>
805  </manifest>
806  <spine>
807    <itemref idref="ch1"/>
808    <itemref idref="ch2"/>
809  </spine>
810</package>"#;
811
812    const NAV: &str = r#"<?xml version="1.0"?>
813<html xmlns="http://www.w3.org/1999/xhtml"><body>
814<nav epub:type="toc" xmlns:epub="http://www.idpf.org/2007/ops">
815  <ol>
816    <li><a href="text/ch1.xhtml">第一章</a></li>
817    <li><a href="text/ch2.xhtml#sec1">第二章</a></li>
818  </ol>
819</nav></body></html>"#;
820
821    const CH1: &str = r#"<html><body><h1>第一章</h1><p>正文一</p></body></html>"#;
822    /// 单文档内两个 h2:应切成两章。
823    const CH2: &str = r#"<html><body><h2>甲节</h2><p>甲正文</p><h2>乙节</h2><ul><li>要点</li></ul></body></html>"#;
824
825    fn write_epub(dir_name: &str, title_opts: Option<&str>) -> std::path::PathBuf {
826        let dir = std::env::temp_dir()
827            .join("docparser_epub_test")
828            .join(dir_name);
829        std::fs::create_dir_all(&dir).unwrap();
830        let path = dir.join("book.epub");
831        let f = std::fs::File::create(&path).unwrap();
832        let mut zw = zip::ZipWriter::new(f);
833        let opts = zip::write::SimpleFileOptions::default();
834        let mut put = |name: &str, body: &str| {
835            zw.start_file(name, opts).unwrap();
836            zw.write_all(body.as_bytes()).unwrap();
837        };
838        put("mimetype", "application/epub+zip");
839        put("META-INF/container.xml", CONTAINER);
840        put("OEBPS/content.opf", title_opts.unwrap_or(OPF));
841        put("OEBPS/nav.xhtml", NAV);
842        put("OEBPS/text/ch1.xhtml", CH1);
843        put("OEBPS/text/ch2.xhtml", CH2);
844        zw.finish().unwrap();
845        path
846    }
847
848    #[test]
849    fn reads_metadata_and_split_chapters() {
850        let p = write_epub("ok", None);
851        let book = read_epub(p.to_str().unwrap()).unwrap();
852        std::fs::remove_file(&p).ok();
853
854        assert_eq!(book.meta.title, "测试书");
855        assert_eq!(book.meta.authors, vec!["作者甲", "作者乙"]);
856        assert_eq!(book.meta.language, "zh");
857        assert_eq!(book.meta.publisher, "某社");
858        assert_eq!(book.meta.year, "2021");
859        assert_eq!(book.meta.cover_href.as_deref(), Some("OEBPS/img/cover.jpg"));
860
861        // ch1 单节(保留 h1 块),ch2 按 h2 切成两节
862        let titles: Vec<&str> = book.chapters.iter().map(|c| c.title.as_str()).collect();
863        assert_eq!(titles, vec!["第一章", "甲节", "乙节"]);
864        assert_eq!(book.chapters[0].blocks[0].kind, BlockKind::Heading);
865        assert_eq!(book.chapters[1].text, "甲正文");
866        assert_eq!(book.chapters[2].blocks[0].kind, BlockKind::List);
867    }
868
869    #[test]
870    fn engine_route_and_meta_extra() {
871        let p = write_epub("engine", None);
872        let mut reg = Registry::new();
873        docparser_core::plugins::text::register_defaults(&mut reg);
874        register_defaults(&mut reg);
875        let doc = docparser_core::DocumentEngine::new(reg)
876            .parse(p.to_str().unwrap())
877            .unwrap();
878        std::fs::remove_file(&p).ok();
879
880        assert_eq!(doc.meta.file_type, "epub");
881        assert_eq!(doc.meta.page_count, 3);
882        assert_eq!(
883            doc.meta.extra.get("epub.title").map(|s| s.as_str()),
884            Some("测试书")
885        );
886        assert!(doc.pages.iter().all(|p| p.name.is_some()));
887    }
888
889    #[test]
890    fn broken_container_is_corrupt() {
891        let dir = std::env::temp_dir()
892            .join("docparser_epub_test")
893            .join("broken");
894        std::fs::create_dir_all(&dir).unwrap();
895        let path = dir.join("bad.epub");
896        let f = std::fs::File::create(&path).unwrap();
897        let mut zw = zip::ZipWriter::new(f);
898        let opts = zip::write::SimpleFileOptions::default();
899        zw.start_file("mimetype", opts).unwrap();
900        zw.write_all(b"application/epub+zip").unwrap();
901        zw.finish().unwrap();
902
903        let err = read_epub(path.to_str().unwrap()).unwrap_err();
904        std::fs::remove_file(&path).ok();
905        assert_eq!(err.code.as_str(), "corrupt");
906        assert!(err.message.contains("container.xml"));
907    }
908
909    #[test]
910    fn non_zip_is_corrupt() {
911        let dir = std::env::temp_dir()
912            .join("docparser_epub_test")
913            .join("notzip");
914        std::fs::create_dir_all(&dir).unwrap();
915        let path = dir.join("x.epub");
916        std::fs::write(&path, b"not a zip").unwrap();
917        let err = read_epub(path.to_str().unwrap()).unwrap_err();
918        std::fs::remove_file(&path).ok();
919        assert_eq!(err.code.as_str(), "corrupt");
920    }
921
922    #[test]
923    fn resolve_normalizes_dot_segments() {
924        assert_eq!(resolve("OEBPS", "text/ch1.xhtml"), "OEBPS/text/ch1.xhtml");
925        assert_eq!(resolve("OEBPS/text", "../img/a.png"), "OEBPS/img/a.png");
926        assert_eq!(resolve("", "ch1.xhtml#frag"), "ch1.xhtml");
927    }
928
929    #[test]
930    fn ncx_titles_pair_label_and_src() {
931        let ncx = r#"<ncx><navMap>
932            <navPoint id="n1"><navLabel><text>卷首</text></navLabel><content src="ch1.xhtml#top"/></navPoint>
933            <navPoint id="n2"><navLabel><text>卷尾</text></navLabel><content src="ch2.xhtml"/></navPoint>
934        </navMap></ncx>"#;
935        let m = ncx_titles(ncx);
936        assert_eq!(m.get("ch1.xhtml").map(|s| s.as_str()), Some("卷首"));
937        assert_eq!(m.get("ch2.xhtml").map(|s| s.as_str()), Some("卷尾"));
938    }
939}