#[cfg(not(feature = "std"))]
use alloc::{
string::{String, ToString},
vec,
vec::Vec,
};
use crate::{
annotation::{Annotation, AnnotationError, MapArea},
bzz_new::bzz_decode,
dirm::{DirmComponentKind, DirmPayload},
error::{BzzError, IffError, Iw44Error, Jb2Error},
iff::{IffChunk, parse_form, parse_form_body},
info::PageInfo,
iw44_new::Iw44Image,
jb2::Jb2Dict,
metadata::{DjVuMetadata, MetadataError},
pixmap::Pixmap,
text::{TextError, TextLayer},
};
#[cfg(feature = "std")]
use std::sync::Arc;
#[derive(Debug, thiserror::Error)]
pub enum DocError {
#[error("IFF error: {0}")]
Iff(#[from] IffError),
#[error("BZZ error: {0}")]
Bzz(#[from] BzzError),
#[error("IW44 error: {0}")]
Iw44(#[from] Iw44Error),
#[error("JB2 error: {0}")]
Jb2(#[from] Jb2Error),
#[error("not a DjVu file: found form type {0:?}")]
NotDjVu([u8; 4]),
#[error("missing required chunk: {0}")]
MissingChunk(&'static str),
#[error("malformed DjVu document: {0}")]
Malformed(&'static str),
#[error("failed to resolve indirect page '{0}'")]
IndirectResolve(String),
#[error("page index {index} is out of range (document has {count} pages)")]
PageOutOfRange { index: usize, count: usize },
#[error("invalid UTF-8 in DjVu metadata")]
InvalidUtf8,
#[error("indirect DjVu document requires a resolver callback")]
NoResolver,
#[cfg(feature = "std")]
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Smmr decode error: {0}")]
Smmr(String),
#[error("text layer error: {0}")]
Text(#[from] TextError),
#[error("annotation error: {0}")]
Annotation(#[from] AnnotationError),
#[error("metadata error: {0}")]
Metadata(#[from] MetadataError),
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DjVuBookmark {
pub title: String,
pub url: String,
pub children: Vec<DjVuBookmark>,
}
#[derive(Debug, Clone)]
struct RawChunk {
id: [u8; 4],
data: Vec<u8>,
}
fn decode_paired_payload(z: Option<&[u8]>, a: Option<&[u8]>) -> Result<Option<Vec<u8>>, BzzError> {
if let Some(z) = z {
return if z.is_empty() {
Ok(None)
} else {
Ok(Some(bzz_decode(z)?))
};
}
if let Some(a) = a {
return Ok(if a.is_empty() { None } else { Some(a.to_vec()) });
}
Ok(None)
}
pub struct DjVuPage {
info: PageInfo,
chunks: Vec<RawChunk>,
index: usize,
#[cfg(feature = "std")]
shared_djbz: Option<Arc<Vec<u8>>>,
#[cfg(not(feature = "std"))]
shared_djbz: Option<Vec<u8>>,
#[cfg(feature = "std")]
render_layers: std::sync::OnceLock<crate::djvu_render::PageLayers>,
#[cfg(feature = "std")]
jb2_dict_decoded: std::sync::OnceLock<Option<Jb2Dict>>,
}
impl Clone for DjVuPage {
fn clone(&self) -> Self {
DjVuPage {
info: self.info.clone(),
chunks: self.chunks.clone(),
index: self.index,
shared_djbz: self.shared_djbz.clone(),
#[cfg(feature = "std")]
render_layers: std::sync::OnceLock::new(),
#[cfg(feature = "std")]
jb2_dict_decoded: std::sync::OnceLock::new(),
}
}
}
impl core::fmt::Debug for DjVuPage {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("DjVuPage")
.field("info", &self.info)
.field("chunks", &self.chunks)
.field("index", &self.index)
.field("shared_djbz", &self.shared_djbz.as_ref().map(|v| v.len()))
.finish_non_exhaustive()
}
}
impl DjVuPage {
pub fn width(&self) -> u16 {
self.info.width
}
pub fn height(&self) -> u16 {
self.info.height
}
pub fn dpi(&self) -> u16 {
self.info.dpi
}
pub fn gamma(&self) -> f32 {
self.info.gamma
}
pub fn rotation(&self) -> crate::info::Rotation {
self.info.rotation
}
pub fn index(&self) -> usize {
self.index
}
pub fn dimensions(&self) -> (u16, u16) {
(self.info.width, self.info.height)
}
pub fn thumbnail(&self) -> Result<Option<Pixmap>, DocError> {
let th44_chunks: Vec<&[u8]> = self
.chunks
.iter()
.filter(|c| &c.id == b"TH44")
.map(|c| c.data.as_slice())
.collect();
if th44_chunks.is_empty() {
return Ok(None);
}
let mut img = Iw44Image::new();
for chunk_data in &th44_chunks {
img.decode_chunk(chunk_data)?;
}
let pixmap = img.to_rgb()?;
Ok(Some(pixmap))
}
pub fn raw_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
self.chunks
.iter()
.find(|c| &c.id == id)
.map(|c| c.data.as_slice())
}
pub fn all_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
self.chunks
.iter()
.filter(|c| &c.id == id)
.map(|c| c.data.as_slice())
.collect()
}
pub fn chunk_ids(&self) -> Vec<[u8; 4]> {
self.chunks.iter().map(|c| c.id).collect()
}
#[doc(hidden)]
pub fn find_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
self.raw_chunk(id)
}
#[doc(hidden)]
pub fn find_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
self.all_chunks(id)
}
pub fn chunk_payload(
&self,
id_z: &[u8; 4],
id_a: &[u8; 4],
) -> Result<Option<Vec<u8>>, DocError> {
Ok(decode_paired_payload(
self.raw_chunk(id_z),
self.raw_chunk(id_a),
)?)
}
pub fn bg44_chunks(&self) -> Vec<&[u8]> {
self.find_chunks(b"BG44")
}
#[cfg(feature = "std")]
pub(crate) fn render_layers(&self) -> &crate::djvu_render::PageLayers {
self.render_layers
.get_or_init(crate::djvu_render::PageLayers::new)
}
#[cfg(feature = "std")]
pub fn decoded_bg44(&self) -> Option<&Iw44Image> {
self.render_layers().bg44(self)
}
#[cfg(not(feature = "std"))]
pub fn decoded_bg44(&self) -> Option<&Iw44Image> {
None
}
#[cfg(feature = "std")]
pub fn decoded_bg44_partial(&self) -> Option<&Iw44Image> {
self.render_layers().bg44_partial(self)
}
#[cfg(not(feature = "std"))]
pub fn decoded_bg44_partial(&self) -> Option<&Iw44Image> {
None
}
#[cfg(feature = "std")]
pub(crate) fn decoded_shared_dict(&self) -> Option<&Jb2Dict> {
self.jb2_dict_decoded
.get_or_init(|| {
let djbz = self.shared_djbz.as_deref()?;
crate::jb2::decode_dict(djbz, None).ok()
})
.as_ref()
}
#[cfg(not(feature = "std"))]
pub(crate) fn decoded_shared_dict(&self) -> Option<&Jb2Dict> {
None
}
pub fn fg44_chunks(&self) -> Vec<&[u8]> {
self.find_chunks(b"FG44")
}
pub fn text_layer(&self) -> Result<Option<TextLayer>, DocError> {
let page_height = self.info.height as u32;
match self.chunk_payload(b"TXTz", b"TXTa")? {
Some(bytes) => Ok(Some(crate::text::parse_text_layer(&bytes, page_height)?)),
None => Ok(None),
}
}
pub fn text_layer_at_size(
&self,
render_w: u32,
render_h: u32,
) -> Result<Option<TextLayer>, DocError> {
let page_w = self.info.width as u32;
let page_h = self.info.height as u32;
let rotation = self.info.rotation;
Ok(self
.text_layer()?
.map(|tl| tl.transform(page_w, page_h, rotation, render_w, render_h)))
}
pub fn text(&self) -> Result<Option<String>, DocError> {
Ok(self.text_layer()?.map(|tl| tl.text))
}
pub fn annotations(&self) -> Result<Option<(Annotation, Vec<MapArea>)>, DocError> {
match self.chunk_payload(b"ANTz", b"ANTa")? {
Some(bytes) => Ok(Some(crate::annotation::parse_annotations(&bytes)?)),
None => Ok(None),
}
}
pub fn hyperlinks(&self) -> Result<Vec<MapArea>, DocError> {
match self.annotations()? {
None => Ok(Vec::new()),
Some((_, mapareas)) => Ok(mapareas.into_iter().filter(|m| !m.url.is_empty()).collect()),
}
}
pub fn extract_mask(&self) -> Result<Option<crate::bitmap::Bitmap>, DocError> {
if let Some(sjbz) = self.find_chunk(b"Sjbz") {
let inline_dict;
let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
inline_dict = crate::jb2::decode_dict(djbz, None)?;
Some(&inline_dict)
} else {
self.decoded_shared_dict()
};
let bm = crate::jb2::decode(sjbz, dict_ref)?;
return Ok(Some(bm));
}
if let Some(smmr) = self.find_chunk(b"Smmr") {
let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
return Ok(Some(bm));
}
Ok(None)
}
pub fn extract_mask_indexed(
&self,
) -> Result<Option<(crate::bitmap::Bitmap, Vec<i32>)>, DocError> {
if let Some(sjbz) = self.find_chunk(b"Sjbz") {
let inline_dict;
let dict_ref = if let Some(djbz) = self.find_chunk(b"Djbz") {
inline_dict = crate::jb2::decode_dict(djbz, None)?;
Some(&inline_dict)
} else {
self.decoded_shared_dict()
};
let (bm, blit_map) = crate::jb2::decode_indexed(sjbz, dict_ref)?;
return Ok(Some((bm, blit_map)));
}
if let Some(smmr) = self.find_chunk(b"Smmr") {
let bm = crate::smmr::decode_smmr(smmr).map_err(|e| DocError::Smmr(e.to_string()))?;
let len = (bm.width * bm.height) as usize;
return Ok(Some((bm, vec![0i32; len])));
}
Ok(None)
}
pub fn extract_foreground(&self) -> Result<Option<Pixmap>, DocError> {
let chunks = self.fg44_chunks();
if chunks.is_empty() {
return Ok(None);
}
let mut img = Iw44Image::new();
for chunk_data in &chunks {
img.decode_chunk(chunk_data)?;
}
let pixmap = img.to_rgb()?;
Ok(Some(pixmap))
}
#[cfg(feature = "std")]
pub fn decoded_mask(&self) -> Option<&crate::bitmap::Bitmap> {
self.render_layers().mask(self)
}
#[cfg(not(feature = "std"))]
pub fn decoded_mask(&self) -> Option<&crate::bitmap::Bitmap> {
None
}
#[cfg(feature = "std")]
pub fn decoded_fg44(&self) -> Option<&Pixmap> {
self.render_layers().fg44(self)
}
#[cfg(not(feature = "std"))]
pub fn decoded_fg44(&self) -> Option<&Pixmap> {
None
}
#[cfg(feature = "std")]
pub(crate) fn decoded_bg_rgb_s1(&self) -> Option<&Pixmap> {
self.render_layers().bg_rgb_s1(self)
}
#[cfg(not(feature = "std"))]
pub(crate) fn decoded_bg_rgb_s1(&self) -> Option<&Pixmap> {
None
}
#[cfg(feature = "std")]
pub(crate) fn decoded_bg_rgb_s2(&self) -> Option<&Pixmap> {
self.render_layers().bg_rgb_s2(self)
}
#[cfg(not(feature = "std"))]
pub(crate) fn decoded_bg_rgb_s2(&self) -> Option<&Pixmap> {
None
}
#[cfg(feature = "std")]
pub(crate) fn decoded_mask_indexed(&self) -> Option<&(crate::bitmap::Bitmap, Vec<i32>)> {
self.render_layers().mask_indexed(self)
}
#[cfg(not(feature = "std"))]
pub(crate) fn decoded_mask_indexed(&self) -> Option<&(crate::bitmap::Bitmap, Vec<i32>)> {
None
}
pub fn extract_background(&self) -> Result<Option<Pixmap>, DocError> {
let chunks = self.bg44_chunks();
if chunks.is_empty() {
return Ok(None);
}
let mut img = Iw44Image::new();
for chunk_data in &chunks {
img.decode_chunk(chunk_data)?;
}
let pixmap = img.to_rgb()?;
Ok(Some(pixmap))
}
pub fn render_into(
&self,
opts: &crate::djvu_render::RenderOptions,
buf: &mut [u8],
) -> Result<(), crate::djvu_render::RenderError> {
crate::djvu_render::render_into(self, opts, buf)
}
}
#[derive(Debug)]
pub struct DjVuDocument {
pages: Vec<DjVuPage>,
bookmarks: Vec<DjVuBookmark>,
global_chunks: Vec<RawChunk>,
page_byte_ranges: Vec<core::ops::Range<u64>>,
}
impl DjVuDocument {
pub fn parse(data: &[u8]) -> Result<Self, DocError> {
Self::parse_with_resolver(data, None::<fn(&str) -> Result<Vec<u8>, DocError>>)
}
pub fn parse_with_resolver<R>(data: &[u8], resolver: Option<R>) -> Result<Self, DocError>
where
R: Fn(&str) -> Result<Vec<u8>, DocError>,
{
let form = parse_form(data)?;
match &form.form_type {
b"DJVU" => {
let global_chunks: Vec<RawChunk> = form
.chunks
.iter()
.map(|c| RawChunk {
id: c.id,
data: c.data.to_vec(),
})
.collect();
let page = parse_page_from_chunks(&form.chunks, 0, None)?;
#[allow(clippy::single_range_in_vec_init)]
let page_byte_ranges = vec![0u64..(data.len() as u64)];
Ok(DjVuDocument {
pages: vec![page],
bookmarks: vec![],
global_chunks,
page_byte_ranges,
})
}
b"DJVM" => {
let dirm_chunk = form
.chunks
.iter()
.find(|c| &c.id == b"DIRM")
.ok_or(DocError::MissingChunk("DIRM"))?;
let payload = DirmPayload::decode(dirm_chunk.data).map_err(DocError::Malformed)?;
let entries = payload.components();
let is_bundled = payload.is_bundled();
let comp_offsets = payload.offsets;
let bookmarks = parse_navm_bookmarks(&form.chunks)?;
let global_chunks: Vec<RawChunk> = form
.chunks
.iter()
.filter(|c| &c.id != b"FORM")
.map(|c| RawChunk {
id: c.id,
data: c.data.to_vec(),
})
.collect();
if is_bundled {
let sub_forms: Vec<&IffChunk<'_>> =
form.chunks.iter().filter(|c| &c.id == b"FORM").collect();
#[cfg(not(feature = "std"))]
use alloc::collections::BTreeMap;
#[cfg(feature = "std")]
use std::collections::BTreeMap;
#[cfg(feature = "std")]
let djvi_djbz: BTreeMap<String, Arc<Vec<u8>>> = entries
.iter()
.enumerate()
.filter(|(_, e)| e.kind == DirmComponentKind::Shared)
.filter_map(|(comp_idx, entry)| {
let sf = sub_forms.get(comp_idx)?;
let chunks = parse_sub_form(sf.data).ok()?;
let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
Some((entry.id.clone(), Arc::new(djbz.data.to_vec())))
})
.collect();
#[cfg(not(feature = "std"))]
let djvi_djbz: BTreeMap<String, Vec<u8>> = entries
.iter()
.enumerate()
.filter(|(_, e)| e.kind == DirmComponentKind::Shared)
.filter_map(|(comp_idx, entry)| {
let sf = sub_forms.get(comp_idx)?;
let chunks = parse_sub_form(sf.data).ok()?;
let djbz = chunks.iter().find(|c| &c.id == b"Djbz")?;
Some((entry.id.clone(), djbz.data.to_vec()))
})
.collect();
let mut pages = Vec::new();
let mut page_byte_ranges = Vec::new();
let mut page_idx = 0usize;
for (comp_idx, entry) in entries.iter().enumerate() {
if entry.kind != DirmComponentKind::Page {
continue;
}
let sub_form = sub_forms.get(comp_idx).ok_or(DocError::Malformed(
"DIRM entry count exceeds FORM children",
))?;
let sub_chunks = parse_sub_form(sub_form.data)?;
#[cfg(feature = "std")]
let shared_djbz = sub_chunks
.iter()
.find(|c| &c.id == b"INCL")
.and_then(|incl| core::str::from_utf8(incl.data.trim_ascii_end()).ok())
.and_then(|name| djvi_djbz.get(name))
.cloned();
#[cfg(not(feature = "std"))]
let shared_djbz = sub_chunks
.iter()
.find(|c| &c.id == b"INCL")
.and_then(|incl| core::str::from_utf8(incl.data.trim_ascii_end()).ok())
.and_then(|name| djvi_djbz.get(name))
.cloned();
let page = parse_page_from_chunks(&sub_chunks, page_idx, shared_djbz)?;
pages.push(page);
if let Some(off) = comp_offsets.get(comp_idx) {
let start = *off as usize;
if let Some(size_bytes) = data.get(start + 4..start + 8) {
let size_be =
[size_bytes[0], size_bytes[1], size_bytes[2], size_bytes[3]];
page_byte_ranges.push(crate::dirm::form_byte_range(*off, size_be));
}
}
page_idx += 1;
}
if page_byte_ranges.len() != pages.len() {
page_byte_ranges.clear();
}
Ok(DjVuDocument {
pages,
bookmarks,
global_chunks,
page_byte_ranges,
})
} else {
let resolver = resolver.ok_or(DocError::NoResolver)?;
let mut pages = Vec::new();
let mut page_idx = 0usize;
for entry in &entries {
if entry.kind != DirmComponentKind::Page {
continue;
}
let resolved_data = resolver(&entry.id)
.map_err(|_| DocError::IndirectResolve(entry.id.clone()))?;
let sub_form = parse_form(&resolved_data)?;
let page = parse_page_from_chunks(&sub_form.chunks, page_idx, None)?;
pages.push(page);
page_idx += 1;
}
Ok(DjVuDocument {
pages,
bookmarks,
global_chunks,
page_byte_ranges: Vec::new(),
})
}
}
other => Err(DocError::NotDjVu(*other)),
}
}
#[cfg(all(feature = "std", feature = "async"))]
pub(crate) fn parse_single_page_with_shared(
data: &[u8],
index: usize,
shared_djbz: Option<Arc<Vec<u8>>>,
) -> Result<DjVuPage, DocError> {
let form = parse_form(data)?;
if form.form_type != *b"DJVU" {
return Err(DocError::NotDjVu(form.form_type));
}
parse_page_from_chunks(&form.chunks, index, shared_djbz)
}
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn page_byte_range(&self, index: usize) -> Option<core::ops::Range<u64>> {
self.page_byte_ranges.get(index).cloned()
}
pub fn page(&self, index: usize) -> Result<&DjVuPage, DocError> {
self.pages.get(index).ok_or(DocError::PageOutOfRange {
index,
count: self.pages.len(),
})
}
pub fn bookmarks(&self) -> &[DjVuBookmark] {
&self.bookmarks
}
pub fn metadata(&self) -> Result<Option<DjVuMetadata>, DocError> {
match self.chunk_payload(b"METz", b"METa")? {
Some(bytes) => Ok(Some(crate::metadata::parse_metadata(&bytes)?)),
None => Ok(None),
}
}
pub fn raw_chunk(&self, id: &[u8; 4]) -> Option<&[u8]> {
self.global_chunks
.iter()
.find(|c| &c.id == id)
.map(|c| c.data.as_slice())
}
pub fn all_chunks(&self, id: &[u8; 4]) -> Vec<&[u8]> {
self.global_chunks
.iter()
.filter(|c| &c.id == id)
.map(|c| c.data.as_slice())
.collect()
}
pub fn chunk_ids(&self) -> Vec<[u8; 4]> {
self.global_chunks.iter().map(|c| c.id).collect()
}
pub fn chunk_payload(
&self,
id_z: &[u8; 4],
id_a: &[u8; 4],
) -> Result<Option<Vec<u8>>, DocError> {
Ok(decode_paired_payload(
self.raw_chunk(id_z),
self.raw_chunk(id_a),
)?)
}
#[cfg(feature = "std")]
pub fn parse_from_dir(
data: &[u8],
base_dir: impl AsRef<std::path::Path>,
) -> Result<Self, DocError> {
let base = base_dir.as_ref().to_path_buf();
let resolver = move |name: &str| -> Result<Vec<u8>, DocError> {
let name = name.strip_prefix("file://").unwrap_or(name);
let path = if std::path::Path::new(name).is_absolute() {
std::path::PathBuf::from(name)
} else {
base.join(name)
};
std::fs::read(&path).map_err(|_| DocError::IndirectResolve(name.to_string()))
};
Self::parse_with_resolver(data, Some(resolver))
}
}
#[cfg(feature = "mmap")]
pub struct MmapDocument {
_mmap: memmap2::Mmap,
doc: DjVuDocument,
}
#[cfg(feature = "mmap")]
impl MmapDocument {
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, DocError> {
let file = std::fs::File::open(path.as_ref())?;
#[allow(unsafe_code)]
let mmap = unsafe { memmap2::Mmap::map(&file) }?;
let doc = DjVuDocument::parse(&mmap)?;
Ok(MmapDocument { _mmap: mmap, doc })
}
pub fn open_indirect(path: impl AsRef<std::path::Path>) -> Result<Self, DocError> {
let path = path.as_ref();
let file = std::fs::File::open(path)?;
#[allow(unsafe_code)]
let mmap = unsafe { memmap2::Mmap::map(&file) }?;
let base_dir = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let doc = DjVuDocument::parse_from_dir(&mmap, &base_dir)?;
Ok(MmapDocument { _mmap: mmap, doc })
}
pub fn document(&self) -> &DjVuDocument {
&self.doc
}
pub fn page_count(&self) -> usize {
self.doc.page_count()
}
pub fn page(&self, index: usize) -> Result<&DjVuPage, DocError> {
self.doc.page(index)
}
}
#[cfg(feature = "mmap")]
impl core::ops::Deref for MmapDocument {
type Target = DjVuDocument;
fn deref(&self) -> &DjVuDocument {
&self.doc
}
}
#[cfg(feature = "std")]
fn parse_page_from_chunks(
chunks: &[IffChunk<'_>],
index: usize,
shared_djbz: Option<Arc<Vec<u8>>>,
) -> Result<DjVuPage, DocError> {
let info_chunk = chunks
.iter()
.find(|c| &c.id == b"INFO")
.ok_or(DocError::MissingChunk("INFO"))?;
let info = PageInfo::parse(info_chunk.data)?;
let raw_chunks: Vec<RawChunk> = chunks
.iter()
.map(|c| RawChunk {
id: c.id,
data: c.data.to_vec(),
})
.collect();
Ok(DjVuPage {
info,
chunks: raw_chunks,
index,
shared_djbz,
render_layers: std::sync::OnceLock::new(),
jb2_dict_decoded: std::sync::OnceLock::new(),
})
}
#[cfg(not(feature = "std"))]
fn parse_page_from_chunks(
chunks: &[IffChunk<'_>],
index: usize,
shared_djbz: Option<Vec<u8>>,
) -> Result<DjVuPage, DocError> {
let info_chunk = chunks
.iter()
.find(|c| &c.id == b"INFO")
.ok_or(DocError::MissingChunk("INFO"))?;
let info = PageInfo::parse(info_chunk.data)?;
let raw_chunks: Vec<RawChunk> = chunks
.iter()
.map(|c| RawChunk {
id: c.id,
data: c.data.to_vec(),
})
.collect();
Ok(DjVuPage {
info,
chunks: raw_chunks,
index,
shared_djbz,
})
}
fn parse_sub_form(data: &[u8]) -> Result<Vec<IffChunk<'_>>, DocError> {
if data.len() < 4 {
return Err(DocError::Malformed("sub-form data too short"));
}
let body = data
.get(4..)
.ok_or(DocError::Malformed("sub-form body missing"))?;
let chunks = parse_form_body(body).map_err(DocError::Iff)?;
Ok(chunks)
}
const MAX_NAVM_DEPTH: u32 = 256;
fn parse_navm_bookmarks(chunks: &[IffChunk<'_>]) -> Result<Vec<DjVuBookmark>, DocError> {
let navm_data = match chunks.iter().find(|c| &c.id == b"NAVM") {
Some(c) => c.data,
None => return Ok(vec![]),
};
let decoded = bzz_decode(navm_data)?;
if decoded.len() < 2 {
return Ok(vec![]);
}
let b0 = *decoded
.first()
.ok_or(DocError::Malformed("NAVM total count byte 0"))?;
let b1 = *decoded
.get(1)
.ok_or(DocError::Malformed("NAVM total count byte 1"))?;
let total_count = u16::from_be_bytes([b0, b1]) as usize;
let mut pos = 2usize;
let mut bookmarks = Vec::new();
let mut decoded_count = 0usize;
while decoded_count < total_count {
let bm = parse_bookmark_entry(&decoded, &mut pos, &mut decoded_count, 0)?;
bookmarks.push(bm);
}
Ok(bookmarks)
}
fn parse_bookmark_entry(
data: &[u8],
pos: &mut usize,
total_counter: &mut usize,
depth: u32,
) -> Result<DjVuBookmark, DocError> {
if depth > MAX_NAVM_DEPTH {
return Err(DocError::Malformed("NAVM bookmark nesting too deep"));
}
if *pos >= data.len() {
return Err(DocError::Malformed("NAVM bookmark entry truncated"));
}
let n_children = *data
.get(*pos)
.ok_or(DocError::Malformed("NAVM children count"))? as usize;
*pos += 1;
let title = read_navm_str(data, pos)?;
let url = read_navm_str(data, pos)?;
*total_counter += 1;
let mut children = Vec::with_capacity(n_children);
for _ in 0..n_children {
let child = parse_bookmark_entry(data, pos, total_counter, depth + 1)?;
children.push(child);
}
Ok(DjVuBookmark {
title,
url,
children,
})
}
fn read_navm_str(data: &[u8], pos: &mut usize) -> Result<String, DocError> {
if *pos + 3 > data.len() {
return Err(DocError::Malformed("NAVM string length truncated"));
}
let len = ((*data.get(*pos).ok_or(DocError::Malformed("NAVM str"))? as usize) << 16)
| ((*data.get(*pos + 1).ok_or(DocError::Malformed("NAVM str"))? as usize) << 8)
| (*data.get(*pos + 2).ok_or(DocError::Malformed("NAVM str"))? as usize);
*pos += 3;
let bytes = data
.get(*pos..*pos + len)
.ok_or(DocError::Malformed("NAVM string bytes truncated"))?;
*pos += len;
core::str::from_utf8(bytes)
.map(|s| s.to_string())
.map_err(|_| DocError::InvalidUtf8)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deeply_nested_bookmarks_are_rejected_not_overflow() {
let mut decoded = vec![0x00, 0x01];
for _ in 0..400 {
decoded.push(1); decoded.extend_from_slice(&[0, 0, 0]); decoded.extend_from_slice(&[0, 0, 0]); }
decoded.push(0); decoded.extend_from_slice(&[0, 0, 0]);
decoded.extend_from_slice(&[0, 0, 0]);
let mut pos = 2usize;
let mut counter = 0usize;
let r = parse_bookmark_entry(&decoded, &mut pos, &mut counter, 0);
assert!(r.is_err(), "deep bookmark chain must error, not overflow");
}
fn assets_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("references/djvujs/library/assets")
}
#[test]
fn single_page_parse_and_metadata() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse should succeed");
assert_eq!(doc.page_count(), 1);
let page = doc.page(0).expect("page 0 must exist");
assert_eq!(page.width(), 181);
assert_eq!(page.height(), 240);
assert_eq!(page.dpi(), 100);
assert!((page.gamma() - 2.2).abs() < 0.01, "gamma should be ~2.2");
}
#[test]
fn single_page_out_of_range() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse should succeed");
let err = doc.page(1).expect_err("page 1 should be out of range");
assert!(
matches!(err, DocError::PageOutOfRange { index: 1, count: 1 }),
"unexpected error: {err:?}"
);
}
#[test]
fn paired_payload_prefers_compressed_z_chunk() {
let raw = b"the quick brown fox".as_slice();
let z = crate::bzz_encode::bzz_encode(raw);
let out = decode_paired_payload(Some(&z), Some(b"ignored raw"))
.expect("bzz decode should succeed");
assert_eq!(out.as_deref(), Some(raw));
}
#[test]
fn paired_payload_falls_back_to_raw_a_chunk() {
let raw = b"plain uncompressed payload".as_slice();
let out = decode_paired_payload(None, Some(raw)).expect("raw passthrough");
assert_eq!(out.as_deref(), Some(raw));
}
#[test]
fn paired_payload_missing_both_is_none() {
assert_eq!(decode_paired_payload(None, None).expect("none"), None);
}
#[test]
fn paired_payload_empty_chunk_is_placeholder_none() {
assert_eq!(
decode_paired_payload(Some(&[]), None).expect("empty z"),
None
);
assert_eq!(
decode_paired_payload(None, Some(&[])).expect("empty a"),
None
);
}
#[test]
fn paired_payload_invalid_bzz_errors() {
let result = decode_paired_payload(Some(&[0xff, 0x00, 0x13, 0x37]), None);
assert!(result.is_err(), "invalid BZZ must error, got {result:?}");
}
fn make_info(width: u16, height: u16) -> Vec<u8> {
let mut v = Vec::with_capacity(10);
v.extend_from_slice(&width.to_be_bytes());
v.extend_from_slice(&height.to_be_bytes());
v.extend_from_slice(&[0, 0]); v.extend_from_slice(&100u16.to_le_bytes()); v.push(22); v.push(0); v
}
fn page_with_chunks(extra: &[(&[u8; 4], &[u8])]) -> DjVuPage {
let info = make_info(64, 48);
let mut chunks = Vec::new();
chunks.push(IffChunk {
id: *b"INFO",
data: &info,
});
for (id, data) in extra {
chunks.push(IffChunk { id: **id, data });
}
parse_page_from_chunks(&chunks, 0, None).expect("page should build")
}
#[test]
fn chunk_payload_decodes_compressed_txtz() {
let raw = b"decoded text-layer payload".as_slice();
let z = crate::bzz_encode::bzz_encode(raw);
let page = page_with_chunks(&[(b"TXTz", &z)]);
let out = page
.chunk_payload(b"TXTz", b"TXTa")
.expect("chunk_payload should succeed");
assert_eq!(out.as_deref(), Some(raw));
}
#[test]
fn chunk_payload_passes_through_raw_txta() {
let raw = b"raw text-layer payload".as_slice();
let page = page_with_chunks(&[(b"TXTa", raw)]);
let out = page
.chunk_payload(b"TXTz", b"TXTa")
.expect("chunk_payload should succeed");
assert_eq!(out.as_deref(), Some(raw));
}
#[test]
fn chunk_payload_missing_chunk_is_none() {
let page = page_with_chunks(&[]); let out = page
.chunk_payload(b"TXTz", b"TXTa")
.expect("chunk_payload should succeed");
assert_eq!(out, None);
}
#[test]
fn single_page_no_thumbnail() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse should succeed");
let page = doc.page(0).expect("page 0 must exist");
let thumb = page.thumbnail().expect("thumbnail() should not error");
assert!(
thumb.is_none(),
"single-page chicken.djvu has no TH44 chunks"
);
}
#[test]
fn single_page_dimensions() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse should succeed");
let page = doc.page(0).unwrap();
assert_eq!(page.dimensions(), (181, 240));
}
#[test]
fn multipage_bundled_page_count() {
let data = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu"))
.expect("DjVu3Spec_bundled.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("bundled parse should succeed");
assert!(
doc.page_count() > 1,
"bundled document should have more than 1 page, got {}",
doc.page_count()
);
}
#[test]
fn multipage_bundled_page_metadata() {
let data = std::fs::read(assets_path().join("DjVu3Spec_bundled.djvu"))
.expect("DjVu3Spec_bundled.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("bundled parse should succeed");
let page0 = doc.page(0).expect("page 0 must exist");
assert!(page0.width() > 0, "page width must be non-zero");
assert!(page0.height() > 0, "page height must be non-zero");
assert!(page0.dpi() > 0, "page dpi must be non-zero");
}
#[test]
fn navm_bookmarks_present() {
let data =
std::fs::read(assets_path().join("navm_fgbz.djvu")).expect("navm_fgbz.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse should succeed");
let bm = doc.bookmarks();
assert!(
!bm.is_empty(),
"navm_fgbz.djvu should have at least one bookmark"
);
}
#[test]
fn no_navm_returns_empty_bookmarks() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse should succeed");
assert!(
doc.bookmarks().is_empty(),
"chicken.djvu has no NAVM — bookmarks should be empty"
);
}
#[test]
fn indirect_document_with_resolver() {
let chicken_data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let djvm_data = build_indirect_djvm_bytes("chicken.djvu");
let resolver = |name: &str| -> Result<Vec<u8>, DocError> {
if name == "chicken.djvu" {
Ok(chicken_data.clone())
} else {
Err(DocError::IndirectResolve(name.to_string()))
}
};
let doc = DjVuDocument::parse_with_resolver(&djvm_data, Some(resolver))
.expect("indirect parse should succeed");
assert_eq!(doc.page_count(), 1);
let page = doc.page(0).unwrap();
assert_eq!(page.width(), 181);
assert_eq!(page.height(), 240);
}
#[test]
fn indirect_document_no_resolver_returns_error() {
let djvm_data = build_indirect_djvm_bytes("chicken.djvu");
let err = DjVuDocument::parse(&djvm_data).expect_err("should fail without resolver");
assert!(
matches!(err, DocError::NoResolver),
"expected NoResolver, got {err:?}"
);
}
#[test]
fn page_is_lazy_no_decode_before_thumbnail() {
let data =
std::fs::read(assets_path().join("boy_jb2.djvu")).expect("boy_jb2.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse should succeed");
let page = doc.page(0).expect("page 0 must exist");
assert!(!page.chunks.is_empty(), "chunks must be stored (lazy)");
let thumb = page.thumbnail().expect("thumbnail() should not error");
assert!(thumb.is_none());
}
#[test]
fn not_djvu_returns_error() {
let data = crate::iff::partial_emit(*b"XXXX", &[crate::iff::EmitPart::Verbatim(b"XXXX")])
.expect("fits within u32");
let err = DjVuDocument::parse(&data).expect_err("should fail");
assert!(
matches!(err, DocError::NotDjVu(_) | DocError::Iff(_)),
"expected NotDjVu or Iff error, got {err:?}"
);
}
fn build_indirect_djvm_bytes(_page_name: &str) -> Vec<u8> {
let bzz_meta: &[u8] = &[
0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
];
let mut dirm_data = Vec::new();
dirm_data.push(0x00); dirm_data.push(0x00); dirm_data.push(0x01); dirm_data.extend_from_slice(bzz_meta);
build_djvm_with_dirm(&dirm_data)
}
fn build_djvm_with_dirm(dirm_data: &[u8]) -> Vec<u8> {
let dirm = crate::iff::Chunk::Leaf {
id: *b"DIRM",
data: dirm_data.to_vec(),
};
crate::iff::partial_emit(*b"DJVM", &[crate::iff::EmitPart::Chunk(&dirm)])
.expect("fits within u32")
}
#[test]
fn parse_bundled_djvm_with_short_sub_form_returns_malformed() {
use crate::dirm::DirmPayload;
let dirm_payload = DirmPayload::build_bundled(1, &[0x01], &["p0001.djvu".to_string()]);
let dirm = crate::iff::Chunk::Leaf {
id: *b"DIRM",
data: dirm_payload.encode(),
};
let short_form_bytes: &[u8] = b"FORM\x00\x00\x00\x02AB";
let djvm = crate::iff::partial_emit(
*b"DJVM",
&[
crate::iff::EmitPart::Chunk(&dirm),
crate::iff::EmitPart::Verbatim(short_form_bytes),
],
)
.expect("fits within u32");
let err = DjVuDocument::parse(&djvm).expect_err("short sub-form must error");
assert!(
matches!(err, DocError::Malformed(_)),
"expected Malformed, got {err:?}"
);
}
#[test]
fn page_raw_chunk_info_present() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let page = doc.page(0).expect("page 0 must exist");
let info = page.raw_chunk(b"INFO").expect("INFO chunk must be present");
assert_eq!(info.len(), 10, "INFO chunk is always 10 bytes");
}
#[test]
fn page_raw_chunk_absent() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let page = doc.page(0).expect("page 0 must exist");
assert!(
page.raw_chunk(b"XXXX").is_none(),
"unknown chunk type must return None"
);
}
#[test]
fn page_all_chunks_bg44_multiple() {
let data = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/big-scanned-page.djvu"),
)
.expect("big-scanned-page.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let page = doc.page(0).expect("page 0 must exist");
let bg44 = page.all_chunks(b"BG44");
assert!(
bg44.len() >= 2,
"colour page must have ≥2 BG44 chunks, got {}",
bg44.len()
);
for (i, chunk) in bg44.iter().enumerate() {
assert!(!chunk.is_empty(), "BG44 chunk {i} must not be empty");
}
}
#[test]
fn page_chunk_ids_includes_info() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let page = doc.page(0).expect("page 0 must exist");
let ids = page.chunk_ids();
assert!(!ids.is_empty(), "chunk_ids must not be empty");
assert!(
ids.contains(b"INFO"),
"chunk_ids must include INFO, got: {:?}",
ids.iter()
.map(|id| std::str::from_utf8(id).unwrap_or("????"))
.collect::<Vec<_>>()
);
}
#[test]
fn document_raw_chunk_single_page() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let info = doc
.raw_chunk(b"INFO")
.expect("document must expose INFO chunk");
assert_eq!(info.len(), 10);
}
#[test]
fn djvi_shared_dict_parsed_from_bundled_djvm() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/DjVu3Spec_bundled.djvu");
let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
assert!(doc.page_count() > 0, "document must have pages");
let pages_with_dict = doc.pages.iter().filter(|p| p.shared_djbz.is_some()).count();
assert!(
pages_with_dict > 0,
"at least one page must have a resolved shared DJVI dict"
);
}
#[test]
fn djvi_incl_page_mask_renders_ok() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/DjVu3Spec_bundled.djvu");
let data = std::fs::read(&path).expect("DjVu3Spec_bundled.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let page = doc
.pages
.iter()
.find(|p| p.shared_djbz.is_some())
.expect("at least one page must have a shared dict");
let mask = page
.extract_mask()
.expect("extract_mask must succeed for INCL page");
assert!(mask.is_some(), "INCL page must have a JB2 mask");
let bm = mask.unwrap();
assert!(
bm.width > 0 && bm.height > 0,
"mask must have non-zero dimensions"
);
}
#[test]
fn no_regression_non_incl_pages() {
let data = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/boy_jb2.djvu"),
)
.expect("boy_jb2.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let page = doc.page(0).expect("page 0 must exist");
assert!(
page.shared_djbz.is_none(),
"single-page DJVU has no shared dict"
);
let mask = page.extract_mask().expect("extract_mask must succeed");
assert!(mask.is_some(), "boy_jb2.djvu page must have a JB2 mask");
}
#[test]
fn page_raw_chunk_info_roundtrip() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let page = doc.page(0).expect("page 0 must exist");
let raw_info = page.raw_chunk(b"INFO").expect("INFO chunk must be present");
let reparsed = crate::info::PageInfo::parse(raw_info).expect("re-parse must succeed");
assert_eq!(reparsed.width, page.width() as u16);
assert_eq!(reparsed.height, page.height() as u16);
assert_eq!(reparsed.dpi, page.dpi());
}
#[test]
fn page_byte_range_single_page_covers_full_buffer() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
let r = doc.page_byte_range(0).expect("page 0 must have a range");
assert_eq!(r.start, 0);
assert_eq!(r.end, data.len() as u64);
assert!(
doc.page_byte_range(1).is_none(),
"out-of-range index returns None"
);
}
#[test]
fn page_byte_range_bundled_djvm_round_trips() {
let path = assets_path().join("DjVu3Spec_bundled.djvu");
let Ok(data) = std::fs::read(&path) else {
eprintln!("skip: {} missing", path.display());
return;
};
let doc = DjVuDocument::parse(&data).expect("bundled DJVM parse must succeed");
let mut prev_end = 0u64;
for i in 0..doc.page_count() {
let r = doc
.page_byte_range(i)
.unwrap_or_else(|| panic!("page {i} must have a range"));
assert!(r.end <= data.len() as u64, "page {i} range OOB");
assert!(r.start < r.end, "page {i} range empty");
assert!(r.start >= prev_end, "page {i} overlaps previous");
prev_end = r.end;
let slice = &data[r.start as usize..r.end as usize];
assert_eq!(&slice[..4], b"FORM", "page {i} range must start with FORM");
}
}
#[test]
fn page_thumbnail_with_th44_data() {
let carte = std::fs::read(assets_path().join("carte.djvu")).unwrap();
let th44_pos = carte.windows(4).position(|w| w == b"TH44");
if let Some(pos) = th44_pos
&& pos + 8 <= carte.len()
{
let chunk_len = u32::from_be_bytes([
carte[pos + 4],
carte[pos + 5],
carte[pos + 6],
carte[pos + 7],
]) as usize;
let chunk_data = carte.get(pos + 8..pos + 8 + chunk_len).unwrap_or(&[]);
if !chunk_data.is_empty() {
let page = page_with_chunks(&[(b"TH44", chunk_data)]);
let thumb = page.thumbnail();
assert!(thumb.is_ok(), "thumbnail decode should not error");
}
}
}
#[test]
fn extract_mask_from_smmr_chunk() {
use crate::chunk_encode::{ChunkEncoder, SmmrChunk};
let mut bm = crate::bitmap::Bitmap::new(8, 8);
bm.set_black(2, 2);
let smmr_chunk = SmmrChunk(&bm).encode_chunk().unwrap();
let page = page_with_chunks(&[(b"Smmr", &smmr_chunk.payload)]);
let result = page.extract_mask().unwrap();
assert!(result.is_some(), "Smmr page should have a mask");
assert_eq!(result.unwrap().width, 8);
}
#[test]
fn extract_background_returns_none_for_jb2_only_page() {
let jb2_data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
let doc = DjVuDocument::parse(&jb2_data).unwrap();
let page = doc.page(0).unwrap();
let bg = page.extract_background().unwrap();
assert!(bg.is_none(), "JB2-only page should have no background");
}
#[test]
fn extract_mask_indexed_smmr_path() {
use crate::chunk_encode::{ChunkEncoder, SmmrChunk};
let mut bm = crate::bitmap::Bitmap::new(4, 4);
bm.set_black(1, 1);
let smmr_chunk = SmmrChunk(&bm).encode_chunk().unwrap();
let page = page_with_chunks(&[(b"Smmr", &smmr_chunk.payload)]);
let result = page.extract_mask_indexed().unwrap();
assert!(result.is_some());
let (mask, indices) = result.unwrap();
assert_eq!(mask.width, 4);
assert_eq!(indices.len(), 4 * 4);
}
#[test]
fn extract_mask_indexed_no_chunks_returns_none() {
let page = page_with_chunks(&[]);
let result = page.extract_mask_indexed().unwrap();
assert!(result.is_none());
}
#[test]
fn extract_background_decodes_iw44_from_color_page() {
let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let page = doc.page(0).unwrap();
let bg = page.extract_background().unwrap();
assert!(bg.is_some(), "chicken.djvu page should have a background");
let pm = bg.unwrap();
assert!(pm.width > 0 && pm.height > 0);
}
#[test]
fn djvu_page_debug_impl_does_not_panic() {
let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let page = doc.page(0).unwrap();
let s = format!("{page:?}");
assert!(s.contains("DjVuPage"));
}
#[test]
fn page_index_returns_zero_for_first_page() {
let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let page = doc.page(0).unwrap();
assert_eq!(page.index(), 0);
}
#[test]
fn page_text_returns_some_for_text_page() {
let data = std::fs::read(assets_path().join("colorbook.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let page = doc.page(0).unwrap();
let t = page.text().unwrap();
assert!(t.is_some(), "colorbook page 0 should have text");
}
#[test]
fn page_byte_range_out_of_range() {
let data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let doc = DjVuDocument::parse(&data).expect("parse must succeed");
assert!(doc.page_byte_range(99).is_none());
}
#[test]
#[cfg(feature = "mmap")]
fn mmap_document_matches_parse() {
let path = assets_path().join("chicken.djvu");
let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
let data = std::fs::read(&path).expect("read should succeed");
let mem_doc = DjVuDocument::parse(&data).expect("parse should succeed");
assert_eq!(mmap_doc.page_count(), mem_doc.page_count());
for i in 0..mmap_doc.page_count() {
let mp = mmap_doc.page(i).unwrap();
let pp = mem_doc.page(i).unwrap();
assert_eq!(mp.width(), pp.width());
assert_eq!(mp.height(), pp.height());
assert_eq!(mp.dpi(), pp.dpi());
}
}
#[test]
fn extract_foreground_returns_none_when_no_fg44() {
let data = std::fs::read(assets_path().join("boy_jb2.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let fg = doc.page(0).unwrap().extract_foreground().unwrap();
assert!(fg.is_none());
}
#[test]
fn metadata_returns_none_for_doc_without_meta_chunk() {
let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let meta = doc.metadata().unwrap();
assert!(meta.is_none());
}
#[test]
fn all_chunks_returns_matching_chunks() {
let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let info = doc.all_chunks(b"INFO");
assert!(!info.is_empty());
let none = doc.all_chunks(b"XXXX");
assert!(none.is_empty());
}
#[test]
fn chunk_ids_returns_nonempty_for_djvu() {
let data = std::fs::read(assets_path().join("chicken.djvu")).unwrap();
let doc = DjVuDocument::parse(&data).unwrap();
let ids = doc.chunk_ids();
assert!(!ids.is_empty());
}
#[test]
#[cfg(feature = "mmap")]
fn mmap_open_indirect_on_bundled_doc_succeeds() {
let path = assets_path().join("chicken.djvu");
let doc = MmapDocument::open_indirect(&path).expect("open_indirect should work on bundled");
assert!(doc.page_count() > 0);
}
#[test]
#[cfg(feature = "mmap")]
fn mmap_document_method_and_deref_are_reachable() {
let path = assets_path().join("chicken.djvu");
let mmap_doc = MmapDocument::open(&path).expect("mmap open should succeed");
assert!(mmap_doc.document().page_count() > 0);
let inner: &DjVuDocument = &*mmap_doc;
assert!(inner.page_count() > 0);
}
#[test]
fn metadata_returns_some_for_doc_with_meta_chunk() {
use crate::iff::{Chunk, DjvuFile, emit};
use crate::metadata::{DjVuMetadata, encode_metadata};
let info = make_info(100, 100);
let meta = DjVuMetadata {
author: Some("TestAuthor".into()),
..DjVuMetadata::default()
};
let meta_bytes = encode_metadata(&meta);
if meta_bytes.is_empty() {
return; }
let file = DjvuFile {
root: Chunk::Form {
secondary_id: *b"DJVU",
length: 0, children: vec![
Chunk::Leaf {
id: *b"INFO",
data: info,
},
Chunk::Leaf {
id: *b"METa",
data: meta_bytes,
},
],
},
};
let bytes = emit(&file);
let doc = DjVuDocument::parse(&bytes).expect("parse should succeed");
let m = doc.metadata().expect("metadata() should not error");
assert!(
m.is_some(),
"metadata should be Some for a doc with METa chunk"
);
assert_eq!(m.unwrap().author.as_deref(), Some("TestAuthor"));
}
#[test]
fn extract_mask_uses_inline_djbz_when_present() {
use crate::jb2_encode::{
cluster_shared_symbols, encode_jb2_dict_with_shared, encode_jb2_djbz,
};
let mut shape = crate::bitmap::Bitmap::new(8, 8);
shape.set_black(2, 2);
shape.set_black(3, 3);
let shapes = cluster_shared_symbols(&[shape.clone(), shape.clone()], 2);
if shapes.is_empty() {
return; }
let djbz_data = encode_jb2_djbz(&shapes);
let sjbz_data = encode_jb2_dict_with_shared(&shape, &shapes);
let page = page_with_chunks(&[(b"Djbz", &djbz_data), (b"Sjbz", &sjbz_data)]);
let result = page.extract_mask();
assert!(
result.is_ok(),
"extract_mask with inline Djbz should succeed"
);
}
#[test]
fn extract_mask_indexed_uses_inline_djbz_when_present() {
use crate::jb2_encode::{
cluster_shared_symbols, encode_jb2_dict_with_shared, encode_jb2_djbz,
};
let mut shape = crate::bitmap::Bitmap::new(8, 8);
shape.set_black(2, 2);
shape.set_black(3, 3);
let shapes = cluster_shared_symbols(&[shape.clone(), shape.clone()], 2);
if shapes.is_empty() {
return;
}
let djbz_data = encode_jb2_djbz(&shapes);
let sjbz_data = encode_jb2_dict_with_shared(&shape, &shapes);
let page = page_with_chunks(&[(b"Djbz", &djbz_data), (b"Sjbz", &sjbz_data)]);
let result = page.extract_mask_indexed();
assert!(
result.is_ok(),
"extract_mask_indexed with inline Djbz should succeed"
);
}
#[test]
fn parse_navm_bookmarks_short_decoded_returns_empty() {
use crate::bzz_encode::bzz_encode;
let bzz = bzz_encode(b"x");
let chunk = crate::iff::IffChunk {
id: *b"NAVM",
data: &bzz,
};
let result = parse_navm_bookmarks(&[chunk]).unwrap();
assert!(
result.is_empty(),
"NAVM with decoded < 2 bytes must yield empty bookmarks"
);
}
#[test]
fn parse_navm_bookmarks_truncated_entry_returns_error() {
use crate::bzz_encode::bzz_encode;
let payload = vec![0x00, 0x01]; let bzz = bzz_encode(&payload);
let chunk = crate::iff::IffChunk {
id: *b"NAVM",
data: &bzz,
};
let result = parse_navm_bookmarks(&[chunk]);
assert!(
result.is_err(),
"NAVM with declared count > 0 but no entry data must error"
);
}
#[test]
fn parse_navm_bookmarks_string_length_truncated_returns_error() {
use crate::bzz_encode::bzz_encode;
let payload = vec![0x00, 0x01, 0x00]; let bzz = bzz_encode(&payload);
let chunk = crate::iff::IffChunk {
id: *b"NAVM",
data: &bzz,
};
let result = parse_navm_bookmarks(&[chunk]);
assert!(
result.is_err(),
"NAVM with truncated string length must error"
);
}
#[test]
fn indirect_djvm_with_shared_djvi_entry_skips_to_page() {
use crate::dirm::DirmPayload;
let chicken_data =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let dirm_payload = DirmPayload::build_indirect(
2,
&[0x00, 0x01],
&["shared.djvi".to_string(), "page.djvu".to_string()],
);
let dirm_data = dirm_payload.encode();
let djvm_data = build_djvm_with_dirm(&dirm_data);
let resolver = |name: &str| -> Result<Vec<u8>, DocError> {
if name == "page.djvu" {
Ok(chicken_data.clone())
} else {
Err(DocError::IndirectResolve(name.to_string()))
}
};
let doc = DjVuDocument::parse_with_resolver(&djvm_data, Some(resolver))
.expect("indirect DJVM with shared entry must parse");
assert_eq!(doc.page_count(), 1);
let page = doc.page(0).unwrap();
assert_eq!(page.width(), 181);
}
#[test]
fn parse_from_dir_resolves_absolute_component_path() {
use crate::dirm::DirmPayload;
use crate::iff::{self as iff_mod, Chunk, EmitPart};
let chicken =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let tmp_dir = std::env::temp_dir();
let abs_name = tmp_dir.join("djvu_rs_test_abs_component.djvu");
std::fs::write(&abs_name, &chicken).expect("write tmp component");
let abs_name_str = abs_name.to_str().unwrap().to_string();
let dirm_payload =
DirmPayload::build_indirect(1, &[0x01], std::slice::from_ref(&abs_name_str));
let dirm = Chunk::Leaf {
id: *b"DIRM",
data: dirm_payload.encode(),
};
let djvm =
iff_mod::partial_emit(*b"DJVM", &[EmitPart::Chunk(&dirm)]).expect("fits within u32");
let doc = DjVuDocument::parse_from_dir(&djvm, &tmp_dir)
.expect("absolute-path component must resolve");
assert_eq!(doc.page_count(), 1);
let _ = std::fs::remove_file(&abs_name);
}
#[cfg(all(feature = "std", feature = "async"))]
#[test]
fn parse_single_page_with_shared_wrong_form_type_returns_not_djvu() {
use crate::iff::{self as iff_mod, Chunk, DjvuFile};
let bytes = iff_mod::emit(&DjvuFile {
root: Chunk::Form {
secondary_id: *b"DJVI",
length: 0,
children: vec![],
},
});
let err = DjVuDocument::parse_single_page_with_shared(&bytes, 0, None)
.expect_err("FORM:DJVI must not be accepted as a page");
assert!(
matches!(err, DocError::NotDjVu(_)),
"expected NotDjVu, got {err:?}"
);
}
#[test]
fn bundled_djvm_out_of_bounds_dirm_offset_clears_page_byte_ranges() {
use crate::dirm::DirmPayload;
use crate::iff::{self as iff_mod, Chunk, EmitPart};
let chicken =
std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu must exist");
let mut dirm_payload = DirmPayload::build_bundled(1, &[0x01], &["p.djvu".to_string()]);
dirm_payload.offsets[0] = 0xFFFF_FFFF; let dirm_data = dirm_payload.encode();
let dirm = Chunk::Leaf {
id: *b"DIRM",
data: dirm_data,
};
let form_bytes = chicken
.strip_prefix(b"AT&T")
.expect("chicken.djvu must start with AT&T");
let djvm = iff_mod::partial_emit(
*b"DJVM",
&[EmitPart::Chunk(&dirm), EmitPart::Verbatim(form_bytes)],
)
.expect("fits within u32");
let doc = DjVuDocument::parse(&djvm).expect("DJVM with bad offset must still parse");
assert_eq!(doc.page_count(), 1, "page must still be accessible");
assert!(
doc.page_byte_range(0).is_none(),
"page_byte_range must be None when DIRM offset is out of bounds"
);
}
}