1use std::io::{Seek, Write};
22
23use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
24
25use crate::{
26 annotation::MapArea,
27 djvu_document::{DjVuBookmark, DjVuDocument, DjVuPage, DocError},
28 djvu_render::{RenderError, RenderOptions},
29 export_control::{ExportObserver, NoOpObserver},
30};
31
32#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum EpubError {
38 #[error("document error: {0}")]
40 Doc(#[from] DocError),
41 #[error("render error: {0}")]
43 Render(#[from] RenderError),
44 #[error("zip error: {0}")]
46 Zip(#[from] zip::result::ZipError),
47 #[error("io error: {0}")]
49 Io(#[from] std::io::Error),
50 #[error("export cancelled")]
52 Cancelled,
53}
54
55#[derive(Debug, Clone)]
59pub struct EpubOptions {
60 pub title: String,
62 pub author: String,
64 pub dpi: u32,
66 pub language: String,
68 pub modified: Option<String>,
71 pub reflowable_text: bool,
81 pub jpeg_quality: Option<u8>,
85 pub adaptive: bool,
89}
90
91impl Default for EpubOptions {
92 fn default() -> Self {
93 Self {
94 title: "DjVu Document".to_owned(),
95 author: String::new(),
96 dpi: 150,
97 language: "en".to_owned(),
98 modified: None,
99 reflowable_text: false,
100 jpeg_quality: None,
101 adaptive: false,
102 }
103 }
104}
105
106pub fn djvu_to_epub(doc: &DjVuDocument, opts: &EpubOptions) -> Result<Vec<u8>, EpubError> {
116 let mut cursor = std::io::Cursor::new(Vec::new());
117 djvu_to_epub_writer(doc, opts, &mut cursor)?;
118 Ok(cursor.into_inner())
119}
120
121pub fn djvu_to_epub_writer<W: Write + Seek>(
135 doc: &DjVuDocument,
136 opts: &EpubOptions,
137 sink: W,
138) -> Result<(), EpubError> {
139 let mut observer = NoOpObserver;
140 djvu_to_epub_writer_with_observer(doc, opts, sink, &mut observer)
141}
142
143pub fn djvu_to_epub_writer_with_observer<W: Write + Seek>(
154 doc: &DjVuDocument,
155 opts: &EpubOptions,
156 sink: W,
157 observer: &mut dyn ExportObserver,
158) -> Result<(), EpubError> {
159 let mut zip = ZipWriter::new(sink);
160
161 zip.start_file(
163 "mimetype",
164 SimpleFileOptions::default().compression_method(CompressionMethod::Stored),
165 )?;
166 zip.write_all(b"application/epub+zip")?;
167
168 zip.start_file(
170 "META-INF/container.xml",
171 SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
172 )?;
173 zip.write_all(CONTAINER_XML.as_bytes())?;
174
175 let page_count = doc.page_count();
182
183 let mut image_names: Vec<String> = Vec::with_capacity(page_count);
184 #[cfg(feature = "parallel")]
185 {
186 use rayon::prelude::*;
187 let chunk = rayon::current_num_threads().max(1) * 8;
188 let mut start = 0;
189 while start < page_count {
190 if observer.cancelled() {
191 return finish_cancelled_epub(zip);
192 }
193 let end = (start + chunk).min(page_count);
194 let artifacts: Vec<PageArtifacts> = (start..end)
195 .into_par_iter()
196 .map(|i| {
197 let page = doc.page(i)?.clone();
199 build_page_artifacts(&page, i, opts)
200 })
201 .collect::<Result<_, EpubError>>()?;
202 for (offset, art) in artifacts.iter().enumerate() {
203 if observer.cancelled() {
204 return finish_cancelled_epub(zip);
205 }
206 write_page_artifacts(&mut zip, art)?;
207 image_names.push(art.img_name.clone());
208 observer.on_progress(start + offset + 1, page_count);
209 }
210 start = end;
211 }
212 }
213
214 #[cfg(not(feature = "parallel"))]
215 for i in 0..page_count {
216 if observer.cancelled() {
217 return finish_cancelled_epub(zip);
218 }
219 let page = doc.page(i)?.clone();
221 let art = build_page_artifacts(&page, i, opts)?;
222 write_page_artifacts(&mut zip, &art)?;
223 image_names.push(art.img_name.clone());
224 observer.on_progress(i + 1, page_count);
225 }
226
227 let nav_xhtml = build_nav(doc.bookmarks(), page_count);
229 zip.start_file(
230 "OEBPS/nav.xhtml",
231 SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
232 )?;
233 zip.write_all(nav_xhtml.as_bytes())?;
234
235 let opf = build_opf(opts, page_count, &image_names);
237 zip.start_file(
238 "OEBPS/content.opf",
239 SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
240 )?;
241 zip.write_all(opf.as_bytes())?;
242
243 zip.finish()?;
244 Ok(())
245}
246
247fn finish_cancelled_epub<W: Write + Seek>(zip: ZipWriter<W>) -> Result<(), EpubError> {
250 zip.finish()?;
251 Err(EpubError::Cancelled)
252}
253
254struct PageArtifacts {
261 img_path: String,
262 img_name: String,
264 png_bytes: Vec<u8>,
265 xhtml_path: String,
266 xhtml_bytes: Vec<u8>,
267}
268
269fn write_page_artifacts<W: Write + Seek>(
272 zip: &mut ZipWriter<W>,
273 art: &PageArtifacts,
274) -> Result<(), EpubError> {
275 zip.start_file(
276 &art.img_path,
277 SimpleFileOptions::default().compression_method(CompressionMethod::Stored),
278 )?;
279 zip.write_all(&art.png_bytes)?;
280
281 zip.start_file(
282 &art.xhtml_path,
283 SimpleFileOptions::default().compression_method(CompressionMethod::Deflated),
284 )?;
285 zip.write_all(&art.xhtml_bytes)?;
286 Ok(())
287}
288
289fn build_page_artifacts(
290 page: &DjVuPage,
291 index: usize,
292 opts: &EpubOptions,
293) -> Result<PageArtifacts, EpubError> {
294 let pw = page.width() as u32;
296 let ph = page.height() as u32;
297
298 let (w, h) = crate::export_common::size_at_dpi(page, opts.dpi as f32);
301
302 let render_opts = RenderOptions {
303 width: w,
304 height: h,
305 ..RenderOptions::default()
306 };
307 let mut rgba = Vec::with_capacity(w as usize * h as usize * 4);
310 crate::export_common::render_rows_or_pixmap(page, &render_opts, |row| {
311 rgba.extend_from_slice(row);
312 })?;
313
314 let gray = rgba
319 .as_chunks::<4>()
320 .0
321 .iter()
322 .all(|px| px[0] == px[1] && px[1] == px[2]);
323 let make_png = || encode_rgba_to_png(&rgba, w, h, gray);
324 let make_jpeg = |q: u8| encode_rgba_to_jpeg(&rgba, w, h, q, gray);
325 let (img_bytes, is_jpeg) = match (opts.jpeg_quality, opts.adaptive) {
326 (None, _) => (make_png(), false),
327 (Some(q), false) => {
328 let j = make_jpeg(q);
329 if j.is_empty() {
330 (make_png(), false)
331 } else {
332 (j, true)
333 }
334 }
335 (Some(q), true) => {
336 let p = make_png();
337 let j = make_jpeg(q);
338 if !j.is_empty() && j.len() < p.len() {
339 (j, true)
340 } else {
341 (p, false)
342 }
343 }
344 };
345 let png_bytes = img_bytes;
346
347 let page_num = index + 1;
348 let ext = if is_jpeg { "jpg" } else { "png" };
349 let img_name = format!("page_{page_num:04}.{ext}");
350 let img_path = format!("OEBPS/images/{img_name}");
351
352 let text_overlay = build_text_overlay(page, pw, ph);
354
355 let hyperlinks = page.hyperlinks().unwrap_or_default();
357
358 let reflowable: Vec<String> = if opts.reflowable_text {
361 page.text_layer()
362 .ok()
363 .flatten()
364 .map(|tl| {
365 tl.reflowable_text()
366 .into_iter()
367 .map(|p| p.text)
368 .collect::<Vec<_>>()
369 })
370 .unwrap_or_default()
371 } else {
372 Vec::new()
373 };
374
375 let xhtml = build_page_xhtml(
377 &img_name,
378 w,
379 h,
380 pw,
381 ph,
382 &text_overlay,
383 &hyperlinks,
384 &reflowable,
385 );
386 let xhtml_path = format!("OEBPS/pages/page_{page_num:04}.xhtml");
387
388 Ok(PageArtifacts {
389 img_path,
390 img_name,
391 png_bytes,
392 xhtml_path,
393 xhtml_bytes: xhtml.into_bytes(),
394 })
395}
396
397fn encode_rgba_to_png(rgba: &[u8], width: u32, height: u32, gray: bool) -> Vec<u8> {
400 let data: Vec<u8> = if gray {
405 rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect()
406 } else {
407 rgba_to_rgb(rgba)
408 };
409 let mut buf = Vec::new();
410 {
411 let mut enc = png::Encoder::new(std::io::Cursor::new(&mut buf), width, height);
412 enc.set_color(if gray {
413 png::ColorType::Grayscale
414 } else {
415 png::ColorType::Rgb
416 });
417 enc.set_depth(png::BitDepth::Eight);
418 if let Ok(mut writer) = enc.write_header() {
419 let _ = writer.write_image_data(&data);
420 }
421 }
422 buf
423}
424
425fn encode_rgba_to_jpeg(rgba: &[u8], width: u32, height: u32, quality: u8, gray: bool) -> Vec<u8> {
428 use jpeg_encoder::{ColorType, Encoder};
429 let mut out = Vec::new();
430 let (data, ct): (Vec<u8>, ColorType) = if gray {
431 (
432 rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(),
433 ColorType::Luma,
434 )
435 } else {
436 (rgba_to_rgb(rgba), ColorType::Rgb)
437 };
438 let enc = Encoder::new(&mut out, quality);
439 if enc.encode(&data, width as u16, height as u16, ct).is_err() {
440 return Vec::new();
441 }
442 out
443}
444
445fn rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
447 let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
448 for px in rgba.as_chunks::<4>().0 {
449 rgb.extend_from_slice(&px[..3]);
450 }
451 rgb
452}
453
454fn build_text_overlay(page: &DjVuPage, pw: u32, ph: u32) -> Vec<(f32, f32, f32, f32, String)> {
461 let text_layer = match page.text_layer() {
462 Ok(Some(tl)) => tl,
463 _ => return Vec::new(),
464 };
465
466 let mut spans = Vec::new();
467
468 for span in crate::export_common::word_spans(&text_layer) {
472 let r = span.rect;
473 let x = r.x as f32 / pw as f32 * 100.0;
474 let y = crate::export_common::flip_y_bottom(ph, r.y, r.height) as f32 / ph as f32 * 100.0;
475 let w = r.width as f32 / pw as f32 * 100.0;
476 let h = r.height as f32 / ph as f32 * 100.0;
477 if w > 0.0 && h > 0.0 {
478 spans.push((x, y, w, h, xml_escape(span.text)));
479 }
480 }
481
482 spans
483}
484
485#[allow(clippy::too_many_arguments)]
488fn build_page_xhtml(
489 img_name: &str,
490 w: u32,
491 h: u32,
492 pw: u32,
493 ph: u32,
494 text_overlay: &[(f32, f32, f32, f32, String)],
495 hyperlinks: &[MapArea],
496 reflowable: &[String],
497) -> String {
498 let mut html = String::new();
499 html.push_str(
500 r#"<?xml version="1.0" encoding="UTF-8"?>
501<!DOCTYPE html>
502<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
503<head>
504<meta charset="UTF-8"/>
505<title>Page</title>
506<style>
507body { margin: 0; padding: 0; }
508.djvu-page { position: relative; display: block; }
509.djvu-page img { display: block; width: 100%; height: auto; }
510.djvu-text {
511 position: absolute;
512 color: transparent;
513 background: transparent;
514 white-space: pre;
515 overflow: hidden;
516 pointer-events: none;
517}
518.djvu-link {
519 position: absolute;
520 display: block;
521}
522</style>
523</head>
524<body>
525"#,
526 );
527
528 html.push_str(&format!(
529 r#"<div class="djvu-page" style="width:{w}px; height:{h}px;">"#
530 ));
531 html.push_str(&format!(
532 r#"<img src="../images/{img_name}" alt="page" width="{w}" height="{h}"/>"#
533 ));
534
535 for (x, y, ww, hh, text) in text_overlay {
536 html.push_str(&format!(
537 r#"<span class="djvu-text" aria-hidden="true" style="left:{x:.3}%;top:{y:.3}%;width:{ww:.3}%;height:{hh:.3}%;">{text}</span>"#
538 ));
539 }
540
541 for ma in hyperlinks {
542 if let Some((x, y, ww, hh)) = map_area_to_css(ma, pw, ph) {
543 let href = resolve_link_href(&ma.url);
544 let title = xml_escape(&ma.description);
545 html.push_str(&format!(
546 r#"<a class="djvu-link" href="{href}" title="{title}" style="left:{x:.3}%;top:{y:.3}%;width:{ww:.3}%;height:{hh:.3}%;"></a>"#
547 ));
548 }
549 }
550
551 html.push_str("</div>\n");
552
553 if !reflowable.is_empty() {
554 html.push_str(r#"<section class="djvu-reflowable">"#);
555 html.push('\n');
556 for para in reflowable {
557 html.push_str(" <p>");
558 html.push_str(&xml_escape(para));
559 html.push_str("</p>\n");
560 }
561 html.push_str("</section>\n");
562 }
563
564 html.push_str("</body>\n</html>\n");
565 html
566}
567
568fn map_area_to_css(ma: &MapArea, pw: u32, ph: u32) -> Option<(f32, f32, f32, f32)> {
576 if pw == 0 || ph == 0 {
577 return None;
578 }
579 let rect = crate::export_common::shape_bbox(&ma.shape)?;
580 let x = (rect.x as f32 / pw as f32) * 100.0;
581 let y =
582 (crate::export_common::flip_y_bottom(ph, rect.y, rect.height) as f32 / ph as f32) * 100.0;
583 let ww = (rect.width as f32 / pw as f32) * 100.0;
584 let hh = (rect.height as f32 / ph as f32) * 100.0;
585 Some((x, y, ww, hh))
586}
587
588fn resolve_link_href(url: &str) -> String {
590 bookmark_href(url)
591}
592
593fn build_opf(opts: &EpubOptions, page_count: usize, image_names: &[String]) -> String {
596 let title = xml_escape(&opts.title);
597 let author = xml_escape(&opts.author);
598 let language = xml_escape(&opts.language);
599 let modified = opts
600 .modified
601 .as_deref()
602 .map(str::to_owned)
603 .unwrap_or_else(current_timestamp);
604
605 let mut manifest_items = String::new();
606 let mut spine_items = String::new();
607
608 manifest_items.push_str(
610 r#" <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
611"#,
612 );
613
614 let media_type = |name: &str| {
615 if name.ends_with(".jpg") {
616 "image/jpeg"
617 } else {
618 "image/png"
619 }
620 };
621 let img = |i: usize| -> String {
622 image_names
623 .get(i - 1)
624 .cloned()
625 .unwrap_or_else(|| format!("page_{i:04}.png"))
626 };
627
628 if page_count > 0 {
630 let name = img(1);
631 manifest_items.push_str(&format!(
632 " <item id=\"cover-image\" href=\"images/{name}\" media-type=\"{}\" properties=\"cover-image\"/>\n",
633 media_type(&name)
634 ));
635 }
636
637 for i in 1..=page_count {
638 let pid = format!("page_{i:04}");
639 if i > 1 {
641 let name = img(i);
642 manifest_items.push_str(&format!(
643 " <item id=\"img_{pid}\" href=\"images/{name}\" media-type=\"{}\"/>\n",
644 media_type(&name)
645 ));
646 }
647 manifest_items.push_str(&format!(
648 " <item id=\"{pid}\" href=\"pages/page_{i:04}.xhtml\" media-type=\"application/xhtml+xml\"/>\n"
649 ));
650 spine_items.push_str(&format!(" <itemref idref=\"{pid}\"/>\n"));
651 }
652
653 format!(
654 r#"<?xml version="1.0" encoding="UTF-8"?>
655<package xmlns="http://www.idpf.org/2007/opf" version="3.0" epub:type="book"
656 xmlns:epub="http://www.idpf.org/2007/ops" unique-identifier="uid">
657 <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
658 <dc:title>{title}</dc:title>
659 <dc:creator>{author}</dc:creator>
660 <dc:language>{language}</dc:language>
661 <dc:identifier id="uid">djvu-rs-export</dc:identifier>
662 <meta property="dcterms:modified">{modified}</meta>
663 </metadata>
664 <manifest>
665{manifest_items} </manifest>
666 <spine>
667{spine_items} </spine>
668</package>
669"#
670 )
671}
672
673fn current_timestamp() -> String {
677 use std::time::{SystemTime, UNIX_EPOCH};
678 let secs = SystemTime::now()
679 .duration_since(UNIX_EPOCH)
680 .map(|d| d.as_secs())
681 .unwrap_or(0);
682
683 let (y, mo, d, hh, mm, ss) = unix_secs_to_parts(secs);
685 format!("{y:04}-{mo:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
686}
687
688fn unix_secs_to_parts(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
691 let ss = (secs % 60) as u32;
692 let mins = secs / 60;
693 let mm = (mins % 60) as u32;
694 let hours = mins / 60;
695 let hh = (hours % 24) as u32;
696 let days = (hours / 24) as u32;
697
698 let z = days + 719468;
700 let era = z / 146097;
701 let doe = z - era * 146097;
702 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
703 let y = yoe + era * 400;
704 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
705 let mp = (5 * doy + 2) / 153;
706 let d = doy - (153 * mp + 2) / 5 + 1;
707 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
708 let y = if mo <= 2 { y + 1 } else { y };
709 (y, mo, d, hh, mm, ss)
710}
711
712fn build_nav(bookmarks: &[DjVuBookmark], page_count: usize) -> String {
715 let toc_items = if bookmarks.is_empty() {
716 build_default_nav_items(page_count)
717 } else {
718 build_bookmark_nav_items(bookmarks)
719 };
720
721 format!(
722 r#"<?xml version="1.0" encoding="UTF-8"?>
723<!DOCTYPE html>
724<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
725<head><meta charset="UTF-8"/><title>Navigation</title></head>
726<body>
727<nav epub:type="toc" id="toc">
728 <h1>Contents</h1>
729 <ol>
730{toc_items} </ol>
731</nav>
732</body>
733</html>
734"#
735 )
736}
737
738fn build_default_nav_items(page_count: usize) -> String {
739 let mut s = String::new();
740 for i in 1..=page_count {
741 s.push_str(&format!(
742 " <li><a href=\"pages/page_{i:04}.xhtml\">Page {i}</a></li>\n"
743 ));
744 }
745 s
746}
747
748fn build_bookmark_nav_items(bookmarks: &[DjVuBookmark]) -> String {
749 let mut s = String::new();
750 for bm in bookmarks {
751 let title = xml_escape(&bm.title);
752 let href = bookmark_href(&bm.url);
753 s.push_str(&format!(" <li><a href=\"{href}\">{title}</a>"));
754 if !bm.children.is_empty() {
755 s.push_str("\n <ol>\n");
756 s.push_str(&build_bookmark_nav_items_inner(&bm.children, 2));
757 s.push_str(" </ol>");
758 }
759 s.push_str("</li>\n");
760 }
761 s
762}
763
764fn build_bookmark_nav_items_inner(bookmarks: &[DjVuBookmark], depth: usize) -> String {
765 let indent = " ".repeat(depth + 1);
766 let mut s = String::new();
767 for bm in bookmarks {
768 let title = xml_escape(&bm.title);
769 let href = bookmark_href(&bm.url);
770 s.push_str(&format!("{indent}<li><a href=\"{href}\">{title}</a>"));
771 if !bm.children.is_empty() {
772 s.push_str(&format!("\n{indent}<ol>\n"));
773 s.push_str(&build_bookmark_nav_items_inner(&bm.children, depth + 1));
774 s.push_str(&format!("{indent}</ol>"));
775 }
776 s.push_str("</li>\n");
777 }
778 s
779}
780
781fn bookmark_href(url: &str) -> String {
784 if let Some(idx) = crate::export_common::bookmark_page_index(url) {
787 let page_num = idx + 1;
788 return format!("pages/page_{page_num:04}.xhtml");
789 }
790 if url.starts_with('#') {
791 return format!("pages/page_0001.xhtml{}", xml_escape(url));
793 }
794 xml_escape(url)
796}
797
798fn xml_escape(s: &str) -> String {
801 s.replace('&', "&")
802 .replace('<', "<")
803 .replace('>', ">")
804 .replace('"', """)
805 .replace('\'', "'")
806}
807
808const CONTAINER_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
809<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
810 <rootfiles>
811 <rootfile full-path="OEBPS/content.opf"
812 media-type="application/oebps-package+xml"/>
813 </rootfiles>
814</container>
815"#;
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[derive(Default)]
822 struct RecordingObserver {
823 progress: Vec<(usize, usize)>,
824 cancel_after: Option<usize>,
825 }
826
827 impl ExportObserver for RecordingObserver {
828 fn on_progress(&mut self, done: usize, total: usize) {
829 self.progress.push((done, total));
830 }
831
832 fn cancelled(&self) -> bool {
833 self.cancel_after
834 .is_some_and(|after| self.progress.len() >= after)
835 }
836 }
837
838 fn load_doc(name: &str) -> DjVuDocument {
839 let data = std::fs::read(
840 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
841 .join("tests/fixtures")
842 .join(name),
843 )
844 .unwrap();
845 DjVuDocument::parse(&data).unwrap()
846 }
847
848 #[test]
849 fn epub_writer_observer_reports_each_page_in_order() {
850 let doc = load_doc("vega.djvu");
851 let total = doc.page_count();
852 let opts = EpubOptions {
853 modified: Some("2026-01-01T00:00:00Z".to_owned()),
854 ..EpubOptions::default()
855 };
856 let mut observer = RecordingObserver::default();
857
858 djvu_to_epub_writer_with_observer(
859 &doc,
860 &opts,
861 std::io::Cursor::new(Vec::new()),
862 &mut observer,
863 )
864 .expect("observer export must succeed");
865
866 assert_eq!(
867 observer.progress,
868 (1..=total).map(|done| (done, total)).collect::<Vec<_>>()
869 );
870 }
871
872 #[test]
873 fn epub_writer_cancellation_leaves_only_completed_pages() {
874 let doc = load_doc("vega.djvu");
875 assert!(doc.page_count() > 1, "fixture must contain multiple pages");
876 let opts = EpubOptions {
877 modified: Some("2026-01-01T00:00:00Z".to_owned()),
878 ..EpubOptions::default()
879 };
880 let mut observer = RecordingObserver {
881 cancel_after: Some(1),
882 ..RecordingObserver::default()
883 };
884 let mut cursor = std::io::Cursor::new(Vec::new());
885
886 let error = djvu_to_epub_writer_with_observer(&doc, &opts, &mut cursor, &mut observer)
887 .expect_err("observer must cancel the export");
888 assert!(matches!(error, EpubError::Cancelled));
889 assert_eq!(observer.progress.len(), 1);
890
891 let archive = zip::ZipArchive::new(std::io::Cursor::new(cursor.into_inner()))
892 .expect("partial archive must remain readable");
893 let page_images = archive
894 .file_names()
895 .filter(|name| name.starts_with("OEBPS/images/"))
896 .count();
897 assert!(page_images <= 1, "no additional page may be written");
898 }
899
900 #[test]
901 fn epub_default_writer_delegates_to_noop_observer() {
902 let doc = load_doc("vega.djvu");
903 let opts = EpubOptions {
904 modified: Some("2026-01-01T00:00:00Z".to_owned()),
905 ..EpubOptions::default()
906 };
907
908 let mut default_cursor = std::io::Cursor::new(Vec::new());
909 djvu_to_epub_writer(&doc, &opts, &mut default_cursor).unwrap();
910
911 let mut observed_cursor = std::io::Cursor::new(Vec::new());
912 let mut observer = NoOpObserver;
913 djvu_to_epub_writer_with_observer(&doc, &opts, &mut observed_cursor, &mut observer)
914 .unwrap();
915
916 assert_eq!(observed_cursor.into_inner(), default_cursor.into_inner());
917 }
918
919 #[test]
920 fn epub_writer_failing_sink_returns_io_error() {
921 let doc = load_doc("chicken.djvu");
922 let opts = EpubOptions {
923 modified: Some("2026-01-01T00:00:00Z".to_owned()),
924 ..EpubOptions::default()
925 };
926
927 let error = djvu_to_epub_writer(
928 &doc,
929 &opts,
930 crate::export_test_support::FailingWriter::after(2),
931 )
932 .expect_err("injected sink failure must be returned");
933
934 assert!(
935 matches!(
936 error,
937 EpubError::Io(ref error) if error.kind() == std::io::ErrorKind::Other
938 ) || matches!(error, EpubError::Zip(zip::result::ZipError::Io(_)))
939 );
940 }
941
942 #[test]
943 fn xml_escape_basic() {
944 assert_eq!(
945 xml_escape("a&b<c>d\"e'f"),
946 "a&b<c>d"e'f"
947 );
948 }
949
950 #[test]
951 fn bookmark_href_page_number() {
952 assert_eq!(bookmark_href("#page=3"), "pages/page_0003.xhtml");
953 assert_eq!(bookmark_href("#page=1"), "pages/page_0001.xhtml");
954 }
955
956 #[test]
957 fn bookmark_href_external() {
958 assert_eq!(bookmark_href("https://example.com"), "https://example.com");
959 }
960
961 #[test]
962 fn nav_has_toc_for_empty_bookmarks() {
963 let nav = build_nav(&[], 2);
964 assert!(nav.contains("epub:type=\"toc\""));
965 assert!(nav.contains("page_0001.xhtml"));
966 assert!(nav.contains("page_0002.xhtml"));
967 }
968
969 #[test]
970 fn current_timestamp_looks_like_iso8601() {
971 let ts = current_timestamp();
972 assert_eq!(ts.len(), 20);
974 assert!(ts.ends_with('Z'));
975 assert_eq!(&ts[4..5], "-");
976 assert_eq!(&ts[7..8], "-");
977 assert_eq!(&ts[10..11], "T");
978 }
979
980 #[test]
981 fn unix_secs_epoch() {
982 let (y, mo, d, hh, mm, ss) = unix_secs_to_parts(0);
983 assert_eq!((y, mo, d, hh, mm, ss), (1970, 1, 1, 0, 0, 0));
984 }
985
986 #[test]
987 fn unix_secs_known_date() {
988 let (y, mo, d, hh, mm, ss) = unix_secs_to_parts(1_776_124_800);
990 assert_eq!((y, mo, d, hh, mm, ss), (2026, 4, 14, 0, 0, 0));
991 }
992
993 #[test]
994 fn epub_options_default_language_is_en() {
995 assert_eq!(EpubOptions::default().language, "en");
996 }
997
998 #[test]
999 fn epub_options_default_modified_is_none() {
1000 assert!(EpubOptions::default().modified.is_none());
1001 }
1002
1003 #[test]
1004 fn epub_options_default_reflowable_text_is_off() {
1005 assert!(!EpubOptions::default().reflowable_text);
1006 }
1007
1008 #[test]
1009 fn build_page_xhtml_omits_reflowable_when_empty() {
1010 let html = build_page_xhtml("p_0001.png", 800, 1000, 800, 1000, &[], &[], &[]);
1011 assert!(!html.contains("djvu-reflowable"));
1012 }
1013
1014 #[test]
1015 fn build_page_xhtml_emits_reflowable_paragraphs() {
1016 let paras = vec!["First paragraph.".to_string(), "Second & last.".to_string()];
1017 let html = build_page_xhtml("p_0001.png", 800, 1000, 800, 1000, &[], &[], ¶s);
1018 assert!(html.contains(r#"<section class="djvu-reflowable">"#));
1019 assert!(html.contains("<p>First paragraph.</p>"));
1020 assert!(html.contains("<p>Second & last.</p>"));
1022 }
1023
1024 #[test]
1025 fn opf_contains_cover_image_for_nonempty_doc() {
1026 let opf = build_opf(&EpubOptions::default(), 3, &[]);
1027 assert!(opf.contains("cover-image"));
1028 assert!(opf.contains("properties=\"cover-image\""));
1029 }
1030
1031 #[test]
1032 fn opf_no_cover_image_for_empty_doc() {
1033 let opf = build_opf(&EpubOptions::default(), 0, &[]);
1034 assert!(!opf.contains("cover-image"));
1035 }
1036
1037 #[test]
1038 fn opf_uses_custom_language() {
1039 let opts = EpubOptions {
1040 language: "ru".to_owned(),
1041 ..Default::default()
1042 };
1043 let opf = build_opf(&opts, 1, &[]);
1044 assert!(opf.contains("<dc:language>ru</dc:language>"));
1045 }
1046
1047 #[test]
1048 fn opf_uses_custom_modified() {
1049 let opts = EpubOptions {
1050 modified: Some("2025-01-01T00:00:00Z".to_owned()),
1051 ..Default::default()
1052 };
1053 let opf = build_opf(&opts, 1, &[]);
1054 assert!(opf.contains("2025-01-01T00:00:00Z"));
1055 }
1056}