1use std::io::{Cursor, Seek, Write};
32
33use tiff::encoder::{Rational, TiffEncoder, colortype, compression::Deflate};
34use tiff::tags::ResolutionUnit;
35
36use crate::{
37 djvu_document::{DjVuDocument, DjVuPage, DocError},
38 djvu_render::{self, RenderError, RenderOptions},
39 export_control::{ExportObserver, NoOpObserver},
40};
41
42#[derive(Debug, thiserror::Error)]
46#[non_exhaustive]
47pub enum TiffError {
48 #[error("document error: {0}")]
50 Doc(#[from] DocError),
51
52 #[error("render error: {0}")]
54 Render(#[from] RenderError),
55
56 #[error("TIFF encoding error: {0}")]
58 Encode(String),
59
60 #[error("export cancelled")]
62 Cancelled,
63}
64
65impl From<tiff::TiffError> for TiffError {
66 fn from(e: tiff::TiffError) -> Self {
67 TiffError::Encode(e.to_string())
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum TiffMode {
76 #[default]
78 Color,
79 Bilevel,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum TiffBilevelCompression {
89 #[default]
91 Deflate,
92 G4,
97}
98
99#[derive(Debug, Clone)]
101pub struct TiffOptions {
102 pub mode: TiffMode,
104 pub scale: f32,
106 pub bilevel_compression: TiffBilevelCompression,
108}
109
110impl Default for TiffOptions {
111 fn default() -> Self {
112 TiffOptions {
113 mode: TiffMode::Color,
114 scale: 1.0,
115 bilevel_compression: TiffBilevelCompression::default(),
116 }
117 }
118}
119
120pub fn djvu_to_tiff(doc: &DjVuDocument, opts: &TiffOptions) -> Result<Vec<u8>, TiffError> {
129 let mut buf: Vec<u8> = Vec::new();
130 {
131 let cursor = Cursor::new(&mut buf);
132 djvu_to_tiff_writer(doc, opts, cursor)?;
133 }
134 Ok(buf)
135}
136
137pub fn djvu_to_tiff_writer<W: Write + Seek>(
155 doc: &DjVuDocument,
156 opts: &TiffOptions,
157 writer: W,
158) -> Result<(), TiffError> {
159 let mut observer = NoOpObserver;
160 djvu_to_tiff_writer_with_observer(doc, opts, writer, &mut observer)
161}
162
163pub fn djvu_to_tiff_writer_with_observer<W: Write + Seek>(
173 doc: &DjVuDocument,
174 opts: &TiffOptions,
175 writer: W,
176 observer: &mut dyn ExportObserver,
177) -> Result<(), TiffError> {
178 if opts.mode == TiffMode::Bilevel && opts.bilevel_compression == TiffBilevelCompression::G4 {
179 return write_bilevel_g4_tiff(doc, writer, observer);
180 }
181 let mut encoder = TiffEncoder::new(writer)?;
182 let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
183 let total = indices.len();
184
185 #[cfg(feature = "parallel")]
195 {
196 use rayon::prelude::*;
197 if observer.cancelled() {
198 return Err(TiffError::Cancelled);
199 }
200 let images: Vec<PageImage> = indices
201 .par_iter()
202 .map(|&i| {
203 let page = doc.page(i)?.clone();
205 build_page_image(&page, opts)
206 })
207 .collect::<Result<Vec<_>, TiffError>>()?;
208 for (done, img) in images.iter().enumerate() {
209 if observer.cancelled() {
210 return Err(TiffError::Cancelled);
211 }
212 write_page_image(&mut encoder, img)?;
213 observer.on_progress(done + 1, total);
214 }
215 }
216
217 #[cfg(not(feature = "parallel"))]
218 for (done, &i) in indices.iter().enumerate() {
219 if observer.cancelled() {
220 return Err(TiffError::Cancelled);
221 }
222 let page = doc.page(i)?.clone();
224 match opts.mode {
225 TiffMode::Color => write_color_page(&mut encoder, &page, opts.scale)?,
226 TiffMode::Bilevel => write_bilevel_page(&mut encoder, &page)?,
227 }
228 observer.on_progress(done + 1, total);
229 }
230 Ok(())
231}
232
233#[cfg(feature = "parallel")]
237struct PageImage {
238 w: u32,
239 h: u32,
240 dpi: u32,
241 data: PageImageData,
242}
243
244#[cfg(feature = "parallel")]
245enum PageImageData {
246 Rgb(Vec<u8>),
248 GrayDeflate(Vec<u8>),
250}
251
252#[cfg(feature = "parallel")]
255fn build_page_image(page: &DjVuPage, opts: &TiffOptions) -> Result<PageImage, TiffError> {
256 match opts.mode {
257 TiffMode::Color => {
258 let (w, h, ropts) = color_render_options(page, opts.scale);
259 let dpi = (page.dpi() as f32 * opts.scale).round() as u32;
260 let rgb = if ropts.can_stream(page) {
264 let mut rgb = Vec::with_capacity(w as usize * h as usize * 3);
265 djvu_render::render_streaming(page, &ropts, |_, rgba_row| {
266 crate::export_common::rgba_row_to_rgb(&mut rgb, rgba_row);
267 })?;
268 rgb
269 } else {
270 djvu_render::render_pixmap(page, &ropts)?.to_rgb()
271 };
272 Ok(PageImage {
273 w,
274 h,
275 dpi,
276 data: PageImageData::Rgb(rgb),
277 })
278 }
279 TiffMode::Bilevel => {
280 let w = page.width() as u32;
281 let h = page.height() as u32;
282 let gray = extract_bilevel_pixels(page, w, h)?;
283 let dpi = page.dpi() as u32;
284 Ok(PageImage {
285 w,
286 h,
287 dpi,
288 data: PageImageData::GrayDeflate(gray),
289 })
290 }
291 }
292}
293
294#[cfg(feature = "parallel")]
296fn write_page_image<W: Write + Seek>(
297 encoder: &mut TiffEncoder<W>,
298 img: &PageImage,
299) -> Result<(), TiffError> {
300 match &img.data {
301 PageImageData::Rgb(rgb) => {
302 let mut image = encoder.new_image::<colortype::RGB8>(img.w, img.h)?;
303 image.resolution(ResolutionUnit::Inch, Rational { n: img.dpi, d: 1 });
304 image.write_data(rgb)?;
305 }
306 PageImageData::GrayDeflate(gray) => {
307 let mut image = encoder.new_image_with_compression::<colortype::Gray8, _>(
308 img.w,
309 img.h,
310 Deflate::default(),
311 )?;
312 image.resolution(ResolutionUnit::Inch, Rational { n: img.dpi, d: 1 });
313 image.write_data(gray)?;
314 }
315 }
316 Ok(())
317}
318
319#[cfg(not(feature = "parallel"))]
323fn write_color_page<W: Write + Seek>(
324 encoder: &mut TiffEncoder<W>,
325 page: &DjVuPage,
326 scale: f32,
327) -> Result<(), TiffError> {
328 let (w, h, opts) = color_render_options(page, scale);
329 let dpi = (page.dpi() as f32 * scale).round() as u32;
330
331 if opts.can_stream(page) {
332 write_color_page_streaming(encoder, page, &opts, w, h, dpi)
333 } else {
334 write_color_page_pixmap(encoder, page, &opts, w, h, dpi)
335 }
336}
337
338fn color_render_options(page: &DjVuPage, scale: f32) -> (u32, u32, RenderOptions) {
339 let (w, h) =
340 crate::export_common::scaled_size(page.width() as u32, page.height() as u32, scale);
341
342 let opts = RenderOptions {
346 width: w,
347 height: h,
348 ..RenderOptions::default()
349 };
350 (w, h, opts)
351}
352
353#[cfg(not(feature = "parallel"))]
354fn write_color_page_streaming<W: Write + Seek>(
355 encoder: &mut TiffEncoder<W>,
356 page: &DjVuPage,
357 opts: &RenderOptions,
358 w: u32,
359 h: u32,
360 dpi: u32,
361) -> Result<(), TiffError> {
362 let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
363 img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
364
365 let mut next_strip_samples = img.next_strip_sample_count() as usize;
366 let mut strip = Vec::with_capacity(next_strip_samples);
367 let mut encode_error: Option<tiff::TiffError> = None;
368
369 djvu_render::render_streaming(page, opts, |_, rgba_row| {
370 if encode_error.is_some() {
371 return;
372 }
373
374 crate::export_common::rgba_row_to_rgb(&mut strip, rgba_row);
375
376 if strip.len() > next_strip_samples {
377 encode_error = Some(
378 std::io::Error::new(
379 std::io::ErrorKind::InvalidData,
380 "streamed RGB strip exceeded expected TIFF strip size",
381 )
382 .into(),
383 );
384 return;
385 }
386
387 if strip.len() == next_strip_samples {
388 if let Err(e) = img.write_strip(&strip) {
389 encode_error = Some(e);
390 return;
391 }
392 strip.clear();
393 next_strip_samples = img.next_strip_sample_count() as usize;
394 }
395 })?;
396
397 if let Some(e) = encode_error {
398 return Err(e.into());
399 }
400 if !strip.is_empty() || img.next_strip_sample_count() != 0 {
401 return Err(TiffError::Encode(
402 "streamed render ended before all TIFF strips were written".to_string(),
403 ));
404 }
405
406 img.finish()?;
407 Ok(())
408}
409
410#[cfg(not(feature = "parallel"))]
411fn write_color_page_pixmap<W: Write + Seek>(
412 encoder: &mut TiffEncoder<W>,
413 page: &DjVuPage,
414 opts: &RenderOptions,
415 w: u32,
416 h: u32,
417 dpi: u32,
418) -> Result<(), TiffError> {
419 let pixmap = djvu_render::render_pixmap(page, opts)?;
420 let rgb = pixmap.to_rgb();
421
422 let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
423 img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
424 img.write_data(&rgb)?;
425 Ok(())
426}
427
428#[cfg(not(feature = "parallel"))]
434fn write_bilevel_page<W: std::io::Write + std::io::Seek>(
435 encoder: &mut TiffEncoder<W>,
436 page: &DjVuPage,
437) -> Result<(), TiffError> {
438 let w = page.width() as u32;
439 let h = page.height() as u32;
440
441 let gray = extract_bilevel_pixels(page, w, h)?;
443 let dpi = page.dpi() as u32;
444 let mut img =
449 encoder.new_image_with_compression::<colortype::Gray8, _>(w, h, Deflate::default())?;
450 img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
451 img.write_data(&gray)?;
452 Ok(())
453}
454
455#[derive(Clone, Copy)]
470struct G4PagePlan {
471 width: u32,
472 height: u32,
473 dpi: u32,
474 encoded_len: u32,
475}
476
477#[derive(Clone, Copy)]
478struct G4PageLayout {
479 strip_offset: u32,
480 rational_offset: u32,
481 ifd_offset: u32,
482}
483
484fn encode_bilevel_g4_page(
489 doc: &DjVuDocument,
490 page_index: usize,
491) -> Result<(G4PagePlan, Vec<u8>), TiffError> {
492 let page = doc.page(page_index)?.clone();
494 let width = page.width() as u32;
495 let height = page.height() as u32;
496 let dpi = page.dpi().max(1) as u32;
497 let mask = page
498 .extract_mask()
499 .map_err(TiffError::Doc)?
500 .filter(|m| m.width >= width && m.height >= height)
501 .unwrap_or_else(|| crate::bitmap::Bitmap::new(width, height));
502 let encoded = crate::smmr::encode_g4(&mask);
503 let plan = G4PagePlan {
504 width,
505 height,
506 dpi,
507 encoded_len: encoded.len() as u32,
508 };
509 Ok((plan, encoded))
510}
511
512fn write_bilevel_g4_tiff<W: Write + Seek>(
513 doc: &DjVuDocument,
514 mut w: W,
515 observer: &mut dyn ExportObserver,
516) -> Result<(), TiffError> {
517 let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
518
519 let mut plans = Vec::with_capacity(indices.len());
523 for &page_index in &indices {
524 if observer.cancelled() {
525 return Err(TiffError::Cancelled);
526 }
527 let (plan, encoded) = encode_bilevel_g4_page(doc, page_index)?;
528 drop(encoded);
529 plans.push(plan);
530 }
531
532 let io = |e: std::io::Error| TiffError::Encode(e.to_string());
533
534 const NTAGS: u16 = 11;
536 let ifd_size = 2 + NTAGS as u32 * 12 + 4; let mut offset: u32 = 8;
538 let mut layout = Vec::with_capacity(plans.len());
539 for plan in &plans {
540 let strip_offset = offset;
541 let rational_offset = (strip_offset + plan.encoded_len).div_ceil(2) * 2;
543 let ifd_offset = (rational_offset + 16).div_ceil(2) * 2;
544 layout.push(G4PageLayout {
545 strip_offset,
546 rational_offset,
547 ifd_offset,
548 });
549 offset = ifd_offset + ifd_size;
550 }
551
552 w.write_all(b"II\x2a\x00").map_err(io)?;
554 w.write_all(&layout[0].ifd_offset.to_le_bytes())
555 .map_err(io)?;
556
557 let entry = |tag: u16, typ: u16, count: u32, value: u32| -> [u8; 12] {
558 let mut e = [0u8; 12];
559 e[0..2].copy_from_slice(&tag.to_le_bytes());
560 e[2..4].copy_from_slice(&typ.to_le_bytes());
561 e[4..8].copy_from_slice(&count.to_le_bytes());
562 e[8..12].copy_from_slice(&value.to_le_bytes());
563 e
564 };
565
566 let mut pos: u32 = 8;
569 for (idx, ((&page_index, plan), page_layout)) in
570 indices.iter().zip(&plans).zip(&layout).enumerate()
571 {
572 if observer.cancelled() {
573 return Err(TiffError::Cancelled);
574 }
575 let (emitted_plan, encoded) = encode_bilevel_g4_page(doc, page_index)?;
576 debug_assert_eq!(plan.encoded_len, emitted_plan.encoded_len);
577 if plan.encoded_len != emitted_plan.encoded_len {
578 return Err(TiffError::Encode(format!(
579 "G4 encoded length diverged between sizing and emission passes for page {}",
580 idx + 1
581 )));
582 }
583
584 debug_assert_eq!(pos, page_layout.strip_offset);
585 w.write_all(&encoded).map_err(io)?;
586 pos += emitted_plan.encoded_len;
587 while pos < page_layout.rational_offset {
588 w.write_all(&[0]).map_err(io)?;
589 pos += 1;
590 }
591 for _ in 0..2 {
593 w.write_all(&plan.dpi.to_le_bytes()).map_err(io)?;
594 w.write_all(&1u32.to_le_bytes()).map_err(io)?;
595 }
596 pos += 16;
597 while pos < page_layout.ifd_offset {
598 w.write_all(&[0]).map_err(io)?;
599 pos += 1;
600 }
601
602 w.write_all(&NTAGS.to_le_bytes()).map_err(io)?;
603 w.write_all(&entry(256, 4, 1, plan.width)).map_err(io)?; w.write_all(&entry(257, 4, 1, plan.height)).map_err(io)?; w.write_all(&entry(258, 3, 1, 1)).map_err(io)?; w.write_all(&entry(259, 3, 1, 4)).map_err(io)?; w.write_all(&entry(262, 3, 1, 0)).map_err(io)?; w.write_all(&entry(273, 4, 1, page_layout.strip_offset))
610 .map_err(io)?; w.write_all(&entry(277, 3, 1, 1)).map_err(io)?; w.write_all(&entry(278, 4, 1, plan.height)).map_err(io)?; w.write_all(&entry(279, 4, 1, plan.encoded_len))
614 .map_err(io)?; w.write_all(&entry(282, 5, 1, page_layout.rational_offset))
616 .map_err(io)?; w.write_all(&entry(283, 5, 1, page_layout.rational_offset + 8))
618 .map_err(io)?; let next = if idx + 1 < layout.len() {
620 layout[idx + 1].ifd_offset
621 } else {
622 0
623 };
624 w.write_all(&next.to_le_bytes()).map_err(io)?;
625 pos = page_layout.ifd_offset + ifd_size;
626 observer.on_progress(idx + 1, plans.len());
627 }
628 w.flush().map_err(io)?;
629 Ok(())
630}
631
632fn extract_bilevel_pixels(page: &DjVuPage, w: u32, h: u32) -> Result<Vec<u8>, TiffError> {
633 let sjbz = match page.find_chunk(b"Sjbz") {
634 Some(d) => d,
635 None => return Ok(vec![0u8; (w * h) as usize]),
636 };
637
638 let dict = page
639 .find_chunk(b"Djbz")
640 .and_then(|djbz| crate::jb2::decode_dict(djbz, None).ok());
641
642 let bm = crate::jb2::decode(sjbz, dict.as_ref())
643 .map_err(|e| TiffError::Encode(format!("JB2 decode failed: {e}")))?;
644
645 let wq = w as usize;
646 let mut pixels = vec![0u8; wq * h as usize];
647
648 if bm.width >= w && bm.height >= h {
653 let stride = bm.row_stride();
654 let nb_full = wq / 8;
655 let rem = wq % 8;
656 for y in 0..h as usize {
657 let row = &bm.data[y * stride..];
658 let out = &mut pixels[y * wq..(y + 1) * wq];
659 for bi in 0..nb_full {
660 out[bi * 8..bi * 8 + 8].copy_from_slice(&BILEVEL_GRAY8[row[bi] as usize]);
661 }
662 if rem > 0 {
663 let src = &BILEVEL_GRAY8[row[nb_full] as usize];
664 out[nb_full * 8..nb_full * 8 + rem].copy_from_slice(&src[..rem]);
665 }
666 }
667 return Ok(pixels);
668 }
669
670 for y in 0..h {
673 for x in 0..w {
674 pixels[(y * w + x) as usize] = if bm.get(x, y) { 255u8 } else { 0u8 };
675 }
676 }
677 Ok(pixels)
678}
679
680const BILEVEL_GRAY8: [[u8; 8]; 256] = {
683 let mut lut = [[0u8; 8]; 256];
684 let mut mb = 0usize;
685 while mb < 256 {
686 let mut j = 0usize;
687 while j < 8 {
688 lut[mb][j] = if (mb >> (7 - j)) & 1 != 0 { 255u8 } else { 0u8 };
689 j += 1;
690 }
691 mb += 1;
692 }
693 lut
694};
695
696#[cfg(test)]
699mod tests {
700 use super::*;
701 use std::cell::Cell;
702
703 use crate::djvu_render;
704
705 fn assets_path() -> std::path::PathBuf {
706 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
707 .join("references/djvujs/library/assets")
708 }
709
710 fn load_doc(filename: &str) -> DjVuDocument {
711 let data = std::fs::read(assets_path().join(filename))
712 .unwrap_or_else(|_| panic!("{filename} must exist"));
713 DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
714 }
715
716 #[derive(Default)]
717 struct RecordingObserver {
718 progress: Vec<(usize, usize)>,
719 cancel_after: Option<usize>,
720 }
721
722 impl ExportObserver for RecordingObserver {
723 fn on_progress(&mut self, done: usize, total: usize) {
724 self.progress.push((done, total));
725 }
726
727 fn cancelled(&self) -> bool {
728 self.cancel_after
729 .is_some_and(|after| self.progress.len() >= after)
730 }
731 }
732
733 struct CancelOnPollObserver {
736 cancel_on_poll: usize,
737 polls: Cell<usize>,
738 progress: Vec<(usize, usize)>,
739 }
740
741 impl ExportObserver for CancelOnPollObserver {
742 fn on_progress(&mut self, done: usize, total: usize) {
743 self.progress.push((done, total));
744 }
745
746 fn cancelled(&self) -> bool {
747 let polls = self.polls.get() + 1;
748 self.polls.set(polls);
749 polls >= self.cancel_on_poll
750 }
751 }
752
753 fn load_fixture_doc(filename: &str) -> DjVuDocument {
754 let data = std::fs::read(
755 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
756 .join("tests/fixtures")
757 .join(filename),
758 )
759 .unwrap();
760 DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
761 }
762
763 fn g4_options() -> TiffOptions {
764 TiffOptions {
765 mode: TiffMode::Bilevel,
766 bilevel_compression: TiffBilevelCompression::G4,
767 ..Default::default()
768 }
769 }
770
771 fn load_multipage_g4_doc() -> DjVuDocument {
772 let data = std::fs::read(
773 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
774 .join("tests/corpus/cable_1973_100133.djvu"),
775 )
776 .unwrap();
777 DjVuDocument::parse(&data).unwrap()
778 }
779
780 fn legacy_all_in_memory_g4_tiff(doc: &DjVuDocument) -> Result<Vec<u8>, TiffError> {
783 let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
784 let mut pages = Vec::with_capacity(indices.len());
785 for page_index in indices {
786 pages.push(encode_bilevel_g4_page(doc, page_index)?);
787 }
788
789 let io = |e: std::io::Error| TiffError::Encode(e.to_string());
790 const NTAGS: u16 = 11;
791 let ifd_size = 2 + NTAGS as u32 * 12 + 4;
792 let mut offset: u32 = 8;
793 let mut layout = Vec::with_capacity(pages.len());
794 for (plan, g4) in &pages {
795 let strip_off = offset;
796 let rat_off = (strip_off + g4.len() as u32).div_ceil(2) * 2;
797 let ifd_off = (rat_off + 16).div_ceil(2) * 2;
798 layout.push((strip_off, rat_off, ifd_off));
799 offset = ifd_off + ifd_size;
800 assert_eq!(plan.encoded_len, g4.len() as u32);
801 }
802
803 let mut w = std::io::Cursor::new(Vec::new());
804 w.write_all(b"II\x2a\x00").map_err(io)?;
805 w.write_all(&layout[0].2.to_le_bytes()).map_err(io)?;
806
807 let entry = |tag: u16, typ: u16, count: u32, value: u32| -> [u8; 12] {
808 let mut e = [0u8; 12];
809 e[0..2].copy_from_slice(&tag.to_le_bytes());
810 e[2..4].copy_from_slice(&typ.to_le_bytes());
811 e[4..8].copy_from_slice(&count.to_le_bytes());
812 e[8..12].copy_from_slice(&value.to_le_bytes());
813 e
814 };
815
816 let mut pos: u32 = 8;
817 for (idx, ((plan, g4), &(strip_off, rat_off, ifd_off))) in
818 pages.iter().zip(&layout).enumerate()
819 {
820 debug_assert_eq!(pos, strip_off);
821 w.write_all(g4).map_err(io)?;
822 pos += g4.len() as u32;
823 while pos < rat_off {
824 w.write_all(&[0]).map_err(io)?;
825 pos += 1;
826 }
827 for _ in 0..2 {
828 w.write_all(&plan.dpi.to_le_bytes()).map_err(io)?;
829 w.write_all(&1u32.to_le_bytes()).map_err(io)?;
830 }
831 pos += 16;
832 while pos < ifd_off {
833 w.write_all(&[0]).map_err(io)?;
834 pos += 1;
835 }
836
837 w.write_all(&NTAGS.to_le_bytes()).map_err(io)?;
838 w.write_all(&entry(256, 4, 1, plan.width)).map_err(io)?;
839 w.write_all(&entry(257, 4, 1, plan.height)).map_err(io)?;
840 w.write_all(&entry(258, 3, 1, 1)).map_err(io)?;
841 w.write_all(&entry(259, 3, 1, 4)).map_err(io)?;
842 w.write_all(&entry(262, 3, 1, 0)).map_err(io)?;
843 w.write_all(&entry(273, 4, 1, strip_off)).map_err(io)?;
844 w.write_all(&entry(277, 3, 1, 1)).map_err(io)?;
845 w.write_all(&entry(278, 4, 1, plan.height)).map_err(io)?;
846 w.write_all(&entry(279, 4, 1, g4.len() as u32))
847 .map_err(io)?;
848 w.write_all(&entry(282, 5, 1, rat_off)).map_err(io)?;
849 w.write_all(&entry(283, 5, 1, rat_off + 8)).map_err(io)?;
850 let next = if idx + 1 < layout.len() {
851 layout[idx + 1].2
852 } else {
853 0
854 };
855 w.write_all(&next.to_le_bytes()).map_err(io)?;
856 pos = ifd_off + ifd_size;
857 }
858 w.flush().map_err(io)?;
859 Ok(w.into_inner())
860 }
861
862 #[test]
863 fn tiff_writer_observer_reports_each_page_in_order() {
864 let doc = load_fixture_doc("vega.djvu");
865 let total = doc.page_count();
866 let mut observer = RecordingObserver::default();
867
868 djvu_to_tiff_writer_with_observer(
869 &doc,
870 &TiffOptions::default(),
871 std::io::Cursor::new(Vec::new()),
872 &mut observer,
873 )
874 .expect("observer export must succeed");
875
876 assert_eq!(
877 observer.progress,
878 (1..=total).map(|done| (done, total)).collect::<Vec<_>>()
879 );
880 }
881
882 #[test]
883 fn tiff_writer_cancellation_stops_after_completed_page() {
884 let doc = load_fixture_doc("vega.djvu");
885 assert!(doc.page_count() > 1, "fixture must contain multiple pages");
886 let mut observer = RecordingObserver {
887 cancel_after: Some(1),
888 ..RecordingObserver::default()
889 };
890
891 let error = djvu_to_tiff_writer_with_observer(
892 &doc,
893 &TiffOptions::default(),
894 std::io::Cursor::new(Vec::new()),
895 &mut observer,
896 )
897 .expect_err("observer must cancel the export");
898
899 assert!(matches!(error, TiffError::Cancelled));
900 assert_eq!(observer.progress.len(), 1);
901 }
902
903 #[test]
904 fn tiff_default_writer_delegates_to_noop_observer() {
905 let doc = load_fixture_doc("vega.djvu");
906 let opts = TiffOptions::default();
907
908 let mut default_cursor = std::io::Cursor::new(Vec::new());
909 djvu_to_tiff_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_tiff_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 tiff_writer_failing_sink_returns_io_error() {
921 let doc = load_fixture_doc("chicken.djvu");
922 let error = djvu_to_tiff_writer(
923 &doc,
924 &TiffOptions::default(),
925 crate::export_test_support::FailingWriter::after(2),
926 )
927 .expect_err("injected sink failure must be returned");
928
929 assert!(
930 matches!(error, TiffError::Encode(message) if message.contains("injected sink failure"))
931 );
932 }
933
934 fn decode_first_tiff_rgb(tiff_bytes: &[u8]) -> (u32, u32, Vec<u8>) {
935 let cursor = std::io::Cursor::new(tiff_bytes);
936 let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
937 let (w, h) = decoder.dimensions().expect("must have dimensions");
938 let img = decoder.read_image().expect("image must decode");
939 let tiff::decoder::DecodingResult::U8(pixels) = img else {
940 panic!("expected RGB8 TIFF pixels");
941 };
942 (w, h, pixels)
943 }
944
945 fn assert_streamed_color_tiff_matches_render_pixmap(filename: &str) {
946 let doc = load_doc(filename);
947 let page = doc.page(0).unwrap();
948 let (_, _, render_opts) = color_render_options(page, 1.0);
949 assert!(
950 render_opts.can_stream(page),
951 "fixture should use the streaming TIFF color path"
952 );
953
954 let mut cursor = std::io::Cursor::new(Vec::new());
955 djvu_to_tiff_writer(&doc, &TiffOptions::default(), &mut cursor)
956 .expect("streamed TIFF writer must succeed");
957 let tiff_bytes = cursor.into_inner();
958 let (w, h, pixels) = decode_first_tiff_rgb(&tiff_bytes);
959
960 assert_eq!((w, h), (render_opts.width, render_opts.height));
961 let expected = djvu_render::render_pixmap(page, &render_opts)
962 .expect("render_pixmap must succeed")
963 .to_rgb();
964 assert_eq!(pixels, expected);
965 }
966
967 #[test]
971 fn color_export_produces_bytes() {
972 let doc = load_doc("chicken.djvu");
973 let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).expect("color export must succeed");
974 assert!(!tiff.is_empty(), "TIFF output must not be empty");
975 }
976
977 #[test]
979 fn output_starts_with_tiff_magic() {
980 let doc = load_doc("chicken.djvu");
981 let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
982 let magic = &tiff[..4];
983 assert!(
984 magic == b"II\x2A\x00" || magic == b"MM\x00\x2A",
985 "must start with TIFF magic, got: {magic:?}"
986 );
987 }
988
989 #[test]
991 fn bilevel_export_produces_bytes() {
992 let doc = load_doc("boy_jb2.djvu");
993 let opts = TiffOptions {
994 mode: TiffMode::Bilevel,
995 ..Default::default()
996 };
997 let tiff = djvu_to_tiff(&doc, &opts).expect("bilevel export must succeed");
998 assert!(!tiff.is_empty());
999 }
1000
1001 #[test]
1003 fn bilevel_output_starts_with_tiff_magic() {
1004 let doc = load_doc("boy_jb2.djvu");
1005 let opts = TiffOptions {
1006 mode: TiffMode::Bilevel,
1007 ..Default::default()
1008 };
1009 let tiff = djvu_to_tiff(&doc, &opts).unwrap();
1010 let magic = &tiff[..4];
1011 assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
1012 }
1013
1014 #[test]
1016 fn multipage_larger_than_single_page() {
1017 let doc_a = load_doc("chicken.djvu");
1020 let doc_b = load_doc("boy.djvu");
1021 let opts = TiffOptions::default();
1022
1023 let tiff_a = djvu_to_tiff(&doc_a, &opts).expect("page A export must succeed");
1024 let tiff_b = djvu_to_tiff(&doc_b, &opts).expect("page B export must succeed");
1025
1026 assert!(tiff_a.len() > 100, "page A TIFF must be non-trivial");
1028 assert!(tiff_b.len() > 100, "page B TIFF must be non-trivial");
1029 }
1030
1031 #[test]
1033 fn different_pages_produce_different_sizes() {
1034 let doc_a = load_doc("chicken.djvu");
1035 let doc_b = load_doc("boy.djvu");
1036 let opts = TiffOptions::default();
1037
1038 let tiff_a = djvu_to_tiff(&doc_a, &opts).unwrap();
1039 let tiff_b = djvu_to_tiff(&doc_b, &opts).unwrap();
1040 assert_ne!(
1042 tiff_a.len(),
1043 tiff_b.len(),
1044 "different pages must produce different TIFF sizes"
1045 );
1046 }
1047
1048 #[test]
1050 fn scale_factor_reduces_file_size() {
1051 let doc = load_doc("chicken.djvu");
1052 let full = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
1053 let half = djvu_to_tiff(
1054 &doc,
1055 &TiffOptions {
1056 scale: 0.5,
1057 ..Default::default()
1058 },
1059 )
1060 .unwrap();
1061 assert!(
1062 half.len() < full.len(),
1063 "half-scale TIFF must be smaller: half={} full={}",
1064 half.len(),
1065 full.len()
1066 );
1067 }
1068
1069 #[test]
1071 fn color_tiff_round_trips_via_tiff_decoder() {
1072 let doc = load_doc("chicken.djvu");
1073 let tiff_bytes = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
1074
1075 let cursor = std::io::Cursor::new(&tiff_bytes);
1076 let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
1077 let (w, h) = decoder.dimensions().expect("must have dimensions");
1079 let page = doc.page(0).unwrap();
1080 assert_eq!(w, page.width() as u32);
1081 assert_eq!(h, page.height() as u32);
1082 }
1083
1084 #[test]
1086 fn streamed_color_tiff_matches_render_pixmap_color_page() {
1087 assert_streamed_color_tiff_matches_render_pixmap("chicken.djvu");
1088 }
1089
1090 #[test]
1092 fn streamed_color_tiff_matches_render_pixmap_bilevel_page() {
1093 assert_streamed_color_tiff_matches_render_pixmap("boy_jb2.djvu");
1094 }
1095
1096 #[test]
1098 fn bilevel_jb2_page_has_black_pixels() {
1099 let doc = load_doc("boy_jb2.djvu");
1100 let opts = TiffOptions {
1101 mode: TiffMode::Bilevel,
1102 ..Default::default()
1103 };
1104 let tiff_bytes = djvu_to_tiff(&doc, &opts).unwrap();
1105
1106 let cursor = std::io::Cursor::new(&tiff_bytes);
1107 let mut decoder = tiff::decoder::Decoder::new(cursor).unwrap();
1108 let img = decoder.read_image().unwrap();
1109 if let tiff::decoder::DecodingResult::U8(pixels) = img {
1110 let has_black = pixels.contains(&255);
1111 assert!(
1112 has_black,
1113 "bilevel JB2 page must have at least one black pixel"
1114 );
1115 }
1116 }
1117
1118 #[test]
1120 fn bilevel_blank_when_no_jb2_mask() {
1121 let doc = load_doc("chicken.djvu");
1123 let page = doc.page(0).unwrap();
1124 let w = page.width() as u32;
1125 let h = page.height() as u32;
1126
1127 let pixels = extract_bilevel_pixels(page, w, h).unwrap();
1128 assert!(
1129 pixels.iter().all(|&p| p == 0),
1130 "page without JB2 must be all-white (0)"
1131 );
1132 }
1133
1134 #[test]
1137 fn color_export_rotated_page_uses_pixmap_path() {
1138 let doc = load_doc("boy_jb2_rotate90.djvu");
1139 let page = doc.page(0).unwrap();
1140 assert_ne!(
1142 page.rotation(),
1143 crate::info::Rotation::None,
1144 "fixture must have rotation set"
1145 );
1146 let tiff =
1147 djvu_to_tiff(&doc, &TiffOptions::default()).expect("rotated color export must succeed");
1148 assert!(!tiff.is_empty());
1149 let magic = &tiff[..4];
1150 assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
1151 }
1152
1153 #[test]
1155 fn tiff_options_default() {
1156 let opts = TiffOptions::default();
1157 assert_eq!(opts.mode, TiffMode::Color);
1158 assert!((opts.scale - 1.0).abs() < 1e-6);
1159 }
1160
1161 #[test]
1163 fn from_tiff_error_wraps_message() {
1164 let io_err = std::io::Error::other("test tiff failure");
1165 let tiff_err: tiff::TiffError = io_err.into();
1166 let djvu_tiff_err: TiffError = tiff_err.into();
1167 let s = djvu_tiff_err.to_string();
1168 assert!(
1169 s.contains("TIFF encoding error"),
1170 "must mention TIFF encoding error: {s}"
1171 );
1172 }
1173
1174 #[test]
1176 fn tiff_error_display() {
1177 let e = TiffError::Encode("something went wrong".to_string());
1178 assert!(e.to_string().contains("something went wrong"));
1179 }
1180
1181 #[test]
1185 fn bilevel_g4_tiff_round_trips_through_own_decoder() {
1186 let data = std::fs::read(
1187 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1188 .join("tests/fixtures/boy_jb2.djvu"),
1189 )
1190 .unwrap();
1191 let doc = DjVuDocument::parse(&data).unwrap();
1192 let tiff = djvu_to_tiff(
1193 &doc,
1194 &TiffOptions {
1195 mode: TiffMode::Bilevel,
1196 bilevel_compression: TiffBilevelCompression::G4,
1197 ..Default::default()
1198 },
1199 )
1200 .unwrap();
1201
1202 assert_eq!(&tiff[0..4], b"II\x2a\x00", "little-endian TIFF header");
1203 let rd32 = |o: usize| u32::from_le_bytes(tiff[o..o + 4].try_into().unwrap());
1204 let rd16 = |o: usize| u16::from_le_bytes(tiff[o..o + 2].try_into().unwrap());
1205 let ifd = rd32(4) as usize;
1206 let ntags = rd16(ifd) as usize;
1207 let mut tags = std::collections::BTreeMap::new();
1208 for i in 0..ntags {
1209 let e = ifd + 2 + i * 12;
1210 tags.insert(rd16(e), rd32(e + 8));
1211 }
1212 assert_eq!(tags[&259], 4, "Compression must be CCITT G4");
1213 assert_eq!(tags[&258], 1, "BitsPerSample must be 1");
1214 assert_eq!(tags[&262], 0, "Photometric must be min-is-white");
1215 assert_eq!(rd32(ifd + 2 + ntags * 12), 0, "single page: next IFD = 0");
1216
1217 let (w, h) = (tags[&256], tags[&257]);
1218 let off = tags[&273] as usize;
1219 let len = tags[&279] as usize;
1220 let mut chunk = Vec::with_capacity(4 + len);
1221 chunk.extend_from_slice(&(w as u16).to_be_bytes());
1222 chunk.extend_from_slice(&(h as u16).to_be_bytes());
1223 chunk.extend_from_slice(&tiff[off..off + len]);
1224 let decoded = crate::smmr::decode_smmr(&chunk).expect("G4 strip must decode");
1225
1226 let mask = doc.page(0).unwrap().extract_mask().unwrap().unwrap();
1227 assert_eq!((decoded.width, decoded.height), (w, h));
1228 for y in 0..h {
1229 for x in 0..w {
1230 assert_eq!(decoded.get(x, y), mask.get(x, y), "pixel ({x},{y})");
1231 }
1232 }
1233 }
1234
1235 #[test]
1237 fn bilevel_g4_tiff_multipage_chains_ifds() {
1238 let data = std::fs::read(
1239 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1240 .join("tests/corpus/cable_1973_100133.djvu"),
1241 )
1242 .unwrap();
1243 let doc = DjVuDocument::parse(&data).unwrap();
1244 let tiff = djvu_to_tiff(
1245 &doc,
1246 &TiffOptions {
1247 mode: TiffMode::Bilevel,
1248 bilevel_compression: TiffBilevelCompression::G4,
1249 ..Default::default()
1250 },
1251 )
1252 .unwrap();
1253 let rd32 = |o: usize| u32::from_le_bytes(tiff[o..o + 4].try_into().unwrap());
1254 let rd16 = |o: usize| u16::from_le_bytes(tiff[o..o + 2].try_into().unwrap());
1255 let mut ifd = rd32(4) as usize;
1256 let mut pages = 0;
1257 while ifd != 0 {
1258 let ntags = rd16(ifd) as usize;
1259 pages += 1;
1260 ifd = rd32(ifd + 2 + ntags * 12) as usize;
1261 }
1262 assert_eq!(pages, doc.page_count(), "one IFD per page");
1263 }
1264
1265 #[test]
1268 fn bilevel_g4_two_pass_matches_legacy_all_in_memory_bytes() {
1269 let doc = load_multipage_g4_doc();
1270 let expected = legacy_all_in_memory_g4_tiff(&doc).unwrap();
1271 let actual = djvu_to_tiff(&doc, &g4_options()).unwrap();
1272
1273 assert_eq!(actual, expected);
1274 }
1275
1276 #[test]
1278 fn bilevel_g4_two_pass_reports_each_written_page_in_order() {
1279 let doc = load_multipage_g4_doc();
1280 let total = doc.page_count();
1281 let mut observer = RecordingObserver::default();
1282
1283 djvu_to_tiff_writer_with_observer(
1284 &doc,
1285 &g4_options(),
1286 std::io::Cursor::new(Vec::new()),
1287 &mut observer,
1288 )
1289 .expect("G4 export must succeed");
1290
1291 assert_eq!(
1292 observer.progress,
1293 (1..=total).map(|done| (done, total)).collect::<Vec<_>>()
1294 );
1295 }
1296
1297 #[test]
1300 fn bilevel_g4_two_pass_cancels_during_sizing_pass() {
1301 let doc = load_multipage_g4_doc();
1302 assert!(doc.page_count() > 1, "fixture must contain multiple pages");
1303 let mut observer = CancelOnPollObserver {
1304 cancel_on_poll: 2,
1305 polls: Cell::new(0),
1306 progress: Vec::new(),
1307 };
1308
1309 let error = djvu_to_tiff_writer_with_observer(
1310 &doc,
1311 &g4_options(),
1312 std::io::Cursor::new(Vec::new()),
1313 &mut observer,
1314 )
1315 .expect_err("G4 sizing pass must observe cancellation");
1316
1317 assert!(matches!(error, TiffError::Cancelled));
1318 assert_eq!(observer.polls.get(), 2);
1319 assert!(observer.progress.is_empty());
1320 }
1321
1322 #[test]
1325 fn bilevel_g4_two_pass_cancels_during_emission_pass() {
1326 let doc = load_multipage_g4_doc();
1327 let total = doc.page_count();
1328 assert!(total > 1, "fixture must contain multiple pages");
1329 let mut observer = CancelOnPollObserver {
1330 cancel_on_poll: total + 2,
1333 polls: Cell::new(0),
1334 progress: Vec::new(),
1335 };
1336
1337 let error = djvu_to_tiff_writer_with_observer(
1338 &doc,
1339 &g4_options(),
1340 std::io::Cursor::new(Vec::new()),
1341 &mut observer,
1342 )
1343 .expect_err("G4 emission pass must observe cancellation");
1344
1345 assert!(matches!(error, TiffError::Cancelled));
1346 assert_eq!(observer.polls.get(), total + 2);
1347 assert_eq!(observer.progress, vec![(1, total)]);
1348 }
1349}