#[cfg(not(feature = "std"))]
use alloc::{format, string::String, vec, vec::Vec};
use crate::{
annotation::Shape,
djvu_document::{DjVuBookmark, DjVuDocument, DjVuPage, DocError},
djvu_render::{self, RenderOptions},
text::{TextZone, TextZoneKind},
};
#[derive(Debug, thiserror::Error)]
pub enum PdfError {
#[error("document error: {0}")]
Doc(#[from] DocError),
#[error("render error: {0}")]
Render(#[from] djvu_render::RenderError),
}
struct PdfObj {
id: usize,
body: Vec<u8>,
}
struct PdfWriter {
objects: Vec<PdfObj>,
next_id: usize,
}
impl PdfWriter {
fn new() -> Self {
PdfWriter {
objects: Vec::new(),
next_id: 1,
}
}
fn alloc_id(&mut self) -> usize {
let id = self.next_id;
self.next_id += 1;
id
}
fn add_obj(&mut self, id: usize, body: Vec<u8>) {
self.objects.push(PdfObj { id, body });
}
fn add(&mut self, body: Vec<u8>) -> usize {
let id = self.alloc_id();
self.add_obj(id, body);
id
}
fn serialize(self) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
let mut offsets: Vec<(usize, usize)> = Vec::new();
for obj in &self.objects {
offsets.push((obj.id, buf.len()));
buf.extend_from_slice(format!("{} 0 obj\n", obj.id).as_bytes());
buf.extend_from_slice(&obj.body);
buf.extend_from_slice(b"\nendobj\n");
}
let xref_offset = buf.len();
let max_id = offsets.iter().map(|(id, _)| *id).max().unwrap_or(0);
buf.extend_from_slice(format!("xref\n0 {}\n", max_id + 1).as_bytes());
buf.extend_from_slice(b"0000000000 65535 f \n");
let mut offset_map = vec![None; max_id + 1];
for (obj_id, off) in &offsets {
if *obj_id <= max_id {
offset_map[*obj_id] = Some(*off);
}
}
for entry in offset_map.iter().skip(1) {
match entry {
Some(off) => {
buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
None => buf.extend_from_slice(b"0000000000 65535 f \n"),
}
}
buf.extend_from_slice(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
max_id + 1,
xref_offset
)
.as_bytes(),
);
buf
}
}
fn make_stream(dict_extra: &str, data: &[u8]) -> Vec<u8> {
let len = data.len();
let mut body = format!("<< /Length {len}{dict_extra} >>\nstream\n").into_bytes();
body.extend_from_slice(data);
body.extend_from_slice(b"\nendstream");
body
}
fn deflate(data: &[u8]) -> Vec<u8> {
miniz_oxide::deflate::compress_to_vec_zlib(data, 6)
}
fn make_deflate_stream(dict_extra: &str, data: &[u8]) -> Vec<u8> {
let compressed = deflate(data);
let extra = format!(" /Filter /FlateDecode{dict_extra}");
make_stream(&extra, &compressed)
}
fn encode_rgb_to_jpeg(rgb: &[u8], width: u32, height: u32, quality: u8) -> Vec<u8> {
use jpeg_encoder::{ColorType, Encoder};
let mut out = Vec::new();
let enc = Encoder::new(&mut out, quality);
let _ = enc.encode(rgb, width as u16, height as u16, ColorType::Rgb);
out
}
fn make_dct_stream(dict_extra: &str, jpeg_bytes: &[u8]) -> Vec<u8> {
let extra = format!(" /Filter /DCTDecode{dict_extra}");
make_stream(&extra, jpeg_bytes)
}
fn font_dict() -> Vec<u8> {
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec()
}
fn px_to_pt(px: f32, dpi: f32) -> f32 {
px * 72.0 / dpi
}
fn build_page_objects(
w: &mut PdfWriter,
page: &DjVuPage,
pages_id: usize,
font_id: usize,
pdf_opts: &PdfOptions,
) -> Result<usize, PdfError> {
let pw = page.width() as u32;
let ph = page.height() as u32;
let dpi = page.dpi().max(1) as f32;
let pt_w = px_to_pt(pw as f32, dpi);
let pt_h = px_to_pt(ph as f32, dpi);
let render_opts = RenderOptions {
width: pw,
height: ph,
..RenderOptions::default()
};
let pixmap = djvu_render::render_pixmap(page, &render_opts)?;
let rgb = pixmap.to_rgb();
let img_dict = format!(
" /Type /XObject /Subtype /Image /Width {pw} /Height {ph}\
/ColorSpace /DeviceRGB /BitsPerComponent 8"
);
let img_body = match pdf_opts.jpeg_quality {
Some(quality) => {
let jpeg = encode_rgb_to_jpeg(&rgb, pw, ph, quality);
if jpeg.is_empty() {
make_deflate_stream(&img_dict, &rgb)
} else {
make_dct_stream(&img_dict, &jpeg)
}
}
None => make_deflate_stream(&img_dict, &rgb),
};
let img_id = w.add(img_body);
let mask_img_id = build_mask_image(w, page, pw, ph);
let mut content = String::new();
content.push_str(&format!("q {pt_w:.4} 0 0 {pt_h:.4} 0 0 cm /Im0 Do Q\n"));
if let Some(mask_id) = mask_img_id {
content.push_str(&format!(
"q 0 0 0 rg {pt_w:.4} 0 0 {pt_h:.4} 0 0 cm /Mask0 Do Q\n"
));
let _ = mask_id; }
let text_ops = build_text_content(page, dpi, pt_h);
if !text_ops.is_empty() {
content.push_str(&text_ops);
}
let content_bytes = content.as_bytes();
let content_body = make_deflate_stream("", content_bytes);
let content_id = w.add(content_body);
let mut resources = format!("/XObject << /Im0 {img_id} 0 R");
if let Some(mid) = mask_img_id {
resources.push_str(&format!(" /Mask0 {mid} 0 R"));
}
resources.push_str(" >>");
if !text_ops.is_empty() {
resources.push_str(&format!(" /Font << /F1 {font_id} 0 R >>"));
}
let annot_ids = build_link_annotations(w, page, dpi, pt_h);
let mut annots_str = String::new();
if !annot_ids.is_empty() {
annots_str.push_str(" /Annots [");
for aid in &annot_ids {
annots_str.push_str(&format!(" {aid} 0 R"));
}
annots_str.push_str(" ]");
}
let page_id = w.add(
format!(
"<< /Type /Page /Parent {pages_id} 0 R\n\
/MediaBox [0 0 {pt_w:.4} {pt_h:.4}]\n\
/Contents {content_id} 0 R\n\
/Resources << {resources} >>{annots_str} >>"
)
.into_bytes(),
);
Ok(page_id)
}
fn build_mask_image(w: &mut PdfWriter, page: &DjVuPage, _pw: u32, _ph: u32) -> Option<usize> {
let sjbz = page.find_chunk(b"Sjbz")?;
let dict = page
.find_chunk(b"Djbz")
.and_then(|djbz| crate::jb2_new::decode_dict(djbz, None).ok());
let bitmap = crate::jb2_new::decode(sjbz, dict.as_ref()).ok()?;
let bw = bitmap.width;
let bh = bitmap.height;
let dict_extra = format!(
" /Type /XObject /Subtype /Image /Width {bw} /Height {bh}\
/ImageMask true /BitsPerComponent 1 /Decode [1 0]"
);
let body = make_deflate_stream(&dict_extra, &bitmap.data);
let id = w.add(body);
Some(id)
}
fn build_text_content(page: &DjVuPage, dpi: f32, pt_h: f32) -> String {
let text_layer = match page.text_layer() {
Ok(Some(tl)) => tl,
_ => return String::new(),
};
let mut ops = String::new();
ops.push_str("BT\n");
ops.push_str("3 Tr\n");
ops.push_str("/F1 1 Tf\n");
for zone in &text_layer.zones {
emit_text_zones(&mut ops, zone, dpi, pt_h);
}
ops.push_str("ET\n");
if ops == "BT\n3 Tr\n/F1 1 Tf\nET\n" {
return String::new();
}
ops
}
fn emit_text_zones(ops: &mut String, zone: &TextZone, dpi: f32, pt_h: f32) {
match zone.kind {
TextZoneKind::Word | TextZoneKind::Character => {
if zone.text.is_empty() {
return;
}
let r = &zone.rect;
let x = px_to_pt(r.x as f32, dpi);
let y = pt_h - px_to_pt((r.y + r.height) as f32, dpi);
let w = px_to_pt(r.width as f32, dpi);
let h = px_to_pt(r.height as f32, dpi);
if w <= 0.0 || h <= 0.0 {
return;
}
let font_size = h;
if font_size < 0.5 {
return;
}
let text_escaped = pdf_escape_string(&zone.text);
let char_count = zone.text.chars().count().max(1) as f32;
let natural_width = char_count * 0.5 * font_size;
let h_scale = if natural_width > 0.01 {
(w / natural_width) * 100.0
} else {
100.0
};
ops.push_str(&format!(
"{font_size:.2} 0 0 {font_size:.2} {x:.4} {y:.4} Tm\n"
));
if (h_scale - 100.0).abs() > 1.0 {
ops.push_str(&format!("{h_scale:.2} Tz\n"));
}
ops.push_str(&format!("({text_escaped}) Tj\n"));
}
_ => {
for child in &zone.children {
emit_text_zones(ops, child, dpi, pt_h);
}
}
}
}
fn pdf_escape_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'(' => out.push_str("\\("),
')' => out.push_str("\\)"),
'\\' => out.push_str("\\\\"),
c if c.is_ascii() => out.push(c),
_ => {
out.push('?');
}
}
}
out
}
fn build_link_annotations(w: &mut PdfWriter, page: &DjVuPage, dpi: f32, pt_h: f32) -> Vec<usize> {
let hyperlinks = match page.hyperlinks() {
Ok(links) => links,
Err(_) => return Vec::new(),
};
let mut ids = Vec::new();
for link in &hyperlinks {
if let Some(rect) = shape_to_pdf_rect(&link.shape, dpi, pt_h) {
let url_escaped = pdf_escape_string(&link.url);
let body = format!(
"<< /Type /Annot /Subtype /Link\n\
/Rect [{:.4} {:.4} {:.4} {:.4}]\n\
/Border [0 0 0]\n\
/A << /S /URI /URI ({url_escaped}) >> >>",
rect.0, rect.1, rect.2, rect.3
);
let id = w.add(body.into_bytes());
ids.push(id);
}
}
ids
}
fn shape_to_pdf_rect(shape: &Shape, dpi: f32, _pt_h: f32) -> Option<(f32, f32, f32, f32)> {
match shape {
Shape::Rect(r) | Shape::Oval(r) | Shape::Text(r) => {
let x1 = px_to_pt(r.x as f32, dpi);
let y1 = px_to_pt(r.y as f32, dpi);
let x2 = px_to_pt((r.x + r.width) as f32, dpi);
let y2 = px_to_pt((r.y + r.height) as f32, dpi);
Some((x1, y1, x2, y2))
}
Shape::Poly(points) => {
if points.is_empty() {
return None;
}
let mut min_x = f32::MAX;
let mut min_y = f32::MAX;
let mut max_x = f32::MIN;
let mut max_y = f32::MIN;
for (px, py) in points {
let x = px_to_pt(*px as f32, dpi);
let y = px_to_pt(*py as f32, dpi);
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
Some((min_x, min_y, max_x, max_y))
}
Shape::Line(x1, y1, x2, y2) => {
let px1 = px_to_pt(*x1 as f32, dpi);
let py1 = px_to_pt(*y1 as f32, dpi);
let px2 = px_to_pt(*x2 as f32, dpi);
let py2 = px_to_pt(*y2 as f32, dpi);
Some((px1.min(px2), py1.min(py2), px1.max(px2), py1.max(py2)))
}
}
}
fn build_outline(
w: &mut PdfWriter,
bookmarks: &[DjVuBookmark],
page_ids: &[usize],
) -> Option<usize> {
if bookmarks.is_empty() {
return None;
}
let outline_id = w.alloc_id();
let item_ids = build_outline_items(w, bookmarks, outline_id, page_ids);
if item_ids.is_empty() {
return None;
}
let first = item_ids[0];
let last = *item_ids.last().unwrap();
let count = count_outline_items(bookmarks);
w.add_obj(
outline_id,
format!("<< /Type /Outlines /First {first} 0 R /Last {last} 0 R /Count {count} >>")
.into_bytes(),
);
Some(outline_id)
}
fn build_outline_items(
w: &mut PdfWriter,
bookmarks: &[DjVuBookmark],
parent_id: usize,
page_ids: &[usize],
) -> Vec<usize> {
let mut ids = Vec::new();
for _bm in bookmarks {
let item_id = w.alloc_id();
ids.push(item_id);
}
for (i, bm) in bookmarks.iter().enumerate() {
let item_id = ids[i];
let prev = if i > 0 {
format!(" /Prev {} 0 R", ids[i - 1])
} else {
String::new()
};
let next = if i + 1 < ids.len() {
format!(" /Next {} 0 R", ids[i + 1])
} else {
String::new()
};
let dest = resolve_bookmark_dest(&bm.url, page_ids);
let child_ids = build_outline_items(w, &bm.children, item_id, page_ids);
let children_str = if !child_ids.is_empty() {
let first = child_ids[0];
let last = *child_ids.last().unwrap();
let count = count_outline_items(&bm.children);
format!(" /First {first} 0 R /Last {last} 0 R /Count {count}")
} else {
String::new()
};
let title = pdf_escape_string(&bm.title);
w.add_obj(
item_id,
format!(
"<< /Title ({title}) /Parent {parent_id} 0 R{prev}{next}{dest}{children_str} >>"
)
.into_bytes(),
);
}
ids
}
fn count_outline_items(bookmarks: &[DjVuBookmark]) -> usize {
let mut n = bookmarks.len();
for bm in bookmarks {
n += count_outline_items(&bm.children);
}
n
}
fn resolve_bookmark_dest(url: &str, page_ids: &[usize]) -> String {
if let Some(stripped) = url.strip_prefix('#') {
if let Some(page_str) = stripped.strip_prefix("page")
&& let Ok(page_num) = page_str.trim_start_matches('_').parse::<usize>()
{
let idx = page_num.saturating_sub(1);
if let Some(&pid) = page_ids.get(idx) {
return format!(" /Dest [{pid} 0 R /Fit]");
}
}
if let Ok(n) = stripped.parse::<i64>() {
let idx = (n.max(1) - 1) as usize;
if let Some(&pid) = page_ids.get(idx) {
return format!(" /Dest [{pid} 0 R /Fit]");
}
}
if let Ok(n) = stripped.parse::<usize>() {
let idx = n.saturating_sub(1);
if let Some(&pid) = page_ids.get(idx) {
return format!(" /Dest [{pid} 0 R /Fit]");
}
}
}
if !url.is_empty() {
let escaped = pdf_escape_string(url);
return format!(" /A << /S /URI /URI ({escaped}) >>");
}
String::new()
}
#[derive(Debug, Clone)]
pub struct PdfOptions {
pub jpeg_quality: Option<u8>,
}
impl Default for PdfOptions {
fn default() -> Self {
PdfOptions {
jpeg_quality: Some(80),
}
}
}
pub fn djvu_to_pdf_with_options(
doc: &DjVuDocument,
opts: &PdfOptions,
) -> Result<Vec<u8>, PdfError> {
djvu_to_pdf_impl(doc, opts)
}
pub fn djvu_to_pdf(doc: &DjVuDocument) -> Result<Vec<u8>, PdfError> {
djvu_to_pdf_impl(doc, &PdfOptions::default())
}
fn djvu_to_pdf_impl(doc: &DjVuDocument, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let mut w = PdfWriter::new();
let catalog_id = w.alloc_id(); let pages_id = w.alloc_id();
let font_id = w.alloc_id(); w.add_obj(font_id, font_dict());
let mut page_obj_ids = Vec::new();
for i in 0..doc.page_count() {
let page = doc.page(i)?;
let page_id = match build_page_objects(&mut w, page, pages_id, font_id, opts) {
Ok(id) => id,
Err(_) => {
let dpi = page.dpi().max(1) as f32;
let pt_w = px_to_pt(page.width() as f32, dpi);
let pt_h = px_to_pt(page.height() as f32, dpi);
w.add(
format!(
"<< /Type /Page /Parent {pages_id} 0 R\n\
/MediaBox [0 0 {pt_w:.4} {pt_h:.4}]\n\
/Resources << >> >>"
)
.into_bytes(),
)
}
};
page_obj_ids.push(page_id);
}
let outline_id = build_outline(&mut w, doc.bookmarks(), &page_obj_ids);
let kids = page_obj_ids
.iter()
.map(|id| format!("{id} 0 R"))
.collect::<Vec<_>>()
.join(" ");
let n = page_obj_ids.len();
w.add_obj(
pages_id,
format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(),
);
let outline_ref = match outline_id {
Some(oid) => format!(" /Outlines {oid} 0 R /PageMode /UseOutlines"),
None => String::new(),
};
w.add_obj(
catalog_id,
format!("<< /Type /Catalog /Pages {pages_id} 0 R{outline_ref} >>").into_bytes(),
);
Ok(w.serialize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pdf_escape_string() {
assert_eq!(pdf_escape_string("hello"), "hello");
assert_eq!(pdf_escape_string("a(b)c"), "a\\(b\\)c");
assert_eq!(pdf_escape_string("a\\b"), "a\\\\b");
}
#[test]
fn test_px_to_pt() {
assert!((px_to_pt(72.0, 72.0) - 72.0).abs() < 0.01);
assert!((px_to_pt(300.0, 300.0) - 72.0).abs() < 0.01);
}
#[test]
fn test_resolve_bookmark_dest_page_number() {
let page_ids = vec![10, 20, 30];
let dest = resolve_bookmark_dest("#1", &page_ids);
assert!(dest.contains("10 0 R"));
}
#[test]
fn test_pdf_writer_serialize() {
let mut w = PdfWriter::new();
let id = w.add(b"<< /Type /Catalog >>".to_vec());
assert_eq!(id, 1);
let pdf = w.serialize();
assert!(pdf.starts_with(b"%PDF-1.4"));
assert!(pdf.windows(5).any(|w| w == b"%%EOF"));
}
#[test]
fn test_make_stream() {
let stream = make_stream(" /Filter /FlateDecode", b"hello");
let s = String::from_utf8_lossy(&stream);
assert!(s.contains("/Length 5"));
assert!(s.contains("stream\nhello\nendstream"));
}
#[test]
fn test_deflate_roundtrip() {
let data = b"hello world, this is a test of deflate compression";
let compressed = deflate(data);
assert!(!compressed.is_empty());
let decompressed = miniz_oxide::inflate::decompress_to_vec_zlib(&compressed).unwrap();
assert_eq!(&decompressed, data);
}
#[test]
fn test_make_deflate_stream() {
let body = make_deflate_stream(" /Type /XObject", b"test data");
let s = String::from_utf8_lossy(&body);
assert!(s.contains("/Filter /FlateDecode"));
assert!(s.contains("/Type /XObject"));
assert!(s.contains("stream\n"));
assert!(s.contains("\nendstream"));
}
#[test]
fn test_font_dict() {
let d = font_dict();
let s = String::from_utf8_lossy(&d);
assert!(s.contains("/Type /Font"));
assert!(s.contains("/BaseFont /Helvetica"));
}
#[test]
fn test_pdf_writer_alloc_ids() {
let mut w = PdfWriter::new();
let id1 = w.alloc_id();
let id2 = w.alloc_id();
let id3 = w.alloc_id();
assert_eq!(id1, 1);
assert_eq!(id2, 2);
assert_eq!(id3, 3);
}
#[test]
fn test_pdf_writer_multiple_objects() {
let mut w = PdfWriter::new();
w.add(b"<< /Type /Catalog >>".to_vec());
w.add(b"<< /Type /Pages >>".to_vec());
let pdf = w.serialize();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("1 0 obj"));
assert!(s.contains("2 0 obj"));
assert!(s.contains("/Size 3")); }
#[test]
fn test_resolve_bookmark_dest_page_prefix() {
let page_ids = vec![10, 20, 30];
let dest = resolve_bookmark_dest("#page2", &page_ids);
assert!(dest.contains("20 0 R"));
assert!(dest.contains("/Fit"));
}
#[test]
fn test_resolve_bookmark_dest_page_underscore() {
let page_ids = vec![10, 20, 30];
let dest = resolve_bookmark_dest("#page_3", &page_ids);
assert!(dest.contains("30 0 R"));
}
#[test]
fn test_resolve_bookmark_dest_out_of_range() {
let page_ids = vec![10];
let dest = resolve_bookmark_dest("#page99", &page_ids);
assert!(!dest.contains("10 0 R"));
}
#[test]
fn test_resolve_bookmark_dest_external_url() {
let page_ids = vec![10];
let dest = resolve_bookmark_dest("http://example.com", &page_ids);
assert!(dest.contains("/S /URI"));
assert!(dest.contains("http://example.com"));
}
#[test]
fn test_resolve_bookmark_dest_empty_url() {
let page_ids = vec![10];
let dest = resolve_bookmark_dest("", &page_ids);
assert!(dest.is_empty());
}
#[test]
fn test_pdf_escape_special_chars() {
assert_eq!(pdf_escape_string("a(b)c\\d"), "a\\(b\\)c\\\\d");
}
#[test]
fn test_pdf_escape_non_ascii() {
let result = pdf_escape_string("caf\u{00e9}");
assert_eq!(result, "caf?");
}
#[test]
fn test_shape_to_pdf_rect_rect() {
use crate::annotation;
let shape = annotation::Shape::Rect(annotation::Rect {
x: 0,
y: 0,
width: 300,
height: 300,
});
let rect = shape_to_pdf_rect(&shape, 300.0, 72.0).unwrap();
assert!((rect.0 - 0.0).abs() < 0.01); assert!((rect.2 - 72.0).abs() < 0.01); }
#[test]
fn test_shape_to_pdf_rect_poly() {
use crate::annotation;
let shape = annotation::Shape::Poly(vec![(0, 0), (300, 0), (300, 300), (0, 300)]);
let rect = shape_to_pdf_rect(&shape, 300.0, 72.0).unwrap();
assert!((rect.0 - 0.0).abs() < 0.01);
assert!((rect.2 - 72.0).abs() < 0.01);
}
#[test]
fn test_shape_to_pdf_rect_empty_poly() {
use crate::annotation;
let shape = annotation::Shape::Poly(vec![]);
assert!(shape_to_pdf_rect(&shape, 300.0, 72.0).is_none());
}
#[test]
fn test_shape_to_pdf_rect_line() {
use crate::annotation;
let shape = annotation::Shape::Line(0, 0, 150, 150);
let rect = shape_to_pdf_rect(&shape, 150.0, 72.0).unwrap();
assert!((rect.0 - 0.0).abs() < 0.01);
assert!((rect.2 - 72.0).abs() < 0.01);
}
#[test]
fn test_count_outline_items_empty() {
let bookmarks: Vec<crate::djvu_document::DjVuBookmark> = vec![];
assert_eq!(count_outline_items(&bookmarks), 0);
}
#[test]
fn test_count_outline_items_nested() {
use crate::djvu_document::DjVuBookmark;
let bookmarks = vec![DjVuBookmark {
title: "Chapter 1".into(),
url: "#1".into(),
children: vec![
DjVuBookmark {
title: "Section 1.1".into(),
url: "#2".into(),
children: vec![],
},
DjVuBookmark {
title: "Section 1.2".into(),
url: "#3".into(),
children: vec![],
},
],
}];
assert_eq!(count_outline_items(&bookmarks), 3);
}
fn assets_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("references/djvujs/library/assets")
}
fn load_doc(name: &str) -> crate::djvu_document::DjVuDocument {
let data =
std::fs::read(assets_path().join(name)).unwrap_or_else(|_| panic!("{name} must exist"));
crate::djvu_document::DjVuDocument::parse(&data)
.unwrap_or_else(|e| panic!("parse failed: {e}"))
}
#[test]
fn pdf_options_default_is_jpeg80() {
let opts = PdfOptions::default();
assert_eq!(opts.jpeg_quality, Some(80));
}
#[test]
fn encode_rgb_to_jpeg_returns_jpeg() {
let rgb = [255u8, 0, 0].repeat(16); let jpeg = encode_rgb_to_jpeg(&rgb, 4, 4, 80);
assert!(!jpeg.is_empty(), "JPEG output must not be empty");
assert_eq!(jpeg[0], 0xFF);
assert_eq!(jpeg[1], 0xD8);
}
#[test]
fn make_dct_stream_has_dctdecode_filter() {
let fake_jpeg = b"\xFF\xD8\xFF\xD9"; let stream = make_dct_stream(" /Type /XObject", fake_jpeg);
let s = String::from_utf8_lossy(&stream);
assert!(
s.contains("/Filter /DCTDecode"),
"must contain DCTDecode filter"
);
assert!(s.contains("/Type /XObject"));
}
#[test]
fn dct_pdf_is_smaller_than_deflate_pdf() {
let doc = load_doc("chicken.djvu");
let dct_pdf = djvu_to_pdf_with_options(
&doc,
&PdfOptions {
jpeg_quality: Some(75),
},
)
.expect("DCT conversion must succeed");
let flat_pdf = djvu_to_pdf_with_options(&doc, &PdfOptions { jpeg_quality: None })
.expect("FlateDecode conversion must succeed");
assert!(
dct_pdf.len() < flat_pdf.len(),
"DCT PDF ({} bytes) must be smaller than FlateDecode PDF ({} bytes)",
dct_pdf.len(),
flat_pdf.len()
);
}
#[test]
fn pdf_with_dct_contains_dctdecode_marker() {
let doc = load_doc("chicken.djvu");
let pdf = djvu_to_pdf_with_options(
&doc,
&PdfOptions {
jpeg_quality: Some(80),
},
)
.unwrap();
let has_dct = pdf.windows(9).any(|w| w == b"DCTDecode");
assert!(has_dct, "PDF must contain DCTDecode");
}
#[test]
fn pdf_without_dct_has_no_dctdecode() {
let doc = load_doc("chicken.djvu");
let pdf = djvu_to_pdf_with_options(&doc, &PdfOptions { jpeg_quality: None }).unwrap();
let has_dct = pdf.windows(9).any(|w| w == b"DCTDecode");
assert!(!has_dct, "FlateDecode PDF must not contain DCTDecode");
}
#[test]
fn default_djvu_to_pdf_is_dct() {
let doc = load_doc("chicken.djvu");
let default_pdf = djvu_to_pdf(&doc).unwrap();
let flat_pdf = djvu_to_pdf_with_options(&doc, &PdfOptions { jpeg_quality: None }).unwrap();
assert!(
default_pdf.len() < flat_pdf.len(),
"default PDF must use DCT and be smaller than FlateDecode"
);
}
}