#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::annotation::{Annotation, MapArea, encode_annotations_bzz};
use crate::djvu_document::DjVuBookmark;
use crate::error::{IffError, LegacyError};
use crate::iff::{self, Chunk, DjvuFile};
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,
}
#[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 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(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 count = self.page_count();
if index >= count {
return Err(MutError::PageOutOfRange { index, count });
}
let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
if &root_form_type == b"DJVU" {
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 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(())
}
}
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)
})
}
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"));
};
if data.len() < 3 {
return Err(MutError::DirmMalformed("DIRM payload < 3 bytes"));
}
let bundled = (data[0] & 0x80) != 0;
if !bundled {
return Ok(());
}
let nfiles = u16::from_be_bytes([data[1], data[2]]) as usize;
if nfiles != new_offsets.len() {
return Err(MutError::DirmComponentCountMismatch {
dirm: nfiles,
children: new_offsets.len(),
});
}
let needed = 3usize
.checked_add(4 * nfiles)
.ok_or(MutError::DirmMalformed("DIRM offset table size overflow"))?;
if data.len() < needed {
return Err(MutError::DirmMalformed("DIRM offset table truncated"));
}
for (i, &off) in new_offsets.iter().enumerate() {
let base = 3 + i * 4;
data[base..base + 4].copy_from_slice(&off.to_be_bytes());
}
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(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 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 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 (parsed_ann, _areas) =
crate::annotation::parse_annotations_bzz(data).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 parsed = crate::metadata::parse_metadata_bzz(metz.data()).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 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
}
}