#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use core::ops::Range;
use crate::annotation::{Annotation, MapArea, encode_annotations_bzz};
use crate::dirm::DirmPayload;
use crate::djvu_document::DjVuBookmark;
use crate::error::{IffError, LegacyError};
use crate::iff::{self, Chunk, DjvuFile, parse_form_body};
use crate::info::PageInfo;
use crate::metadata::{DjVuMetadata, encode_metadata_bzz};
use crate::navm_encode::encode_navm;
use crate::text::TextLayer;
use crate::text_encode::encode_text_layer;
#[derive(Debug, thiserror::Error)]
pub enum MutError {
#[error("IFF parse error: {0}")]
Parse(#[from] LegacyError),
#[error("chunk path out of range: index {index} at depth {depth} (form has {len} children)")]
PathOutOfRange {
index: usize,
depth: usize,
len: usize,
},
#[error("chunk path enters a leaf at depth {depth} but is {len} levels long")]
PathTraversesLeaf { depth: usize, len: usize },
#[error("path ends on a FORM, not a leaf chunk")]
NotALeaf,
#[error("path must not be empty")]
EmptyPath,
#[error("page index {index} out of range (document has {count} pages)")]
PageOutOfRange {
index: usize,
count: usize,
},
#[error("page has no INFO chunk; cannot encode height-dependent chunk")]
MissingPageInfo,
#[error("INFO chunk parse error: {0}")]
InfoParse(#[from] IffError),
#[error("mutation of indirect DJVM documents is not supported")]
IndirectDjvmUnsupported,
#[error("DIRM chunk is malformed: {0}")]
DirmMalformed(&'static str),
#[error("DIRM component count {dirm} does not match bundle child count {children}")]
DirmComponentCountMismatch {
dirm: usize,
children: usize,
},
#[error("set_bookmarks requires a FORM:DJVM bundle (this document is FORM:DJVU)")]
BookmarksRequireDjvm,
#[error("from_indirect_resolved requires an indirect FORM:DJVM document")]
NotIndirectDjvm,
#[error("resolver did not supply DIRM component {name:?}")]
ComponentResolve {
name: String,
},
#[error("DIRM component {name:?} is malformed: {reason}")]
ComponentMalformed {
name: String,
reason: &'static str,
},
#[error("unsafe component file name {name:?}: {reason}")]
UnsafeComponentName {
name: String,
reason: &'static str,
},
#[error("duplicate DIRM component file name {name:?}")]
DuplicateComponentName {
name: String,
},
#[error("rewrite I/O error for {name:?}: {message}")]
RewriteIo {
name: String,
message: String,
},
}
#[derive(Debug, Clone)]
pub struct DjVuDocumentMut {
file: DjvuFile,
original_bytes: Vec<u8>,
dirty: bool,
}
impl DjVuDocumentMut {
pub fn from_bytes(data: &[u8]) -> Result<Self, MutError> {
let file = iff::parse(data)?;
Ok(Self {
file,
original_bytes: data.to_vec(),
dirty: false,
})
}
pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
where
R: Fn(&str) -> Result<Vec<u8>, E>,
{
let (dirm_data, components) = resolve_indirect_components(root_bytes)?;
if !components.iter().any(|c| c.is_page) {
return Err(MutError::DirmMalformed(
"indirect DIRM lists no page component",
));
}
let mut component_forms: Vec<Chunk> = Vec::with_capacity(components.len());
for comp in &components {
let bytes = resolver(&comp.id).map_err(|_| MutError::ComponentResolve {
name: comp.id.clone(),
})?;
let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
name: comp.id.clone(),
reason: "not a parseable IFF document",
})?;
match &parsed.root {
Chunk::Form { secondary_id, .. }
if secondary_id == b"DJVU"
|| secondary_id == b"DJVI"
|| secondary_id == b"THUM" => {}
_ => {
return Err(MutError::ComponentMalformed {
name: comp.id.clone(),
reason: "root is not a FORM:DJVU/DJVI/THUM",
});
}
}
component_forms.push(parsed.root);
}
let bundled_dirm = bundled_dirm_from_indirect(dirm_data, component_forms.len())?;
let mut children: Vec<Chunk> = Vec::with_capacity(1 + component_forms.len());
children.push(Chunk::Leaf {
id: *b"DIRM",
data: bundled_dirm,
});
children.extend(component_forms);
let mut file = DjvuFile {
root: Chunk::Form {
secondary_id: *b"DJVM",
length: 0,
children,
},
};
recompute_dirm_offsets(&mut file.root)?;
let bundled_bytes = iff::emit(&file);
Ok(Self {
file,
original_bytes: bundled_bytes,
dirty: false,
})
}
pub fn root_child_count(&self) -> usize {
self.file.root.children().len()
}
pub fn root_form_type(&self) -> Option<&[u8; 4]> {
match &self.file.root {
Chunk::Form { secondary_id, .. } => Some(secondary_id),
Chunk::Leaf { .. } => None,
}
}
pub fn replace_leaf(&mut self, path: &[usize], new_data: Vec<u8>) -> Result<(), MutError> {
let chunk = self.chunk_at_path_mut(path)?;
match chunk {
Chunk::Leaf { data, .. } => {
*data = new_data;
self.dirty = true;
Ok(())
}
Chunk::Form { .. } => Err(MutError::NotALeaf),
}
}
pub fn chunk_at_path(&self, path: &[usize]) -> Result<&Chunk, MutError> {
if path.is_empty() {
return Err(MutError::EmptyPath);
}
let mut current = &self.file.root;
for (depth, &idx) in path.iter().enumerate() {
let children = current.children();
if children.is_empty() && depth < path.len() - 1 {
return Err(MutError::PathTraversesLeaf {
depth,
len: path.len(),
});
}
if let Chunk::Leaf { .. } = current {
return Err(MutError::PathTraversesLeaf {
depth,
len: path.len(),
});
}
if idx >= children.len() {
return Err(MutError::PathOutOfRange {
index: idx,
depth,
len: children.len(),
});
}
current = &children[idx];
}
Ok(current)
}
fn chunk_at_path_mut(&mut self, path: &[usize]) -> Result<&mut Chunk, MutError> {
if path.is_empty() {
return Err(MutError::EmptyPath);
}
let _ = self.chunk_at_path(path)?;
let mut current = &mut self.file.root;
for &idx in path {
match current {
Chunk::Form { children, .. } => {
current = &mut children[idx];
}
Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
}
}
Ok(current)
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn into_bytes(self) -> Vec<u8> {
self.try_into_bytes()
.expect("DIRM recomputation failed — inconsistent document")
}
pub fn try_into_bytes(mut self) -> Result<Vec<u8>, MutError> {
if !self.dirty {
return Ok(self.original_bytes);
}
recompute_dirm_offsets(&mut self.file.root)?;
Ok(
emit_patched_single_page(&self.file.root, &self.original_bytes)
.unwrap_or_else(|| iff::emit(&self.file)),
)
}
pub fn page_count(&self) -> usize {
match self.root_form_type() {
Some(b"DJVM") => self
.file
.root
.children()
.iter()
.filter(
|c| matches!(c, Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU"),
)
.count(),
_ => 1,
}
}
pub fn page_mut(&mut self, index: usize) -> Result<PageMut<'_>, MutError> {
let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
if &root_form_type == b"DJVU" {
let count = self.page_count();
if index >= count {
return Err(MutError::PageOutOfRange { index, count });
}
debug_assert_eq!(index, 0);
return Ok(PageMut {
form: &mut self.file.root,
dirty: &mut self.dirty,
});
}
debug_assert_eq!(&root_form_type, b"DJVM");
if !is_bundled_djvm(&self.file.root) {
return Err(MutError::IndirectDjvmUnsupported);
}
let count = self.page_count();
if index >= count {
return Err(MutError::PageOutOfRange { index, count });
}
let children = match &mut self.file.root {
Chunk::Form { children, .. } => children,
Chunk::Leaf { .. } => unreachable!("validated FORM root"),
};
let mut seen = 0usize;
for child in children.iter_mut() {
if let Chunk::Form { secondary_id, .. } = child
&& secondary_id == b"DJVU"
{
if seen == index {
return Ok(PageMut {
form: child,
dirty: &mut self.dirty,
});
}
seen += 1;
}
}
unreachable!("page_count agreed with bundle but iteration disagreed")
}
pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
if &root_form_type != b"DJVM" {
return Err(MutError::BookmarksRequireDjvm);
}
let children = match &mut self.file.root {
Chunk::Form { children, .. } => children,
Chunk::Leaf { .. } => unreachable!("validated FORM root"),
};
let pos = children
.iter()
.position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"NAVM"));
match (pos, bookmarks.is_empty()) {
(Some(i), true) => {
children.remove(i);
}
(Some(i), false) => {
children[i] = Chunk::Leaf {
id: *b"NAVM",
data: encode_navm(bookmarks),
};
}
(None, true) => { }
(None, false) => {
let dirm_pos = children
.iter()
.position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"DIRM"));
let insert_at = dirm_pos.map(|i| i + 1).unwrap_or(0);
children.insert(
insert_at,
Chunk::Leaf {
id: *b"NAVM",
data: encode_navm(bookmarks),
},
);
}
}
self.dirty = true;
Ok(())
}
}
#[cfg(feature = "std")]
fn resolve_indirect_components(
root_bytes: &[u8],
) -> Result<(&[u8], Vec<crate::djvu_document::DirmComponentInfo>), MutError> {
let form = iff::parse_form(root_bytes)?;
if &form.form_type != b"DJVM" {
return Err(MutError::NotIndirectDjvm);
}
let dirm_data: &[u8] = form
.chunks
.iter()
.find(|c| &c.id == b"DIRM")
.ok_or(MutError::DirmMalformed("indirect DJVM has no DIRM chunk"))?
.data;
let (components, is_bundled) = crate::djvu_document::parse_dirm_components(dirm_data)
.map_err(|_| MutError::DirmMalformed("DIRM directory could not be parsed"))?;
if is_bundled {
return Err(MutError::NotIndirectDjvm);
}
if components.is_empty() {
return Err(MutError::DirmMalformed("indirect DIRM lists no components"));
}
Ok((dirm_data, components))
}
fn bundled_dirm_from_indirect(indirect: &[u8], nfiles: usize) -> Result<Vec<u8>, MutError> {
let mut payload = DirmPayload::decode(indirect).map_err(MutError::DirmMalformed)?;
payload.flags |= 0x80;
payload.offsets = core::iter::repeat_n(0u32, nfiles).collect();
Ok(payload.encode())
}
fn is_bundled_djvm(chunk: &Chunk) -> bool {
let Chunk::Form {
secondary_id,
children,
..
} = chunk
else {
return false;
};
if secondary_id != b"DJVM" {
return false;
}
children.iter().any(|c| {
matches!(c, Chunk::Leaf { id, data } if id == b"DIRM" && !data.is_empty() && (data[0] & 0x80) != 0)
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OriginalChildRange {
id: [u8; 4],
data: Vec<u8>,
range: Range<usize>,
}
fn emit_patched_single_page(root: &Chunk, original: &[u8]) -> Option<Vec<u8>> {
let Chunk::Form {
secondary_id,
children,
..
} = root
else {
return None;
};
if secondary_id != b"DJVU" {
return None;
}
let original_children = original_single_page_child_ranges(original)?;
if original_children.len() != children.len() {
return None;
}
let mut payload = Vec::new();
payload.extend_from_slice(b"DJVU");
for (child, original_child) in children.iter().zip(original_children.iter()) {
match child {
Chunk::Leaf { id, data }
if id == &original_child.id && data == &original_child.data =>
{
payload.extend_from_slice(&original[original_child.range.clone()]);
}
Chunk::Leaf { .. } => emit_leaf_chunk(child, &mut payload),
Chunk::Form { .. } => return None,
}
}
let len = u32::try_from(payload.len()).ok()?;
let mut out = Vec::with_capacity(12 + payload.len() + (payload.len() & 1));
out.extend_from_slice(b"AT&T");
out.extend_from_slice(b"FORM");
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(&payload);
if (8 + payload.len()) & 1 == 1 {
out.push(0);
}
Some(out)
}
fn emit_leaf_chunk(chunk: &Chunk, out: &mut Vec<u8>) {
let Chunk::Leaf { id, data } = chunk else {
unreachable!("caller passes leaf chunks only")
};
let len = data.len() as u32;
out.extend_from_slice(id);
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(data);
if (8 + data.len()) & 1 == 1 {
out.push(0);
}
}
fn original_single_page_child_ranges(original: &[u8]) -> Option<Vec<OriginalChildRange>> {
if original.len() < 16 || &original[..4] != b"AT&T" || &original[4..8] != b"FORM" {
return None;
}
let form_len = u32::from_be_bytes(original[8..12].try_into().ok()?) as usize;
let body_end = 12usize.checked_add(form_len)?;
if body_end > original.len() || &original[12..16] != b"DJVU" {
return None;
}
let chunks = parse_form_body(original.get(16..body_end)?).ok()?;
let mut ranges = Vec::with_capacity(chunks.len());
let mut pos = 16usize;
for chunk in &chunks {
if &chunk.id == b"FORM" {
return None;
}
let data_end = pos + 8 + chunk.data.len();
let mut next = data_end;
if next & 1 == 1 {
if next >= body_end {
return None;
}
next += 1;
}
ranges.push(OriginalChildRange {
id: chunk.id,
data: chunk.data.to_vec(),
range: pos..next,
});
pos = next;
}
if pos != body_end {
return None;
}
Some(ranges)
}
fn emitted_chunk_size(chunk: &Chunk) -> usize {
match chunk {
Chunk::Form {
secondary_id: _,
children,
..
} => {
let payload: usize = 4 + children.iter().map(emitted_chunk_size).sum::<usize>();
let total = 8 + payload;
total + (total & 1)
}
Chunk::Leaf { data, .. } => {
let total = 8 + data.len();
total + (total & 1)
}
}
}
fn recompute_dirm_offsets(root: &mut Chunk) -> Result<(), MutError> {
let Chunk::Form {
secondary_id,
children,
..
} = root
else {
return Ok(());
};
if secondary_id != b"DJVM" {
return Ok(());
}
let mut pos: usize = 16;
let mut new_offsets: Vec<u32> = Vec::new();
let mut dirm_idx: Option<usize> = None;
#[allow(clippy::redundant_guards)]
for (i, child) in children.iter().enumerate() {
match child {
Chunk::Leaf { id, .. } if id == b"DIRM" => {
dirm_idx = Some(i);
}
Chunk::Form {
secondary_id: sid, ..
} if sid == b"DJVU" || sid == b"DJVI" || sid == b"THUM" => {
new_offsets.push(u32::try_from(pos).map_err(|_| {
MutError::DirmMalformed("component offset exceeds u32 (file > 4 GiB)")
})?);
}
_ => {}
}
pos += emitted_chunk_size(child);
}
let Some(dirm_idx) = dirm_idx else {
return Ok(());
};
let dirm = &mut children[dirm_idx];
let Chunk::Leaf { data, .. } = dirm else {
return Err(MutError::DirmMalformed("DIRM is not a leaf chunk"));
};
let mut payload = DirmPayload::decode(data).map_err(MutError::DirmMalformed)?;
if !payload.is_bundled() {
return Ok(());
}
if payload.nfiles as usize != new_offsets.len() {
return Err(MutError::DirmComponentCountMismatch {
dirm: payload.nfiles as usize,
children: new_offsets.len(),
});
}
payload.offsets = new_offsets;
*data = payload.encode();
Ok(())
}
pub struct PageMut<'doc> {
form: &'doc mut Chunk,
dirty: &'doc mut bool,
}
impl PageMut<'_> {
pub fn set_text_layer(&mut self, layer: &TextLayer) -> Result<(), MutError> {
let info_data = self
.find_leaf_data(b"INFO")
.ok_or(MutError::MissingPageInfo)?;
let info = PageInfo::parse(info_data)?;
let plain = encode_text_layer(layer, info.height as u32);
let compressed = crate::bzz_encode::bzz_encode(&plain);
self.replace_or_insert_text(compressed);
*self.dirty = true;
Ok(())
}
pub fn set_annotations(&mut self, annotation: &Annotation, areas: &[MapArea]) {
let bytes = encode_annotations_bzz(annotation, areas);
self.replace_or_insert(b"ANTa", b"ANTz", bytes);
*self.dirty = true;
}
pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
let bytes = encode_metadata_bzz(meta);
self.replace_or_insert(b"METa", b"METz", bytes);
*self.dirty = true;
}
fn find_leaf_data(&self, id: &[u8; 4]) -> Option<&[u8]> {
for child in self.form.children() {
if let Chunk::Leaf { id: cid, data } = child
&& cid == id
{
return Some(data);
}
}
None
}
fn replace_or_insert(&mut self, id_a: &[u8; 4], id_z: &[u8; 4], data: Vec<u8>) {
let children = match self.form {
Chunk::Form { children, .. } => children,
Chunk::Leaf { .. } => unreachable!("PageMut wraps a FORM"),
};
let pos = children
.iter()
.position(|c| matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z));
match (pos, data.is_empty()) {
(Some(i), true) => {
children.remove(i);
}
(Some(i), false) => {
children[i] = Chunk::Leaf { id: *id_z, data };
}
(None, true) => { }
(None, false) => {
children.push(Chunk::Leaf { id: *id_z, data });
}
}
}
fn replace_or_insert_text(&mut self, data: Vec<u8>) {
self.replace_or_insert(b"TXTa", b"TXTz", data);
}
}
#[cfg(feature = "std")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RewriteItem {
pub name: String,
pub is_root: bool,
pub changed: bool,
}
#[cfg(feature = "std")]
#[derive(Debug, Clone)]
struct PlannedComponent {
name: String,
is_page: bool,
original: Vec<u8>,
edited: Option<Vec<u8>>,
}
#[cfg(feature = "std")]
#[derive(Debug, Clone)]
pub struct IndirectRewritePlan {
root_bytes: Vec<u8>,
root_changed: bool,
components: Vec<PlannedComponent>,
}
#[cfg(feature = "std")]
impl IndirectRewritePlan {
pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
where
R: Fn(&str) -> Result<Vec<u8>, E>,
{
let (_dirm_data, infos) = resolve_indirect_components(root_bytes)?;
let mut seen = std::collections::HashSet::new();
for info in &infos {
validate_safe_component_name(&info.id)?;
if !seen.insert(info.id.clone()) {
return Err(MutError::DuplicateComponentName {
name: info.id.clone(),
});
}
}
let mut components = Vec::with_capacity(infos.len());
for info in &infos {
let bytes = resolver(&info.id).map_err(|_| MutError::ComponentResolve {
name: info.id.clone(),
})?;
let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
name: info.id.clone(),
reason: "not a parseable IFF document",
})?;
match &parsed.root {
Chunk::Form { secondary_id, .. }
if secondary_id == b"DJVU"
|| secondary_id == b"DJVI"
|| secondary_id == b"THUM" => {}
_ => {
return Err(MutError::ComponentMalformed {
name: info.id.clone(),
reason: "root is not a FORM:DJVU/DJVI/THUM",
});
}
}
components.push(PlannedComponent {
name: info.id.clone(),
is_page: info.is_page,
original: bytes,
edited: None,
});
}
Ok(Self {
root_bytes: root_bytes.to_vec(),
root_changed: false,
components,
})
}
pub fn page_count(&self) -> usize {
self.components.iter().filter(|c| c.is_page).count()
}
pub fn component_count(&self) -> usize {
self.components.len()
}
pub fn edit_page<F>(&mut self, index: usize, edit: F) -> Result<(), MutError>
where
F: FnOnce(&mut DjVuDocumentMut) -> Result<(), MutError>,
{
let count = self.page_count();
let comp = self
.components
.iter_mut()
.filter(|c| c.is_page)
.nth(index)
.ok_or(MutError::PageOutOfRange { index, count })?;
let current: &[u8] = comp.edited.as_deref().unwrap_or(&comp.original);
let mut doc = DjVuDocumentMut::from_bytes(current)?;
edit(&mut doc)?;
if doc.is_dirty() {
comp.edited = Some(doc.try_into_bytes()?);
}
Ok(())
}
pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
let mut root = DjVuDocumentMut::from_bytes(&self.root_bytes)?;
root.set_bookmarks(bookmarks)?;
if root.is_dirty() {
self.root_bytes = root.try_into_bytes()?;
self.root_changed = true;
}
Ok(())
}
pub fn plan(&self, root_name: &str) -> Vec<RewriteItem> {
let mut items: Vec<RewriteItem> = self
.components
.iter()
.map(|c| RewriteItem {
name: c.name.clone(),
is_root: false,
changed: c.edited.is_some(),
})
.collect();
items.push(RewriteItem {
name: root_name.to_string(),
is_root: true,
changed: self.root_changed,
});
items
}
pub fn commit_to_dir(
&self,
dir: impl AsRef<std::path::Path>,
root_name: &str,
) -> Result<Vec<std::path::PathBuf>, MutError> {
let dir = dir.as_ref();
validate_safe_component_name(root_name)?;
if self.components.iter().any(|c| c.name == root_name) {
return Err(MutError::DuplicateComponentName {
name: root_name.to_string(),
});
}
std::fs::create_dir_all(dir).map_err(|e| MutError::RewriteIo {
name: dir.display().to_string(),
message: e.to_string(),
})?;
let mut written = Vec::with_capacity(self.components.len() + 1);
for comp in &self.components {
let bytes = comp.edited.as_deref().unwrap_or(&comp.original);
written.push(stage_and_rename(dir, &comp.name, bytes)?);
}
written.push(stage_and_rename(dir, root_name, &self.root_bytes)?);
Ok(written)
}
}
#[cfg(feature = "std")]
fn validate_safe_component_name(name: &str) -> Result<(), MutError> {
let reject = |reason: &'static str| {
Err(MutError::UnsafeComponentName {
name: name.to_string(),
reason,
})
};
if name.is_empty() {
return reject("name is empty");
}
if name.contains('\0') {
return reject("name contains a NUL byte");
}
if name.contains('/') || name.contains('\\') {
return reject("name contains a path separator");
}
if name.contains(':') {
return reject("name contains a drive-letter / stream colon");
}
if name == "." || name == ".." {
return reject("name is a relative directory reference");
}
Ok(())
}
#[cfg(feature = "std")]
fn stage_and_rename(
dir: &std::path::Path,
name: &str,
bytes: &[u8],
) -> Result<std::path::PathBuf, MutError> {
use std::io::Write;
let final_path = dir.join(name);
let tmp_path = dir.join(format!(".{name}.djvu-rs.tmp"));
let io_err = |path: &std::path::Path, e: std::io::Error| MutError::RewriteIo {
name: path.display().to_string(),
message: e.to_string(),
};
{
let mut f = std::fs::File::create(&tmp_path).map_err(|e| io_err(&tmp_path, e))?;
f.write_all(bytes).map_err(|e| io_err(&tmp_path, e))?;
f.sync_all().map_err(|e| io_err(&tmp_path, e))?;
}
std::fs::rename(&tmp_path, &final_path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
io_err(&final_path, e)
})?;
Ok(final_path)
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
use std::path::PathBuf;
fn corpus_path(name: &str) -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/fixtures");
p.push(name);
p
}
fn read_corpus(name: &str) -> Vec<u8> {
std::fs::read(corpus_path(name)).expect("corpus fixture missing")
}
#[test]
fn roundtrip_byte_identical_chicken() {
let original = read_corpus("chicken.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert!(!doc.is_dirty());
assert_eq!(doc.into_bytes(), original);
}
#[test]
fn roundtrip_byte_identical_boy_jb2() {
let original = read_corpus("boy_jb2.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert_eq!(doc.into_bytes(), original);
}
#[test]
fn roundtrip_byte_identical_djvm_bundle() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert_eq!(doc.root_form_type(), Some(b"DJVM"));
assert_eq!(doc.into_bytes(), original);
}
#[test]
fn roundtrip_byte_identical_navm() {
let original = read_corpus("navm_fgbz.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert_eq!(doc.into_bytes(), original);
}
#[test]
fn replace_leaf_changes_emitted_bytes() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let first = doc.chunk_at_path(&[0]).unwrap();
let original_first_data = first.data().to_vec();
assert!(!original_first_data.is_empty());
let marker = b"PR1_TEST_MARKER".to_vec();
doc.replace_leaf(&[0], marker.clone()).unwrap();
assert!(doc.is_dirty());
let edited = doc.into_bytes();
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let new_first = reparsed.chunk_at_path(&[0]).unwrap();
assert_eq!(new_first.data(), marker.as_slice());
}
#[test]
fn single_page_patch_preserves_unedited_child_bytes() {
let original = read_corpus("chicken.djvu");
let original_ranges =
original_single_page_child_ranges(&original).expect("single-page child ranges");
assert!(
original_ranges.len() > 2,
"fixture must have unrelated chunks to preserve"
);
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
doc.replace_leaf(&[0], b"PATCHED_INFO".to_vec()).unwrap();
let edited = doc.try_into_bytes().unwrap();
let edited_ranges =
original_single_page_child_ranges(&edited).expect("edited child ranges");
assert_eq!(edited_ranges.len(), original_ranges.len());
for (idx, (before, after)) in original_ranges.iter().zip(edited_ranges.iter()).enumerate() {
if idx == 0 {
assert_ne!(
&original[before.range.clone()],
&edited[after.range.clone()]
);
continue;
}
assert_eq!(before.id, after.id);
assert_eq!(
&original[before.range.clone()],
&edited[after.range.clone()],
"unchanged child #{idx} must be copied byte-for-byte"
);
}
}
#[test]
fn single_page_patch_falls_back_for_bundled_djvm() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert!(
emit_patched_single_page(&doc.file.root, &original).is_none(),
"single-page patch path must decline bundled DJVM layouts"
);
}
#[test]
fn replace_leaf_rejects_empty_path() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let err = doc.replace_leaf(&[], vec![]).unwrap_err();
assert!(matches!(err, MutError::EmptyPath));
}
#[test]
fn replace_leaf_rejects_out_of_range() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let err = doc.replace_leaf(&[9999], vec![]).unwrap_err();
assert!(matches!(err, MutError::PathOutOfRange { .. }));
}
#[test]
fn replace_leaf_rejects_traversing_leaf() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let err = doc.replace_leaf(&[0, 0], vec![]).unwrap_err();
assert!(matches!(err, MutError::PathTraversesLeaf { .. }));
}
#[test]
fn replace_leaf_rejects_form_target() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let last_idx = doc.root_child_count() - 1;
let err = doc.replace_leaf(&[last_idx], vec![]).unwrap_err();
assert!(matches!(err, MutError::NotALeaf));
}
#[test]
fn root_form_type_djvu_single_page() {
let original = read_corpus("chicken.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert_eq!(doc.root_form_type(), Some(b"DJVU"));
}
#[test]
fn page_count_single_page_djvu_is_one() {
let original = read_corpus("chicken.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert_eq!(doc.page_count(), 1);
}
#[test]
fn page_count_djvm_bundle_counts_djvu_components_only() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let direct: usize = doc
.file
.root
.children()
.iter()
.filter(|c| {
matches!(c, crate::iff::Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU")
})
.count();
assert!(direct >= 2, "expected multi-page bundle, got {direct}");
assert_eq!(doc.page_count(), direct);
}
#[test]
fn page_mut_out_of_range_errors() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let err = doc.page_mut(1).err().unwrap();
assert!(matches!(
err,
MutError::PageOutOfRange { index: 1, count: 1 }
));
}
#[test]
fn page_mut_djvm_bundle_succeeds_after_pr3() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
assert!(doc.page_mut(0).is_ok());
let count = doc.page_count();
let err = doc.page_mut(count).err().unwrap();
assert!(matches!(err, MutError::PageOutOfRange { .. }));
}
#[test]
fn page_mut_indirect_djvm_returns_unsupported_before_range_check() {
let mut doc = DjVuDocumentMut::from_bytes(&indirect_djvm_bytes()).unwrap();
let err = doc.page_mut(0).err().unwrap();
assert!(matches!(err, MutError::IndirectDjvmUnsupported));
}
fn indirect_djvm_bytes() -> 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);
let mut dirm_chunk = Vec::new();
dirm_chunk.extend_from_slice(b"DIRM");
dirm_chunk.extend_from_slice(&(dirm_data.len() as u32).to_be_bytes());
dirm_chunk.extend_from_slice(&dirm_data);
if !dirm_data.len().is_multiple_of(2) {
dirm_chunk.push(0);
}
let mut form_body = Vec::new();
form_body.extend_from_slice(b"DJVM");
form_body.extend_from_slice(&dirm_chunk);
let mut out = Vec::new();
out.extend_from_slice(b"AT&T");
out.extend_from_slice(b"FORM");
out.extend_from_slice(&(form_body.len() as u32).to_be_bytes());
out.extend_from_slice(&form_body);
out
}
#[test]
fn set_text_layer_roundtrip_chicken() {
use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let layer = TextLayer {
text: "hello world".to_string(),
zones: vec![TextZone {
kind: TextZoneKind::Page,
rect: Rect {
x: 0,
y: 0,
width: 100,
height: 50,
},
text: "hello world".to_string(),
children: vec![],
}],
};
doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
assert!(doc.is_dirty());
let edited = doc.into_bytes();
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let has_txtz = reparsed
.file
.root
.children()
.iter()
.any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
assert!(
has_txtz,
"TXTz chunk should be present after set_text_layer"
);
}
#[test]
fn set_annotations_roundtrip_chicken() {
use crate::annotation::{Annotation, Color};
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let mut ann = Annotation::default();
ann.background = Some(Color {
r: 0xFF,
g: 0xFF,
b: 0xFF,
});
ann.mode = Some("color".to_string());
doc.page_mut(0).unwrap().set_annotations(&ann, &[]);
let edited = doc.into_bytes();
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let antz = reparsed
.file
.root
.children()
.iter()
.find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"ANTz"));
assert!(antz.is_some(), "ANTz should be inserted");
let data = antz.unwrap().data();
let decoded = crate::bzz_new::bzz_decode(data).expect("ANTz must decompress");
let (parsed_ann, _areas) =
crate::annotation::parse_annotations(&decoded).expect("ANTz must round-trip");
assert_eq!(parsed_ann.mode.as_deref(), Some("color"));
assert_eq!(
parsed_ann.background,
Some(Color {
r: 0xFF,
g: 0xFF,
b: 0xFF
})
);
}
#[test]
fn set_metadata_roundtrip_chicken() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let mut meta = DjVuMetadata::default();
meta.title = Some("Test Title".into());
meta.author = Some("Tester".into());
doc.page_mut(0).unwrap().set_metadata(&meta);
let edited = doc.into_bytes();
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let metz = reparsed
.file
.root
.children()
.iter()
.find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
.expect("METz should be inserted");
let decoded = crate::bzz_new::bzz_decode(metz.data()).unwrap();
let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
assert_eq!(parsed, meta);
}
#[test]
fn set_metadata_empty_removes_existing_chunk() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let mut meta = DjVuMetadata::default();
meta.title = Some("X".into());
doc.page_mut(0).unwrap().set_metadata(&meta);
doc.page_mut(0)
.unwrap()
.set_metadata(&DjVuMetadata::default());
let edited = doc.into_bytes();
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let any_meta = reparsed
.file
.root
.children()
.iter()
.any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
assert!(!any_meta, "set_metadata(empty) should remove any METa/METz");
}
#[test]
fn set_metadata_replaces_existing_chunk_in_place() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let mut m1 = DjVuMetadata::default();
m1.title = Some("First".into());
doc.page_mut(0).unwrap().set_metadata(&m1);
let mut m2 = DjVuMetadata::default();
m2.title = Some("Second".into());
doc.page_mut(0).unwrap().set_metadata(&m2);
let edited = doc.into_bytes();
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let metz_count = reparsed
.file
.root
.children()
.iter()
.filter(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"))
.count();
assert_eq!(metz_count, 1, "should not duplicate METz on repeat set");
}
fn dirm_offsets_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
let form = crate::iff::parse_form(data).expect("parse_form");
assert_eq!(&form.form_type, b"DJVM");
let dirm = form
.chunks
.iter()
.find(|c| &c.id == b"DIRM")
.expect("DIRM present");
let payload = dirm.data;
let nfiles = u16::from_be_bytes([payload[1], payload[2]]) as usize;
let mut declared = Vec::with_capacity(nfiles);
for i in 0..nfiles {
let base = 3 + i * 4;
declared.push(u32::from_be_bytes([
payload[base],
payload[base + 1],
payload[base + 2],
payload[base + 3],
]));
}
let mut actual = Vec::with_capacity(nfiles);
let mut pos = 16usize;
let body_end = 8 + u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
while pos < body_end {
let id = &data[pos..pos + 4];
let len =
u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
as usize;
if id == b"FORM" {
actual.push(pos as u32);
}
let mut next = pos + 8 + len;
if next & 1 == 1 {
next += 1;
}
pos = next;
}
(declared, actual)
}
#[test]
fn dirm_offsets_match_actual_after_no_edit() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let (declared, actual) = dirm_offsets_and_actual(&original);
assert_eq!(declared, actual);
}
#[test]
fn dirm_offsets_recomputed_after_page_metadata_edit() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let mut meta = DjVuMetadata::default();
meta.title = Some("PR3 DJVM bundled mutation".into());
meta.author = Some("djvu-rs PR3 tests".into());
doc.page_mut(0).unwrap().set_metadata(&meta);
assert!(doc.is_dirty());
let edited = doc.into_bytes();
assert_ne!(edited.len(), original.len());
let (declared, actual) = dirm_offsets_and_actual(&edited);
assert_eq!(
declared, actual,
"DIRM offsets must point at the new FORM positions after edit"
);
let reparsed =
crate::djvu_document::DjVuDocument::parse(&edited).expect("edited bundle must parse");
let original_doc =
crate::djvu_document::DjVuDocument::parse(&original).expect("original bundle parses");
assert_eq!(reparsed.page_count(), original_doc.page_count());
}
#[test]
fn dirm_offsets_recomputed_after_middle_page_edit() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let count = doc.page_count();
assert!(count >= 3);
let mid = count / 2;
let mut meta = DjVuMetadata::default();
meta.title = Some("PR3 mid-page edit".into());
doc.page_mut(mid).unwrap().set_metadata(&meta);
let edited = doc.into_bytes();
let (declared, actual) = dirm_offsets_and_actual(&edited);
assert_eq!(declared, actual);
let (orig_declared, _) = dirm_offsets_and_actual(&original);
for i in 0..mid {
assert_eq!(
declared[i], orig_declared[i],
"offset for page {i} (before edit) must be unchanged"
);
}
}
#[test]
fn set_bookmarks_replaces_navm_in_bundle() {
use crate::djvu_document::DjVuBookmark;
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let bookmarks = vec![
DjVuBookmark {
title: "Front matter".into(),
url: "#1".into(),
children: vec![DjVuBookmark {
title: "Acknowledgments".into(),
url: "#3".into(),
children: vec![],
}],
},
DjVuBookmark {
title: "Body".into(),
url: "#10".into(),
children: vec![],
},
];
doc.set_bookmarks(&bookmarks).unwrap();
assert!(doc.is_dirty());
let edited = doc.into_bytes();
let (declared, actual) = dirm_offsets_and_actual(&edited);
assert_eq!(declared, actual);
let reparsed = crate::djvu_document::DjVuDocument::parse(&edited)
.expect("bundle with new bookmarks parses");
let parsed_bms = reparsed.bookmarks();
assert_eq!(parsed_bms.len(), 2);
assert_eq!(parsed_bms[0].title, "Front matter");
assert_eq!(parsed_bms[0].children.len(), 1);
assert_eq!(parsed_bms[0].children[0].title, "Acknowledgments");
assert_eq!(parsed_bms[1].title, "Body");
}
#[test]
fn set_bookmarks_empty_removes_navm() {
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
doc.set_bookmarks(&[]).unwrap();
let edited = doc.into_bytes();
let form = crate::iff::parse_form(&edited).unwrap();
let has_navm = form.chunks.iter().any(|c| &c.id == b"NAVM");
assert!(!has_navm, "set_bookmarks(&[]) must remove NAVM");
let (declared, actual) = dirm_offsets_and_actual(&edited);
assert_eq!(declared, actual);
}
#[test]
fn set_bookmarks_inserts_navm_when_absent() {
use crate::djvu_document::DjVuBookmark;
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
doc.set_bookmarks(&[]).unwrap();
let stripped = doc.into_bytes();
let mut doc = DjVuDocumentMut::from_bytes(&stripped).unwrap();
let bms = vec![DjVuBookmark {
title: "Re-added".into(),
url: "#1".into(),
children: vec![],
}];
doc.set_bookmarks(&bms).unwrap();
let edited = doc.into_bytes();
let form = crate::iff::parse_form(&edited).unwrap();
let navm_pos = form
.chunks
.iter()
.position(|c| &c.id == b"NAVM")
.expect("NAVM should be inserted");
let dirm_pos = form.chunks.iter().position(|c| &c.id == b"DIRM").unwrap();
assert_eq!(
navm_pos,
dirm_pos + 1,
"NAVM should be placed immediately after DIRM"
);
let (declared, actual) = dirm_offsets_and_actual(&edited);
assert_eq!(declared, actual);
}
#[test]
fn set_bookmarks_on_single_page_djvu_errors() {
let original = read_corpus("chicken.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let err = doc.set_bookmarks(&[]).err().unwrap();
assert!(matches!(err, MutError::BookmarksRequireDjvm));
}
#[test]
fn page_mut_djvm_text_layer_roundtrip() {
use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
let original = read_corpus("DjVu3Spec_bundled.djvu");
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let layer = TextLayer {
text: "djvm page-3 text".into(),
zones: vec![TextZone {
kind: TextZoneKind::Page,
rect: Rect {
x: 0,
y: 0,
width: 100,
height: 50,
},
text: "djvm page-3 text".into(),
children: vec![],
}],
};
doc.page_mut(2).unwrap().set_text_layer(&layer).unwrap();
let edited = doc.into_bytes();
let (declared, actual) = dirm_offsets_and_actual(&edited);
assert_eq!(declared, actual);
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let mut djvu_seen = 0usize;
let mut found_txtz = false;
for child in reparsed.file.root.children() {
if let Chunk::Form {
secondary_id,
children,
..
} = child
&& secondary_id == b"DJVU"
{
if djvu_seen == 2 {
found_txtz = children
.iter()
.any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
break;
}
djvu_seen += 1;
}
}
assert!(
found_txtz,
"TXTz chunk should be present on page 2 after set_text_layer"
);
}
#[test]
fn unmutated_pages_byte_identical_after_metadata_edit() {
use crate::metadata::DjVuMetadata;
let original = read_corpus("DjVu3Spec_bundled.djvu");
let orig_ranges = top_form_ranges(&original);
let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
let meta = DjVuMetadata {
title: Some("PR4 byte-identical probe".into()),
..Default::default()
};
doc.page_mut(0).unwrap().set_metadata(&meta);
let edited = doc.into_bytes();
let edited_ranges = top_form_ranges(&edited);
assert_eq!(orig_ranges.len(), edited_ranges.len());
let mut djvu_idx = 0usize;
for (i, (or, er)) in orig_ranges.iter().zip(edited_ranges.iter()).enumerate() {
let is_form_djvu = &original[or.start..or.start + 4] == b"FORM"
&& (&original[or.start + 8..or.start + 12] == b"DJVU"
|| &original[or.start + 8..or.start + 12] == b"DJVI");
if !is_form_djvu {
continue;
}
let is_edited_page = djvu_idx == 0;
djvu_idx += 1;
if is_edited_page {
continue;
}
assert_eq!(
&original[or.clone()],
&edited[er.clone()],
"FORM at top-level child #{i} must be byte-identical after edit"
);
}
}
fn indirect_over_fixtures(
page_names: &[&str],
) -> (
Vec<u8>,
impl Fn(&str) -> Result<Vec<u8>, std::io::Error> + use<>,
) {
let index = crate::djvm::create_indirect(page_names).expect("create_indirect");
let map: std::collections::HashMap<String, Vec<u8>> = page_names
.iter()
.map(|n| (n.to_string(), read_corpus(n)))
.collect();
let resolver = move |name: &str| -> Result<Vec<u8>, std::io::Error> {
map.get(name).cloned().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "no such component")
})
};
(index, resolver)
}
#[test]
fn from_indirect_resolved_rebundles_single_page() {
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
assert_eq!(doc.root_form_type(), Some(b"DJVM"));
assert_eq!(doc.page_count(), 1);
assert!(!doc.is_dirty());
let bundled = doc.try_into_bytes().unwrap();
let reparsed =
crate::djvu_document::DjVuDocument::parse(&bundled).expect("bundled output parses");
assert_eq!(reparsed.page_count(), 1);
assert_eq!(reparsed.page(0).unwrap().width(), 181);
assert_eq!(reparsed.page(0).unwrap().height(), 240);
let (declared, actual) = dirm_offsets_and_actual(&bundled);
assert_eq!(declared, actual);
}
#[test]
fn from_indirect_resolved_multi_page_preserves_order() {
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
assert_eq!(doc.page_count(), 2);
let bundled = doc.try_into_bytes().unwrap();
let reparsed = crate::djvu_document::DjVuDocument::parse(&bundled).expect("parses");
assert_eq!(reparsed.page_count(), 2);
assert_eq!(reparsed.page(0).unwrap().width(), 181);
let irish_doc = crate::djvu_document::DjVuDocument::parse(&read_corpus("irish.djvu"))
.expect("irish parses standalone");
assert_eq!(
reparsed.page(1).unwrap().dimensions(),
irish_doc.page(0).unwrap().dimensions()
);
let (declared, actual) = dirm_offsets_and_actual(&bundled);
assert_eq!(declared, actual);
}
#[test]
fn from_indirect_resolved_then_metadata_edit_roundtrips() {
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
let meta = DjVuMetadata {
title: Some("rebundled indirect".into()),
author: Some("djvu-rs #325".into()),
..Default::default()
};
doc.page_mut(1).unwrap().set_metadata(&meta);
assert!(doc.is_dirty());
let edited = doc.into_bytes();
let (declared, actual) = dirm_offsets_and_actual(&edited);
assert_eq!(declared, actual);
let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
let mut djvu_seen = 0usize;
let mut found = None;
for child in reparsed.file.root.children() {
if let Chunk::Form {
secondary_id,
children,
..
} = child
&& secondary_id == b"DJVU"
{
if djvu_seen == 1 {
found = children
.iter()
.find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
.map(|c| c.data().to_vec());
break;
}
djvu_seen += 1;
}
}
let metz = found.expect("page 1 should have METz after edit");
let decoded = crate::bzz_new::bzz_decode(&metz).unwrap();
let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
assert_eq!(parsed.title.as_deref(), Some("rebundled indirect"));
}
#[test]
fn from_indirect_resolved_then_text_layer_edit() {
use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
let layer = TextLayer {
text: "rebundled text".into(),
zones: vec![TextZone {
kind: TextZoneKind::Page,
rect: Rect {
x: 0,
y: 0,
width: 100,
height: 50,
},
text: "rebundled text".into(),
children: vec![],
}],
};
doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
let edited = doc.into_bytes();
let reparsed = crate::djvu_document::DjVuDocument::parse(&edited).expect("parses");
let text = reparsed.page(0).unwrap().text_layer().unwrap();
assert!(text.is_some(), "edited page should expose a text layer");
assert_eq!(text.unwrap().text, "rebundled text");
}
#[test]
fn from_indirect_resolved_missing_component_errors() {
let index = crate::djvm::create_indirect(&["missing.djvu"]).expect("create_indirect");
let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
Err::<Vec<u8>, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"))
})
.unwrap_err();
match err {
MutError::ComponentResolve { name } => assert_eq!(name, "missing.djvu"),
other => panic!("expected ComponentResolve, got {other:?}"),
}
}
#[test]
fn from_indirect_resolved_malformed_component_errors() {
let index = crate::djvm::create_indirect(&["garbage.djvu"]).expect("create_indirect");
let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
Ok::<Vec<u8>, std::io::Error>(b"not an iff document".to_vec())
})
.unwrap_err();
assert!(
matches!(err, MutError::ComponentMalformed { .. }),
"{err:?}"
);
}
#[test]
fn from_indirect_resolved_rejects_bundled_input() {
let bundled = read_corpus("DjVu3Spec_bundled.djvu");
let err = DjVuDocumentMut::from_indirect_resolved(&bundled, |_n: &str| {
Ok::<Vec<u8>, std::io::Error>(Vec::new())
})
.unwrap_err();
assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
}
#[test]
fn from_indirect_resolved_rejects_single_page_djvu() {
let chicken = read_corpus("chicken.djvu");
let err = DjVuDocumentMut::from_indirect_resolved(&chicken, |_n: &str| {
Ok::<Vec<u8>, std::io::Error>(Vec::new())
})
.unwrap_err();
assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
}
#[test]
fn from_bytes_on_indirect_still_unsupported_for_page_mut() {
let index = crate::djvm::create_indirect(&["chicken.djvu"]).expect("create_indirect");
let mut doc = DjVuDocumentMut::from_bytes(&index).unwrap();
let err = doc.page_mut(0).err().unwrap();
assert!(matches!(err, MutError::IndirectDjvmUnsupported), "{err:?}");
}
fn fresh_temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("djvu_rs_rewrite_{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn rewrite_plan_commits_full_set_to_dir() {
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
assert_eq!(plan.page_count(), 2);
plan.edit_page(0, |doc| {
let meta = DjVuMetadata {
title: Some("rewrite path".into()),
..Default::default()
};
doc.page_mut(0)?.set_metadata(&meta);
Ok(())
})
.unwrap();
let preview = plan.plan("index.djvu");
assert_eq!(preview.len(), 3);
assert_eq!(preview[0].name, "chicken.djvu");
assert!(preview[0].changed, "edited page must show changed");
assert_eq!(preview[1].name, "irish.djvu");
assert!(!preview[1].changed, "untouched page must be unchanged");
assert!(preview[2].is_root);
assert!(!preview[2].changed, "root unchanged for a page-only edit");
let dir = fresh_temp_dir("commit_full_set");
let written = plan.commit_to_dir(&dir, "index.djvu").unwrap();
assert_eq!(written.len(), 3);
for p in &written {
assert!(p.exists(), "committed file {p:?} must exist");
}
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(leftovers.is_empty(), "temp files must be renamed away");
let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
let doc = crate::djvu_document::DjVuDocument::parse_from_dir(&index_bytes, &dir).unwrap();
assert_eq!(doc.page_count(), 2);
let meta_page0 = doc.page(0).unwrap();
let edited_comp = std::fs::read(dir.join("chicken.djvu")).unwrap();
let reparsed = DjVuDocumentMut::from_bytes(&edited_comp).unwrap();
let has_metz = reparsed
.file
.root
.children()
.iter()
.any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"));
assert!(has_metz, "edited component file must contain METz");
let irish_src = read_corpus("irish.djvu");
let irish_out = std::fs::read(dir.join("irish.djvu")).unwrap();
assert_eq!(irish_out, irish_src, "unedited component copied verbatim");
let _ = meta_page0;
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rewrite_plan_rejects_duplicate_dirm_names() {
let index = crate::djvm::create_indirect(&["dup.djvu", "dup.djvu"]).expect("create");
let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
})
.unwrap_err();
match err {
MutError::DuplicateComponentName { name } => assert_eq!(name, "dup.djvu"),
other => panic!("expected DuplicateComponentName, got {other:?}"),
}
}
#[test]
fn rewrite_plan_rejects_unsafe_dirm_names() {
for bad in [
"../evil.djvu",
"/abs.djvu",
"sub/page.djvu",
"..",
"a:b.djvu",
] {
let index = crate::djvm::create_indirect(&[bad]).expect("create");
let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
})
.unwrap_err();
assert!(
matches!(err, MutError::UnsafeComponentName { .. }),
"name {bad:?} should be rejected, got {err:?}"
);
}
}
#[test]
fn rewrite_plan_unsafe_root_name_leaves_dir_unchanged() {
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
let dir = fresh_temp_dir("unsafe_root");
std::fs::write(dir.join("sentinel"), b"keep me").unwrap();
let err = plan.commit_to_dir(&dir, "../escape.djvu").unwrap_err();
assert!(
matches!(err, MutError::UnsafeComponentName { .. }),
"{err:?}"
);
let entries: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries, vec!["sentinel".to_string()]);
assert_eq!(std::fs::read(dir.join("sentinel")).unwrap(), b"keep me");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rewrite_plan_root_name_collision_rejected() {
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
let dir = fresh_temp_dir("root_collision");
let err = plan.commit_to_dir(&dir, "chicken.djvu").unwrap_err();
assert!(
matches!(err, MutError::DuplicateComponentName { .. }),
"{err:?}"
);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rewrite_plan_set_bookmarks_marks_root_changed() {
let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
plan.set_bookmarks(&[DjVuBookmark {
title: "Top".into(),
url: "#1".into(),
children: vec![],
}])
.unwrap();
let preview = plan.plan("index.djvu");
let root = preview.iter().find(|i| i.is_root).unwrap();
assert!(root.changed, "root index must be marked changed");
let dir = fresh_temp_dir("bookmarks");
plan.commit_to_dir(&dir, "index.djvu").unwrap();
let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
let form = crate::iff::parse_form(&index_bytes).unwrap();
assert!(
form.chunks.iter().any(|c| &c.id == b"NAVM"),
"committed index must contain NAVM"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rewrite_plan_rejects_bundled_input() {
let bundled = read_corpus("DjVu3Spec_bundled.djvu");
let err = IndirectRewritePlan::from_indirect_resolved(&bundled, |_n: &str| {
Ok::<Vec<u8>, std::io::Error>(Vec::new())
})
.unwrap_err();
assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
}
fn top_form_ranges(data: &[u8]) -> Vec<core::ops::Range<usize>> {
assert_eq!(&data[..4], b"AT&T");
let form_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
let body_end = 12 + form_len;
let mut pos = 16usize; let mut out = Vec::new();
while pos + 8 <= body_end {
let len =
u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
as usize;
let mut next = pos + 8 + len;
if next & 1 == 1 && next < body_end {
next += 1;
}
out.push(pos..next);
pos = next;
}
out
}
}