use std::io::{Cursor, Seek, Write};
use tiff::encoder::{Rational, TiffEncoder, colortype};
use tiff::tags::ResolutionUnit;
use crate::{
djvu_document::{DjVuDocument, DjVuPage, DocError},
djvu_render::{self, RenderError, RenderOptions},
};
#[derive(Debug, thiserror::Error)]
pub enum TiffError {
#[error("document error: {0}")]
Doc(#[from] DocError),
#[error("render error: {0}")]
Render(#[from] RenderError),
#[error("TIFF encoding error: {0}")]
Encode(String),
}
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)]
pub struct TiffOptions {
pub mode: TiffMode,
pub scale: f32,
}
impl Default for TiffOptions {
fn default() -> Self {
TiffOptions {
mode: TiffMode::Color,
scale: 1.0,
}
}
}
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 encoder = TiffEncoder::new(writer)?;
for i in crate::export_common::page_indices(doc, None) {
let page = doc.page(i)?;
match opts.mode {
TiffMode::Color => write_color_page(&mut encoder, page, opts.scale)?,
TiffMode::Bilevel => write_bilevel_page(&mut encoder, page)?,
}
}
Ok(())
}
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)
}
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(())
}
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(())
}
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::<colortype::Gray8>(w, h)?;
img.resolution(ResolutionUnit::Inch, Rational { n: dpi, d: 1 });
img.write_data(&gray)?;
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 mut pixels = Vec::with_capacity((w * h) as usize);
for y in 0..h {
for x in 0..w {
pixels.push(if bm.get(x, y) { 255u8 } else { 0u8 });
}
}
Ok(pixels)
}
#[cfg(test)]
mod tests {
use super::*;
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}"))
}
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"));
}
}