1use 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
29pub const BIG_SECTION_CHARS: usize = 8000;
31const SECTION_LEVELS: [u8; 3] = [2, 3, 4];
32
33#[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 pub year: String,
42 pub cover_href: Option<String>,
44}
45
46#[derive(Debug, Clone, Default, PartialEq)]
48pub struct EpubChapter {
49 pub title: String,
50 pub blocks: Vec<Block>,
51 pub text: String,
53}
54
55#[derive(Debug, Clone, Default)]
57pub struct EpubBook {
58 pub meta: EpubMeta,
59 pub chapters: Vec<EpubChapter>,
60}
61
62fn 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
84fn 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
97fn 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#[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
131fn 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
163fn 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
301fn 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
350fn 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
405fn 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 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
458fn 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
484fn 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
528pub 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 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 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
661pub 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; }
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 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
732pub fn register_defaults(reg: &mut Registry) {
734 let _ = reg.register_parse(Arc::new(EpubRawBackend), None);
735 reg.register_plugin(Arc::new(EpubPlugin));
736}
737
738pub 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
769pub 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 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 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}