1#[cfg(not(feature = "std"))]
43use alloc::vec::Vec;
44
45use crate::annotation::{Annotation, MapArea, encode_annotations_bzz};
46use crate::djvu_document::DjVuBookmark;
47use crate::error::{IffError, LegacyError};
48use crate::iff::{self, Chunk, DjvuFile};
49use crate::info::PageInfo;
50use crate::metadata::{DjVuMetadata, encode_metadata_bzz};
51use crate::navm_encode::encode_navm;
52use crate::text::TextLayer;
53use crate::text_encode::encode_text_layer;
54
55#[derive(Debug, thiserror::Error)]
57pub enum MutError {
58 #[error("IFF parse error: {0}")]
60 Parse(#[from] LegacyError),
61
62 #[error("chunk path out of range: index {index} at depth {depth} (form has {len} children)")]
64 PathOutOfRange {
65 index: usize,
66 depth: usize,
67 len: usize,
68 },
69
70 #[error("chunk path enters a leaf at depth {depth} but is {len} levels long")]
72 PathTraversesLeaf { depth: usize, len: usize },
73
74 #[error("path ends on a FORM, not a leaf chunk")]
77 NotALeaf,
78
79 #[error("path must not be empty")]
81 EmptyPath,
82
83 #[error("page index {index} out of range (document has {count} pages)")]
85 PageOutOfRange {
86 index: usize,
88 count: usize,
90 },
91
92 #[error("page has no INFO chunk; cannot encode height-dependent chunk")]
95 MissingPageInfo,
96
97 #[error("INFO chunk parse error: {0}")]
99 InfoParse(#[from] IffError),
100
101 #[error("mutation of indirect DJVM documents is not supported")]
108 IndirectDjvmUnsupported,
109
110 #[error("DIRM chunk is malformed: {0}")]
114 DirmMalformed(&'static str),
115
116 #[error("DIRM component count {dirm} does not match bundle child count {children}")]
120 DirmComponentCountMismatch {
121 dirm: usize,
123 children: usize,
125 },
126
127 #[error("set_bookmarks requires a FORM:DJVM bundle (this document is FORM:DJVU)")]
130 BookmarksRequireDjvm,
131}
132
133#[derive(Debug, Clone)]
141pub struct DjVuDocumentMut {
142 file: DjvuFile,
143 original_bytes: Vec<u8>,
148 dirty: bool,
149}
150
151impl DjVuDocumentMut {
152 pub fn from_bytes(data: &[u8]) -> Result<Self, MutError> {
157 let file = iff::parse(data)?;
158 Ok(Self {
159 file,
160 original_bytes: data.to_vec(),
161 dirty: false,
162 })
163 }
164
165 pub fn root_child_count(&self) -> usize {
171 self.file.root.children().len()
172 }
173
174 pub fn root_form_type(&self) -> Option<&[u8; 4]> {
178 match &self.file.root {
179 Chunk::Form { secondary_id, .. } => Some(secondary_id),
180 Chunk::Leaf { .. } => None,
181 }
182 }
183
184 pub fn replace_leaf(&mut self, path: &[usize], new_data: Vec<u8>) -> Result<(), MutError> {
197 let chunk = self.chunk_at_path_mut(path)?;
198 match chunk {
199 Chunk::Leaf { data, .. } => {
200 *data = new_data;
201 self.dirty = true;
202 Ok(())
203 }
204 Chunk::Form { .. } => Err(MutError::NotALeaf),
205 }
206 }
207
208 pub fn chunk_at_path(&self, path: &[usize]) -> Result<&Chunk, MutError> {
210 if path.is_empty() {
211 return Err(MutError::EmptyPath);
212 }
213 let mut current = &self.file.root;
214 for (depth, &idx) in path.iter().enumerate() {
215 let children = current.children();
216 if children.is_empty() && depth < path.len() - 1 {
217 return Err(MutError::PathTraversesLeaf {
219 depth,
220 len: path.len(),
221 });
222 }
223 if let Chunk::Leaf { .. } = current {
224 return Err(MutError::PathTraversesLeaf {
225 depth,
226 len: path.len(),
227 });
228 }
229 if idx >= children.len() {
230 return Err(MutError::PathOutOfRange {
231 index: idx,
232 depth,
233 len: children.len(),
234 });
235 }
236 current = &children[idx];
237 }
238 Ok(current)
239 }
240
241 fn chunk_at_path_mut(&mut self, path: &[usize]) -> Result<&mut Chunk, MutError> {
242 if path.is_empty() {
243 return Err(MutError::EmptyPath);
244 }
245 let _ = self.chunk_at_path(path)?;
248 let mut current = &mut self.file.root;
250 for &idx in path {
251 match current {
254 Chunk::Form { children, .. } => {
255 current = &mut children[idx];
256 }
257 Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
258 }
259 }
260 Ok(current)
261 }
262
263 pub fn is_dirty(&self) -> bool {
265 self.dirty
266 }
267
268 pub fn into_bytes(self) -> Vec<u8> {
284 self.try_into_bytes()
285 .expect("DIRM recomputation failed — inconsistent document")
286 }
287
288 pub fn try_into_bytes(mut self) -> Result<Vec<u8>, MutError> {
291 if !self.dirty {
292 return Ok(self.original_bytes);
293 }
294 recompute_dirm_offsets(&mut self.file.root)?;
295 Ok(iff::emit(&self.file))
296 }
297
298 pub fn page_count(&self) -> usize {
305 match self.root_form_type() {
306 Some(b"DJVM") => self
307 .file
308 .root
309 .children()
310 .iter()
311 .filter(
312 |c| matches!(c, Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU"),
313 )
314 .count(),
315 _ => 1,
316 }
317 }
318
319 pub fn page_mut(&mut self, index: usize) -> Result<PageMut<'_>, MutError> {
335 let count = self.page_count();
336 if index >= count {
337 return Err(MutError::PageOutOfRange { index, count });
338 }
339 let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
340 if &root_form_type == b"DJVU" {
341 debug_assert_eq!(index, 0);
342 return Ok(PageMut {
343 form: &mut self.file.root,
344 dirty: &mut self.dirty,
345 });
346 }
347 debug_assert_eq!(&root_form_type, b"DJVM");
348 if !is_bundled_djvm(&self.file.root) {
349 return Err(MutError::IndirectDjvmUnsupported);
350 }
351 let children = match &mut self.file.root {
353 Chunk::Form { children, .. } => children,
354 Chunk::Leaf { .. } => unreachable!("validated FORM root"),
355 };
356 let mut seen = 0usize;
357 for child in children.iter_mut() {
358 if let Chunk::Form { secondary_id, .. } = child
359 && secondary_id == b"DJVU"
360 {
361 if seen == index {
362 return Ok(PageMut {
363 form: child,
364 dirty: &mut self.dirty,
365 });
366 }
367 seen += 1;
368 }
369 }
370 unreachable!("page_count agreed with bundle but iteration disagreed")
371 }
372
373 pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
384 let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
385 if &root_form_type != b"DJVM" {
386 return Err(MutError::BookmarksRequireDjvm);
387 }
388 let children = match &mut self.file.root {
389 Chunk::Form { children, .. } => children,
390 Chunk::Leaf { .. } => unreachable!("validated FORM root"),
391 };
392 let pos = children
393 .iter()
394 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"NAVM"));
395 match (pos, bookmarks.is_empty()) {
396 (Some(i), true) => {
397 children.remove(i);
398 }
399 (Some(i), false) => {
400 children[i] = Chunk::Leaf {
401 id: *b"NAVM",
402 data: encode_navm(bookmarks),
403 };
404 }
405 (None, true) => { }
406 (None, false) => {
407 let dirm_pos = children
411 .iter()
412 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"DIRM"));
413 let insert_at = dirm_pos.map(|i| i + 1).unwrap_or(0);
414 children.insert(
415 insert_at,
416 Chunk::Leaf {
417 id: *b"NAVM",
418 data: encode_navm(bookmarks),
419 },
420 );
421 }
422 }
423 self.dirty = true;
424 Ok(())
425 }
426}
427
428fn is_bundled_djvm(chunk: &Chunk) -> bool {
432 let Chunk::Form {
433 secondary_id,
434 children,
435 ..
436 } = chunk
437 else {
438 return false;
439 };
440 if secondary_id != b"DJVM" {
441 return false;
442 }
443 children.iter().any(|c| {
444 matches!(c, Chunk::Leaf { id, data } if id == b"DIRM" && !data.is_empty() && (data[0] & 0x80) != 0)
445 })
446}
447
448fn emitted_chunk_size(chunk: &Chunk) -> usize {
455 match chunk {
456 Chunk::Form {
457 secondary_id: _,
458 children,
459 ..
460 } => {
461 let payload: usize = 4 + children.iter().map(emitted_chunk_size).sum::<usize>();
462 let total = 8 + payload;
463 total + (total & 1)
464 }
465 Chunk::Leaf { data, .. } => {
466 let total = 8 + data.len();
467 total + (total & 1)
468 }
469 }
470}
471
472fn recompute_dirm_offsets(root: &mut Chunk) -> Result<(), MutError> {
483 let Chunk::Form {
484 secondary_id,
485 children,
486 ..
487 } = root
488 else {
489 return Ok(());
490 };
491 if secondary_id != b"DJVM" {
492 return Ok(());
493 }
494
495 let mut pos: usize = 16;
498 let mut new_offsets: Vec<u32> = Vec::new();
499 let mut dirm_idx: Option<usize> = None;
500
501 #[allow(clippy::redundant_guards)]
505 for (i, child) in children.iter().enumerate() {
506 match child {
507 Chunk::Leaf { id, .. } if id == b"DIRM" => {
508 dirm_idx = Some(i);
509 }
510 Chunk::Form {
511 secondary_id: sid, ..
512 } if sid == b"DJVU" || sid == b"DJVI" || sid == b"THUM" => {
513 new_offsets.push(u32::try_from(pos).map_err(|_| {
514 MutError::DirmMalformed("component offset exceeds u32 (file > 4 GiB)")
515 })?);
516 }
517 _ => {}
518 }
519 pos += emitted_chunk_size(child);
520 }
521
522 let Some(dirm_idx) = dirm_idx else {
523 return Ok(());
526 };
527
528 let dirm = &mut children[dirm_idx];
529 let Chunk::Leaf { data, .. } = dirm else {
530 return Err(MutError::DirmMalformed("DIRM is not a leaf chunk"));
531 };
532
533 if data.len() < 3 {
534 return Err(MutError::DirmMalformed("DIRM payload < 3 bytes"));
535 }
536 let bundled = (data[0] & 0x80) != 0;
537 if !bundled {
538 return Ok(());
540 }
541 let nfiles = u16::from_be_bytes([data[1], data[2]]) as usize;
542 if nfiles != new_offsets.len() {
543 return Err(MutError::DirmComponentCountMismatch {
544 dirm: nfiles,
545 children: new_offsets.len(),
546 });
547 }
548 let needed = 3usize
549 .checked_add(4 * nfiles)
550 .ok_or(MutError::DirmMalformed("DIRM offset table size overflow"))?;
551 if data.len() < needed {
552 return Err(MutError::DirmMalformed("DIRM offset table truncated"));
553 }
554 for (i, &off) in new_offsets.iter().enumerate() {
555 let base = 3 + i * 4;
556 data[base..base + 4].copy_from_slice(&off.to_be_bytes());
557 }
558 Ok(())
559}
560
561pub struct PageMut<'doc> {
570 form: &'doc mut Chunk,
571 dirty: &'doc mut bool,
572}
573
574impl PageMut<'_> {
575 pub fn set_text_layer(&mut self, layer: &TextLayer) -> Result<(), MutError> {
579 let info_data = self
580 .find_leaf_data(b"INFO")
581 .ok_or(MutError::MissingPageInfo)?;
582 let info = PageInfo::parse(info_data)?;
583 let plain = encode_text_layer(layer, info.height as u32);
584 let compressed = crate::bzz_encode::bzz_encode(&plain);
585 self.replace_or_insert_text(compressed);
586 *self.dirty = true;
587 Ok(())
588 }
589
590 pub fn set_annotations(&mut self, annotation: &Annotation, areas: &[MapArea]) {
593 let bytes = encode_annotations_bzz(annotation, areas);
594 self.replace_or_insert(b"ANTa", b"ANTz", bytes);
595 *self.dirty = true;
596 }
597
598 pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
602 let bytes = encode_metadata_bzz(meta);
603 self.replace_or_insert(b"METa", b"METz", bytes);
604 *self.dirty = true;
605 }
606
607 fn find_leaf_data(&self, id: &[u8; 4]) -> Option<&[u8]> {
608 for child in self.form.children() {
609 if let Chunk::Leaf { id: cid, data } = child
610 && cid == id
611 {
612 return Some(data);
613 }
614 }
615 None
616 }
617
618 fn replace_or_insert(&mut self, id_a: &[u8; 4], id_z: &[u8; 4], data: Vec<u8>) {
622 let children = match self.form {
623 Chunk::Form { children, .. } => children,
624 Chunk::Leaf { .. } => unreachable!("PageMut wraps a FORM"),
625 };
626 let pos = children
627 .iter()
628 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z));
629 match (pos, data.is_empty()) {
630 (Some(i), true) => {
631 children.remove(i);
632 }
633 (Some(i), false) => {
634 children[i] = Chunk::Leaf { id: *id_z, data };
635 }
636 (None, true) => { }
637 (None, false) => {
638 children.push(Chunk::Leaf { id: *id_z, data });
639 }
640 }
641 }
642
643 fn replace_or_insert_text(&mut self, data: Vec<u8>) {
645 self.replace_or_insert(b"TXTa", b"TXTz", data);
646 }
647}
648
649#[cfg(test)]
650#[allow(clippy::field_reassign_with_default)]
651mod tests {
652 use super::*;
653 use std::path::PathBuf;
654
655 fn corpus_path(name: &str) -> PathBuf {
656 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
657 p.push("tests/fixtures");
658 p.push(name);
659 p
660 }
661
662 fn read_corpus(name: &str) -> Vec<u8> {
663 std::fs::read(corpus_path(name)).expect("corpus fixture missing")
664 }
665
666 #[test]
668 fn roundtrip_byte_identical_chicken() {
669 let original = read_corpus("chicken.djvu");
670 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
671 assert!(!doc.is_dirty());
672 assert_eq!(doc.into_bytes(), original);
673 }
674
675 #[test]
677 fn roundtrip_byte_identical_boy_jb2() {
678 let original = read_corpus("boy_jb2.djvu");
679 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
680 assert_eq!(doc.into_bytes(), original);
681 }
682
683 #[test]
685 fn roundtrip_byte_identical_djvm_bundle() {
686 let original = read_corpus("DjVu3Spec_bundled.djvu");
687 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
688 assert_eq!(doc.root_form_type(), Some(b"DJVM"));
689 assert_eq!(doc.into_bytes(), original);
690 }
691
692 #[test]
694 fn roundtrip_byte_identical_navm() {
695 let original = read_corpus("navm_fgbz.djvu");
696 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
697 assert_eq!(doc.into_bytes(), original);
698 }
699
700 #[test]
702 fn replace_leaf_changes_emitted_bytes() {
703 let original = read_corpus("chicken.djvu");
704 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
705
706 let first = doc.chunk_at_path(&[0]).unwrap();
708 let original_first_data = first.data().to_vec();
709 assert!(!original_first_data.is_empty());
710
711 let marker = b"PR1_TEST_MARKER".to_vec();
713 doc.replace_leaf(&[0], marker.clone()).unwrap();
714 assert!(doc.is_dirty());
715
716 let edited = doc.into_bytes();
717
718 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
720 let new_first = reparsed.chunk_at_path(&[0]).unwrap();
721 assert_eq!(new_first.data(), marker.as_slice());
722 }
723
724 #[test]
725 fn replace_leaf_rejects_empty_path() {
726 let original = read_corpus("chicken.djvu");
727 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
728 let err = doc.replace_leaf(&[], vec![]).unwrap_err();
729 assert!(matches!(err, MutError::EmptyPath));
730 }
731
732 #[test]
733 fn replace_leaf_rejects_out_of_range() {
734 let original = read_corpus("chicken.djvu");
735 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
736 let err = doc.replace_leaf(&[9999], vec![]).unwrap_err();
737 assert!(matches!(err, MutError::PathOutOfRange { .. }));
738 }
739
740 #[test]
741 fn replace_leaf_rejects_traversing_leaf() {
742 let original = read_corpus("chicken.djvu");
743 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
744 let err = doc.replace_leaf(&[0, 0], vec![]).unwrap_err();
746 assert!(matches!(err, MutError::PathTraversesLeaf { .. }));
747 }
748
749 #[test]
750 fn replace_leaf_rejects_form_target() {
751 let original = read_corpus("DjVu3Spec_bundled.djvu");
755 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
756 let last_idx = doc.root_child_count() - 1;
757 let err = doc.replace_leaf(&[last_idx], vec![]).unwrap_err();
758 assert!(matches!(err, MutError::NotALeaf));
759 }
760
761 #[test]
762 fn root_form_type_djvu_single_page() {
763 let original = read_corpus("chicken.djvu");
764 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
765 assert_eq!(doc.root_form_type(), Some(b"DJVU"));
766 }
767
768 #[test]
771 fn page_count_single_page_djvu_is_one() {
772 let original = read_corpus("chicken.djvu");
773 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
774 assert_eq!(doc.page_count(), 1);
775 }
776
777 #[test]
778 fn page_count_djvm_bundle_counts_djvu_components_only() {
779 let original = read_corpus("DjVu3Spec_bundled.djvu");
780 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
781 let direct: usize = doc
784 .file
785 .root
786 .children()
787 .iter()
788 .filter(|c| {
789 matches!(c, crate::iff::Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU")
790 })
791 .count();
792 assert!(direct >= 2, "expected multi-page bundle, got {direct}");
793 assert_eq!(doc.page_count(), direct);
794 }
795
796 #[test]
797 fn page_mut_out_of_range_errors() {
798 let original = read_corpus("chicken.djvu");
799 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
800 let err = doc.page_mut(1).err().unwrap();
801 assert!(matches!(
802 err,
803 MutError::PageOutOfRange { index: 1, count: 1 }
804 ));
805 }
806
807 #[test]
808 fn page_mut_djvm_bundle_succeeds_after_pr3() {
809 let original = read_corpus("DjVu3Spec_bundled.djvu");
812 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
813 assert!(doc.page_mut(0).is_ok());
814 let count = doc.page_count();
815 let err = doc.page_mut(count).err().unwrap();
816 assert!(matches!(err, MutError::PageOutOfRange { .. }));
817 }
818
819 #[test]
820 fn set_text_layer_roundtrip_chicken() {
821 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
822
823 let original = read_corpus("chicken.djvu");
824 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
825
826 let layer = TextLayer {
827 text: "hello world".to_string(),
828 zones: vec![TextZone {
829 kind: TextZoneKind::Page,
830 rect: Rect {
831 x: 0,
832 y: 0,
833 width: 100,
834 height: 50,
835 },
836 text: "hello world".to_string(),
837 children: vec![],
838 }],
839 };
840 doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
841 assert!(doc.is_dirty());
842 let edited = doc.into_bytes();
843
844 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
846 let has_txtz = reparsed
847 .file
848 .root
849 .children()
850 .iter()
851 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
852 assert!(
853 has_txtz,
854 "TXTz chunk should be present after set_text_layer"
855 );
856 }
857
858 #[test]
859 fn set_annotations_roundtrip_chicken() {
860 use crate::annotation::{Annotation, Color};
861
862 let original = read_corpus("chicken.djvu");
863 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
864
865 let mut ann = Annotation::default();
866 ann.background = Some(Color {
867 r: 0xFF,
868 g: 0xFF,
869 b: 0xFF,
870 });
871 ann.mode = Some("color".to_string());
872 doc.page_mut(0).unwrap().set_annotations(&ann, &[]);
873 let edited = doc.into_bytes();
874
875 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
876 let antz = reparsed
877 .file
878 .root
879 .children()
880 .iter()
881 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"ANTz"));
882 assert!(antz.is_some(), "ANTz should be inserted");
883 let data = antz.unwrap().data();
884 let (parsed_ann, _areas) =
885 crate::annotation::parse_annotations_bzz(data).expect("ANTz must round-trip");
886 assert_eq!(parsed_ann.mode.as_deref(), Some("color"));
887 assert_eq!(
888 parsed_ann.background,
889 Some(Color {
890 r: 0xFF,
891 g: 0xFF,
892 b: 0xFF
893 })
894 );
895 }
896
897 #[test]
898 fn set_metadata_roundtrip_chicken() {
899 let original = read_corpus("chicken.djvu");
900 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
901
902 let mut meta = DjVuMetadata::default();
903 meta.title = Some("Test Title".into());
904 meta.author = Some("Tester".into());
905 doc.page_mut(0).unwrap().set_metadata(&meta);
906 let edited = doc.into_bytes();
907
908 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
909 let metz = reparsed
910 .file
911 .root
912 .children()
913 .iter()
914 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
915 .expect("METz should be inserted");
916 let parsed = crate::metadata::parse_metadata_bzz(metz.data()).unwrap();
917 assert_eq!(parsed, meta);
918 }
919
920 #[test]
921 fn set_metadata_empty_removes_existing_chunk() {
922 let original = read_corpus("chicken.djvu");
923 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
924
925 let mut meta = DjVuMetadata::default();
927 meta.title = Some("X".into());
928 doc.page_mut(0).unwrap().set_metadata(&meta);
929 doc.page_mut(0)
930 .unwrap()
931 .set_metadata(&DjVuMetadata::default());
932
933 let edited = doc.into_bytes();
934 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
935 let any_meta = reparsed
936 .file
937 .root
938 .children()
939 .iter()
940 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
941 assert!(!any_meta, "set_metadata(empty) should remove any METa/METz");
942 }
943
944 #[test]
945 fn set_metadata_replaces_existing_chunk_in_place() {
946 let original = read_corpus("chicken.djvu");
947 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
948
949 let mut m1 = DjVuMetadata::default();
950 m1.title = Some("First".into());
951 doc.page_mut(0).unwrap().set_metadata(&m1);
952
953 let mut m2 = DjVuMetadata::default();
954 m2.title = Some("Second".into());
955 doc.page_mut(0).unwrap().set_metadata(&m2);
956
957 let edited = doc.into_bytes();
958 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
959 let metz_count = reparsed
960 .file
961 .root
962 .children()
963 .iter()
964 .filter(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"))
965 .count();
966 assert_eq!(metz_count, 1, "should not duplicate METz on repeat set");
967 }
968
969 fn dirm_offsets_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
974 let form = crate::iff::parse_form(data).expect("parse_form");
976 assert_eq!(&form.form_type, b"DJVM");
977
978 let dirm = form
979 .chunks
980 .iter()
981 .find(|c| &c.id == b"DIRM")
982 .expect("DIRM present");
983 let payload = dirm.data;
984 let nfiles = u16::from_be_bytes([payload[1], payload[2]]) as usize;
985 let mut declared = Vec::with_capacity(nfiles);
986 for i in 0..nfiles {
987 let base = 3 + i * 4;
988 declared.push(u32::from_be_bytes([
989 payload[base],
990 payload[base + 1],
991 payload[base + 2],
992 payload[base + 3],
993 ]));
994 }
995
996 let mut actual = Vec::with_capacity(nfiles);
999 let mut pos = 16usize;
1000 let body_end = 8 + u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
1001 while pos < body_end {
1002 let id = &data[pos..pos + 4];
1003 let len =
1004 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
1005 as usize;
1006 if id == b"FORM" {
1007 actual.push(pos as u32);
1008 }
1009 let mut next = pos + 8 + len;
1010 if next & 1 == 1 {
1011 next += 1;
1012 }
1013 pos = next;
1014 }
1015 (declared, actual)
1016 }
1017
1018 #[test]
1019 fn dirm_offsets_match_actual_after_no_edit() {
1020 let original = read_corpus("DjVu3Spec_bundled.djvu");
1023 let (declared, actual) = dirm_offsets_and_actual(&original);
1024 assert_eq!(declared, actual);
1025 }
1026
1027 #[test]
1028 fn dirm_offsets_recomputed_after_page_metadata_edit() {
1029 let original = read_corpus("DjVu3Spec_bundled.djvu");
1030 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1031
1032 let mut meta = DjVuMetadata::default();
1034 meta.title = Some("PR3 DJVM bundled mutation".into());
1035 meta.author = Some("djvu-rs PR3 tests".into());
1036 doc.page_mut(0).unwrap().set_metadata(&meta);
1037 assert!(doc.is_dirty());
1038
1039 let edited = doc.into_bytes();
1040 assert_ne!(edited.len(), original.len());
1042
1043 let (declared, actual) = dirm_offsets_and_actual(&edited);
1046 assert_eq!(
1047 declared, actual,
1048 "DIRM offsets must point at the new FORM positions after edit"
1049 );
1050
1051 let reparsed =
1054 crate::djvu_document::DjVuDocument::parse(&edited).expect("edited bundle must parse");
1055 let original_doc =
1056 crate::djvu_document::DjVuDocument::parse(&original).expect("original bundle parses");
1057 assert_eq!(reparsed.page_count(), original_doc.page_count());
1058 }
1059
1060 #[test]
1061 fn dirm_offsets_recomputed_after_middle_page_edit() {
1062 let original = read_corpus("DjVu3Spec_bundled.djvu");
1064 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1065 let count = doc.page_count();
1066 assert!(count >= 3);
1067
1068 let mid = count / 2;
1069 let mut meta = DjVuMetadata::default();
1070 meta.title = Some("PR3 mid-page edit".into());
1071 doc.page_mut(mid).unwrap().set_metadata(&meta);
1072
1073 let edited = doc.into_bytes();
1074 let (declared, actual) = dirm_offsets_and_actual(&edited);
1075 assert_eq!(declared, actual);
1076
1077 let (orig_declared, _) = dirm_offsets_and_actual(&original);
1079 for i in 0..mid {
1080 assert_eq!(
1081 declared[i], orig_declared[i],
1082 "offset for page {i} (before edit) must be unchanged"
1083 );
1084 }
1085 }
1086
1087 #[test]
1088 fn set_bookmarks_replaces_navm_in_bundle() {
1089 use crate::djvu_document::DjVuBookmark;
1090
1091 let original = read_corpus("DjVu3Spec_bundled.djvu");
1092 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1093
1094 let bookmarks = vec![
1095 DjVuBookmark {
1096 title: "Front matter".into(),
1097 url: "#1".into(),
1098 children: vec![DjVuBookmark {
1099 title: "Acknowledgments".into(),
1100 url: "#3".into(),
1101 children: vec![],
1102 }],
1103 },
1104 DjVuBookmark {
1105 title: "Body".into(),
1106 url: "#10".into(),
1107 children: vec![],
1108 },
1109 ];
1110 doc.set_bookmarks(&bookmarks).unwrap();
1111 assert!(doc.is_dirty());
1112 let edited = doc.into_bytes();
1113
1114 let (declared, actual) = dirm_offsets_and_actual(&edited);
1116 assert_eq!(declared, actual);
1117
1118 let reparsed = crate::djvu_document::DjVuDocument::parse(&edited)
1120 .expect("bundle with new bookmarks parses");
1121 let parsed_bms = reparsed.bookmarks();
1122 assert_eq!(parsed_bms.len(), 2);
1123 assert_eq!(parsed_bms[0].title, "Front matter");
1124 assert_eq!(parsed_bms[0].children.len(), 1);
1125 assert_eq!(parsed_bms[0].children[0].title, "Acknowledgments");
1126 assert_eq!(parsed_bms[1].title, "Body");
1127 }
1128
1129 #[test]
1130 fn set_bookmarks_empty_removes_navm() {
1131 let original = read_corpus("DjVu3Spec_bundled.djvu");
1132 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1133 doc.set_bookmarks(&[]).unwrap();
1136 let edited = doc.into_bytes();
1137
1138 let form = crate::iff::parse_form(&edited).unwrap();
1139 let has_navm = form.chunks.iter().any(|c| &c.id == b"NAVM");
1140 assert!(!has_navm, "set_bookmarks(&[]) must remove NAVM");
1141
1142 let (declared, actual) = dirm_offsets_and_actual(&edited);
1144 assert_eq!(declared, actual);
1145 }
1146
1147 #[test]
1148 fn set_bookmarks_inserts_navm_when_absent() {
1149 use crate::djvu_document::DjVuBookmark;
1150
1151 let original = read_corpus("DjVu3Spec_bundled.djvu");
1154 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1155 doc.set_bookmarks(&[]).unwrap();
1156 let stripped = doc.into_bytes();
1157
1158 let mut doc = DjVuDocumentMut::from_bytes(&stripped).unwrap();
1159 let bms = vec![DjVuBookmark {
1160 title: "Re-added".into(),
1161 url: "#1".into(),
1162 children: vec![],
1163 }];
1164 doc.set_bookmarks(&bms).unwrap();
1165 let edited = doc.into_bytes();
1166
1167 let form = crate::iff::parse_form(&edited).unwrap();
1168 let navm_pos = form
1169 .chunks
1170 .iter()
1171 .position(|c| &c.id == b"NAVM")
1172 .expect("NAVM should be inserted");
1173 let dirm_pos = form.chunks.iter().position(|c| &c.id == b"DIRM").unwrap();
1174 assert_eq!(
1175 navm_pos,
1176 dirm_pos + 1,
1177 "NAVM should be placed immediately after DIRM"
1178 );
1179
1180 let (declared, actual) = dirm_offsets_and_actual(&edited);
1181 assert_eq!(declared, actual);
1182 }
1183
1184 #[test]
1185 fn set_bookmarks_on_single_page_djvu_errors() {
1186 let original = read_corpus("chicken.djvu");
1187 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1188 let err = doc.set_bookmarks(&[]).err().unwrap();
1189 assert!(matches!(err, MutError::BookmarksRequireDjvm));
1190 }
1191
1192 #[test]
1193 fn page_mut_djvm_text_layer_roundtrip() {
1194 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1195
1196 let original = read_corpus("DjVu3Spec_bundled.djvu");
1197 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1198 let layer = TextLayer {
1199 text: "djvm page-3 text".into(),
1200 zones: vec![TextZone {
1201 kind: TextZoneKind::Page,
1202 rect: Rect {
1203 x: 0,
1204 y: 0,
1205 width: 100,
1206 height: 50,
1207 },
1208 text: "djvm page-3 text".into(),
1209 children: vec![],
1210 }],
1211 };
1212 doc.page_mut(2).unwrap().set_text_layer(&layer).unwrap();
1213 let edited = doc.into_bytes();
1214
1215 let (declared, actual) = dirm_offsets_and_actual(&edited);
1216 assert_eq!(declared, actual);
1217
1218 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1220 let mut djvu_seen = 0usize;
1222 let mut found_txtz = false;
1223 for child in reparsed.file.root.children() {
1224 if let Chunk::Form {
1225 secondary_id,
1226 children,
1227 ..
1228 } = child
1229 && secondary_id == b"DJVU"
1230 {
1231 if djvu_seen == 2 {
1232 found_txtz = children
1233 .iter()
1234 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1235 break;
1236 }
1237 djvu_seen += 1;
1238 }
1239 }
1240 assert!(
1241 found_txtz,
1242 "TXTz chunk should be present on page 2 after set_text_layer"
1243 );
1244 }
1245
1246 #[test]
1251 fn unmutated_pages_byte_identical_after_metadata_edit() {
1252 use crate::metadata::DjVuMetadata;
1253
1254 let original = read_corpus("DjVu3Spec_bundled.djvu");
1255
1256 let orig_ranges = top_form_ranges(&original);
1257
1258 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1259 let meta = DjVuMetadata {
1260 title: Some("PR4 byte-identical probe".into()),
1261 ..Default::default()
1262 };
1263 doc.page_mut(0).unwrap().set_metadata(&meta);
1264 let edited = doc.into_bytes();
1265
1266 let edited_ranges = top_form_ranges(&edited);
1267 assert_eq!(orig_ranges.len(), edited_ranges.len());
1268
1269 let mut djvu_idx = 0usize;
1272 for (i, (or, er)) in orig_ranges.iter().zip(edited_ranges.iter()).enumerate() {
1273 let is_form_djvu = &original[or.start..or.start + 4] == b"FORM"
1276 && (&original[or.start + 8..or.start + 12] == b"DJVU"
1277 || &original[or.start + 8..or.start + 12] == b"DJVI");
1278 if !is_form_djvu {
1279 continue;
1280 }
1281 let is_edited_page = djvu_idx == 0;
1282 djvu_idx += 1;
1283 if is_edited_page {
1284 continue;
1285 }
1286 assert_eq!(
1287 &original[or.clone()],
1288 &edited[er.clone()],
1289 "FORM at top-level child #{i} must be byte-identical after edit"
1290 );
1291 }
1292 }
1293
1294 fn top_form_ranges(data: &[u8]) -> Vec<core::ops::Range<usize>> {
1297 assert_eq!(&data[..4], b"AT&T");
1298 let form_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
1299 let body_end = 12 + form_len;
1300 let mut pos = 16usize; let mut out = Vec::new();
1302 while pos + 8 <= body_end {
1303 let len =
1304 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
1305 as usize;
1306 let mut next = pos + 8 + len;
1307 if next & 1 == 1 && next < body_end {
1308 next += 1;
1309 }
1310 out.push(pos..next);
1311 pos = next;
1312 }
1313 out
1314 }
1315}