use std::io::{Cursor, Seek, Write};
use tiff::encoder::{Rational, TiffEncoder, colortype, compression::Deflate};
use tiff::tags::ResolutionUnit;
use crate::{
djvu_document::{DjVuDocument, DjVuPage, DocError},
djvu_render::{self, RenderError, RenderOptions},
export_control::{ExportObserver, NoOpObserver},
};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TiffError {
#[error("document error: {0}")]
Doc(#[from] DocError),
#[error("render error: {0}")]
Render(#[from] RenderError),
#[error("TIFF encoding error: {0}")]
Encode(String),
#[error("export cancelled")]
Cancelled,
}
impl From<tiff::TiffError> for TiffError {
fn from(e: tiff::TiffError) -> Self {
TiffError::Encode(e.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TiffMode {
#[default]
Color,
Bilevel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TiffBilevelCompression {
#[default]
Deflate,
G4,
}
#[derive(Debug, Clone)]
pub struct TiffOptions {
pub mode: TiffMode,
pub scale: f32,
pub bilevel_compression: TiffBilevelCompression,
}
impl Default for TiffOptions {
fn default() -> Self {
TiffOptions {
mode: TiffMode::Color,
scale: 1.0,
bilevel_compression: TiffBilevelCompression::default(),
}
}
}
pub fn djvu_to_tiff(doc: &DjVuDocument, opts: &TiffOptions) -> Result<Vec<u8>, TiffError> {
let mut buf: Vec<u8> = Vec::new();
{
let cursor = Cursor::new(&mut buf);
djvu_to_tiff_writer(doc, opts, cursor)?;
}
Ok(buf)
}
pub fn djvu_to_tiff_writer<W: Write + Seek>(
doc: &DjVuDocument,
opts: &TiffOptions,
writer: W,
) -> Result<(), TiffError> {
let mut observer = NoOpObserver;
djvu_to_tiff_writer_with_observer(doc, opts, writer, &mut observer)
}
pub fn djvu_to_tiff_writer_with_observer<W: Write + Seek>(
doc: &DjVuDocument,
opts: &TiffOptions,
writer: W,
observer: &mut dyn ExportObserver,
) -> Result<(), TiffError> {
if opts.mode == TiffMode::Bilevel && opts.bilevel_compression == TiffBilevelCompression::G4 {
return write_bilevel_g4_tiff(doc, writer, observer);
}
let mut encoder = TiffEncoder::new(writer)?;
let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
let total = indices.len();
#[cfg(feature = "parallel")]
{
use rayon::prelude::*;
if observer.cancelled() {
return Err(TiffError::Cancelled);
}
let images: Vec<PageImage> = indices
.par_iter()
.map(|&i| {
let page = doc.page(i)?.clone();
build_page_image(&page, opts)
})
.collect::<Result<Vec<_>, TiffError>>()?;
for (done, img) in images.iter().enumerate() {
if observer.cancelled() {
return Err(TiffError::Cancelled);
}
write_page_image(&mut encoder, img)?;
observer.on_progress(done + 1, total);
}
}
#[cfg(not(feature = "parallel"))]
for (done, &i) in indices.iter().enumerate() {
if observer.cancelled() {
return Err(TiffError::Cancelled);
}
let page = doc.page(i)?.clone();
match opts.mode {
TiffMode::Color => write_color_page(&mut encoder, &page, opts.scale)?,
TiffMode::Bilevel => write_bilevel_page(&mut encoder, &page)?,
}
observer.on_progress(done + 1, total);
}
Ok(())
}
#[cfg(feature = "parallel")]
struct PageImage {
w: u32,
h: u32,
dpi: u32,
data: PageImageData,
}
#[cfg(feature = "parallel")]
enum PageImageData {
Rgb(Vec<u8>),
GrayDeflate(Vec<u8>),
}
#[cfg(feature = "parallel")]
fn build_page_image(page: &DjVuPage, opts: &TiffOptions) -> Result<PageImage, TiffError> {
match opts.mode {
TiffMode::Color => {
let (w, h, ropts) = color_render_options(page, opts.scale);
let dpi = (page.dpi() as f32 * opts.scale).round() as u32;
let rgb = if ropts.can_stream(page) {
let mut rgb = Vec::with_capacity(w as usize * h as usize * 3);
djvu_render::render_streaming(page, &ropts, |_, rgba_row| {
crate::export_common::rgba_row_to_rgb(&mut rgb, rgba_row);
})?;
rgb
} else {
djvu_render::render_pixmap(page, &ropts)?.to_rgb()
};
Ok(PageImage {
w,
h,
dpi,
data: PageImageData::Rgb(rgb),
})
}
TiffMode::Bilevel => {
let w = page.width() as u32;
let h = page.height() as u32;
let gray = extract_bilevel_pixels(page, w, h)?;
let dpi = page.dpi() as u32;
Ok(PageImage {
w,
h,
dpi,
data: PageImageData::GrayDeflate(gray),
})
}
}
}
#[cfg(feature = "parallel")]
fn write_page_image<W: Write + Seek>(
encoder: &mut TiffEncoder<W>,
img: &PageImage,
) -> Result<(), TiffError> {
match &img.data {
PageImageData::Rgb(rgb) => {
let mut image = encoder.new_image::<colortype::RGB8>(img.w, img.h)?;
image.resolution(ResolutionUnit::Inch, Rational { n: img.dpi, d: 1 });
image.write_data(rgb)?;
}
PageImageData::GrayDeflate(gray) => {
let mut image = encoder.new_image_with_compression::<colortype::Gray8, _>(
img.w,
img.h,
Deflate::default(),
)?;
image.resolution(ResolutionUnit::Inch, Rational { n: img.dpi, d: 1 });
image.write_data(gray)?;
}
}
Ok(())
}
#[cfg(not(feature = "parallel"))]
fn write_color_page<W: Write + Seek>(
encoder: &mut TiffEncoder<W>,
page: &DjVuPage,
scale: f32,
) -> Result<(), TiffError> {
let (w, h, opts) = color_render_options(page, scale);
let dpi = (page.dpi() as f32 * scale).round() as u32;
if opts.can_stream(page) {
write_color_page_streaming(encoder, page, &opts, w, h, dpi)
} else {
write_color_page_pixmap(encoder, page, &opts, w, h, dpi)
}
}
fn color_render_options(page: &DjVuPage, scale: f32) -> (u32, u32, RenderOptions) {
let (w, h) =
crate::export_common::scaled_size(page.width() as u32, page.height() as u32, scale);
let opts = RenderOptions {
width: w,
height: h,
..RenderOptions::default()
};
(w, h, opts)
}
#[cfg(not(feature = "parallel"))]
fn write_color_page_streaming<W: Write + Seek>(
encoder: &mut TiffEncoder<W>,
page: &DjVuPage,
opts: &RenderOptions,
w: u32,
h: u32,
dpi: u32,
) -> Result<(), TiffError> {
let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
let mut next_strip_samples = img.next_strip_sample_count() as usize;
let mut strip = Vec::with_capacity(next_strip_samples);
let mut encode_error: Option<tiff::TiffError> = None;
djvu_render::render_streaming(page, opts, |_, rgba_row| {
if encode_error.is_some() {
return;
}
crate::export_common::rgba_row_to_rgb(&mut strip, rgba_row);
if strip.len() > next_strip_samples {
encode_error = Some(
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"streamed RGB strip exceeded expected TIFF strip size",
)
.into(),
);
return;
}
if strip.len() == next_strip_samples {
if let Err(e) = img.write_strip(&strip) {
encode_error = Some(e);
return;
}
strip.clear();
next_strip_samples = img.next_strip_sample_count() as usize;
}
})?;
if let Some(e) = encode_error {
return Err(e.into());
}
if !strip.is_empty() || img.next_strip_sample_count() != 0 {
return Err(TiffError::Encode(
"streamed render ended before all TIFF strips were written".to_string(),
));
}
img.finish()?;
Ok(())
}
#[cfg(not(feature = "parallel"))]
fn write_color_page_pixmap<W: Write + Seek>(
encoder: &mut TiffEncoder<W>,
page: &DjVuPage,
opts: &RenderOptions,
w: u32,
h: u32,
dpi: u32,
) -> Result<(), TiffError> {
let pixmap = djvu_render::render_pixmap(page, opts)?;
let rgb = pixmap.to_rgb();
let mut img = encoder.new_image::<colortype::RGB8>(w, h)?;
img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
img.write_data(&rgb)?;
Ok(())
}
#[cfg(not(feature = "parallel"))]
fn write_bilevel_page<W: std::io::Write + std::io::Seek>(
encoder: &mut TiffEncoder<W>,
page: &DjVuPage,
) -> Result<(), TiffError> {
let w = page.width() as u32;
let h = page.height() as u32;
let gray = extract_bilevel_pixels(page, w, h)?;
let dpi = page.dpi() as u32;
let mut img =
encoder.new_image_with_compression::<colortype::Gray8, _>(w, h, Deflate::default())?;
img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
img.write_data(&gray)?;
Ok(())
}
#[derive(Clone, Copy)]
struct G4PagePlan {
width: u32,
height: u32,
dpi: u32,
encoded_len: u32,
}
#[derive(Clone, Copy)]
struct G4PageLayout {
strip_offset: u32,
rational_offset: u32,
ifd_offset: u32,
}
fn encode_bilevel_g4_page(
doc: &DjVuDocument,
page_index: usize,
) -> Result<(G4PagePlan, Vec<u8>), TiffError> {
let page = doc.page(page_index)?.clone();
let width = page.width() as u32;
let height = page.height() as u32;
let dpi = page.dpi().max(1) as u32;
let mask = page
.extract_mask()
.map_err(TiffError::Doc)?
.filter(|m| m.width >= width && m.height >= height)
.unwrap_or_else(|| crate::bitmap::Bitmap::new(width, height));
let encoded = crate::smmr::encode_g4(&mask);
let plan = G4PagePlan {
width,
height,
dpi,
encoded_len: encoded.len() as u32,
};
Ok((plan, encoded))
}
fn write_bilevel_g4_tiff<W: Write + Seek>(
doc: &DjVuDocument,
mut w: W,
observer: &mut dyn ExportObserver,
) -> Result<(), TiffError> {
let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
let mut plans = Vec::with_capacity(indices.len());
for &page_index in &indices {
if observer.cancelled() {
return Err(TiffError::Cancelled);
}
let (plan, encoded) = encode_bilevel_g4_page(doc, page_index)?;
drop(encoded);
plans.push(plan);
}
let io = |e: std::io::Error| TiffError::Encode(e.to_string());
const NTAGS: u16 = 11;
let ifd_size = 2 + NTAGS as u32 * 12 + 4; let mut offset: u32 = 8;
let mut layout = Vec::with_capacity(plans.len());
for plan in &plans {
let strip_offset = offset;
let rational_offset = (strip_offset + plan.encoded_len).div_ceil(2) * 2;
let ifd_offset = (rational_offset + 16).div_ceil(2) * 2;
layout.push(G4PageLayout {
strip_offset,
rational_offset,
ifd_offset,
});
offset = ifd_offset + ifd_size;
}
w.write_all(b"II\x2a\x00").map_err(io)?;
w.write_all(&layout[0].ifd_offset.to_le_bytes())
.map_err(io)?;
let entry = |tag: u16, typ: u16, count: u32, value: u32| -> [u8; 12] {
let mut e = [0u8; 12];
e[0..2].copy_from_slice(&tag.to_le_bytes());
e[2..4].copy_from_slice(&typ.to_le_bytes());
e[4..8].copy_from_slice(&count.to_le_bytes());
e[8..12].copy_from_slice(&value.to_le_bytes());
e
};
let mut pos: u32 = 8;
for (idx, ((&page_index, plan), page_layout)) in
indices.iter().zip(&plans).zip(&layout).enumerate()
{
if observer.cancelled() {
return Err(TiffError::Cancelled);
}
let (emitted_plan, encoded) = encode_bilevel_g4_page(doc, page_index)?;
debug_assert_eq!(plan.encoded_len, emitted_plan.encoded_len);
if plan.encoded_len != emitted_plan.encoded_len {
return Err(TiffError::Encode(format!(
"G4 encoded length diverged between sizing and emission passes for page {}",
idx + 1
)));
}
debug_assert_eq!(pos, page_layout.strip_offset);
w.write_all(&encoded).map_err(io)?;
pos += emitted_plan.encoded_len;
while pos < page_layout.rational_offset {
w.write_all(&[0]).map_err(io)?;
pos += 1;
}
for _ in 0..2 {
w.write_all(&plan.dpi.to_le_bytes()).map_err(io)?;
w.write_all(&1u32.to_le_bytes()).map_err(io)?;
}
pos += 16;
while pos < page_layout.ifd_offset {
w.write_all(&[0]).map_err(io)?;
pos += 1;
}
w.write_all(&NTAGS.to_le_bytes()).map_err(io)?;
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))
.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))
.map_err(io)?; w.write_all(&entry(282, 5, 1, page_layout.rational_offset))
.map_err(io)?; w.write_all(&entry(283, 5, 1, page_layout.rational_offset + 8))
.map_err(io)?; let next = if idx + 1 < layout.len() {
layout[idx + 1].ifd_offset
} else {
0
};
w.write_all(&next.to_le_bytes()).map_err(io)?;
pos = page_layout.ifd_offset + ifd_size;
observer.on_progress(idx + 1, plans.len());
}
w.flush().map_err(io)?;
Ok(())
}
fn extract_bilevel_pixels(page: &DjVuPage, w: u32, h: u32) -> Result<Vec<u8>, TiffError> {
let sjbz = match page.find_chunk(b"Sjbz") {
Some(d) => d,
None => return Ok(vec![0u8; (w * h) as usize]),
};
let dict = page
.find_chunk(b"Djbz")
.and_then(|djbz| crate::jb2::decode_dict(djbz, None).ok());
let bm = crate::jb2::decode(sjbz, dict.as_ref())
.map_err(|e| TiffError::Encode(format!("JB2 decode failed: {e}")))?;
let wq = w as usize;
let mut pixels = vec![0u8; wq * h as usize];
if bm.width >= w && bm.height >= h {
let stride = bm.row_stride();
let nb_full = wq / 8;
let rem = wq % 8;
for y in 0..h as usize {
let row = &bm.data[y * stride..];
let out = &mut pixels[y * wq..(y + 1) * wq];
for bi in 0..nb_full {
out[bi * 8..bi * 8 + 8].copy_from_slice(&BILEVEL_GRAY8[row[bi] as usize]);
}
if rem > 0 {
let src = &BILEVEL_GRAY8[row[nb_full] as usize];
out[nb_full * 8..nb_full * 8 + rem].copy_from_slice(&src[..rem]);
}
}
return Ok(pixels);
}
for y in 0..h {
for x in 0..w {
pixels[(y * w + x) as usize] = if bm.get(x, y) { 255u8 } else { 0u8 };
}
}
Ok(pixels)
}
const BILEVEL_GRAY8: [[u8; 8]; 256] = {
let mut lut = [[0u8; 8]; 256];
let mut mb = 0usize;
while mb < 256 {
let mut j = 0usize;
while j < 8 {
lut[mb][j] = if (mb >> (7 - j)) & 1 != 0 { 255u8 } else { 0u8 };
j += 1;
}
mb += 1;
}
lut
};
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use crate::djvu_render;
fn assets_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("references/djvujs/library/assets")
}
fn load_doc(filename: &str) -> DjVuDocument {
let data = std::fs::read(assets_path().join(filename))
.unwrap_or_else(|_| panic!("{filename} must exist"));
DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
}
#[derive(Default)]
struct RecordingObserver {
progress: Vec<(usize, usize)>,
cancel_after: Option<usize>,
}
impl ExportObserver for RecordingObserver {
fn on_progress(&mut self, done: usize, total: usize) {
self.progress.push((done, total));
}
fn cancelled(&self) -> bool {
self.cancel_after
.is_some_and(|after| self.progress.len() >= after)
}
}
struct CancelOnPollObserver {
cancel_on_poll: usize,
polls: Cell<usize>,
progress: Vec<(usize, usize)>,
}
impl ExportObserver for CancelOnPollObserver {
fn on_progress(&mut self, done: usize, total: usize) {
self.progress.push((done, total));
}
fn cancelled(&self) -> bool {
let polls = self.polls.get() + 1;
self.polls.set(polls);
polls >= self.cancel_on_poll
}
}
fn load_fixture_doc(filename: &str) -> DjVuDocument {
let data = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(filename),
)
.unwrap();
DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
}
fn g4_options() -> TiffOptions {
TiffOptions {
mode: TiffMode::Bilevel,
bilevel_compression: TiffBilevelCompression::G4,
..Default::default()
}
}
fn load_multipage_g4_doc() -> DjVuDocument {
let data = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/corpus/cable_1973_100133.djvu"),
)
.unwrap();
DjVuDocument::parse(&data).unwrap()
}
fn legacy_all_in_memory_g4_tiff(doc: &DjVuDocument) -> Result<Vec<u8>, TiffError> {
let indices: Vec<usize> = crate::export_common::page_indices(doc, None).collect();
let mut pages = Vec::with_capacity(indices.len());
for page_index in indices {
pages.push(encode_bilevel_g4_page(doc, page_index)?);
}
let io = |e: std::io::Error| TiffError::Encode(e.to_string());
const NTAGS: u16 = 11;
let ifd_size = 2 + NTAGS as u32 * 12 + 4;
let mut offset: u32 = 8;
let mut layout = Vec::with_capacity(pages.len());
for (plan, g4) in &pages {
let strip_off = offset;
let rat_off = (strip_off + g4.len() as u32).div_ceil(2) * 2;
let ifd_off = (rat_off + 16).div_ceil(2) * 2;
layout.push((strip_off, rat_off, ifd_off));
offset = ifd_off + ifd_size;
assert_eq!(plan.encoded_len, g4.len() as u32);
}
let mut w = std::io::Cursor::new(Vec::new());
w.write_all(b"II\x2a\x00").map_err(io)?;
w.write_all(&layout[0].2.to_le_bytes()).map_err(io)?;
let entry = |tag: u16, typ: u16, count: u32, value: u32| -> [u8; 12] {
let mut e = [0u8; 12];
e[0..2].copy_from_slice(&tag.to_le_bytes());
e[2..4].copy_from_slice(&typ.to_le_bytes());
e[4..8].copy_from_slice(&count.to_le_bytes());
e[8..12].copy_from_slice(&value.to_le_bytes());
e
};
let mut pos: u32 = 8;
for (idx, ((plan, g4), &(strip_off, rat_off, ifd_off))) in
pages.iter().zip(&layout).enumerate()
{
debug_assert_eq!(pos, strip_off);
w.write_all(g4).map_err(io)?;
pos += g4.len() as u32;
while pos < rat_off {
w.write_all(&[0]).map_err(io)?;
pos += 1;
}
for _ in 0..2 {
w.write_all(&plan.dpi.to_le_bytes()).map_err(io)?;
w.write_all(&1u32.to_le_bytes()).map_err(io)?;
}
pos += 16;
while pos < ifd_off {
w.write_all(&[0]).map_err(io)?;
pos += 1;
}
w.write_all(&NTAGS.to_le_bytes()).map_err(io)?;
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, strip_off)).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, g4.len() as u32))
.map_err(io)?;
w.write_all(&entry(282, 5, 1, rat_off)).map_err(io)?;
w.write_all(&entry(283, 5, 1, rat_off + 8)).map_err(io)?;
let next = if idx + 1 < layout.len() {
layout[idx + 1].2
} else {
0
};
w.write_all(&next.to_le_bytes()).map_err(io)?;
pos = ifd_off + ifd_size;
}
w.flush().map_err(io)?;
Ok(w.into_inner())
}
#[test]
fn tiff_writer_observer_reports_each_page_in_order() {
let doc = load_fixture_doc("vega.djvu");
let total = doc.page_count();
let mut observer = RecordingObserver::default();
djvu_to_tiff_writer_with_observer(
&doc,
&TiffOptions::default(),
std::io::Cursor::new(Vec::new()),
&mut observer,
)
.expect("observer export must succeed");
assert_eq!(
observer.progress,
(1..=total).map(|done| (done, total)).collect::<Vec<_>>()
);
}
#[test]
fn tiff_writer_cancellation_stops_after_completed_page() {
let doc = load_fixture_doc("vega.djvu");
assert!(doc.page_count() > 1, "fixture must contain multiple pages");
let mut observer = RecordingObserver {
cancel_after: Some(1),
..RecordingObserver::default()
};
let error = djvu_to_tiff_writer_with_observer(
&doc,
&TiffOptions::default(),
std::io::Cursor::new(Vec::new()),
&mut observer,
)
.expect_err("observer must cancel the export");
assert!(matches!(error, TiffError::Cancelled));
assert_eq!(observer.progress.len(), 1);
}
#[test]
fn tiff_default_writer_delegates_to_noop_observer() {
let doc = load_fixture_doc("vega.djvu");
let opts = TiffOptions::default();
let mut default_cursor = std::io::Cursor::new(Vec::new());
djvu_to_tiff_writer(&doc, &opts, &mut default_cursor).unwrap();
let mut observed_cursor = std::io::Cursor::new(Vec::new());
let mut observer = NoOpObserver;
djvu_to_tiff_writer_with_observer(&doc, &opts, &mut observed_cursor, &mut observer)
.unwrap();
assert_eq!(observed_cursor.into_inner(), default_cursor.into_inner());
}
#[test]
fn tiff_writer_failing_sink_returns_io_error() {
let doc = load_fixture_doc("chicken.djvu");
let error = djvu_to_tiff_writer(
&doc,
&TiffOptions::default(),
crate::export_test_support::FailingWriter::after(2),
)
.expect_err("injected sink failure must be returned");
assert!(
matches!(error, TiffError::Encode(message) if message.contains("injected sink failure"))
);
}
fn decode_first_tiff_rgb(tiff_bytes: &[u8]) -> (u32, u32, Vec<u8>) {
let cursor = std::io::Cursor::new(tiff_bytes);
let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
let (w, h) = decoder.dimensions().expect("must have dimensions");
let img = decoder.read_image().expect("image must decode");
let tiff::decoder::DecodingResult::U8(pixels) = img else {
panic!("expected RGB8 TIFF pixels");
};
(w, h, pixels)
}
fn assert_streamed_color_tiff_matches_render_pixmap(filename: &str) {
let doc = load_doc(filename);
let page = doc.page(0).unwrap();
let (_, _, render_opts) = color_render_options(page, 1.0);
assert!(
render_opts.can_stream(page),
"fixture should use the streaming TIFF color path"
);
let mut cursor = std::io::Cursor::new(Vec::new());
djvu_to_tiff_writer(&doc, &TiffOptions::default(), &mut cursor)
.expect("streamed TIFF writer must succeed");
let tiff_bytes = cursor.into_inner();
let (w, h, pixels) = decode_first_tiff_rgb(&tiff_bytes);
assert_eq!((w, h), (render_opts.width, render_opts.height));
let expected = djvu_render::render_pixmap(page, &render_opts)
.expect("render_pixmap must succeed")
.to_rgb();
assert_eq!(pixels, expected);
}
#[test]
fn color_export_produces_bytes() {
let doc = load_doc("chicken.djvu");
let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).expect("color export must succeed");
assert!(!tiff.is_empty(), "TIFF output must not be empty");
}
#[test]
fn output_starts_with_tiff_magic() {
let doc = load_doc("chicken.djvu");
let tiff = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
let magic = &tiff[..4];
assert!(
magic == b"II\x2A\x00" || magic == b"MM\x00\x2A",
"must start with TIFF magic, got: {magic:?}"
);
}
#[test]
fn bilevel_export_produces_bytes() {
let doc = load_doc("boy_jb2.djvu");
let opts = TiffOptions {
mode: TiffMode::Bilevel,
..Default::default()
};
let tiff = djvu_to_tiff(&doc, &opts).expect("bilevel export must succeed");
assert!(!tiff.is_empty());
}
#[test]
fn bilevel_output_starts_with_tiff_magic() {
let doc = load_doc("boy_jb2.djvu");
let opts = TiffOptions {
mode: TiffMode::Bilevel,
..Default::default()
};
let tiff = djvu_to_tiff(&doc, &opts).unwrap();
let magic = &tiff[..4];
assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
}
#[test]
fn multipage_larger_than_single_page() {
let doc_a = load_doc("chicken.djvu");
let doc_b = load_doc("boy.djvu");
let opts = TiffOptions::default();
let tiff_a = djvu_to_tiff(&doc_a, &opts).expect("page A export must succeed");
let tiff_b = djvu_to_tiff(&doc_b, &opts).expect("page B export must succeed");
assert!(tiff_a.len() > 100, "page A TIFF must be non-trivial");
assert!(tiff_b.len() > 100, "page B TIFF must be non-trivial");
}
#[test]
fn different_pages_produce_different_sizes() {
let doc_a = load_doc("chicken.djvu");
let doc_b = load_doc("boy.djvu");
let opts = TiffOptions::default();
let tiff_a = djvu_to_tiff(&doc_a, &opts).unwrap();
let tiff_b = djvu_to_tiff(&doc_b, &opts).unwrap();
assert_ne!(
tiff_a.len(),
tiff_b.len(),
"different pages must produce different TIFF sizes"
);
}
#[test]
fn scale_factor_reduces_file_size() {
let doc = load_doc("chicken.djvu");
let full = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
let half = djvu_to_tiff(
&doc,
&TiffOptions {
scale: 0.5,
..Default::default()
},
)
.unwrap();
assert!(
half.len() < full.len(),
"half-scale TIFF must be smaller: half={} full={}",
half.len(),
full.len()
);
}
#[test]
fn color_tiff_round_trips_via_tiff_decoder() {
let doc = load_doc("chicken.djvu");
let tiff_bytes = djvu_to_tiff(&doc, &TiffOptions::default()).unwrap();
let cursor = std::io::Cursor::new(&tiff_bytes);
let mut decoder = tiff::decoder::Decoder::new(cursor).expect("tiff must be decodable");
let (w, h) = decoder.dimensions().expect("must have dimensions");
let page = doc.page(0).unwrap();
assert_eq!(w, page.width() as u32);
assert_eq!(h, page.height() as u32);
}
#[test]
fn streamed_color_tiff_matches_render_pixmap_color_page() {
assert_streamed_color_tiff_matches_render_pixmap("chicken.djvu");
}
#[test]
fn streamed_color_tiff_matches_render_pixmap_bilevel_page() {
assert_streamed_color_tiff_matches_render_pixmap("boy_jb2.djvu");
}
#[test]
fn bilevel_jb2_page_has_black_pixels() {
let doc = load_doc("boy_jb2.djvu");
let opts = TiffOptions {
mode: TiffMode::Bilevel,
..Default::default()
};
let tiff_bytes = djvu_to_tiff(&doc, &opts).unwrap();
let cursor = std::io::Cursor::new(&tiff_bytes);
let mut decoder = tiff::decoder::Decoder::new(cursor).unwrap();
let img = decoder.read_image().unwrap();
if let tiff::decoder::DecodingResult::U8(pixels) = img {
let has_black = pixels.contains(&255);
assert!(
has_black,
"bilevel JB2 page must have at least one black pixel"
);
}
}
#[test]
fn bilevel_blank_when_no_jb2_mask() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let w = page.width() as u32;
let h = page.height() as u32;
let pixels = extract_bilevel_pixels(page, w, h).unwrap();
assert!(
pixels.iter().all(|&p| p == 0),
"page without JB2 must be all-white (0)"
);
}
#[test]
fn color_export_rotated_page_uses_pixmap_path() {
let doc = load_doc("boy_jb2_rotate90.djvu");
let page = doc.page(0).unwrap();
assert_ne!(
page.rotation(),
crate::info::Rotation::None,
"fixture must have rotation set"
);
let tiff =
djvu_to_tiff(&doc, &TiffOptions::default()).expect("rotated color export must succeed");
assert!(!tiff.is_empty());
let magic = &tiff[..4];
assert!(magic == b"II\x2A\x00" || magic == b"MM\x00\x2A");
}
#[test]
fn tiff_options_default() {
let opts = TiffOptions::default();
assert_eq!(opts.mode, TiffMode::Color);
assert!((opts.scale - 1.0).abs() < 1e-6);
}
#[test]
fn from_tiff_error_wraps_message() {
let io_err = std::io::Error::other("test tiff failure");
let tiff_err: tiff::TiffError = io_err.into();
let djvu_tiff_err: TiffError = tiff_err.into();
let s = djvu_tiff_err.to_string();
assert!(
s.contains("TIFF encoding error"),
"must mention TIFF encoding error: {s}"
);
}
#[test]
fn tiff_error_display() {
let e = TiffError::Encode("something went wrong".to_string());
assert!(e.to_string().contains("something went wrong"));
}
#[test]
fn bilevel_g4_tiff_round_trips_through_own_decoder() {
let data = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/boy_jb2.djvu"),
)
.unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let tiff = djvu_to_tiff(
&doc,
&TiffOptions {
mode: TiffMode::Bilevel,
bilevel_compression: TiffBilevelCompression::G4,
..Default::default()
},
)
.unwrap();
assert_eq!(&tiff[0..4], b"II\x2a\x00", "little-endian TIFF header");
let rd32 = |o: usize| u32::from_le_bytes(tiff[o..o + 4].try_into().unwrap());
let rd16 = |o: usize| u16::from_le_bytes(tiff[o..o + 2].try_into().unwrap());
let ifd = rd32(4) as usize;
let ntags = rd16(ifd) as usize;
let mut tags = std::collections::BTreeMap::new();
for i in 0..ntags {
let e = ifd + 2 + i * 12;
tags.insert(rd16(e), rd32(e + 8));
}
assert_eq!(tags[&259], 4, "Compression must be CCITT G4");
assert_eq!(tags[&258], 1, "BitsPerSample must be 1");
assert_eq!(tags[&262], 0, "Photometric must be min-is-white");
assert_eq!(rd32(ifd + 2 + ntags * 12), 0, "single page: next IFD = 0");
let (w, h) = (tags[&256], tags[&257]);
let off = tags[&273] as usize;
let len = tags[&279] as usize;
let mut chunk = Vec::with_capacity(4 + len);
chunk.extend_from_slice(&(w as u16).to_be_bytes());
chunk.extend_from_slice(&(h as u16).to_be_bytes());
chunk.extend_from_slice(&tiff[off..off + len]);
let decoded = crate::smmr::decode_smmr(&chunk).expect("G4 strip must decode");
let mask = doc.page(0).unwrap().extract_mask().unwrap().unwrap();
assert_eq!((decoded.width, decoded.height), (w, h));
for y in 0..h {
for x in 0..w {
assert_eq!(decoded.get(x, y), mask.get(x, y), "pixel ({x},{y})");
}
}
}
#[test]
fn bilevel_g4_tiff_multipage_chains_ifds() {
let data = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/corpus/cable_1973_100133.djvu"),
)
.unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let tiff = djvu_to_tiff(
&doc,
&TiffOptions {
mode: TiffMode::Bilevel,
bilevel_compression: TiffBilevelCompression::G4,
..Default::default()
},
)
.unwrap();
let rd32 = |o: usize| u32::from_le_bytes(tiff[o..o + 4].try_into().unwrap());
let rd16 = |o: usize| u16::from_le_bytes(tiff[o..o + 2].try_into().unwrap());
let mut ifd = rd32(4) as usize;
let mut pages = 0;
while ifd != 0 {
let ntags = rd16(ifd) as usize;
pages += 1;
ifd = rd32(ifd + 2 + ntags * 12) as usize;
}
assert_eq!(pages, doc.page_count(), "one IFD per page");
}
#[test]
fn bilevel_g4_two_pass_matches_legacy_all_in_memory_bytes() {
let doc = load_multipage_g4_doc();
let expected = legacy_all_in_memory_g4_tiff(&doc).unwrap();
let actual = djvu_to_tiff(&doc, &g4_options()).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn bilevel_g4_two_pass_reports_each_written_page_in_order() {
let doc = load_multipage_g4_doc();
let total = doc.page_count();
let mut observer = RecordingObserver::default();
djvu_to_tiff_writer_with_observer(
&doc,
&g4_options(),
std::io::Cursor::new(Vec::new()),
&mut observer,
)
.expect("G4 export must succeed");
assert_eq!(
observer.progress,
(1..=total).map(|done| (done, total)).collect::<Vec<_>>()
);
}
#[test]
fn bilevel_g4_two_pass_cancels_during_sizing_pass() {
let doc = load_multipage_g4_doc();
assert!(doc.page_count() > 1, "fixture must contain multiple pages");
let mut observer = CancelOnPollObserver {
cancel_on_poll: 2,
polls: Cell::new(0),
progress: Vec::new(),
};
let error = djvu_to_tiff_writer_with_observer(
&doc,
&g4_options(),
std::io::Cursor::new(Vec::new()),
&mut observer,
)
.expect_err("G4 sizing pass must observe cancellation");
assert!(matches!(error, TiffError::Cancelled));
assert_eq!(observer.polls.get(), 2);
assert!(observer.progress.is_empty());
}
#[test]
fn bilevel_g4_two_pass_cancels_during_emission_pass() {
let doc = load_multipage_g4_doc();
let total = doc.page_count();
assert!(total > 1, "fixture must contain multiple pages");
let mut observer = CancelOnPollObserver {
cancel_on_poll: total + 2,
polls: Cell::new(0),
progress: Vec::new(),
};
let error = djvu_to_tiff_writer_with_observer(
&doc,
&g4_options(),
std::io::Cursor::new(Vec::new()),
&mut observer,
)
.expect_err("G4 emission pass must observe cancellation");
assert!(matches!(error, TiffError::Cancelled));
assert_eq!(observer.polls.get(), total + 2);
assert_eq!(observer.progress, vec![(1, total)]);
}
}