1use 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#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum CbzError {
25 #[error("document error: {0}")]
27 Doc(#[from] DocError),
28 #[error("render error: {0}")]
30 Render(#[from] RenderError),
31 #[error("zip error: {0}")]
33 Zip(#[from] zip::result::ZipError),
34 #[error("io error: {0}")]
36 Io(#[from] std::io::Error),
37 #[error("png encode error: {0}")]
39 Png(String),
40 #[error("export cancelled")]
42 Cancelled,
43}
44
45#[derive(Debug, Clone)]
47pub struct CbzOptions {
48 pub dpi: u32,
50 pub rotation: UserRotation,
52 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
66pub 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
74pub 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
97pub 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
112pub 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 #[cfg(feature = "parallel")]
137 {
138 use rayon::prelude::*;
139 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
224fn 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 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 #[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 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 #[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 #[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}