Skip to main content

djvu_rs/
cbz.rs

1//! CBZ (comic book archive) export.
2//!
3//! A CBZ is a ZIP of page images. Pages are rendered to RGBA, PNG-encoded and
4//! stored uncompressed (PNG is already deflated). Building a page's PNG is
5//! independent per page and CPU-heavy; only the ZIP writing must be serial (a
6//! single `ZipWriter`). With the `parallel` feature bounded batches of PNGs are
7//! built concurrently via rayon and written in index order — the same
8//! render-parallel/write-serial split as the EPUB and PDF exporters (#298,
9//! #598). Output bytes are identical to the sequential path.
10
11use std::io::{Seek, Write};
12
13use zip::CompressionMethod;
14use zip::ZipWriter;
15use zip::write::SimpleFileOptions;
16
17use crate::djvu_document::{DjVuDocument, DjVuPage, DocError};
18use crate::djvu_render::{RenderError, RenderOptions, UserRotation, render_pixmap};
19use crate::export_control::{ExportObserver, NoOpObserver};
20
21/// Errors during CBZ conversion.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum CbzError {
25    /// Document model error.
26    #[error("document error: {0}")]
27    Doc(#[from] DocError),
28    /// Render error.
29    #[error("render error: {0}")]
30    Render(#[from] RenderError),
31    /// ZIP I/O error.
32    #[error("zip error: {0}")]
33    Zip(#[from] zip::result::ZipError),
34    /// I/O error.
35    #[error("io error: {0}")]
36    Io(#[from] std::io::Error),
37    /// PNG encoding error.
38    #[error("png encode error: {0}")]
39    Png(String),
40    /// Export was cancelled by its observer.
41    #[error("export cancelled")]
42    Cancelled,
43}
44
45/// Options for CBZ conversion.
46#[derive(Debug, Clone)]
47pub struct CbzOptions {
48    /// Output resolution in DPI (pages are scaled from their native DPI).
49    pub dpi: u32,
50    /// Extra user rotation applied on top of the INFO-chunk rotation.
51    pub rotation: UserRotation,
52    /// 0-based page indices to export; `None` = all pages in order.
53    pub pages: Option<Vec<usize>>,
54}
55
56impl Default for CbzOptions {
57    fn default() -> Self {
58        CbzOptions {
59            dpi: 150,
60            rotation: UserRotation::None,
61            pages: None,
62        }
63    }
64}
65
66/// Convert a DjVu document to an in-memory CBZ archive.
67pub fn djvu_to_cbz(doc: &DjVuDocument, opts: &CbzOptions) -> Result<Vec<u8>, CbzError> {
68    let cursor = std::io::Cursor::new(Vec::new());
69    let mut zip = ZipWriter::new(cursor);
70    write_pages(&mut zip, doc, opts)?;
71    Ok(zip.finish()?.into_inner())
72}
73
74/// Convert a DjVu document to a CBZ archive written straight to `sink`.
75///
76/// The streaming counterpart of [`djvu_to_cbz`]: only the active page's PNG is
77/// held, instead of the whole archive. `sink` must implement [`Seek`] because
78/// ZIP writes its central directory after the entries.
79///
80/// # Errors
81///
82/// Returns [`CbzError`] if page rendering or ZIP writing fails. On error the
83/// sink may contain a partial archive; the library does not clean it up or
84/// provide atomic replacement (that policy belongs to the CLI/application
85/// layer).
86pub fn djvu_to_cbz_writer<W: Write + Seek>(
87    doc: &DjVuDocument,
88    opts: &CbzOptions,
89    sink: W,
90) -> Result<(), CbzError> {
91    let mut zip = ZipWriter::new(sink);
92    write_pages(&mut zip, doc, opts)?;
93    zip.finish()?;
94    Ok(())
95}
96
97/// Write every requested page into `zip` as `page_%04d.png` entries.
98///
99/// Exposed crate-internally so the CLI can stream straight to a file instead
100/// of buffering the archive. On error, the ZIP sink may contain a partial
101/// archive; the library does not clean it up or provide atomic replacement
102/// (that policy belongs to the CLI/application layer).
103pub fn write_pages<W: Write + Seek>(
104    zip: &mut ZipWriter<W>,
105    doc: &DjVuDocument,
106    opts: &CbzOptions,
107) -> Result<(), CbzError> {
108    let mut observer = NoOpObserver;
109    write_pages_with_observer(zip, doc, opts, &mut observer)
110}
111
112/// Write every requested page into `zip` while reporting progress through
113/// `observer`.
114///
115/// With the `parallel` feature, cancellation is polled before each bounded
116/// render batch. Work already scheduled in the current batch may complete
117/// before the cancellation is observed.
118///
119/// On error, the ZIP's inner sink may contain a partial archive; the library
120/// does not clean it up or provide atomic replacement (that policy belongs to
121/// the CLI/application layer).
122pub fn write_pages_with_observer<W: Write + Seek>(
123    zip: &mut ZipWriter<W>,
124    doc: &DjVuDocument,
125    opts: &CbzOptions,
126    observer: &mut dyn ExportObserver,
127) -> Result<(), CbzError> {
128    let entry_opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
129    let total = opts
130        .pages
131        .as_ref()
132        .map_or_else(|| doc.page_count(), Vec::len);
133
134    // #629: pages render on cold clones so decode caches drop per page
135    // instead of accumulating O(pages) on the document.
136    #[cfg(feature = "parallel")]
137    {
138        use rayon::prelude::*;
139        // Keep the parallel speedup while retaining only one bounded batch of
140        // rendered PNGs. The ZIP writer remains serial, so each batch is
141        // written in page order before the next batch is rendered.
142        let chunk = rayon::current_num_threads().max(1) * 8;
143        match &opts.pages {
144            Some(indices) => {
145                for (chunk_index, indices) in indices.chunks(chunk).enumerate() {
146                    if observer.cancelled() {
147                        return Err(CbzError::Cancelled);
148                    }
149                    let pngs: Vec<Vec<u8>> = indices
150                        .par_iter()
151                        .map(|&i| build_page_png(&doc.page(i)?.clone(), opts))
152                        .collect::<Result<_, CbzError>>()?;
153                    for (offset, png) in pngs.iter().enumerate() {
154                        if observer.cancelled() {
155                            return Err(CbzError::Cancelled);
156                        }
157                        write_page_png(zip, chunk_index * chunk + offset + 1, png, entry_opts)?;
158                        observer.on_progress(chunk_index * chunk + offset + 1, total);
159                    }
160                }
161            }
162            None => {
163                let page_count = doc.page_count();
164                let mut start = 0;
165                while start < page_count {
166                    if observer.cancelled() {
167                        return Err(CbzError::Cancelled);
168                    }
169                    let end = (start + chunk).min(page_count);
170                    let pngs: Vec<Vec<u8>> = (start..end)
171                        .into_par_iter()
172                        .map(|i| build_page_png(&doc.page(i)?.clone(), opts))
173                        .collect::<Result<_, CbzError>>()?;
174                    for (offset, png) in pngs.iter().enumerate() {
175                        if observer.cancelled() {
176                            return Err(CbzError::Cancelled);
177                        }
178                        write_page_png(zip, start + offset + 1, png, entry_opts)?;
179                        observer.on_progress(start + offset + 1, total);
180                    }
181                    start = end;
182                }
183            }
184        }
185    }
186
187    #[cfg(not(feature = "parallel"))]
188    match &opts.pages {
189        Some(indices) => {
190            for (index, &page_index) in indices.iter().enumerate() {
191                if observer.cancelled() {
192                    return Err(CbzError::Cancelled);
193                }
194                let png = build_page_png(&doc.page(page_index)?.clone(), opts)?;
195                write_page_png(zip, index + 1, &png, entry_opts)?;
196                observer.on_progress(index + 1, total);
197            }
198        }
199        None => {
200            for page_index in 0..doc.page_count() {
201                if observer.cancelled() {
202                    return Err(CbzError::Cancelled);
203                }
204                let png = build_page_png(&doc.page(page_index)?.clone(), opts)?;
205                write_page_png(zip, page_index + 1, &png, entry_opts)?;
206                observer.on_progress(page_index + 1, total);
207            }
208        }
209    }
210    Ok(())
211}
212
213fn write_page_png<W: Write + Seek>(
214    zip: &mut ZipWriter<W>,
215    number: usize,
216    png: &[u8],
217    entry_opts: SimpleFileOptions,
218) -> Result<(), CbzError> {
219    zip.start_file(format!("page_{number:04}.png"), entry_opts)?;
220    zip.write_all(png)?;
221    Ok(())
222}
223
224/// Render one page at the target DPI, apply user rotation, encode to PNG.
225fn build_page_png(page: &DjVuPage, opts: &CbzOptions) -> Result<Vec<u8>, CbzError> {
226    let (w, h) = crate::export_common::size_at_dpi(page, opts.dpi as f32);
227    let pixmap = render_pixmap(
228        page,
229        &RenderOptions {
230            width: w,
231            height: h,
232            ..RenderOptions::default()
233        },
234    )?;
235    let pixmap = match opts.rotation {
236        UserRotation::None => pixmap,
237        UserRotation::Cw90 => pixmap.rotate_cw90(),
238        UserRotation::Rot180 => pixmap.rotate_180(),
239        UserRotation::Ccw90 => pixmap.rotate_ccw90(),
240    };
241
242    // Pages are always opaque (alpha=255 inline — ALPHA_INL), so encode RGB:
243    // 25% less raw data into deflate, smaller archives, identical pixels
244    // (#599).
245    let mut rgb = Vec::with_capacity(pixmap.data.len() / 4 * 3);
246    for px in pixmap.data.as_chunks::<4>().0 {
247        rgb.extend_from_slice(&px[..3]);
248    }
249    let mut buf = Vec::new();
250    {
251        let mut enc =
252            png::Encoder::new(std::io::Cursor::new(&mut buf), pixmap.width, pixmap.height);
253        enc.set_color(png::ColorType::Rgb);
254        enc.set_depth(png::BitDepth::Eight);
255        let mut writer = enc
256            .write_header()
257            .map_err(|e| CbzError::Png(e.to_string()))?;
258        writer
259            .write_image_data(&rgb)
260            .map_err(|e| CbzError::Png(e.to_string()))?;
261    }
262    Ok(buf)
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[derive(Default)]
270    struct RecordingObserver {
271        progress: Vec<(usize, usize)>,
272        cancel_after: Option<usize>,
273    }
274
275    impl ExportObserver for RecordingObserver {
276        fn on_progress(&mut self, done: usize, total: usize) {
277            self.progress.push((done, total));
278        }
279
280        fn cancelled(&self) -> bool {
281            self.cancel_after
282                .is_some_and(|after| self.progress.len() >= after)
283        }
284    }
285
286    fn load_doc(name: &str) -> DjVuDocument {
287        let data = std::fs::read(
288            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
289                .join("tests/fixtures")
290                .join(name),
291        )
292        .unwrap();
293        DjVuDocument::parse(&data).unwrap()
294    }
295
296    /// The archive is a valid ZIP with one stored PNG entry per page, named
297    /// `page_%04d.png` in order.
298    #[test]
299    fn cbz_has_one_png_entry_per_page() {
300        let doc = load_doc("navm_fgbz.djvu");
301        let cbz = djvu_to_cbz(&doc, &CbzOptions::default()).unwrap();
302        let mut archive =
303            zip::ZipArchive::new(std::io::Cursor::new(&cbz)).expect("valid zip archive");
304        assert_eq!(archive.len(), doc.page_count());
305        for i in 0..archive.len() {
306            let entry = archive.by_index(i).unwrap();
307            assert_eq!(entry.name(), format!("page_{:04}.png", i + 1));
308        }
309        // PNG magic on the first entry
310        use std::io::Read;
311        let mut first = archive.by_index(0).unwrap();
312        let mut magic = [0u8; 8];
313        first.read_exact(&mut magic).unwrap();
314        assert_eq!(&magic, b"\x89PNG\r\n\x1a\n");
315    }
316
317    /// Byte-determinism: two exports of the same document are identical (no
318    /// wall-clock timestamps, no ordering nondeterminism from the parallel
319    /// build).
320    #[test]
321    fn cbz_output_is_deterministic() {
322        let doc = load_doc("boy.djvu");
323        let a = djvu_to_cbz(&doc, &CbzOptions::default()).unwrap();
324        let b = djvu_to_cbz(&doc, &CbzOptions::default()).unwrap();
325        assert_eq!(a, b);
326    }
327
328    #[test]
329    fn cbz_vec_and_writer_exports_are_byte_identical() {
330        let doc = load_doc("boy.djvu");
331        let opts = CbzOptions::default();
332        let expected = djvu_to_cbz(&doc, &opts).unwrap();
333
334        let cursor = std::io::Cursor::new(Vec::new());
335        let mut zip = ZipWriter::new(cursor);
336        write_pages(&mut zip, &doc, &opts).unwrap();
337        let actual = zip.finish().unwrap().into_inner();
338
339        assert_eq!(actual, expected);
340    }
341
342    #[test]
343    fn cbz_writer_observer_reports_each_page_in_order() {
344        let doc = load_doc("vega.djvu");
345        let total = doc.page_count();
346        let mut observer = RecordingObserver::default();
347        let cursor = std::io::Cursor::new(Vec::new());
348        let mut zip = ZipWriter::new(cursor);
349
350        write_pages_with_observer(&mut zip, &doc, &CbzOptions::default(), &mut observer)
351            .expect("observer export must succeed");
352
353        assert_eq!(
354            observer.progress,
355            (1..=total).map(|done| (done, total)).collect::<Vec<_>>()
356        );
357    }
358
359    #[test]
360    fn cbz_writer_cancellation_leaves_only_completed_pages() {
361        let doc = load_doc("vega.djvu");
362        assert!(doc.page_count() > 1, "fixture must contain multiple pages");
363        let mut observer = RecordingObserver {
364            cancel_after: Some(1),
365            ..RecordingObserver::default()
366        };
367        let cursor = std::io::Cursor::new(Vec::new());
368        let mut zip = ZipWriter::new(cursor);
369
370        let error =
371            write_pages_with_observer(&mut zip, &doc, &CbzOptions::default(), &mut observer)
372                .expect_err("observer must cancel the export");
373        assert!(matches!(error, CbzError::Cancelled));
374        assert_eq!(observer.progress.len(), 1);
375
376        let bytes = zip
377            .finish()
378            .expect("partial archive must finish")
379            .into_inner();
380        let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
381            .expect("partial archive must remain readable");
382        assert!(archive.len() <= 1, "no additional page may be written");
383    }
384
385    #[test]
386    fn cbz_default_writer_delegates_to_noop_observer() {
387        let doc = load_doc("vega.djvu");
388        let opts = CbzOptions::default();
389
390        let default_cursor = std::io::Cursor::new(Vec::new());
391        let mut default_zip = ZipWriter::new(default_cursor);
392        write_pages(&mut default_zip, &doc, &opts).unwrap();
393        let default_bytes = default_zip.finish().unwrap().into_inner();
394
395        let observed_cursor = std::io::Cursor::new(Vec::new());
396        let mut observed_zip = ZipWriter::new(observed_cursor);
397        let mut observer = NoOpObserver;
398        write_pages_with_observer(&mut observed_zip, &doc, &opts, &mut observer).unwrap();
399        let observed_bytes = observed_zip.finish().unwrap().into_inner();
400
401        assert_eq!(observed_bytes, default_bytes);
402    }
403
404    #[test]
405    fn cbz_writer_failing_sink_returns_io_error() {
406        let doc = load_doc("chicken.djvu");
407        let mut zip = ZipWriter::new(crate::export_test_support::FailingWriter::after(2));
408
409        let error = write_pages(&mut zip, &doc, &CbzOptions::default())
410            .expect_err("injected sink failure must be returned");
411
412        assert!(
413            matches!(
414                error,
415                CbzError::Io(ref error) if error.kind() == std::io::ErrorKind::Other
416            ) || matches!(error, CbzError::Zip(zip::result::ZipError::Io(_)))
417        );
418    }
419
420    /// Page subset and rotation options are honoured.
421    #[test]
422    fn cbz_page_subset_and_rotation() {
423        let doc = load_doc("navm_fgbz.djvu");
424        let opts = CbzOptions {
425            pages: Some(vec![0, 2]),
426            rotation: UserRotation::Cw90,
427            ..CbzOptions::default()
428        };
429        let cbz = djvu_to_cbz(&doc, &opts).unwrap();
430        let archive = zip::ZipArchive::new(std::io::Cursor::new(&cbz)).unwrap();
431        assert_eq!(archive.len(), 2);
432    }
433}