1#[cfg(not(feature = "std"))]
51use alloc::vec::Vec;
52use core::ops::Range;
53
54use crate::annotation::{Annotation, MapArea, encode_annotations_bzz};
55use crate::chunk_encode::{ChunkEncoder, NavmChunk};
56use crate::dirm::{DirmComponent, DirmComponentKind, DirmPayload};
57use crate::djvu_document::DjVuBookmark;
58use crate::error::{IffError, LegacyError};
59use crate::iff::{self, Chunk, DjvuFile, parse_form_body};
60use crate::info::PageInfo;
61use crate::metadata::{DjVuMetadata, encode_metadata_bzz};
62use crate::text::TextLayer;
63use crate::text_encode::encode_text_layer;
64
65#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum MutError {
69 #[error("IFF parse error: {0}")]
71 Parse(#[from] LegacyError),
72
73 #[error("chunk path out of range: index {index} at depth {depth} (form has {len} children)")]
75 PathOutOfRange {
76 index: usize,
77 depth: usize,
78 len: usize,
79 },
80
81 #[error("chunk path enters a leaf at depth {depth} but is {len} levels long")]
83 PathTraversesLeaf { depth: usize, len: usize },
84
85 #[error("path ends on a FORM, not a leaf chunk")]
88 NotALeaf,
89
90 #[error("path ends on a leaf chunk, not a FORM")]
92 NotAForm,
93
94 #[error("path must not be empty")]
96 EmptyPath,
97
98 #[error("page index {index} out of range (document has {count} pages)")]
100 PageOutOfRange {
101 index: usize,
103 count: usize,
105 },
106
107 #[error("page has no INFO chunk; cannot encode height-dependent chunk")]
110 MissingPageInfo,
111
112 #[error("INFO chunk parse error: {0}")]
114 InfoParse(#[from] IffError),
115
116 #[error("mutation of indirect DJVM documents is not supported")]
123 IndirectDjvmUnsupported,
124
125 #[error("DIRM chunk is malformed: {0}")]
129 DirmMalformed(&'static str),
130
131 #[error("DIRM component count {dirm} does not match bundle child count {children}")]
135 DirmComponentCountMismatch {
136 dirm: usize,
138 children: usize,
140 },
141
142 #[error("set_bookmarks requires a FORM:DJVM bundle (this document is FORM:DJVU)")]
145 BookmarksRequireDjvm,
146
147 #[error("chunk encode error: {0}")]
150 Encode(#[from] crate::chunk_encode::EncodeError),
151
152 #[error("from_indirect_resolved requires an indirect FORM:DJVM document")]
157 NotIndirectDjvm,
158
159 #[error("resolver did not supply DIRM component {name:?}")]
162 ComponentResolve {
163 name: String,
165 },
166
167 #[error("DIRM component {name:?} is malformed: {reason}")]
170 ComponentMalformed {
171 name: String,
173 reason: &'static str,
175 },
176
177 #[error("unsafe component file name {name:?}: {reason}")]
183 UnsafeComponentName {
184 name: String,
186 reason: &'static str,
188 },
189
190 #[error("duplicate DIRM component file name {name:?}")]
193 DuplicateComponentName {
194 name: String,
196 },
197
198 #[error("rewrite I/O error for {name:?}: {message}")]
200 RewriteIo {
201 name: String,
203 message: String,
205 },
206
207 #[cfg(feature = "std")]
210 #[error("save I/O error: {0}")]
211 SaveIo(#[from] std::io::Error),
212
213 #[cfg(feature = "std")]
217 #[error("save_patched target does not hold this document's original bytes")]
218 PatchTargetMismatch,
219}
220
221#[cfg(feature = "std")]
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct SavePatchStats {
225 pub file_len: u64,
227 pub bytes_written: u64,
229}
230
231#[derive(Debug, Clone)]
239pub struct DjVuDocumentMut {
240 file: DjvuFile,
241 original_bytes: Vec<u8>,
246 dirty: bool,
247}
248
249impl DjVuDocumentMut {
250 pub fn from_bytes(data: &[u8]) -> Result<Self, MutError> {
255 let file = iff::parse(data)?;
256 Ok(Self {
257 file,
258 original_bytes: data.to_vec(),
259 dirty: false,
260 })
261 }
262
263 pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
297 where
298 R: Fn(&str) -> Result<Vec<u8>, E>,
299 {
300 let (dirm_data, components) = resolve_indirect_components(root_bytes)?;
301 if !components.iter().any(|c| c.kind == DirmComponentKind::Page) {
302 return Err(MutError::DirmMalformed(
303 "indirect DIRM lists no page component",
304 ));
305 }
306
307 let mut component_forms: Vec<Chunk> = Vec::with_capacity(components.len());
310 for comp in &components {
311 let bytes = resolver(&comp.id).map_err(|_| MutError::ComponentResolve {
312 name: comp.id.clone(),
313 })?;
314 let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
315 name: comp.id.clone(),
316 reason: "not a parseable IFF document",
317 })?;
318 match &parsed.root {
319 Chunk::Form { secondary_id, .. }
320 if secondary_id == b"DJVU"
321 || secondary_id == b"DJVI"
322 || secondary_id == b"THUM" => {}
323 _ => {
324 return Err(MutError::ComponentMalformed {
325 name: comp.id.clone(),
326 reason: "root is not a FORM:DJVU/DJVI/THUM",
327 });
328 }
329 }
330 component_forms.push(parsed.root);
331 }
332
333 let bundled_dirm = bundled_dirm_from_indirect(dirm_data, component_forms.len())?;
338
339 let mut children: Vec<Chunk> = Vec::with_capacity(1 + component_forms.len());
342 children.push(Chunk::Leaf {
343 id: *b"DIRM",
344 data: bundled_dirm,
345 });
346 children.extend(component_forms);
347 let mut file = DjvuFile {
348 root: Chunk::Form {
349 secondary_id: *b"DJVM",
350 length: 0,
351 children,
352 },
353 };
354
355 recompute_dirm_offsets(&mut file.root)?;
358 let bundled_bytes = iff::emit(&file);
359 Ok(Self {
360 file,
361 original_bytes: bundled_bytes,
362 dirty: false,
363 })
364 }
365
366 pub fn root_child_count(&self) -> usize {
372 self.file.root.children().len()
373 }
374
375 #[doc(hidden)]
377 pub(crate) fn root_chunk(&self) -> &Chunk {
378 &self.file.root
379 }
380
381 pub fn root_form_type(&self) -> Option<&[u8; 4]> {
385 match &self.file.root {
386 Chunk::Form { secondary_id, .. } => Some(secondary_id),
387 Chunk::Leaf { .. } => None,
388 }
389 }
390
391 pub fn replace_leaf(&mut self, path: &[usize], new_data: Vec<u8>) -> Result<(), MutError> {
404 let chunk = self.chunk_at_path_mut(path)?;
405 match chunk {
406 Chunk::Leaf { data, .. } => {
407 *data = new_data;
408 self.dirty = true;
409 Ok(())
410 }
411 Chunk::Form { .. } => Err(MutError::NotALeaf),
412 }
413 }
414
415 pub fn remove_leaf(&mut self, path: &[usize]) -> Result<(), MutError> {
425 if path.is_empty() {
426 return Err(MutError::EmptyPath);
427 }
428 let _ = self.chunk_at_path(path)?;
429
430 let parent_path = &path[..path.len() - 1];
431 let child_index = path[path.len() - 1];
432 {
433 let mut current = &mut self.file.root;
434 for &idx in parent_path {
435 match current {
436 Chunk::Form { children, .. } => {
437 current = &mut children[idx];
438 }
439 Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
440 }
441 }
442 match current {
443 Chunk::Form { children, .. } => {
444 if !matches!(children[child_index], Chunk::Leaf { .. }) {
445 return Err(MutError::NotALeaf);
446 }
447 children.remove(child_index);
448 }
449 Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
450 }
451 }
452 self.dirty = true;
453 Ok(())
454 }
455
456 pub(crate) fn replace_leaves_by_id(
463 &mut self,
464 form_path: &[usize],
465 id: &[u8; 4],
466 payloads: Vec<Vec<u8>>,
467 ) -> Result<(), MutError> {
468 if !form_path.is_empty() {
469 let _ = self.chunk_at_path(form_path)?;
470 }
471 let mut current = &mut self.file.root;
472 for &idx in form_path {
473 match current {
474 Chunk::Form { children, .. } => {
475 current = &mut children[idx];
476 }
477 Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
478 }
479 }
480 let Chunk::Form { children, .. } = current else {
481 return Err(MutError::NotAForm);
482 };
483 let is_old = |chunk: &Chunk| matches!(chunk, Chunk::Leaf { id: leaf, .. } if leaf == id);
484 let first = children.iter().position(is_old).unwrap_or(children.len());
485 children.retain(|chunk| !is_old(chunk));
486 for (offset, data) in payloads.into_iter().enumerate() {
487 children.insert(first + offset, Chunk::Leaf { id: *id, data });
488 }
489 self.dirty = true;
490 Ok(())
491 }
492
493 pub fn chunk_at_path(&self, path: &[usize]) -> Result<&Chunk, MutError> {
495 if path.is_empty() {
496 return Err(MutError::EmptyPath);
497 }
498 let mut current = &self.file.root;
499 for (depth, &idx) in path.iter().enumerate() {
500 let children = current.children();
501 if children.is_empty() && depth < path.len() - 1 {
502 return Err(MutError::PathTraversesLeaf {
504 depth,
505 len: path.len(),
506 });
507 }
508 if let Chunk::Leaf { .. } = current {
509 return Err(MutError::PathTraversesLeaf {
510 depth,
511 len: path.len(),
512 });
513 }
514 if idx >= children.len() {
515 return Err(MutError::PathOutOfRange {
516 index: idx,
517 depth,
518 len: children.len(),
519 });
520 }
521 current = &children[idx];
522 }
523 Ok(current)
524 }
525
526 fn chunk_at_path_mut(&mut self, path: &[usize]) -> Result<&mut Chunk, MutError> {
527 if path.is_empty() {
528 return Err(MutError::EmptyPath);
529 }
530 let _ = self.chunk_at_path(path)?;
533 let mut current = &mut self.file.root;
535 for &idx in path {
536 match current {
539 Chunk::Form { children, .. } => {
540 current = &mut children[idx];
541 }
542 Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
543 }
544 }
545 Ok(current)
546 }
547
548 pub fn is_dirty(&self) -> bool {
550 self.dirty
551 }
552
553 pub fn into_bytes(self) -> Vec<u8> {
569 self.try_into_bytes()
570 .expect("DIRM recomputation failed — inconsistent document")
571 }
572
573 pub fn try_into_bytes(mut self) -> Result<Vec<u8>, MutError> {
576 if !self.dirty {
577 return Ok(self.original_bytes);
578 }
579 recompute_dirm_offsets(&mut self.file.root)?;
580 Ok(
581 emit_patched_single_page(&self.file.root, &self.original_bytes)
582 .unwrap_or_else(|| iff::emit(&self.file)),
583 )
584 }
585
586 #[cfg(feature = "std")]
612 pub fn save_patched(mut self, file: &mut std::fs::File) -> Result<SavePatchStats, MutError> {
613 use std::io::{Read, Seek, SeekFrom, Write};
614
615 let old = core::mem::take(&mut self.original_bytes);
617 let file_len = file.metadata()?.len();
618 if file_len != old.len() as u64 {
619 return Err(MutError::PatchTargetMismatch);
620 }
621 let mut probe = [0u8; 16];
622 let head_len = old.len().min(16);
623 file.seek(SeekFrom::Start(0))?;
624 file.read_exact(&mut probe[..head_len])?;
625 if probe[..head_len] != old[..head_len] {
626 return Err(MutError::PatchTargetMismatch);
627 }
628
629 let new = if self.dirty {
630 recompute_dirm_offsets(&mut self.file.root)?;
631 emit_patched_single_page(&self.file.root, &old).unwrap_or_else(|| iff::emit(&self.file))
632 } else {
633 old.clone()
634 };
635
636 let prefix = old
637 .iter()
638 .zip(new.iter())
639 .take_while(|(a, b)| a == b)
640 .count();
641 let suffix = if old.len() == new.len() {
645 old[prefix..]
646 .iter()
647 .rev()
648 .zip(new[prefix..].iter().rev())
649 .take_while(|(a, b)| a == b)
650 .count()
651 } else {
652 0
653 };
654
655 let write_end = new.len() - suffix;
656 let bytes_written = (write_end - prefix.min(write_end)) as u64;
657 if bytes_written > 0 {
658 file.seek(SeekFrom::Start(prefix as u64))?;
659 file.write_all(&new[prefix..write_end])?;
660 }
661 if new.len() as u64 != file_len {
662 file.set_len(new.len() as u64)?;
663 }
664 file.flush()?;
665 Ok(SavePatchStats {
666 file_len: new.len() as u64,
667 bytes_written,
668 })
669 }
670
671 pub fn page_count(&self) -> usize {
678 match self.root_form_type() {
679 Some(b"DJVM") => self
680 .file
681 .root
682 .children()
683 .iter()
684 .filter(
685 |c| matches!(c, Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU"),
686 )
687 .count(),
688 _ => 1,
689 }
690 }
691
692 pub fn page_mut(&mut self, index: usize) -> Result<PageMut<'_>, MutError> {
708 let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
709 if &root_form_type == b"DJVU" {
710 let count = self.page_count();
711 if index >= count {
712 return Err(MutError::PageOutOfRange { index, count });
713 }
714 debug_assert_eq!(index, 0);
715 return Ok(PageMut {
716 form: &mut self.file.root,
717 dirty: &mut self.dirty,
718 });
719 }
720 debug_assert_eq!(&root_form_type, b"DJVM");
721 if !is_bundled_djvm(&self.file.root) {
722 return Err(MutError::IndirectDjvmUnsupported);
723 }
724 let count = self.page_count();
725 if index >= count {
726 return Err(MutError::PageOutOfRange { index, count });
727 }
728 let children = match &mut self.file.root {
730 Chunk::Form { children, .. } => children,
731 Chunk::Leaf { .. } => unreachable!("validated FORM root"),
732 };
733 let mut seen = 0usize;
734 for child in children.iter_mut() {
735 if let Chunk::Form { secondary_id, .. } = child
736 && secondary_id == b"DJVU"
737 {
738 if seen == index {
739 return Ok(PageMut {
740 form: child,
741 dirty: &mut self.dirty,
742 });
743 }
744 seen += 1;
745 }
746 }
747 unreachable!("page_count agreed with bundle but iteration disagreed")
748 }
749
750 pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
756 let bytes = encode_metadata_bzz(meta);
757 let insert_at = match &self.file.root {
758 Chunk::Form {
759 secondary_id,
760 children,
761 ..
762 } => {
763 if secondary_id == b"DJVM" {
764 children
765 .iter()
766 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"DIRM"))
767 .map(|i| i + 1)
768 } else {
769 None
770 }
771 }
772 _ => None,
773 };
774 replace_or_insert_form_chunk(&mut self.file.root, b"METa", b"METz", bytes, insert_at);
775 self.dirty = true;
776 }
777
778 pub fn remove_metadata(&mut self) {
780 self.set_metadata(&DjVuMetadata::default());
781 }
782
783 pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
797 let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
798 if &root_form_type != b"DJVM" {
799 return Err(MutError::BookmarksRequireDjvm);
800 }
801 let children = match &mut self.file.root {
802 Chunk::Form { children, .. } => children,
803 Chunk::Leaf { .. } => unreachable!("validated FORM root"),
804 };
805 let pos = children
806 .iter()
807 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"NAVM"));
808 match (pos, bookmarks.is_empty()) {
809 (Some(i), true) => {
810 children.remove(i);
811 }
812 (Some(i), false) => {
813 children[i] = NavmChunk(bookmarks).encode_chunk()?.into_leaf();
814 }
815 (None, true) => { }
816 (None, false) => {
817 let dirm_pos = children
821 .iter()
822 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"DIRM"));
823 let insert_at = dirm_pos.map(|i| i + 1).unwrap_or(0);
824 children.insert(insert_at, NavmChunk(bookmarks).encode_chunk()?.into_leaf());
825 }
826 }
827 self.dirty = true;
828 Ok(())
829 }
830}
831
832#[cfg(feature = "std")]
847fn resolve_indirect_components(root_bytes: &[u8]) -> Result<(&[u8], Vec<DirmComponent>), MutError> {
848 let form = iff::parse_form(root_bytes)?;
849 if &form.form_type != b"DJVM" {
850 return Err(MutError::NotIndirectDjvm);
851 }
852 let dirm_data: &[u8] = form
853 .chunks
854 .iter()
855 .find(|c| &c.id == b"DIRM")
856 .ok_or(MutError::DirmMalformed("indirect DJVM has no DIRM chunk"))?
857 .data;
858
859 let payload = DirmPayload::decode(dirm_data)
860 .map_err(|_| MutError::DirmMalformed("DIRM directory could not be parsed"))?;
861 if payload.is_bundled() {
862 return Err(MutError::NotIndirectDjvm);
864 }
865 let components = payload.components();
866 if components.is_empty() {
867 return Err(MutError::DirmMalformed("indirect DIRM lists no components"));
868 }
869 Ok((dirm_data, components))
870}
871
872fn bundled_dirm_from_indirect(indirect: &[u8], nfiles: usize) -> Result<Vec<u8>, MutError> {
882 let mut payload = DirmPayload::decode(indirect).map_err(MutError::DirmMalformed)?;
883 payload.flags |= 0x80;
888 payload.offsets = core::iter::repeat_n(0u32, nfiles).collect();
889 Ok(payload.encode())
890}
891
892fn is_bundled_djvm(chunk: &Chunk) -> bool {
896 let Chunk::Form {
897 secondary_id,
898 children,
899 ..
900 } = chunk
901 else {
902 return false;
903 };
904 if secondary_id != b"DJVM" {
905 return false;
906 }
907 children.iter().any(|c| {
908 matches!(c, Chunk::Leaf { id, data } if id == b"DIRM" && crate::dirm::DirmPayload::peek_bundled(data))
909 })
910}
911
912#[derive(Debug, Clone, PartialEq, Eq)]
914struct OriginalChildRange {
915 id: [u8; 4],
916 data: Vec<u8>,
917 range: Range<usize>,
918}
919
920fn emit_patched_single_page(root: &Chunk, original: &[u8]) -> Option<Vec<u8>> {
924 let Chunk::Form {
925 secondary_id,
926 children,
927 ..
928 } = root
929 else {
930 return None;
931 };
932 if secondary_id != b"DJVU" {
933 return None;
934 }
935 let original_children = original_single_page_child_ranges(original)?;
936 if original_children.len() != children.len() {
937 return None;
938 }
939
940 let mut parts: Vec<iff::EmitPart> = Vec::with_capacity(children.len());
945 for (child, original_child) in children.iter().zip(original_children.iter()) {
946 match child {
947 Chunk::Leaf { id, data }
948 if id == &original_child.id && data == &original_child.data =>
949 {
950 parts.push(iff::EmitPart::Verbatim(
951 &original[original_child.range.clone()],
952 ));
953 }
954 Chunk::Leaf { .. } => parts.push(iff::EmitPart::Chunk(child)),
955 Chunk::Form { .. } => return None,
956 }
957 }
958
959 iff::partial_emit(*secondary_id, &parts)
960}
961
962fn original_single_page_child_ranges(original: &[u8]) -> Option<Vec<OriginalChildRange>> {
963 if original.len() < 16 || &original[..4] != b"AT&T" || &original[4..8] != b"FORM" {
964 return None;
965 }
966 let form_len = u32::from_be_bytes(original[8..12].try_into().ok()?) as usize;
967 let body_end = 12usize.checked_add(form_len)?;
968 if body_end > original.len() || &original[12..16] != b"DJVU" {
969 return None;
970 }
971
972 let chunks = parse_form_body(original.get(16..body_end)?).ok()?;
977
978 let mut ranges = Vec::with_capacity(chunks.len());
979 let mut pos = 16usize;
980 for chunk in &chunks {
981 if &chunk.id == b"FORM" {
983 return None;
984 }
985 let data_end = pos + 8 + chunk.data.len();
986 let mut next = data_end;
987 if next & 1 == 1 {
988 if next >= body_end {
991 return None;
992 }
993 next += 1;
994 }
995 ranges.push(OriginalChildRange {
996 id: chunk.id,
997 data: chunk.data.to_vec(),
998 range: pos..next,
999 });
1000 pos = next;
1001 }
1002
1003 if pos != body_end {
1006 return None;
1007 }
1008 Some(ranges)
1009}
1010
1011fn recompute_dirm_offsets(root: &mut Chunk) -> Result<(), MutError> {
1024 let Chunk::Form {
1025 secondary_id,
1026 children,
1027 ..
1028 } = root
1029 else {
1030 return Ok(());
1031 };
1032 if secondary_id != b"DJVM" {
1033 return Ok(());
1034 }
1035
1036 fn is_component(child: &Chunk) -> bool {
1037 matches!(child, Chunk::Form { secondary_id: sid, .. }
1038 if sid == b"DJVU" || sid == b"DJVI" || sid == b"THUM")
1039 }
1040
1041 #[allow(clippy::redundant_guards)]
1045 let dirm_idx = children
1046 .iter()
1047 .position(|child| matches!(child, Chunk::Leaf { id, .. } if id == b"DIRM"));
1048 let Some(dirm_idx) = dirm_idx else {
1049 return Ok(());
1052 };
1053
1054 let new_sizes: Vec<u32> = children
1058 .iter()
1059 .filter(|child| is_component(child))
1060 .map(|child| u32::try_from(iff::framed_size(child)).unwrap_or(u32::MAX))
1061 .collect();
1062
1063 let Chunk::Leaf { data, .. } = &children[dirm_idx] else {
1064 return Err(MutError::DirmMalformed("DIRM is not a leaf chunk"));
1065 };
1066 let mut payload = DirmPayload::decode(data).map_err(MutError::DirmMalformed)?;
1070 if !payload.is_bundled() {
1071 return Ok(());
1073 }
1074 if payload.nfiles as usize != new_sizes.len() {
1075 return Err(MutError::DirmComponentCountMismatch {
1076 dirm: payload.nfiles as usize,
1077 children: new_sizes.len(),
1078 });
1079 }
1080 if payload.update_sizes(&new_sizes) {
1081 children[dirm_idx] = Chunk::Leaf {
1083 id: *b"DIRM",
1084 data: payload.encode(),
1085 };
1086 }
1087
1088 let mut pos: usize = 16;
1091 let mut new_offsets: Vec<u32> = Vec::with_capacity(new_sizes.len());
1092 for child in children.iter() {
1093 if is_component(child) {
1094 new_offsets.push(u32::try_from(pos).map_err(|_| {
1095 MutError::DirmMalformed("component offset exceeds u32 (file > 4 GiB)")
1096 })?);
1097 }
1098 pos += iff::emitted_size(child);
1099 }
1100 payload.offsets = new_offsets;
1101 children[dirm_idx] = Chunk::Leaf {
1102 id: *b"DIRM",
1103 data: payload.encode(),
1104 };
1105 Ok(())
1106}
1107
1108fn replace_or_insert_form_chunk(
1114 form: &mut Chunk,
1115 id_a: &[u8; 4],
1116 id_z: &[u8; 4],
1117 data: Vec<u8>,
1118 insert_at: Option<usize>,
1119) {
1120 let children = match form {
1121 Chunk::Form { children, .. } => children,
1122 Chunk::Leaf { .. } => unreachable!("chunk-pair helper requires a FORM"),
1123 };
1124 if data.is_empty() {
1125 children.retain(|c| !matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z));
1126 return;
1127 }
1128
1129 if let Some(pos) = children
1130 .iter()
1131 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z))
1132 {
1133 children[pos] = Chunk::Leaf { id: *id_z, data };
1134 } else {
1135 let pos = insert_at.unwrap_or(children.len()).min(children.len());
1136 children.insert(pos, Chunk::Leaf { id: *id_z, data });
1137 }
1138}
1139
1140pub struct PageMut<'doc> {
1149 form: &'doc mut Chunk,
1150 dirty: &'doc mut bool,
1151}
1152
1153impl PageMut<'_> {
1154 pub fn set_text_layer(&mut self, layer: &TextLayer) -> Result<(), MutError> {
1158 let info_data = self
1159 .find_leaf_data(b"INFO")
1160 .ok_or(MutError::MissingPageInfo)?;
1161 let info = PageInfo::parse(info_data)?;
1162 let plain = encode_text_layer(layer, info.height as u32);
1163 let compressed = crate::bzz_encode::bzz_encode(&plain);
1164 self.replace_or_insert_text(compressed);
1165 *self.dirty = true;
1166 Ok(())
1167 }
1168
1169 pub fn remove_text_layer(&mut self) {
1171 self.replace_or_insert_text(Vec::new());
1172 *self.dirty = true;
1173 }
1174
1175 pub fn set_annotations(&mut self, annotation: &Annotation, areas: &[MapArea]) {
1178 let bytes = encode_annotations_bzz(annotation, areas);
1179 self.replace_or_insert(b"ANTa", b"ANTz", bytes);
1180 *self.dirty = true;
1181 }
1182
1183 pub fn remove_annotations(&mut self) {
1185 self.replace_or_insert(b"ANTa", b"ANTz", Vec::new());
1186 *self.dirty = true;
1187 }
1188
1189 pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
1193 let bytes = encode_metadata_bzz(meta);
1194 self.replace_or_insert(b"METa", b"METz", bytes);
1195 *self.dirty = true;
1196 }
1197
1198 pub fn remove_metadata(&mut self) {
1200 self.replace_or_insert(b"METa", b"METz", Vec::new());
1201 *self.dirty = true;
1202 }
1203
1204 fn find_leaf_data(&self, id: &[u8; 4]) -> Option<&[u8]> {
1205 for child in self.form.children() {
1206 if let Chunk::Leaf { id: cid, data } = child
1207 && cid == id
1208 {
1209 return Some(data);
1210 }
1211 }
1212 None
1213 }
1214
1215 fn replace_or_insert(&mut self, id_a: &[u8; 4], id_z: &[u8; 4], data: Vec<u8>) {
1219 replace_or_insert_form_chunk(self.form, id_a, id_z, data, None);
1220 }
1221
1222 fn replace_or_insert_text(&mut self, data: Vec<u8>) {
1224 self.replace_or_insert(b"TXTa", b"TXTz", data);
1225 }
1226}
1227
1228#[cfg(feature = "std")]
1233#[derive(Debug, Clone, PartialEq, Eq)]
1234pub struct RewriteItem {
1235 pub name: String,
1238 pub is_root: bool,
1241 pub changed: bool,
1245}
1246
1247#[cfg(feature = "std")]
1249#[derive(Debug, Clone)]
1250struct PlannedComponent {
1251 name: String,
1254 is_page: bool,
1256 original: Vec<u8>,
1258 edited: Option<Vec<u8>>,
1260}
1261
1262#[cfg(feature = "std")]
1315#[derive(Debug, Clone)]
1316pub struct IndirectRewritePlan {
1317 root_bytes: Vec<u8>,
1319 root_changed: bool,
1321 components: Vec<PlannedComponent>,
1322}
1323
1324#[cfg(feature = "std")]
1325impl IndirectRewritePlan {
1326 pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
1345 where
1346 R: Fn(&str) -> Result<Vec<u8>, E>,
1347 {
1348 let (_dirm_data, infos) = resolve_indirect_components(root_bytes)?;
1351
1352 let mut seen = std::collections::HashSet::new();
1356 for info in &infos {
1357 validate_safe_component_name(&info.id)?;
1358 if !seen.insert(info.id.clone()) {
1359 return Err(MutError::DuplicateComponentName {
1360 name: info.id.clone(),
1361 });
1362 }
1363 }
1364
1365 let mut components = Vec::with_capacity(infos.len());
1366 for info in &infos {
1367 let bytes = resolver(&info.id).map_err(|_| MutError::ComponentResolve {
1368 name: info.id.clone(),
1369 })?;
1370 let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
1373 name: info.id.clone(),
1374 reason: "not a parseable IFF document",
1375 })?;
1376 match &parsed.root {
1377 Chunk::Form { secondary_id, .. }
1378 if secondary_id == b"DJVU"
1379 || secondary_id == b"DJVI"
1380 || secondary_id == b"THUM" => {}
1381 _ => {
1382 return Err(MutError::ComponentMalformed {
1383 name: info.id.clone(),
1384 reason: "root is not a FORM:DJVU/DJVI/THUM",
1385 });
1386 }
1387 }
1388 components.push(PlannedComponent {
1389 name: info.id.clone(),
1390 is_page: info.kind == DirmComponentKind::Page,
1391 original: bytes,
1392 edited: None,
1393 });
1394 }
1395
1396 Ok(Self {
1397 root_bytes: root_bytes.to_vec(),
1398 root_changed: false,
1399 components,
1400 })
1401 }
1402
1403 pub fn page_count(&self) -> usize {
1406 self.components.iter().filter(|c| c.is_page).count()
1407 }
1408
1409 pub fn component_count(&self) -> usize {
1411 self.components.len()
1412 }
1413
1414 pub fn edit_page<F>(&mut self, index: usize, edit: F) -> Result<(), MutError>
1427 where
1428 F: FnOnce(&mut DjVuDocumentMut) -> Result<(), MutError>,
1429 {
1430 let count = self.page_count();
1431 let comp = self
1432 .components
1433 .iter_mut()
1434 .filter(|c| c.is_page)
1435 .nth(index)
1436 .ok_or(MutError::PageOutOfRange { index, count })?;
1437 let current: &[u8] = comp.edited.as_deref().unwrap_or(&comp.original);
1438 let mut doc = DjVuDocumentMut::from_bytes(current)?;
1439 edit(&mut doc)?;
1440 if doc.is_dirty() {
1441 comp.edited = Some(doc.try_into_bytes()?);
1442 }
1443 Ok(())
1444 }
1445
1446 pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
1450 let mut root = DjVuDocumentMut::from_bytes(&self.root_bytes)?;
1451 root.set_bookmarks(bookmarks)?;
1452 if root.is_dirty() {
1453 self.root_bytes = root.try_into_bytes()?;
1454 self.root_changed = true;
1455 }
1456 Ok(())
1457 }
1458
1459 pub fn plan(&self, root_name: &str) -> Vec<RewriteItem> {
1467 let mut items: Vec<RewriteItem> = self
1468 .components
1469 .iter()
1470 .map(|c| RewriteItem {
1471 name: c.name.clone(),
1472 is_root: false,
1473 changed: c.edited.is_some(),
1474 })
1475 .collect();
1476 items.push(RewriteItem {
1477 name: root_name.to_string(),
1478 is_root: true,
1479 changed: self.root_changed,
1480 });
1481 items
1482 }
1483
1484 pub fn commit_to_dir(
1495 &self,
1496 dir: impl AsRef<std::path::Path>,
1497 root_name: &str,
1498 ) -> Result<Vec<std::path::PathBuf>, MutError> {
1499 let dir = dir.as_ref();
1500
1501 validate_safe_component_name(root_name)?;
1503 if self.components.iter().any(|c| c.name == root_name) {
1506 return Err(MutError::DuplicateComponentName {
1507 name: root_name.to_string(),
1508 });
1509 }
1510
1511 std::fs::create_dir_all(dir).map_err(|e| MutError::RewriteIo {
1512 name: dir.display().to_string(),
1513 message: e.to_string(),
1514 })?;
1515
1516 let mut written = Vec::with_capacity(self.components.len() + 1);
1518 for comp in &self.components {
1519 let bytes = comp.edited.as_deref().unwrap_or(&comp.original);
1520 written.push(stage_and_rename(dir, &comp.name, bytes)?);
1521 }
1522 written.push(stage_and_rename(dir, root_name, &self.root_bytes)?);
1523 Ok(written)
1524 }
1525}
1526
1527#[cfg(feature = "std")]
1533fn validate_safe_component_name(name: &str) -> Result<(), MutError> {
1534 let reject = |reason: &'static str| {
1535 Err(MutError::UnsafeComponentName {
1536 name: name.to_string(),
1537 reason,
1538 })
1539 };
1540 if name.is_empty() {
1541 return reject("name is empty");
1542 }
1543 if name.contains('\0') {
1544 return reject("name contains a NUL byte");
1545 }
1546 if name.contains('/') || name.contains('\\') {
1547 return reject("name contains a path separator");
1548 }
1549 if name.contains(':') {
1550 return reject("name contains a drive-letter / stream colon");
1551 }
1552 if name == "." || name == ".." {
1553 return reject("name is a relative directory reference");
1554 }
1555 Ok(())
1556}
1557
1558#[cfg(feature = "std")]
1561fn stage_and_rename(
1562 dir: &std::path::Path,
1563 name: &str,
1564 bytes: &[u8],
1565) -> Result<std::path::PathBuf, MutError> {
1566 use std::io::Write;
1567
1568 let final_path = dir.join(name);
1569 let tmp_path = dir.join(format!(".{name}.djvu-rs.tmp"));
1572
1573 let io_err = |path: &std::path::Path, e: std::io::Error| MutError::RewriteIo {
1574 name: path.display().to_string(),
1575 message: e.to_string(),
1576 };
1577
1578 {
1579 let mut f = std::fs::File::create(&tmp_path).map_err(|e| io_err(&tmp_path, e))?;
1580 f.write_all(bytes).map_err(|e| io_err(&tmp_path, e))?;
1581 f.sync_all().map_err(|e| io_err(&tmp_path, e))?;
1582 }
1583 std::fs::rename(&tmp_path, &final_path).map_err(|e| {
1584 let _ = std::fs::remove_file(&tmp_path);
1586 io_err(&final_path, e)
1587 })?;
1588 Ok(final_path)
1589}
1590
1591#[cfg(test)]
1592#[allow(clippy::field_reassign_with_default)]
1593mod tests {
1594 use super::*;
1595 use std::path::PathBuf;
1596
1597 fn corpus_path(name: &str) -> PathBuf {
1598 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1599 p.push("tests/fixtures");
1600 p.push(name);
1601 p
1602 }
1603
1604 fn read_corpus(name: &str) -> Vec<u8> {
1605 std::fs::read(corpus_path(name)).expect("corpus fixture missing")
1606 }
1607
1608 #[test]
1612 fn save_patched_matches_full_serialization() {
1613 let original = read_corpus("navm_fgbz.djvu");
1614 let tmp = tempfile::NamedTempFile::new().unwrap();
1615
1616 std::fs::write(tmp.path(), &original).unwrap();
1618 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1619 let mut f = std::fs::OpenOptions::new()
1620 .read(true)
1621 .write(true)
1622 .open(tmp.path())
1623 .unwrap();
1624 let stats = doc.save_patched(&mut f).unwrap();
1625 assert_eq!(stats.bytes_written, 0);
1626 assert_eq!(std::fs::read(tmp.path()).unwrap(), original);
1627
1628 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1631 let bookmarks = vec![DjVuBookmark {
1632 title: "patched".into(),
1633 url: "#1".into(),
1634 children: Vec::new(),
1635 }];
1636 doc.set_bookmarks(&bookmarks).unwrap();
1637 let expected = {
1638 let mut clone = DjVuDocumentMut::from_bytes(&original).unwrap();
1639 clone.set_bookmarks(&bookmarks).unwrap();
1640 clone.try_into_bytes().unwrap()
1641 };
1642 std::fs::write(tmp.path(), &original).unwrap();
1643 let mut f = std::fs::OpenOptions::new()
1644 .read(true)
1645 .write(true)
1646 .open(tmp.path())
1647 .unwrap();
1648 let stats = doc.save_patched(&mut f).unwrap();
1649 assert_eq!(std::fs::read(tmp.path()).unwrap(), expected);
1650 assert_eq!(stats.file_len, expected.len() as u64);
1651 assert!(
1652 stats.bytes_written < expected.len() as u64,
1653 "size-changing edit must still skip the untouched head"
1654 );
1655
1656 let original = expected;
1663 let doc0 = DjVuDocumentMut::from_bytes(&original).unwrap();
1664 let info_path = (0..doc0.root_child_count())
1666 .find_map(|i| match doc0.chunk_at_path(&[i]) {
1667 Ok(Chunk::Form {
1668 secondary_id: [b'D', b'J', b'V', b'U'],
1669 children,
1670 ..
1671 }) => children.iter().enumerate().find_map(|(j, c)| match c {
1672 Chunk::Leaf {
1673 id: [b'I', b'N', b'F', b'O'],
1674 ..
1675 } => Some(vec![i, j]),
1676 _ => None,
1677 }),
1678 _ => None,
1679 })
1680 .expect("bundle has a page with INFO");
1681 let mut new_info = doc0.chunk_at_path(&info_path).unwrap().data().to_vec();
1682 new_info[7] ^= 1;
1684 let mut doc = doc0.clone();
1685 doc.replace_leaf(&info_path, new_info.clone()).unwrap();
1686 let expected = {
1687 let mut clone = doc0.clone();
1688 clone.replace_leaf(&info_path, new_info).unwrap();
1689 clone.try_into_bytes().unwrap()
1690 };
1691 assert_eq!(expected.len(), original.len(), "edit must be same-size");
1692 std::fs::write(tmp.path(), &original).unwrap();
1693 let mut f = std::fs::OpenOptions::new()
1694 .read(true)
1695 .write(true)
1696 .open(tmp.path())
1697 .unwrap();
1698 let stats = doc.save_patched(&mut f).unwrap();
1699 assert_eq!(std::fs::read(tmp.path()).unwrap(), expected);
1700 assert!(
1701 stats.bytes_written <= 64,
1702 "same-size single-byte edit must write only the edited span, wrote {}",
1703 stats.bytes_written
1704 );
1705
1706 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1708 std::fs::write(tmp.path(), b"not the original").unwrap();
1709 let mut f = std::fs::OpenOptions::new()
1710 .read(true)
1711 .write(true)
1712 .open(tmp.path())
1713 .unwrap();
1714 assert!(matches!(
1715 doc.save_patched(&mut f),
1716 Err(MutError::PatchTargetMismatch)
1717 ));
1718 assert_eq!(std::fs::read(tmp.path()).unwrap(), b"not the original");
1719 }
1720
1721 #[test]
1723 fn roundtrip_byte_identical_chicken() {
1724 let original = read_corpus("chicken.djvu");
1725 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1726 assert!(!doc.is_dirty());
1727 assert_eq!(doc.into_bytes(), original);
1728 }
1729
1730 #[test]
1732 fn roundtrip_byte_identical_boy_jb2() {
1733 let original = read_corpus("boy_jb2.djvu");
1734 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1735 assert_eq!(doc.into_bytes(), original);
1736 }
1737
1738 #[test]
1740 fn roundtrip_byte_identical_djvm_bundle() {
1741 let original = read_corpus("DjVu3Spec_bundled.djvu");
1742 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1743 assert_eq!(doc.root_form_type(), Some(b"DJVM"));
1744 assert_eq!(doc.into_bytes(), original);
1745 }
1746
1747 #[test]
1749 fn roundtrip_byte_identical_navm() {
1750 let original = read_corpus("navm_fgbz.djvu");
1751 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1752 assert_eq!(doc.into_bytes(), original);
1753 }
1754
1755 #[test]
1757 fn replace_leaf_changes_emitted_bytes() {
1758 let original = read_corpus("chicken.djvu");
1759 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1760
1761 let first = doc.chunk_at_path(&[0]).unwrap();
1763 let original_first_data = first.data().to_vec();
1764 assert!(!original_first_data.is_empty());
1765
1766 let marker = b"PR1_TEST_MARKER".to_vec();
1768 doc.replace_leaf(&[0], marker.clone()).unwrap();
1769 assert!(doc.is_dirty());
1770
1771 let edited = doc.into_bytes();
1772
1773 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1775 let new_first = reparsed.chunk_at_path(&[0]).unwrap();
1776 assert_eq!(new_first.data(), marker.as_slice());
1777 }
1778
1779 #[test]
1780 fn single_page_patch_preserves_unedited_child_bytes() {
1781 let original = read_corpus("chicken.djvu");
1782 let original_ranges =
1783 original_single_page_child_ranges(&original).expect("single-page child ranges");
1784 assert!(
1785 original_ranges.len() > 2,
1786 "fixture must have unrelated chunks to preserve"
1787 );
1788
1789 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1790 doc.replace_leaf(&[0], b"PATCHED_INFO".to_vec()).unwrap();
1791 let edited = doc.try_into_bytes().unwrap();
1792 let edited_ranges =
1793 original_single_page_child_ranges(&edited).expect("edited child ranges");
1794 assert_eq!(edited_ranges.len(), original_ranges.len());
1795
1796 for (idx, (before, after)) in original_ranges.iter().zip(edited_ranges.iter()).enumerate() {
1797 if idx == 0 {
1798 assert_ne!(
1799 &original[before.range.clone()],
1800 &edited[after.range.clone()]
1801 );
1802 continue;
1803 }
1804 assert_eq!(before.id, after.id);
1805 assert_eq!(
1806 &original[before.range.clone()],
1807 &edited[after.range.clone()],
1808 "unchanged child #{idx} must be copied byte-for-byte"
1809 );
1810 }
1811 }
1812
1813 #[test]
1814 fn single_page_patch_falls_back_for_bundled_djvm() {
1815 let original = read_corpus("DjVu3Spec_bundled.djvu");
1816 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1817 assert!(
1818 emit_patched_single_page(&doc.file.root, &original).is_none(),
1819 "single-page patch path must decline bundled DJVM layouts"
1820 );
1821 }
1822
1823 #[test]
1824 fn replace_leaf_rejects_empty_path() {
1825 let original = read_corpus("chicken.djvu");
1826 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1827 let err = doc.replace_leaf(&[], vec![]).unwrap_err();
1828 assert!(matches!(err, MutError::EmptyPath));
1829 }
1830
1831 #[test]
1832 fn replace_leaf_rejects_out_of_range() {
1833 let original = read_corpus("chicken.djvu");
1834 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1835 let err = doc.replace_leaf(&[9999], vec![]).unwrap_err();
1836 assert!(matches!(err, MutError::PathOutOfRange { .. }));
1837 }
1838
1839 #[test]
1840 fn replace_leaf_rejects_traversing_leaf() {
1841 let original = read_corpus("chicken.djvu");
1842 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1843 let err = doc.replace_leaf(&[0, 0], vec![]).unwrap_err();
1845 assert!(matches!(err, MutError::PathTraversesLeaf { .. }));
1846 }
1847
1848 #[test]
1849 fn replace_leaf_rejects_form_target() {
1850 let original = read_corpus("DjVu3Spec_bundled.djvu");
1854 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1855 let last_idx = doc.root_child_count() - 1;
1856 let err = doc.replace_leaf(&[last_idx], vec![]).unwrap_err();
1857 assert!(matches!(err, MutError::NotALeaf));
1858 }
1859
1860 #[test]
1861 fn root_form_type_djvu_single_page() {
1862 let original = read_corpus("chicken.djvu");
1863 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1864 assert_eq!(doc.root_form_type(), Some(b"DJVU"));
1865 }
1866
1867 #[test]
1870 fn page_count_single_page_djvu_is_one() {
1871 let original = read_corpus("chicken.djvu");
1872 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1873 assert_eq!(doc.page_count(), 1);
1874 }
1875
1876 #[test]
1877 fn page_count_djvm_bundle_counts_djvu_components_only() {
1878 let original = read_corpus("DjVu3Spec_bundled.djvu");
1879 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1880 let direct: usize = doc
1883 .file
1884 .root
1885 .children()
1886 .iter()
1887 .filter(|c| {
1888 matches!(c, crate::iff::Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU")
1889 })
1890 .count();
1891 assert!(direct >= 2, "expected multi-page bundle, got {direct}");
1892 assert_eq!(doc.page_count(), direct);
1893 }
1894
1895 #[test]
1896 fn page_mut_out_of_range_errors() {
1897 let original = read_corpus("chicken.djvu");
1898 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1899 let err = doc.page_mut(1).err().unwrap();
1900 assert!(matches!(
1901 err,
1902 MutError::PageOutOfRange { index: 1, count: 1 }
1903 ));
1904 }
1905
1906 #[test]
1907 fn page_mut_djvm_bundle_succeeds_after_pr3() {
1908 let original = read_corpus("DjVu3Spec_bundled.djvu");
1911 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1912 assert!(doc.page_mut(0).is_ok());
1913 let count = doc.page_count();
1914 let err = doc.page_mut(count).err().unwrap();
1915 assert!(matches!(err, MutError::PageOutOfRange { .. }));
1916 }
1917
1918 #[test]
1919 fn page_mut_indirect_djvm_returns_unsupported_before_range_check() {
1920 let mut doc = DjVuDocumentMut::from_bytes(&indirect_djvm_bytes()).unwrap();
1921 let err = doc.page_mut(0).err().unwrap();
1922 assert!(matches!(err, MutError::IndirectDjvmUnsupported));
1923 }
1924
1925 fn indirect_djvm_bytes() -> Vec<u8> {
1926 let bzz_meta: &[u8] = &[
1927 0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
1928 0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
1929 ];
1930
1931 let mut dirm_data = Vec::new();
1932 dirm_data.push(0x00);
1933 dirm_data.push(0x00);
1934 dirm_data.push(0x01);
1935 dirm_data.extend_from_slice(bzz_meta);
1936
1937 let dirm = Chunk::Leaf {
1940 id: *b"DIRM",
1941 data: dirm_data,
1942 };
1943 iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits within u32")
1944 }
1945
1946 #[test]
1947 fn set_text_layer_roundtrip_chicken() {
1948 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1949
1950 let original = read_corpus("chicken.djvu");
1951 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1952
1953 let layer = TextLayer {
1954 text: "hello world".to_string(),
1955 zones: vec![TextZone {
1956 kind: TextZoneKind::Page,
1957 rect: Rect {
1958 x: 0,
1959 y: 0,
1960 width: 100,
1961 height: 50,
1962 },
1963 text: "hello world".to_string(),
1964 children: vec![],
1965 }],
1966 };
1967 doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
1968 assert!(doc.is_dirty());
1969 let edited = doc.into_bytes();
1970
1971 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1973 let has_txtz = reparsed
1974 .file
1975 .root
1976 .children()
1977 .iter()
1978 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1979 assert!(
1980 has_txtz,
1981 "TXTz chunk should be present after set_text_layer"
1982 );
1983 }
1984
1985 #[test]
1986 fn set_annotations_roundtrip_chicken() {
1987 use crate::annotation::{Annotation, Color};
1988
1989 let original = read_corpus("chicken.djvu");
1990 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1991
1992 let mut ann = Annotation::default();
1993 ann.background = Some(Color {
1994 r: 0xFF,
1995 g: 0xFF,
1996 b: 0xFF,
1997 });
1998 ann.mode = Some("color".to_string());
1999 doc.page_mut(0).unwrap().set_annotations(&ann, &[]);
2000 let edited = doc.into_bytes();
2001
2002 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2003 let antz = reparsed
2004 .file
2005 .root
2006 .children()
2007 .iter()
2008 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"ANTz"));
2009 assert!(antz.is_some(), "ANTz should be inserted");
2010 let data = antz.unwrap().data();
2011 let decoded = crate::bzz::bzz_decode(data).expect("ANTz must decompress");
2012 let (parsed_ann, _areas) =
2013 crate::annotation::parse_annotations(&decoded).expect("ANTz must round-trip");
2014 assert_eq!(parsed_ann.mode.as_deref(), Some("color"));
2015 assert_eq!(
2016 parsed_ann.background,
2017 Some(Color {
2018 r: 0xFF,
2019 g: 0xFF,
2020 b: 0xFF
2021 })
2022 );
2023 }
2024
2025 #[test]
2026 fn set_metadata_roundtrip_chicken() {
2027 let original = read_corpus("chicken.djvu");
2028 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2029
2030 let mut meta = DjVuMetadata::default();
2031 meta.title = Some("Test Title".into());
2032 meta.author = Some("Tester".into());
2033 doc.page_mut(0).unwrap().set_metadata(&meta);
2034 let edited = doc.into_bytes();
2035
2036 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2037 let metz = reparsed
2038 .file
2039 .root
2040 .children()
2041 .iter()
2042 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2043 .expect("METz should be inserted");
2044 let decoded = crate::bzz::bzz_decode(metz.data()).unwrap();
2045 let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2046 assert_eq!(parsed, meta);
2047 }
2048
2049 #[test]
2050 fn set_metadata_empty_removes_existing_chunk() {
2051 let original = read_corpus("chicken.djvu");
2052 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2053
2054 let mut meta = DjVuMetadata::default();
2056 meta.title = Some("X".into());
2057 doc.page_mut(0).unwrap().set_metadata(&meta);
2058 doc.page_mut(0)
2059 .unwrap()
2060 .set_metadata(&DjVuMetadata::default());
2061
2062 let edited = doc.into_bytes();
2063 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2064 let any_meta = reparsed
2065 .file
2066 .root
2067 .children()
2068 .iter()
2069 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2070 assert!(!any_meta, "set_metadata(empty) should remove any METa/METz");
2071 }
2072
2073 #[test]
2074 fn set_metadata_replaces_existing_chunk_in_place() {
2075 let original = read_corpus("chicken.djvu");
2076 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2077
2078 let mut m1 = DjVuMetadata::default();
2079 m1.title = Some("First".into());
2080 doc.page_mut(0).unwrap().set_metadata(&m1);
2081
2082 let mut m2 = DjVuMetadata::default();
2083 m2.title = Some("Second".into());
2084 doc.page_mut(0).unwrap().set_metadata(&m2);
2085
2086 let edited = doc.into_bytes();
2087 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2088 let metz_count = reparsed
2089 .file
2090 .root
2091 .children()
2092 .iter()
2093 .filter(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"))
2094 .count();
2095 assert_eq!(metz_count, 1, "should not duplicate METz on repeat set");
2096 }
2097
2098 fn dirm_offsets_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
2103 let form = crate::iff::parse_form(data).expect("parse_form");
2105 assert_eq!(&form.form_type, b"DJVM");
2106
2107 let dirm = form
2108 .chunks
2109 .iter()
2110 .find(|c| &c.id == b"DIRM")
2111 .expect("DIRM present");
2112 let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
2114 let declared = payload.offsets;
2115 let nfiles = declared.len();
2116
2117 let mut actual = Vec::with_capacity(nfiles);
2120 let mut pos = 16usize;
2121 let body_end = 8 + u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2122 while pos < body_end {
2123 let id = &data[pos..pos + 4];
2124 let len =
2125 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
2126 as usize;
2127 if id == b"FORM" {
2128 actual.push(pos as u32);
2129 }
2130 let mut next = pos + 8 + len;
2131 if next & 1 == 1 {
2132 next += 1;
2133 }
2134 pos = next;
2135 }
2136 (declared, actual)
2137 }
2138
2139 #[test]
2140 fn dirm_offsets_match_actual_after_no_edit() {
2141 let original = read_corpus("DjVu3Spec_bundled.djvu");
2144 let (declared, actual) = dirm_offsets_and_actual(&original);
2145 assert_eq!(declared, actual);
2146 }
2147
2148 #[test]
2149 fn dirm_offsets_recomputed_after_page_metadata_edit() {
2150 let original = read_corpus("DjVu3Spec_bundled.djvu");
2151 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2152
2153 let mut meta = DjVuMetadata::default();
2155 meta.title = Some("PR3 DJVM bundled mutation".into());
2156 meta.author = Some("djvu-rs PR3 tests".into());
2157 doc.page_mut(0).unwrap().set_metadata(&meta);
2158 assert!(doc.is_dirty());
2159
2160 let edited = doc.into_bytes();
2161 assert_ne!(edited.len(), original.len());
2163
2164 let (declared, actual) = dirm_offsets_and_actual(&edited);
2167 assert_eq!(
2168 declared, actual,
2169 "DIRM offsets must point at the new FORM positions after edit"
2170 );
2171
2172 let reparsed =
2175 crate::djvu_document::DjVuDocument::parse(&edited).expect("edited bundle must parse");
2176 let original_doc =
2177 crate::djvu_document::DjVuDocument::parse(&original).expect("original bundle parses");
2178 assert_eq!(reparsed.page_count(), original_doc.page_count());
2179 }
2180
2181 fn dirm_sizes_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
2184 let form = crate::iff::parse_form(data).expect("parse_form");
2185 let dirm = form
2186 .chunks
2187 .iter()
2188 .find(|c| &c.id == b"DIRM")
2189 .expect("DIRM present");
2190 let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
2191 let declared = payload.components().iter().map(|c| c.size).collect();
2192 let actual = payload
2193 .offsets
2194 .iter()
2195 .map(|&off| {
2196 let o = off as usize;
2197 u32::from_be_bytes([data[o + 4], data[o + 5], data[o + 6], data[o + 7]]) + 8
2198 })
2199 .collect();
2200 (declared, actual)
2201 }
2202
2203 #[test]
2204 fn dirm_sizes_recomputed_after_page_edit() {
2205 let original = read_corpus("DjVu3Spec_bundled.djvu");
2208 let (declared, actual) = dirm_sizes_and_actual(&original);
2209 assert_eq!(declared, actual, "fixture sizes are exact");
2210
2211 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2212 let mut meta = DjVuMetadata::default();
2213 meta.title = Some("grow page 1".into());
2214 doc.page_mut(1).unwrap().set_metadata(&meta);
2215 let edited = doc.into_bytes();
2216
2217 let (declared, actual) = dirm_sizes_and_actual(&edited);
2218 assert_eq!(declared, actual, "DIRM sizes must follow the edited FORMs");
2219 let (orig_declared, _) = dirm_sizes_and_actual(&original);
2220 let changed: Vec<usize> = (0..declared.len())
2221 .filter(|&i| declared[i] != orig_declared[i])
2222 .collect();
2223 assert_eq!(changed.len(), 1, "only the edited page's size changes");
2224 }
2225
2226 #[test]
2227 fn dirm_offsets_recomputed_after_middle_page_edit() {
2228 let original = read_corpus("DjVu3Spec_bundled.djvu");
2230 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2231 let count = doc.page_count();
2232 assert!(count >= 3);
2233
2234 let mid = count / 2;
2235 let mut meta = DjVuMetadata::default();
2236 meta.title = Some("PR3 mid-page edit".into());
2237 doc.page_mut(mid).unwrap().set_metadata(&meta);
2238
2239 let edited = doc.into_bytes();
2240 let (declared, actual) = dirm_offsets_and_actual(&edited);
2241 assert_eq!(declared, actual);
2242
2243 let (orig_declared, _) = dirm_offsets_and_actual(&original);
2246 let shift = i64::from(declared[0]) - i64::from(orig_declared[0]);
2247 for i in 0..mid {
2248 assert_eq!(
2249 i64::from(declared[i]) - i64::from(orig_declared[i]),
2250 shift,
2251 "offset for page {i} (before edit) must shift only with DIRM"
2252 );
2253 }
2254 }
2255
2256 #[test]
2257 fn set_bookmarks_replaces_navm_in_bundle() {
2258 use crate::djvu_document::DjVuBookmark;
2259
2260 let original = read_corpus("DjVu3Spec_bundled.djvu");
2261 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2262
2263 let bookmarks = vec![
2264 DjVuBookmark {
2265 title: "Front matter".into(),
2266 url: "#1".into(),
2267 children: vec![DjVuBookmark {
2268 title: "Acknowledgments".into(),
2269 url: "#3".into(),
2270 children: vec![],
2271 }],
2272 },
2273 DjVuBookmark {
2274 title: "Body".into(),
2275 url: "#10".into(),
2276 children: vec![],
2277 },
2278 ];
2279 doc.set_bookmarks(&bookmarks).unwrap();
2280 assert!(doc.is_dirty());
2281 let edited = doc.into_bytes();
2282
2283 let (declared, actual) = dirm_offsets_and_actual(&edited);
2285 assert_eq!(declared, actual);
2286
2287 let reparsed = crate::djvu_document::DjVuDocument::parse(&edited)
2289 .expect("bundle with new bookmarks parses");
2290 let parsed_bms = reparsed.bookmarks();
2291 assert_eq!(parsed_bms.len(), 2);
2292 assert_eq!(parsed_bms[0].title, "Front matter");
2293 assert_eq!(parsed_bms[0].children.len(), 1);
2294 assert_eq!(parsed_bms[0].children[0].title, "Acknowledgments");
2295 assert_eq!(parsed_bms[1].title, "Body");
2296 }
2297
2298 #[test]
2299 fn set_bookmarks_empty_removes_navm() {
2300 let original = read_corpus("DjVu3Spec_bundled.djvu");
2301 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2302 doc.set_bookmarks(&[]).unwrap();
2305 let edited = doc.into_bytes();
2306
2307 let form = crate::iff::parse_form(&edited).unwrap();
2308 let has_navm = form.chunks.iter().any(|c| &c.id == b"NAVM");
2309 assert!(!has_navm, "set_bookmarks(&[]) must remove NAVM");
2310
2311 let (declared, actual) = dirm_offsets_and_actual(&edited);
2313 assert_eq!(declared, actual);
2314 }
2315
2316 #[test]
2317 fn set_bookmarks_inserts_navm_when_absent() {
2318 use crate::djvu_document::DjVuBookmark;
2319
2320 let original = read_corpus("DjVu3Spec_bundled.djvu");
2323 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2324 doc.set_bookmarks(&[]).unwrap();
2325 let stripped = doc.into_bytes();
2326
2327 let mut doc = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2328 let bms = vec![DjVuBookmark {
2329 title: "Re-added".into(),
2330 url: "#1".into(),
2331 children: vec![],
2332 }];
2333 doc.set_bookmarks(&bms).unwrap();
2334 let edited = doc.into_bytes();
2335
2336 let form = crate::iff::parse_form(&edited).unwrap();
2337 let navm_pos = form
2338 .chunks
2339 .iter()
2340 .position(|c| &c.id == b"NAVM")
2341 .expect("NAVM should be inserted");
2342 let dirm_pos = form.chunks.iter().position(|c| &c.id == b"DIRM").unwrap();
2343 assert_eq!(
2344 navm_pos,
2345 dirm_pos + 1,
2346 "NAVM should be placed immediately after DIRM"
2347 );
2348
2349 let (declared, actual) = dirm_offsets_and_actual(&edited);
2350 assert_eq!(declared, actual);
2351 }
2352
2353 #[test]
2354 fn set_bookmarks_on_single_page_djvu_errors() {
2355 let original = read_corpus("chicken.djvu");
2356 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2357 let err = doc.set_bookmarks(&[]).err().unwrap();
2358 assert!(matches!(err, MutError::BookmarksRequireDjvm));
2359 }
2360
2361 #[test]
2362 fn page_mut_djvm_text_layer_roundtrip() {
2363 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2364
2365 let original = read_corpus("DjVu3Spec_bundled.djvu");
2366 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2367 let layer = TextLayer {
2368 text: "djvm page-3 text".into(),
2369 zones: vec![TextZone {
2370 kind: TextZoneKind::Page,
2371 rect: Rect {
2372 x: 0,
2373 y: 0,
2374 width: 100,
2375 height: 50,
2376 },
2377 text: "djvm page-3 text".into(),
2378 children: vec![],
2379 }],
2380 };
2381 doc.page_mut(2).unwrap().set_text_layer(&layer).unwrap();
2382 let edited = doc.into_bytes();
2383
2384 let (declared, actual) = dirm_offsets_and_actual(&edited);
2385 assert_eq!(declared, actual);
2386
2387 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2389 let mut djvu_seen = 0usize;
2391 let mut found_txtz = false;
2392 for child in reparsed.file.root.children() {
2393 if let Chunk::Form {
2394 secondary_id,
2395 children,
2396 ..
2397 } = child
2398 && secondary_id == b"DJVU"
2399 {
2400 if djvu_seen == 2 {
2401 found_txtz = children
2402 .iter()
2403 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
2404 break;
2405 }
2406 djvu_seen += 1;
2407 }
2408 }
2409 assert!(
2410 found_txtz,
2411 "TXTz chunk should be present on page 2 after set_text_layer"
2412 );
2413 }
2414
2415 #[test]
2420 fn unmutated_pages_byte_identical_after_metadata_edit() {
2421 use crate::metadata::DjVuMetadata;
2422
2423 let original = read_corpus("DjVu3Spec_bundled.djvu");
2424
2425 let orig_ranges = top_form_ranges(&original);
2426
2427 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2428 let meta = DjVuMetadata {
2429 title: Some("PR4 byte-identical probe".into()),
2430 ..Default::default()
2431 };
2432 doc.page_mut(0).unwrap().set_metadata(&meta);
2433 let edited = doc.into_bytes();
2434
2435 let edited_ranges = top_form_ranges(&edited);
2436 assert_eq!(orig_ranges.len(), edited_ranges.len());
2437
2438 let mut djvu_idx = 0usize;
2441 for (i, (or, er)) in orig_ranges.iter().zip(edited_ranges.iter()).enumerate() {
2442 let is_form_djvu = &original[or.start..or.start + 4] == b"FORM"
2445 && (&original[or.start + 8..or.start + 12] == b"DJVU"
2446 || &original[or.start + 8..or.start + 12] == b"DJVI");
2447 if !is_form_djvu {
2448 continue;
2449 }
2450 let is_edited_page = djvu_idx == 0;
2451 djvu_idx += 1;
2452 if is_edited_page {
2453 continue;
2454 }
2455 assert_eq!(
2456 &original[or.clone()],
2457 &edited[er.clone()],
2458 "FORM at top-level child #{i} must be byte-identical after edit"
2459 );
2460 }
2461 }
2462
2463 fn indirect_over_fixtures(
2468 page_names: &[&str],
2469 ) -> (
2470 Vec<u8>,
2471 impl Fn(&str) -> Result<Vec<u8>, std::io::Error> + use<>,
2472 ) {
2473 let index = crate::djvm::create_indirect(page_names).expect("create_indirect");
2474 let map: std::collections::HashMap<String, Vec<u8>> = page_names
2476 .iter()
2477 .map(|n| (n.to_string(), read_corpus(n)))
2478 .collect();
2479 let resolver = move |name: &str| -> Result<Vec<u8>, std::io::Error> {
2480 map.get(name).cloned().ok_or_else(|| {
2481 std::io::Error::new(std::io::ErrorKind::NotFound, "no such component")
2482 })
2483 };
2484 (index, resolver)
2485 }
2486
2487 #[test]
2488 fn from_indirect_resolved_rebundles_single_page() {
2489 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2490 let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2491 assert_eq!(doc.root_form_type(), Some(b"DJVM"));
2492 assert_eq!(doc.page_count(), 1);
2493 assert!(!doc.is_dirty());
2494
2495 let bundled = doc.try_into_bytes().unwrap();
2497 let reparsed =
2498 crate::djvu_document::DjVuDocument::parse(&bundled).expect("bundled output parses");
2499 assert_eq!(reparsed.page_count(), 1);
2500 assert_eq!(reparsed.page(0).unwrap().width(), 181);
2502 assert_eq!(reparsed.page(0).unwrap().height(), 240);
2503
2504 let (declared, actual) = dirm_offsets_and_actual(&bundled);
2506 assert_eq!(declared, actual);
2507 }
2508
2509 #[test]
2510 fn from_indirect_resolved_multi_page_preserves_order() {
2511 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2512 let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2513 assert_eq!(doc.page_count(), 2);
2514 let bundled = doc.try_into_bytes().unwrap();
2515
2516 let reparsed = crate::djvu_document::DjVuDocument::parse(&bundled).expect("parses");
2517 assert_eq!(reparsed.page_count(), 2);
2518 assert_eq!(reparsed.page(0).unwrap().width(), 181);
2520 let irish_doc = crate::djvu_document::DjVuDocument::parse(&read_corpus("irish.djvu"))
2521 .expect("irish parses standalone");
2522 assert_eq!(
2523 reparsed.page(1).unwrap().dimensions(),
2524 irish_doc.page(0).unwrap().dimensions()
2525 );
2526
2527 let (declared, actual) = dirm_offsets_and_actual(&bundled);
2528 assert_eq!(declared, actual);
2529 }
2530
2531 #[test]
2532 fn from_indirect_resolved_then_metadata_edit_roundtrips() {
2533 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2534 let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2535
2536 let meta = DjVuMetadata {
2537 title: Some("rebundled indirect".into()),
2538 author: Some("djvu-rs #325".into()),
2539 ..Default::default()
2540 };
2541 doc.page_mut(1).unwrap().set_metadata(&meta);
2542 assert!(doc.is_dirty());
2543 let edited = doc.into_bytes();
2544
2545 let (declared, actual) = dirm_offsets_and_actual(&edited);
2547 assert_eq!(declared, actual);
2548
2549 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2551 let mut djvu_seen = 0usize;
2552 let mut found = None;
2553 for child in reparsed.file.root.children() {
2554 if let Chunk::Form {
2555 secondary_id,
2556 children,
2557 ..
2558 } = child
2559 && secondary_id == b"DJVU"
2560 {
2561 if djvu_seen == 1 {
2562 found = children
2563 .iter()
2564 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2565 .map(|c| c.data().to_vec());
2566 break;
2567 }
2568 djvu_seen += 1;
2569 }
2570 }
2571 let metz = found.expect("page 1 should have METz after edit");
2572 let decoded = crate::bzz::bzz_decode(&metz).unwrap();
2573 let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2574 assert_eq!(parsed.title.as_deref(), Some("rebundled indirect"));
2575 }
2576
2577 #[test]
2578 fn from_indirect_resolved_then_text_layer_edit() {
2579 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2580
2581 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2582 let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2583 let layer = TextLayer {
2584 text: "rebundled text".into(),
2585 zones: vec![TextZone {
2586 kind: TextZoneKind::Page,
2587 rect: Rect {
2588 x: 0,
2589 y: 0,
2590 width: 100,
2591 height: 50,
2592 },
2593 text: "rebundled text".into(),
2594 children: vec![],
2595 }],
2596 };
2597 doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
2598 let edited = doc.into_bytes();
2599
2600 let reparsed = crate::djvu_document::DjVuDocument::parse(&edited).expect("parses");
2601 let text = reparsed.page(0).unwrap().text_layer().unwrap();
2602 assert!(text.is_some(), "edited page should expose a text layer");
2603 assert_eq!(text.unwrap().text, "rebundled text");
2604 }
2605
2606 #[test]
2607 fn from_indirect_resolved_missing_component_errors() {
2608 let index = crate::djvm::create_indirect(&["missing.djvu"]).expect("create_indirect");
2610 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2611 Err::<Vec<u8>, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"))
2612 })
2613 .unwrap_err();
2614 match err {
2615 MutError::ComponentResolve { name } => assert_eq!(name, "missing.djvu"),
2616 other => panic!("expected ComponentResolve, got {other:?}"),
2617 }
2618 }
2619
2620 #[test]
2621 fn from_indirect_resolved_malformed_component_errors() {
2622 let index = crate::djvm::create_indirect(&["garbage.djvu"]).expect("create_indirect");
2623 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2624 Ok::<Vec<u8>, std::io::Error>(b"not an iff document".to_vec())
2625 })
2626 .unwrap_err();
2627 assert!(
2628 matches!(err, MutError::ComponentMalformed { .. }),
2629 "{err:?}"
2630 );
2631 }
2632
2633 #[test]
2635 fn from_indirect_resolved_wrong_form_type_errors() {
2636 let index = crate::djvm::create_indirect(&["fake.djvu"]).expect("create_indirect");
2637 let fake = iff::emit(&DjvuFile {
2638 root: Chunk::Form {
2639 secondary_id: *b"FAKE",
2640 length: 0,
2641 children: vec![],
2642 },
2643 });
2644 let err = DjVuDocumentMut::from_indirect_resolved(&index, move |_name: &str| {
2645 Ok::<Vec<u8>, std::io::Error>(fake.clone())
2646 })
2647 .unwrap_err();
2648 assert!(
2649 matches!(err, MutError::ComponentMalformed { .. }),
2650 "{err:?}"
2651 );
2652 }
2653
2654 #[test]
2655 fn from_indirect_resolved_rejects_bundled_input() {
2656 let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2658 let err = DjVuDocumentMut::from_indirect_resolved(&bundled, |_n: &str| {
2659 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2660 })
2661 .unwrap_err();
2662 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2663 }
2664
2665 #[test]
2666 fn from_indirect_resolved_rejects_single_page_djvu() {
2667 let chicken = read_corpus("chicken.djvu");
2668 let err = DjVuDocumentMut::from_indirect_resolved(&chicken, |_n: &str| {
2669 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2670 })
2671 .unwrap_err();
2672 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2673 }
2674
2675 #[test]
2678 fn from_indirect_resolved_no_page_component_returns_dirm_malformed() {
2679 use crate::dirm::DirmPayload;
2680 let dirm_payload = DirmPayload::build_indirect(1, &[0x00], &["shared.djvi".to_string()]);
2682 let dirm_chunk = iff::Chunk::Leaf {
2683 id: *b"DIRM",
2684 data: dirm_payload.encode(),
2685 };
2686 let index =
2687 iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm_chunk)]).expect("fits");
2688
2689 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2690 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2691 })
2692 .unwrap_err();
2693 assert!(
2694 matches!(err, MutError::DirmMalformed(_)),
2695 "expected DirmMalformed, got {err:?}"
2696 );
2697 }
2698
2699 #[test]
2700 fn from_bytes_on_indirect_still_unsupported_for_page_mut() {
2701 let index = crate::djvm::create_indirect(&["chicken.djvu"]).expect("create_indirect");
2703 let mut doc = DjVuDocumentMut::from_bytes(&index).unwrap();
2704 let err = doc.page_mut(0).err().unwrap();
2705 assert!(matches!(err, MutError::IndirectDjvmUnsupported), "{err:?}");
2706 }
2707
2708 fn fresh_temp_dir(tag: &str) -> PathBuf {
2712 let dir = std::env::temp_dir().join(format!("djvu_rs_rewrite_{tag}"));
2713 let _ = std::fs::remove_dir_all(&dir);
2714 std::fs::create_dir_all(&dir).unwrap();
2715 dir
2716 }
2717
2718 #[test]
2719 fn rewrite_plan_commits_full_set_to_dir() {
2720 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2721 let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2722 assert_eq!(plan.page_count(), 2);
2723
2724 plan.edit_page(0, |doc| {
2726 let meta = DjVuMetadata {
2727 title: Some("rewrite path".into()),
2728 ..Default::default()
2729 };
2730 doc.page_mut(0)?.set_metadata(&meta);
2731 Ok(())
2732 })
2733 .unwrap();
2734
2735 let preview = plan.plan("index.djvu");
2737 assert_eq!(preview.len(), 3);
2738 assert_eq!(preview[0].name, "chicken.djvu");
2739 assert!(preview[0].changed, "edited page must show changed");
2740 assert_eq!(preview[1].name, "irish.djvu");
2741 assert!(!preview[1].changed, "untouched page must be unchanged");
2742 assert!(preview[2].is_root);
2743 assert!(!preview[2].changed, "root unchanged for a page-only edit");
2744
2745 let dir = fresh_temp_dir("commit_full_set");
2746 let written = plan.commit_to_dir(&dir, "index.djvu").unwrap();
2747 assert_eq!(written.len(), 3);
2748 for p in &written {
2749 assert!(p.exists(), "committed file {p:?} must exist");
2750 }
2751 let leftovers: Vec<_> = std::fs::read_dir(&dir)
2753 .unwrap()
2754 .filter_map(|e| e.ok())
2755 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
2756 .collect();
2757 assert!(leftovers.is_empty(), "temp files must be renamed away");
2758
2759 let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2762 let doc = crate::djvu_document::DjVuDocument::parse_from_dir(&index_bytes, &dir).unwrap();
2763 assert_eq!(doc.page_count(), 2);
2764 let meta_page0 = doc.page(0).unwrap();
2765 let edited_comp = std::fs::read(dir.join("chicken.djvu")).unwrap();
2768 let reparsed = DjVuDocumentMut::from_bytes(&edited_comp).unwrap();
2769 let has_metz = reparsed
2770 .file
2771 .root
2772 .children()
2773 .iter()
2774 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"));
2775 assert!(has_metz, "edited component file must contain METz");
2776 let irish_src = read_corpus("irish.djvu");
2778 let irish_out = std::fs::read(dir.join("irish.djvu")).unwrap();
2779 assert_eq!(irish_out, irish_src, "unedited component copied verbatim");
2780 let _ = meta_page0;
2781
2782 let _ = std::fs::remove_dir_all(&dir);
2783 }
2784
2785 #[test]
2786 fn rewrite_plan_rejects_duplicate_dirm_names() {
2787 let index = crate::djvm::create_indirect(&["dup.djvu", "dup.djvu"]).expect("create");
2789 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2790 Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2791 })
2792 .unwrap_err();
2793 match err {
2794 MutError::DuplicateComponentName { name } => assert_eq!(name, "dup.djvu"),
2795 other => panic!("expected DuplicateComponentName, got {other:?}"),
2796 }
2797 }
2798
2799 #[test]
2800 fn rewrite_plan_rejects_unsafe_dirm_names() {
2801 for bad in [
2802 "../evil.djvu",
2803 "/abs.djvu",
2804 "sub/page.djvu",
2805 "..",
2806 "a:b.djvu",
2807 ] {
2808 let index = crate::djvm::create_indirect(&[bad]).expect("create");
2809 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2810 Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2811 })
2812 .unwrap_err();
2813 assert!(
2814 matches!(err, MutError::UnsafeComponentName { .. }),
2815 "name {bad:?} should be rejected, got {err:?}"
2816 );
2817 }
2818 }
2819
2820 #[test]
2821 fn rewrite_plan_unsafe_root_name_leaves_dir_unchanged() {
2822 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2823 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2824
2825 let dir = fresh_temp_dir("unsafe_root");
2826 std::fs::write(dir.join("sentinel"), b"keep me").unwrap();
2828
2829 let err = plan.commit_to_dir(&dir, "../escape.djvu").unwrap_err();
2830 assert!(
2831 matches!(err, MutError::UnsafeComponentName { .. }),
2832 "{err:?}"
2833 );
2834
2835 let entries: Vec<String> = std::fs::read_dir(&dir)
2837 .unwrap()
2838 .filter_map(|e| e.ok())
2839 .map(|e| e.file_name().to_string_lossy().into_owned())
2840 .collect();
2841 assert_eq!(entries, vec!["sentinel".to_string()]);
2842 assert_eq!(std::fs::read(dir.join("sentinel")).unwrap(), b"keep me");
2843
2844 let _ = std::fs::remove_dir_all(&dir);
2845 }
2846
2847 #[test]
2848 fn rewrite_plan_root_name_collision_rejected() {
2849 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2850 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2851 let dir = fresh_temp_dir("root_collision");
2852 let err = plan.commit_to_dir(&dir, "chicken.djvu").unwrap_err();
2854 assert!(
2855 matches!(err, MutError::DuplicateComponentName { .. }),
2856 "{err:?}"
2857 );
2858 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
2860 let _ = std::fs::remove_dir_all(&dir);
2861 }
2862
2863 #[test]
2864 fn rewrite_plan_set_bookmarks_marks_root_changed() {
2865 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2866 let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2867 plan.set_bookmarks(&[DjVuBookmark {
2868 title: "Top".into(),
2869 url: "#1".into(),
2870 children: vec![],
2871 }])
2872 .unwrap();
2873
2874 let preview = plan.plan("index.djvu");
2875 let root = preview.iter().find(|i| i.is_root).unwrap();
2876 assert!(root.changed, "root index must be marked changed");
2877
2878 let dir = fresh_temp_dir("bookmarks");
2880 plan.commit_to_dir(&dir, "index.djvu").unwrap();
2881 let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2882 let form = crate::iff::parse_form(&index_bytes).unwrap();
2883 assert!(
2884 form.chunks.iter().any(|c| &c.id == b"NAVM"),
2885 "committed index must contain NAVM"
2886 );
2887 let _ = std::fs::remove_dir_all(&dir);
2888 }
2889
2890 #[test]
2891 fn rewrite_plan_rejects_bundled_input() {
2892 let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2893 let err = IndirectRewritePlan::from_indirect_resolved(&bundled, |_n: &str| {
2894 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2895 })
2896 .unwrap_err();
2897 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2898 }
2899
2900 #[test]
2901 fn chunk_at_path_rejects_empty_path() {
2902 let original = read_corpus("chicken.djvu");
2903 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2904 let err = doc.chunk_at_path(&[]).unwrap_err();
2905 assert!(matches!(err, MutError::EmptyPath));
2906 }
2907
2908 #[test]
2909 fn root_form_type_returns_some_for_form_root() {
2910 let original = read_corpus("chicken.djvu");
2911 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2912 let t = doc.root_form_type();
2913 assert!(t.is_some());
2914 }
2915
2916 #[test]
2919 fn set_bookmarks_empty_on_djvm_without_navm_is_noop() {
2920 let original = read_corpus("DjVu3Spec_bundled.djvu");
2922 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2923 doc.set_bookmarks(&[]).unwrap(); let stripped = doc.into_bytes();
2925
2926 let mut doc2 = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2928 doc2.set_bookmarks(&[]).unwrap();
2929 assert_eq!(
2930 doc2.into_bytes(),
2931 stripped,
2932 "no-op set_bookmarks should not change bytes"
2933 );
2934 }
2935
2936 #[test]
2939 fn set_metadata_empty_on_page_without_meta_is_noop() {
2940 let original = read_corpus("chicken.djvu"); let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2942 doc.page_mut(0)
2944 .unwrap()
2945 .set_metadata(&DjVuMetadata::default());
2946 let bytes = doc.into_bytes();
2948 let reparsed = DjVuDocumentMut::from_bytes(&bytes).unwrap();
2949 let has_meta = reparsed
2950 .file
2951 .root
2952 .children()
2953 .iter()
2954 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2955 assert!(
2956 !has_meta,
2957 "empty set_metadata should not insert a METa chunk"
2958 );
2959 }
2960
2961 #[test]
2964 fn chunk_at_path_traverses_leaf_first_branch() {
2965 let original = read_corpus("chicken.djvu");
2966 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2967 let err = doc.chunk_at_path(&[0, 0, 0]).unwrap_err();
2968 assert!(
2969 matches!(err, MutError::PathTraversesLeaf { depth: 1, len: 3 }),
2970 "{err:?}"
2971 );
2972 }
2973
2974 #[test]
2976 fn rewrite_plan_resolver_failure_returns_component_resolve_error() {
2977 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2978 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2979 Err::<Vec<u8>, std::io::Error>(std::io::Error::new(
2980 std::io::ErrorKind::NotFound,
2981 "nope",
2982 ))
2983 })
2984 .unwrap_err();
2985 assert!(matches!(err, MutError::ComponentResolve { .. }), "{err:?}");
2986 }
2987
2988 #[test]
2989 fn rewrite_plan_non_iff_component_returns_malformed_error() {
2990 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2991 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2992 Ok::<Vec<u8>, std::io::Error>(b"not iff".to_vec())
2993 })
2994 .unwrap_err();
2995 assert!(
2996 matches!(err, MutError::ComponentMalformed { .. }),
2997 "{err:?}"
2998 );
2999 }
3000
3001 #[test]
3003 fn rewrite_plan_wrong_form_type_returns_malformed_error() {
3004 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
3005 let fake = iff::emit(&DjvuFile {
3006 root: Chunk::Form {
3007 secondary_id: *b"FAKE",
3008 length: 0,
3009 children: vec![],
3010 },
3011 });
3012 let err = IndirectRewritePlan::from_indirect_resolved(&index, move |_name: &str| {
3013 Ok::<Vec<u8>, std::io::Error>(fake.clone())
3014 })
3015 .unwrap_err();
3016 assert!(
3017 matches!(err, MutError::ComponentMalformed { .. }),
3018 "{err:?}"
3019 );
3020 }
3021
3022 #[test]
3024 fn rewrite_plan_component_count() {
3025 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
3026 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
3027 assert_eq!(plan.component_count(), 2);
3028 assert_eq!(plan.page_count(), 2);
3029 }
3030
3031 #[cfg(feature = "std")]
3032 #[test]
3033 fn validate_safe_component_name_rejects_empty() {
3034 let err = validate_safe_component_name("").unwrap_err();
3035 assert!(matches!(err, MutError::UnsafeComponentName { .. }));
3036 }
3037
3038 #[cfg(feature = "std")]
3039 #[test]
3040 fn validate_safe_component_name_rejects_nul() {
3041 let err = validate_safe_component_name("a\0b").unwrap_err();
3042 assert!(matches!(err, MutError::UnsafeComponentName { .. }));
3043 }
3044
3045 fn top_form_ranges(data: &[u8]) -> Vec<core::ops::Range<usize>> {
3048 assert_eq!(&data[..4], b"AT&T");
3049 let form_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
3050 let body_end = 12 + form_len;
3051 let mut pos = 16usize; let mut out = Vec::new();
3053 while pos + 8 <= body_end {
3054 let len =
3055 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
3056 as usize;
3057 let mut next = pos + 8 + len;
3058 if next & 1 == 1 && next < body_end {
3059 next += 1;
3060 }
3061 out.push(pos..next);
3062 pos = next;
3063 }
3064 out
3065 }
3066
3067 #[test]
3071 fn is_bundled_djvm_leaf_returns_false() {
3072 let leaf = Chunk::Leaf {
3073 id: *b"INFO",
3074 data: vec![],
3075 };
3076 assert!(!is_bundled_djvm(&leaf));
3077 }
3078
3079 #[test]
3081 fn is_bundled_djvm_non_djvm_form_returns_false() {
3082 let form = Chunk::Form {
3083 secondary_id: *b"DJVU",
3084 length: 0,
3085 children: vec![],
3086 };
3087 assert!(!is_bundled_djvm(&form));
3088 }
3089
3090 #[test]
3094 fn from_indirect_resolved_empty_dirm_returns_dirm_malformed() {
3095 let dirm_payload = DirmPayload::build_indirect(0, &[], &[]);
3096 let dirm = Chunk::Leaf {
3097 id: *b"DIRM",
3098 data: dirm_payload.encode(),
3099 };
3100 let index = iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits");
3101 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_n: &str| {
3102 Ok::<Vec<u8>, std::io::Error>(Vec::new())
3103 })
3104 .unwrap_err();
3105 assert!(
3106 matches!(err, MutError::DirmMalformed(_)),
3107 "expected DirmMalformed, got {err:?}"
3108 );
3109 }
3110
3111 #[test]
3114 fn set_text_layer_missing_info_chunk_returns_missing_page_info() {
3115 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
3116
3117 let bytes = iff::emit(&iff::DjvuFile {
3118 root: Chunk::Form {
3119 secondary_id: *b"DJVU",
3120 length: 0,
3121 children: vec![Chunk::Leaf {
3123 id: *b"ANTz",
3124 data: vec![0u8; 4],
3125 }],
3126 },
3127 });
3128 let mut doc = DjVuDocumentMut::from_bytes(&bytes).expect("no-INFO DJVU must parse");
3129 let layer = TextLayer {
3130 text: "hello".to_string(),
3131 zones: vec![TextZone {
3132 kind: TextZoneKind::Page,
3133 rect: Rect {
3134 x: 0,
3135 y: 0,
3136 width: 10,
3137 height: 10,
3138 },
3139 text: "hello".to_string(),
3140 children: vec![],
3141 }],
3142 };
3143 let err = doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap_err();
3144 assert!(matches!(err, MutError::MissingPageInfo), "{err:?}");
3145 }
3146
3147 #[test]
3151 fn emit_patched_leaf_root_returns_none() {
3152 let leaf = Chunk::Leaf {
3153 id: *b"INFO",
3154 data: vec![0u8; 4],
3155 };
3156 assert!(emit_patched_single_page(&leaf, &[]).is_none());
3157 }
3158
3159 #[test]
3161 fn emit_patched_form_child_in_djvu_returns_none() {
3162 let original = iff::partial_emit(
3165 *b"DJVU",
3166 &[iff::EmitPart::Chunk(&Chunk::Leaf {
3167 id: *b"INFO",
3168 data: vec![0u8; 4],
3169 })],
3170 )
3171 .unwrap();
3172
3173 let root = Chunk::Form {
3175 secondary_id: *b"DJVU",
3176 length: 0,
3177 children: vec![Chunk::Form {
3178 secondary_id: *b"INFO",
3179 length: 0,
3180 children: vec![],
3181 }],
3182 };
3183 assert!(emit_patched_single_page(&root, &original).is_none());
3184 }
3185
3186 #[test]
3188 fn original_child_ranges_too_short_returns_none() {
3189 assert!(original_single_page_child_ranges(b"AT&TFORM").is_none());
3190 }
3191
3192 #[test]
3194 fn original_child_ranges_not_djvu_returns_none() {
3195 let bytes = iff::partial_emit(*b"DJVI", &[]).unwrap();
3196 assert!(original_single_page_child_ranges(&bytes).is_none());
3197 }
3198
3199 #[test]
3201 fn original_child_ranges_nested_form_tag_returns_none() {
3202 let inner: &[u8] = b"FORM\x00\x00\x00\x04\x00\x00\x00\x00";
3207 let bytes = iff::partial_emit(*b"DJVU", &[iff::EmitPart::Verbatim(inner)]).unwrap();
3208 assert!(original_single_page_child_ranges(&bytes).is_none());
3209 }
3210
3211 #[test]
3213 fn original_child_ranges_odd_length_at_body_end_returns_none() {
3214 let form_tag: [u8; 4] = *b"FORM";
3218 let mut bytes: Vec<u8> = Vec::new();
3219 bytes.extend_from_slice(&iff::MAGIC);
3220 bytes.extend_from_slice(&form_tag);
3221 bytes.extend_from_slice(&15u32.to_be_bytes()); bytes.extend_from_slice(b"DJVU");
3223 bytes.extend_from_slice(b"INFO");
3224 bytes.extend_from_slice(&3u32.to_be_bytes());
3225 bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
3226 assert!(original_single_page_child_ranges(&bytes).is_none());
3227 }
3228
3229 #[test]
3231 fn original_child_ranges_short_tail_returns_none() {
3232 let form_tag: [u8; 4] = *b"FORM";
3235 let mut bytes: Vec<u8> = Vec::new();
3236 bytes.extend_from_slice(&iff::MAGIC);
3237 bytes.extend_from_slice(&form_tag);
3238 bytes.extend_from_slice(&17u32.to_be_bytes()); bytes.extend_from_slice(b"DJVU");
3240 bytes.extend_from_slice(b"INFO");
3241 bytes.extend_from_slice(&4u32.to_be_bytes());
3242 bytes.extend_from_slice(&[0u8; 4]);
3243 bytes.push(0x00); assert!(original_single_page_child_ranges(&bytes).is_none());
3245 }
3246
3247 #[test]
3251 fn recompute_dirm_offsets_leaf_root_is_noop() {
3252 let mut leaf = Chunk::Leaf {
3253 id: *b"INFO",
3254 data: vec![0u8; 4],
3255 };
3256 assert!(recompute_dirm_offsets(&mut leaf).is_ok());
3257 }
3258
3259 #[test]
3261 fn recompute_dirm_offsets_djvm_no_dirm_is_noop() {
3262 let mut root = Chunk::Form {
3263 secondary_id: *b"DJVM",
3264 length: 0,
3265 children: vec![Chunk::Form {
3266 secondary_id: *b"DJVU",
3267 length: 0,
3268 children: vec![],
3269 }],
3270 };
3271 assert!(recompute_dirm_offsets(&mut root).is_ok());
3272 }
3273
3274 #[test]
3276 fn recompute_dirm_offsets_count_mismatch_errors() {
3277 let dirm_payload = DirmPayload::build_bundled(
3279 2,
3280 &[0x01, 0x01],
3281 &["p1.djvu".to_string(), "p2.djvu".to_string()],
3282 &[],
3283 );
3284 let dirm_data = dirm_payload.encode();
3285 let mut root = Chunk::Form {
3286 secondary_id: *b"DJVM",
3287 length: 0,
3288 children: vec![
3289 Chunk::Leaf {
3290 id: *b"DIRM",
3291 data: dirm_data,
3292 },
3293 Chunk::Form {
3294 secondary_id: *b"DJVU",
3295 length: 0,
3296 children: vec![],
3297 },
3298 ],
3300 };
3301 let err = recompute_dirm_offsets(&mut root).unwrap_err();
3302 assert!(
3303 matches!(err, MutError::DirmComponentCountMismatch { .. }),
3304 "{err:?}"
3305 );
3306 }
3307}