Skip to main content

djvu_rs/
epub.rs

1//! DjVu to EPUB 3 converter — preserves document structure.
2//!
3//! Converts DjVu documents to EPUB 3 while preserving:
4//! - Page images as PNG (one per page)
5//! - Invisible text overlay for search and copy
6//! - NAVM bookmarks as EPUB navigation (`nav.xhtml`)
7//! - ANTz/ANTa hyperlinks as `<a href>` overlays on each page
8//!
9//! # Example
10//!
11//! ```no_run
12//! use djvu_rs::djvu_document::DjVuDocument;
13//! use djvu_rs::epub::{djvu_to_epub, EpubOptions};
14//!
15//! let data = std::fs::read("book.djvu").unwrap();
16//! let doc = DjVuDocument::parse(&data).unwrap();
17//! let epub_bytes = djvu_to_epub(&doc, &EpubOptions::default()).unwrap();
18//! std::fs::write("book.epub", epub_bytes).unwrap();
19//! ```
20
21use 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// ── Errors ────────────────────────────────────────────────────────────────────
33
34/// Errors from EPUB conversion.
35#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum EpubError {
38    /// Document model error.
39    #[error("document error: {0}")]
40    Doc(#[from] DocError),
41    /// Render error.
42    #[error("render error: {0}")]
43    Render(#[from] RenderError),
44    /// ZIP I/O error.
45    #[error("zip error: {0}")]
46    Zip(#[from] zip::result::ZipError),
47    /// I/O error.
48    #[error("io error: {0}")]
49    Io(#[from] std::io::Error),
50    /// Export was cancelled by its observer.
51    #[error("export cancelled")]
52    Cancelled,
53}
54
55// ── Options ───────────────────────────────────────────────────────────────────
56
57/// Options for EPUB conversion.
58#[derive(Debug, Clone)]
59pub struct EpubOptions {
60    /// Title embedded in the OPF metadata. Defaults to `"DjVu Document"`.
61    pub title: String,
62    /// Author embedded in the OPF metadata. Defaults to empty.
63    pub author: String,
64    /// DPI for page rendering. Defaults to 150.
65    pub dpi: u32,
66    /// BCP-47 language tag for `<dc:language>`. Defaults to `"en"`.
67    pub language: String,
68    /// ISO 8601 timestamp for `dcterms:modified` (e.g. `"2026-04-14T00:00:00Z"`).
69    /// When `None`, the current UTC time is used (computed from `std::time::SystemTime`).
70    pub modified: Option<String>,
71    /// Append a reflowable-text section after the page image on each page
72    /// XHTML, populated from [`TextLayer::reflowable_text`]. Defaults to
73    /// `false` (existing fixed-layout behaviour, page image + invisible
74    /// overlay text).
75    ///
76    /// When `true`, each page body gets an extra `<section class="djvu-reflowable">`
77    /// containing one `<p>` per extracted paragraph. Reading apps that prefer
78    /// flowable text (most e-readers) can render it; image-first viewers
79    /// still get the bitmap above.
80    pub reflowable_text: bool,
81    /// JPEG quality (1–100) for page images. `None` (default) keeps the
82    /// PNG-only behaviour. JPEG is core EPUB — every reader renders it — and
83    /// wins heavily on photo/continuous-tone pages (#580).
84    pub jpeg_quality: Option<u8>,
85    /// With `jpeg_quality: Some(_)`, encode each page image *both* ways and
86    /// keep the smaller (the PDF_ADAPTIVE_RASTER pattern; only one page's
87    /// pair is ever live at once). Without it, `Some(q)` means JPEG always.
88    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
106// ── Public API ────────────────────────────────────────────────────────────────
107
108/// Convert a DjVu document to EPUB 3.
109///
110/// Returns the raw bytes of a valid EPUB file (ZIP archive).
111///
112/// # Errors
113///
114/// Returns [`EpubError`] if page rendering or ZIP writing fails.
115pub 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
121/// Convert a DjVu document to EPUB 3 and write it directly to `sink`.
122///
123/// `sink` must implement [`Seek`] because ZIP writes its central directory
124/// after the file entries. The generated EPUB is otherwise streamed directly
125/// to the sink, retaining only the active page's artifacts (or one bounded
126/// parallel batch) in memory.
127///
128/// # Errors
129///
130/// Returns [`EpubError`] if page rendering or ZIP writing fails. On error,
131/// `sink` may contain a partial EPUB; the library does not clean it up or
132/// provide atomic replacement (that policy belongs to the CLI/application
133/// layer).
134pub 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
143/// Convert a DjVu document to EPUB 3 while reporting progress through
144/// `observer`.
145///
146/// With the `parallel` feature, cancellation is polled before each bounded
147/// render batch. Work already scheduled in the current batch may complete
148/// before the cancellation is observed.
149///
150/// On error, `sink` may contain a partial EPUB; the library does not clean it
151/// up or provide atomic replacement (that policy belongs to the CLI/application
152/// layer).
153pub 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    // 1. mimetype — MUST be first and STORED (no compression), per EPUB spec
162    zip.start_file(
163        "mimetype",
164        SimpleFileOptions::default().compression_method(CompressionMethod::Stored),
165    )?;
166    zip.write_all(b"application/epub+zip")?;
167
168    // 2. META-INF/container.xml
169    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    // 3. Per-page content. Building a page's artifacts (render → PNG encode →
176    //    text overlay → XHTML) is independent per page and CPU-heavy; only the
177    //    ZIP writing must be serial (a single `ZipWriter`, not `Send`). With the
178    //    `parallel` feature, build bounded batches concurrently via rayon, then
179    //    write each batch in index order — mirrors the PDF parallel exporter
180    //    (#298). Output bytes are identical to the sequential path.
181    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                    // #629: cold clone — decode caches drop with the page.
198                    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        // #629: cold clone — decode caches drop with the page.
220        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    // 4. Navigation document
228    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    // 5. OPF package document
236    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
247/// Finish the ZIP central directory so callers can inspect the valid partial
248/// archive produced before cancellation, then report cancellation.
249fn finish_cancelled_epub<W: Write + Seek>(zip: ZipWriter<W>) -> Result<(), EpubError> {
250    zip.finish()?;
251    Err(EpubError::Cancelled)
252}
253
254// ── Per-page writer ───────────────────────────────────────────────────────────
255
256/// CPU-built, ZIP-ready artifacts for one page: the two entries a page
257/// contributes (the PNG image and the XHTML document), each with its archive
258/// path. Producing these is the parallelisable, `Send`-safe work; writing them
259/// into the single `ZipWriter` is the serial tail.
260struct PageArtifacts {
261    img_path: String,
262    /// Image file name (with extension) as referenced by the XHTML/OPF.
263    img_name: String,
264    png_bytes: Vec<u8>,
265    xhtml_path: String,
266    xhtml_bytes: Vec<u8>,
267}
268
269/// Write one page's pre-built artifacts into the ZIP, in the same order and with
270/// the same compression methods the old inline writer used.
271fn 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    // Native page dimensions in DjVu pixels
295    let pw = page.width() as u32;
296    let ph = page.height() as u32;
297
298    // Scale to the requested output DPI. The pipeline derives the decode scale
299    // from `width`, so we set only the size.
300    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    // Stream the RGBA scanlines when the page allows it, else fall back to a
308    // full pixmap — the shared raster seam every exporter routes through.
309    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    // Encode the page image (#580): PNG (Gray8 when the render is pure
315    // grayscale — 3× less raw data into deflate, pixel-identical), optionally
316    // JPEG, or both with keep-smaller (`adaptive`, the PDF_ADAPTIVE_RASTER
317    // pattern — only one page's pair of encodings is ever live at once).
318    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    // Text overlay (invisible selectable text)
353    let text_overlay = build_text_overlay(page, pw, ph);
354
355    // Hyperlink overlays from ANTz/ANTa annotations
356    let hyperlinks = page.hyperlinks().unwrap_or_default();
357
358    // Optional reflowable paragraphs (#228) — extracted from the same text
359    // layer that feeds the overlay, but joined with reading-order rules.
360    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    // Build XHTML page
376    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
397// ── PNG encoder ───────────────────────────────────────────────────────────────
398
399fn encode_rgba_to_png(rgba: &[u8], width: u32, height: u32, gray: bool) -> Vec<u8> {
400    // Pages are always opaque (the compositor writes alpha=255 inline —
401    // ALPHA_INL), so encode RGB — 25% less raw data into deflate for zero
402    // information loss (#599) — or Gray8 when the render is pure grayscale
403    // (a further 3×, pixel-identical; #580).
404    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
425/// Encode the page as JPEG (`quality` 1–100); empty `Vec` on encoder failure
426/// (the caller falls back to PNG).
427fn 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
445/// Strip the constant alpha channel from packed RGBA rows.
446fn 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
454// ── Text overlay ─────────────────────────────────────────────────────────────
455
456/// Returns `(x_pct, y_pct, w_pct, h_pct, text)` for word/char zones.
457///
458/// Coordinates are CSS percentages of the rendered image dimensions.
459/// DjVu text zones use bottom-left origin; the y-axis is inverted for CSS.
460fn 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    // Map each leaf word/character zone (shared zone-walk) to a CSS-percentage
469    // overlay rect. DjVu rects are top-left origin; the overlay is anchored
470    // from the bottom, so the vertical position is flipped.
471    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// ── XHTML page ────────────────────────────────────────────────────────────────
486
487#[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
568/// Convert a `MapArea` shape to CSS percentage coordinates `(left, top, width, height)`.
569///
570/// Returns `None` for empty or degenerate (zero-area) shapes. The bounding box
571/// and that skip are the shared [`crate::export_common::shape_bbox`]; here we
572/// only scale into page percentages and flip the bottom-left-origin box to a
573/// CSS top offset via [`crate::export_common::flip_y_bottom`] — the same flip
574/// the text overlay uses.
575fn 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
588/// Resolve a DjVu annotation URL to an EPUB-relative href.
589fn resolve_link_href(url: &str) -> String {
590    bookmark_href(url)
591}
592
593// ── OPF package ──────────────────────────────────────────────────────────────
594
595fn 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    // nav document
609    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    // cover image (first page)
629    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        // skip the cover-image item (already added above) but still add the page entry
640        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
673/// Return an ISO 8601 UTC timestamp for the current time.
674///
675/// Uses only `std::time::SystemTime` — no external crate dependency.
676fn 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    // Compute Y/M/D H:M:S from Unix timestamp (no leap seconds, Gregorian)
684    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
688/// Decompose a Unix timestamp (seconds since 1970-01-01 00:00:00 UTC) into
689/// `(year, month, day, hour, min, sec)`.
690fn 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    // Days since 1970-01-01 → Gregorian date (algorithm by Henry Fliegel)
699    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
712// ── Navigation document ───────────────────────────────────────────────────────
713
714fn 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
781/// Convert a DjVu bookmark URL to an EPUB relative href.
782/// DjVu bookmarks use `#page=N` (1-based) or bare `#anchor` format.
783fn bookmark_href(url: &str) -> String {
784    // Resolve internal `#page=N` / `#page_N` / `#N` references through the
785    // shared bookmark parser the PDF exporter also uses (#364).
786    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        // plain anchor — link to page 1 with anchor
792        return format!("pages/page_0001.xhtml{}", xml_escape(url));
793    }
794    // External URL — keep as-is
795    xml_escape(url)
796}
797
798// ── Helpers ───────────────────────────────────────────────────────────────────
799
800fn xml_escape(s: &str) -> String {
801    s.replace('&', "&amp;")
802        .replace('<', "&lt;")
803        .replace('>', "&gt;")
804        .replace('"', "&quot;")
805        .replace('\'', "&apos;")
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&amp;b&lt;c&gt;d&quot;e&apos;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        // e.g. "2026-04-14T12:34:56Z"
973        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        // 2026-04-14T00:00:00Z = 1776124800
989        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, &[], &[], &paras);
1018        assert!(html.contains(r#"<section class="djvu-reflowable">"#));
1019        assert!(html.contains("<p>First paragraph.</p>"));
1020        // XML-escapes ampersand
1021        assert!(html.contains("<p>Second &amp; 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}