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> {
1022 let Chunk::Form {
1023 secondary_id,
1024 children,
1025 ..
1026 } = root
1027 else {
1028 return Ok(());
1029 };
1030 if secondary_id != b"DJVM" {
1031 return Ok(());
1032 }
1033
1034 let mut pos: usize = 16;
1037 let mut new_offsets: Vec<u32> = Vec::new();
1038 let mut dirm_idx: Option<usize> = None;
1039
1040 #[allow(clippy::redundant_guards)]
1044 for (i, child) in children.iter().enumerate() {
1045 match child {
1046 Chunk::Leaf { id, .. } if id == b"DIRM" => {
1047 dirm_idx = Some(i);
1048 }
1049 Chunk::Form {
1050 secondary_id: sid, ..
1051 } if sid == b"DJVU" || sid == b"DJVI" || sid == b"THUM" => {
1052 new_offsets.push(u32::try_from(pos).map_err(|_| {
1053 MutError::DirmMalformed("component offset exceeds u32 (file > 4 GiB)")
1054 })?);
1055 }
1056 _ => {}
1057 }
1058 pos += iff::emitted_size(child);
1059 }
1060
1061 let Some(dirm_idx) = dirm_idx else {
1062 return Ok(());
1065 };
1066
1067 let dirm = &mut children[dirm_idx];
1068 let Chunk::Leaf { data, .. } = dirm else {
1069 return Err(MutError::DirmMalformed("DIRM is not a leaf chunk"));
1070 };
1071
1072 let mut payload = DirmPayload::decode(data).map_err(MutError::DirmMalformed)?;
1076 if !payload.is_bundled() {
1077 return Ok(());
1079 }
1080 if payload.nfiles as usize != new_offsets.len() {
1081 return Err(MutError::DirmComponentCountMismatch {
1082 dirm: payload.nfiles as usize,
1083 children: new_offsets.len(),
1084 });
1085 }
1086 payload.offsets = new_offsets;
1087 *data = payload.encode();
1088 Ok(())
1089}
1090
1091fn replace_or_insert_form_chunk(
1097 form: &mut Chunk,
1098 id_a: &[u8; 4],
1099 id_z: &[u8; 4],
1100 data: Vec<u8>,
1101 insert_at: Option<usize>,
1102) {
1103 let children = match form {
1104 Chunk::Form { children, .. } => children,
1105 Chunk::Leaf { .. } => unreachable!("chunk-pair helper requires a FORM"),
1106 };
1107 if data.is_empty() {
1108 children.retain(|c| !matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z));
1109 return;
1110 }
1111
1112 if let Some(pos) = children
1113 .iter()
1114 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z))
1115 {
1116 children[pos] = Chunk::Leaf { id: *id_z, data };
1117 } else {
1118 let pos = insert_at.unwrap_or(children.len()).min(children.len());
1119 children.insert(pos, Chunk::Leaf { id: *id_z, data });
1120 }
1121}
1122
1123pub struct PageMut<'doc> {
1132 form: &'doc mut Chunk,
1133 dirty: &'doc mut bool,
1134}
1135
1136impl PageMut<'_> {
1137 pub fn set_text_layer(&mut self, layer: &TextLayer) -> Result<(), MutError> {
1141 let info_data = self
1142 .find_leaf_data(b"INFO")
1143 .ok_or(MutError::MissingPageInfo)?;
1144 let info = PageInfo::parse(info_data)?;
1145 let plain = encode_text_layer(layer, info.height as u32);
1146 let compressed = crate::bzz_encode::bzz_encode(&plain);
1147 self.replace_or_insert_text(compressed);
1148 *self.dirty = true;
1149 Ok(())
1150 }
1151
1152 pub fn remove_text_layer(&mut self) {
1154 self.replace_or_insert_text(Vec::new());
1155 *self.dirty = true;
1156 }
1157
1158 pub fn set_annotations(&mut self, annotation: &Annotation, areas: &[MapArea]) {
1161 let bytes = encode_annotations_bzz(annotation, areas);
1162 self.replace_or_insert(b"ANTa", b"ANTz", bytes);
1163 *self.dirty = true;
1164 }
1165
1166 pub fn remove_annotations(&mut self) {
1168 self.replace_or_insert(b"ANTa", b"ANTz", Vec::new());
1169 *self.dirty = true;
1170 }
1171
1172 pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
1176 let bytes = encode_metadata_bzz(meta);
1177 self.replace_or_insert(b"METa", b"METz", bytes);
1178 *self.dirty = true;
1179 }
1180
1181 pub fn remove_metadata(&mut self) {
1183 self.replace_or_insert(b"METa", b"METz", Vec::new());
1184 *self.dirty = true;
1185 }
1186
1187 fn find_leaf_data(&self, id: &[u8; 4]) -> Option<&[u8]> {
1188 for child in self.form.children() {
1189 if let Chunk::Leaf { id: cid, data } = child
1190 && cid == id
1191 {
1192 return Some(data);
1193 }
1194 }
1195 None
1196 }
1197
1198 fn replace_or_insert(&mut self, id_a: &[u8; 4], id_z: &[u8; 4], data: Vec<u8>) {
1202 replace_or_insert_form_chunk(self.form, id_a, id_z, data, None);
1203 }
1204
1205 fn replace_or_insert_text(&mut self, data: Vec<u8>) {
1207 self.replace_or_insert(b"TXTa", b"TXTz", data);
1208 }
1209}
1210
1211#[cfg(feature = "std")]
1216#[derive(Debug, Clone, PartialEq, Eq)]
1217pub struct RewriteItem {
1218 pub name: String,
1221 pub is_root: bool,
1224 pub changed: bool,
1228}
1229
1230#[cfg(feature = "std")]
1232#[derive(Debug, Clone)]
1233struct PlannedComponent {
1234 name: String,
1237 is_page: bool,
1239 original: Vec<u8>,
1241 edited: Option<Vec<u8>>,
1243}
1244
1245#[cfg(feature = "std")]
1298#[derive(Debug, Clone)]
1299pub struct IndirectRewritePlan {
1300 root_bytes: Vec<u8>,
1302 root_changed: bool,
1304 components: Vec<PlannedComponent>,
1305}
1306
1307#[cfg(feature = "std")]
1308impl IndirectRewritePlan {
1309 pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
1328 where
1329 R: Fn(&str) -> Result<Vec<u8>, E>,
1330 {
1331 let (_dirm_data, infos) = resolve_indirect_components(root_bytes)?;
1334
1335 let mut seen = std::collections::HashSet::new();
1339 for info in &infos {
1340 validate_safe_component_name(&info.id)?;
1341 if !seen.insert(info.id.clone()) {
1342 return Err(MutError::DuplicateComponentName {
1343 name: info.id.clone(),
1344 });
1345 }
1346 }
1347
1348 let mut components = Vec::with_capacity(infos.len());
1349 for info in &infos {
1350 let bytes = resolver(&info.id).map_err(|_| MutError::ComponentResolve {
1351 name: info.id.clone(),
1352 })?;
1353 let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
1356 name: info.id.clone(),
1357 reason: "not a parseable IFF document",
1358 })?;
1359 match &parsed.root {
1360 Chunk::Form { secondary_id, .. }
1361 if secondary_id == b"DJVU"
1362 || secondary_id == b"DJVI"
1363 || secondary_id == b"THUM" => {}
1364 _ => {
1365 return Err(MutError::ComponentMalformed {
1366 name: info.id.clone(),
1367 reason: "root is not a FORM:DJVU/DJVI/THUM",
1368 });
1369 }
1370 }
1371 components.push(PlannedComponent {
1372 name: info.id.clone(),
1373 is_page: info.kind == DirmComponentKind::Page,
1374 original: bytes,
1375 edited: None,
1376 });
1377 }
1378
1379 Ok(Self {
1380 root_bytes: root_bytes.to_vec(),
1381 root_changed: false,
1382 components,
1383 })
1384 }
1385
1386 pub fn page_count(&self) -> usize {
1389 self.components.iter().filter(|c| c.is_page).count()
1390 }
1391
1392 pub fn component_count(&self) -> usize {
1394 self.components.len()
1395 }
1396
1397 pub fn edit_page<F>(&mut self, index: usize, edit: F) -> Result<(), MutError>
1410 where
1411 F: FnOnce(&mut DjVuDocumentMut) -> Result<(), MutError>,
1412 {
1413 let count = self.page_count();
1414 let comp = self
1415 .components
1416 .iter_mut()
1417 .filter(|c| c.is_page)
1418 .nth(index)
1419 .ok_or(MutError::PageOutOfRange { index, count })?;
1420 let current: &[u8] = comp.edited.as_deref().unwrap_or(&comp.original);
1421 let mut doc = DjVuDocumentMut::from_bytes(current)?;
1422 edit(&mut doc)?;
1423 if doc.is_dirty() {
1424 comp.edited = Some(doc.try_into_bytes()?);
1425 }
1426 Ok(())
1427 }
1428
1429 pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
1433 let mut root = DjVuDocumentMut::from_bytes(&self.root_bytes)?;
1434 root.set_bookmarks(bookmarks)?;
1435 if root.is_dirty() {
1436 self.root_bytes = root.try_into_bytes()?;
1437 self.root_changed = true;
1438 }
1439 Ok(())
1440 }
1441
1442 pub fn plan(&self, root_name: &str) -> Vec<RewriteItem> {
1450 let mut items: Vec<RewriteItem> = self
1451 .components
1452 .iter()
1453 .map(|c| RewriteItem {
1454 name: c.name.clone(),
1455 is_root: false,
1456 changed: c.edited.is_some(),
1457 })
1458 .collect();
1459 items.push(RewriteItem {
1460 name: root_name.to_string(),
1461 is_root: true,
1462 changed: self.root_changed,
1463 });
1464 items
1465 }
1466
1467 pub fn commit_to_dir(
1478 &self,
1479 dir: impl AsRef<std::path::Path>,
1480 root_name: &str,
1481 ) -> Result<Vec<std::path::PathBuf>, MutError> {
1482 let dir = dir.as_ref();
1483
1484 validate_safe_component_name(root_name)?;
1486 if self.components.iter().any(|c| c.name == root_name) {
1489 return Err(MutError::DuplicateComponentName {
1490 name: root_name.to_string(),
1491 });
1492 }
1493
1494 std::fs::create_dir_all(dir).map_err(|e| MutError::RewriteIo {
1495 name: dir.display().to_string(),
1496 message: e.to_string(),
1497 })?;
1498
1499 let mut written = Vec::with_capacity(self.components.len() + 1);
1501 for comp in &self.components {
1502 let bytes = comp.edited.as_deref().unwrap_or(&comp.original);
1503 written.push(stage_and_rename(dir, &comp.name, bytes)?);
1504 }
1505 written.push(stage_and_rename(dir, root_name, &self.root_bytes)?);
1506 Ok(written)
1507 }
1508}
1509
1510#[cfg(feature = "std")]
1516fn validate_safe_component_name(name: &str) -> Result<(), MutError> {
1517 let reject = |reason: &'static str| {
1518 Err(MutError::UnsafeComponentName {
1519 name: name.to_string(),
1520 reason,
1521 })
1522 };
1523 if name.is_empty() {
1524 return reject("name is empty");
1525 }
1526 if name.contains('\0') {
1527 return reject("name contains a NUL byte");
1528 }
1529 if name.contains('/') || name.contains('\\') {
1530 return reject("name contains a path separator");
1531 }
1532 if name.contains(':') {
1533 return reject("name contains a drive-letter / stream colon");
1534 }
1535 if name == "." || name == ".." {
1536 return reject("name is a relative directory reference");
1537 }
1538 Ok(())
1539}
1540
1541#[cfg(feature = "std")]
1544fn stage_and_rename(
1545 dir: &std::path::Path,
1546 name: &str,
1547 bytes: &[u8],
1548) -> Result<std::path::PathBuf, MutError> {
1549 use std::io::Write;
1550
1551 let final_path = dir.join(name);
1552 let tmp_path = dir.join(format!(".{name}.djvu-rs.tmp"));
1555
1556 let io_err = |path: &std::path::Path, e: std::io::Error| MutError::RewriteIo {
1557 name: path.display().to_string(),
1558 message: e.to_string(),
1559 };
1560
1561 {
1562 let mut f = std::fs::File::create(&tmp_path).map_err(|e| io_err(&tmp_path, e))?;
1563 f.write_all(bytes).map_err(|e| io_err(&tmp_path, e))?;
1564 f.sync_all().map_err(|e| io_err(&tmp_path, e))?;
1565 }
1566 std::fs::rename(&tmp_path, &final_path).map_err(|e| {
1567 let _ = std::fs::remove_file(&tmp_path);
1569 io_err(&final_path, e)
1570 })?;
1571 Ok(final_path)
1572}
1573
1574#[cfg(test)]
1575#[allow(clippy::field_reassign_with_default)]
1576mod tests {
1577 use super::*;
1578 use std::path::PathBuf;
1579
1580 fn corpus_path(name: &str) -> PathBuf {
1581 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1582 p.push("tests/fixtures");
1583 p.push(name);
1584 p
1585 }
1586
1587 fn read_corpus(name: &str) -> Vec<u8> {
1588 std::fs::read(corpus_path(name)).expect("corpus fixture missing")
1589 }
1590
1591 #[test]
1595 fn save_patched_matches_full_serialization() {
1596 let original = read_corpus("navm_fgbz.djvu");
1597 let tmp = tempfile::NamedTempFile::new().unwrap();
1598
1599 std::fs::write(tmp.path(), &original).unwrap();
1601 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1602 let mut f = std::fs::OpenOptions::new()
1603 .read(true)
1604 .write(true)
1605 .open(tmp.path())
1606 .unwrap();
1607 let stats = doc.save_patched(&mut f).unwrap();
1608 assert_eq!(stats.bytes_written, 0);
1609 assert_eq!(std::fs::read(tmp.path()).unwrap(), original);
1610
1611 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1614 let bookmarks = vec![DjVuBookmark {
1615 title: "patched".into(),
1616 url: "#1".into(),
1617 children: Vec::new(),
1618 }];
1619 doc.set_bookmarks(&bookmarks).unwrap();
1620 let expected = {
1621 let mut clone = DjVuDocumentMut::from_bytes(&original).unwrap();
1622 clone.set_bookmarks(&bookmarks).unwrap();
1623 clone.try_into_bytes().unwrap()
1624 };
1625 std::fs::write(tmp.path(), &original).unwrap();
1626 let mut f = std::fs::OpenOptions::new()
1627 .read(true)
1628 .write(true)
1629 .open(tmp.path())
1630 .unwrap();
1631 let stats = doc.save_patched(&mut f).unwrap();
1632 assert_eq!(std::fs::read(tmp.path()).unwrap(), expected);
1633 assert_eq!(stats.file_len, expected.len() as u64);
1634 assert!(
1635 stats.bytes_written < expected.len() as u64,
1636 "size-changing edit must still skip the untouched head"
1637 );
1638
1639 let original = expected;
1646 let doc0 = DjVuDocumentMut::from_bytes(&original).unwrap();
1647 let info_path = (0..doc0.root_child_count())
1649 .find_map(|i| match doc0.chunk_at_path(&[i]) {
1650 Ok(Chunk::Form {
1651 secondary_id: [b'D', b'J', b'V', b'U'],
1652 children,
1653 ..
1654 }) => children.iter().enumerate().find_map(|(j, c)| match c {
1655 Chunk::Leaf {
1656 id: [b'I', b'N', b'F', b'O'],
1657 ..
1658 } => Some(vec![i, j]),
1659 _ => None,
1660 }),
1661 _ => None,
1662 })
1663 .expect("bundle has a page with INFO");
1664 let mut new_info = doc0.chunk_at_path(&info_path).unwrap().data().to_vec();
1665 new_info[7] ^= 1;
1667 let mut doc = doc0.clone();
1668 doc.replace_leaf(&info_path, new_info.clone()).unwrap();
1669 let expected = {
1670 let mut clone = doc0.clone();
1671 clone.replace_leaf(&info_path, new_info).unwrap();
1672 clone.try_into_bytes().unwrap()
1673 };
1674 assert_eq!(expected.len(), original.len(), "edit must be same-size");
1675 std::fs::write(tmp.path(), &original).unwrap();
1676 let mut f = std::fs::OpenOptions::new()
1677 .read(true)
1678 .write(true)
1679 .open(tmp.path())
1680 .unwrap();
1681 let stats = doc.save_patched(&mut f).unwrap();
1682 assert_eq!(std::fs::read(tmp.path()).unwrap(), expected);
1683 assert!(
1684 stats.bytes_written <= 64,
1685 "same-size single-byte edit must write only the edited span, wrote {}",
1686 stats.bytes_written
1687 );
1688
1689 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1691 std::fs::write(tmp.path(), b"not the original").unwrap();
1692 let mut f = std::fs::OpenOptions::new()
1693 .read(true)
1694 .write(true)
1695 .open(tmp.path())
1696 .unwrap();
1697 assert!(matches!(
1698 doc.save_patched(&mut f),
1699 Err(MutError::PatchTargetMismatch)
1700 ));
1701 assert_eq!(std::fs::read(tmp.path()).unwrap(), b"not the original");
1702 }
1703
1704 #[test]
1706 fn roundtrip_byte_identical_chicken() {
1707 let original = read_corpus("chicken.djvu");
1708 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1709 assert!(!doc.is_dirty());
1710 assert_eq!(doc.into_bytes(), original);
1711 }
1712
1713 #[test]
1715 fn roundtrip_byte_identical_boy_jb2() {
1716 let original = read_corpus("boy_jb2.djvu");
1717 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1718 assert_eq!(doc.into_bytes(), original);
1719 }
1720
1721 #[test]
1723 fn roundtrip_byte_identical_djvm_bundle() {
1724 let original = read_corpus("DjVu3Spec_bundled.djvu");
1725 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1726 assert_eq!(doc.root_form_type(), Some(b"DJVM"));
1727 assert_eq!(doc.into_bytes(), original);
1728 }
1729
1730 #[test]
1732 fn roundtrip_byte_identical_navm() {
1733 let original = read_corpus("navm_fgbz.djvu");
1734 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1735 assert_eq!(doc.into_bytes(), original);
1736 }
1737
1738 #[test]
1740 fn replace_leaf_changes_emitted_bytes() {
1741 let original = read_corpus("chicken.djvu");
1742 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1743
1744 let first = doc.chunk_at_path(&[0]).unwrap();
1746 let original_first_data = first.data().to_vec();
1747 assert!(!original_first_data.is_empty());
1748
1749 let marker = b"PR1_TEST_MARKER".to_vec();
1751 doc.replace_leaf(&[0], marker.clone()).unwrap();
1752 assert!(doc.is_dirty());
1753
1754 let edited = doc.into_bytes();
1755
1756 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1758 let new_first = reparsed.chunk_at_path(&[0]).unwrap();
1759 assert_eq!(new_first.data(), marker.as_slice());
1760 }
1761
1762 #[test]
1763 fn single_page_patch_preserves_unedited_child_bytes() {
1764 let original = read_corpus("chicken.djvu");
1765 let original_ranges =
1766 original_single_page_child_ranges(&original).expect("single-page child ranges");
1767 assert!(
1768 original_ranges.len() > 2,
1769 "fixture must have unrelated chunks to preserve"
1770 );
1771
1772 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1773 doc.replace_leaf(&[0], b"PATCHED_INFO".to_vec()).unwrap();
1774 let edited = doc.try_into_bytes().unwrap();
1775 let edited_ranges =
1776 original_single_page_child_ranges(&edited).expect("edited child ranges");
1777 assert_eq!(edited_ranges.len(), original_ranges.len());
1778
1779 for (idx, (before, after)) in original_ranges.iter().zip(edited_ranges.iter()).enumerate() {
1780 if idx == 0 {
1781 assert_ne!(
1782 &original[before.range.clone()],
1783 &edited[after.range.clone()]
1784 );
1785 continue;
1786 }
1787 assert_eq!(before.id, after.id);
1788 assert_eq!(
1789 &original[before.range.clone()],
1790 &edited[after.range.clone()],
1791 "unchanged child #{idx} must be copied byte-for-byte"
1792 );
1793 }
1794 }
1795
1796 #[test]
1797 fn single_page_patch_falls_back_for_bundled_djvm() {
1798 let original = read_corpus("DjVu3Spec_bundled.djvu");
1799 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1800 assert!(
1801 emit_patched_single_page(&doc.file.root, &original).is_none(),
1802 "single-page patch path must decline bundled DJVM layouts"
1803 );
1804 }
1805
1806 #[test]
1807 fn replace_leaf_rejects_empty_path() {
1808 let original = read_corpus("chicken.djvu");
1809 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1810 let err = doc.replace_leaf(&[], vec![]).unwrap_err();
1811 assert!(matches!(err, MutError::EmptyPath));
1812 }
1813
1814 #[test]
1815 fn replace_leaf_rejects_out_of_range() {
1816 let original = read_corpus("chicken.djvu");
1817 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1818 let err = doc.replace_leaf(&[9999], vec![]).unwrap_err();
1819 assert!(matches!(err, MutError::PathOutOfRange { .. }));
1820 }
1821
1822 #[test]
1823 fn replace_leaf_rejects_traversing_leaf() {
1824 let original = read_corpus("chicken.djvu");
1825 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1826 let err = doc.replace_leaf(&[0, 0], vec![]).unwrap_err();
1828 assert!(matches!(err, MutError::PathTraversesLeaf { .. }));
1829 }
1830
1831 #[test]
1832 fn replace_leaf_rejects_form_target() {
1833 let original = read_corpus("DjVu3Spec_bundled.djvu");
1837 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1838 let last_idx = doc.root_child_count() - 1;
1839 let err = doc.replace_leaf(&[last_idx], vec![]).unwrap_err();
1840 assert!(matches!(err, MutError::NotALeaf));
1841 }
1842
1843 #[test]
1844 fn root_form_type_djvu_single_page() {
1845 let original = read_corpus("chicken.djvu");
1846 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1847 assert_eq!(doc.root_form_type(), Some(b"DJVU"));
1848 }
1849
1850 #[test]
1853 fn page_count_single_page_djvu_is_one() {
1854 let original = read_corpus("chicken.djvu");
1855 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1856 assert_eq!(doc.page_count(), 1);
1857 }
1858
1859 #[test]
1860 fn page_count_djvm_bundle_counts_djvu_components_only() {
1861 let original = read_corpus("DjVu3Spec_bundled.djvu");
1862 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1863 let direct: usize = doc
1866 .file
1867 .root
1868 .children()
1869 .iter()
1870 .filter(|c| {
1871 matches!(c, crate::iff::Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU")
1872 })
1873 .count();
1874 assert!(direct >= 2, "expected multi-page bundle, got {direct}");
1875 assert_eq!(doc.page_count(), direct);
1876 }
1877
1878 #[test]
1879 fn page_mut_out_of_range_errors() {
1880 let original = read_corpus("chicken.djvu");
1881 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1882 let err = doc.page_mut(1).err().unwrap();
1883 assert!(matches!(
1884 err,
1885 MutError::PageOutOfRange { index: 1, count: 1 }
1886 ));
1887 }
1888
1889 #[test]
1890 fn page_mut_djvm_bundle_succeeds_after_pr3() {
1891 let original = read_corpus("DjVu3Spec_bundled.djvu");
1894 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1895 assert!(doc.page_mut(0).is_ok());
1896 let count = doc.page_count();
1897 let err = doc.page_mut(count).err().unwrap();
1898 assert!(matches!(err, MutError::PageOutOfRange { .. }));
1899 }
1900
1901 #[test]
1902 fn page_mut_indirect_djvm_returns_unsupported_before_range_check() {
1903 let mut doc = DjVuDocumentMut::from_bytes(&indirect_djvm_bytes()).unwrap();
1904 let err = doc.page_mut(0).err().unwrap();
1905 assert!(matches!(err, MutError::IndirectDjvmUnsupported));
1906 }
1907
1908 fn indirect_djvm_bytes() -> Vec<u8> {
1909 let bzz_meta: &[u8] = &[
1910 0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
1911 0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
1912 ];
1913
1914 let mut dirm_data = Vec::new();
1915 dirm_data.push(0x00);
1916 dirm_data.push(0x00);
1917 dirm_data.push(0x01);
1918 dirm_data.extend_from_slice(bzz_meta);
1919
1920 let dirm = Chunk::Leaf {
1923 id: *b"DIRM",
1924 data: dirm_data,
1925 };
1926 iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits within u32")
1927 }
1928
1929 #[test]
1930 fn set_text_layer_roundtrip_chicken() {
1931 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1932
1933 let original = read_corpus("chicken.djvu");
1934 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1935
1936 let layer = TextLayer {
1937 text: "hello world".to_string(),
1938 zones: vec![TextZone {
1939 kind: TextZoneKind::Page,
1940 rect: Rect {
1941 x: 0,
1942 y: 0,
1943 width: 100,
1944 height: 50,
1945 },
1946 text: "hello world".to_string(),
1947 children: vec![],
1948 }],
1949 };
1950 doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
1951 assert!(doc.is_dirty());
1952 let edited = doc.into_bytes();
1953
1954 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1956 let has_txtz = reparsed
1957 .file
1958 .root
1959 .children()
1960 .iter()
1961 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1962 assert!(
1963 has_txtz,
1964 "TXTz chunk should be present after set_text_layer"
1965 );
1966 }
1967
1968 #[test]
1969 fn set_annotations_roundtrip_chicken() {
1970 use crate::annotation::{Annotation, Color};
1971
1972 let original = read_corpus("chicken.djvu");
1973 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1974
1975 let mut ann = Annotation::default();
1976 ann.background = Some(Color {
1977 r: 0xFF,
1978 g: 0xFF,
1979 b: 0xFF,
1980 });
1981 ann.mode = Some("color".to_string());
1982 doc.page_mut(0).unwrap().set_annotations(&ann, &[]);
1983 let edited = doc.into_bytes();
1984
1985 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1986 let antz = reparsed
1987 .file
1988 .root
1989 .children()
1990 .iter()
1991 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"ANTz"));
1992 assert!(antz.is_some(), "ANTz should be inserted");
1993 let data = antz.unwrap().data();
1994 let decoded = crate::bzz::bzz_decode(data).expect("ANTz must decompress");
1995 let (parsed_ann, _areas) =
1996 crate::annotation::parse_annotations(&decoded).expect("ANTz must round-trip");
1997 assert_eq!(parsed_ann.mode.as_deref(), Some("color"));
1998 assert_eq!(
1999 parsed_ann.background,
2000 Some(Color {
2001 r: 0xFF,
2002 g: 0xFF,
2003 b: 0xFF
2004 })
2005 );
2006 }
2007
2008 #[test]
2009 fn set_metadata_roundtrip_chicken() {
2010 let original = read_corpus("chicken.djvu");
2011 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2012
2013 let mut meta = DjVuMetadata::default();
2014 meta.title = Some("Test Title".into());
2015 meta.author = Some("Tester".into());
2016 doc.page_mut(0).unwrap().set_metadata(&meta);
2017 let edited = doc.into_bytes();
2018
2019 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2020 let metz = reparsed
2021 .file
2022 .root
2023 .children()
2024 .iter()
2025 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2026 .expect("METz should be inserted");
2027 let decoded = crate::bzz::bzz_decode(metz.data()).unwrap();
2028 let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2029 assert_eq!(parsed, meta);
2030 }
2031
2032 #[test]
2033 fn set_metadata_empty_removes_existing_chunk() {
2034 let original = read_corpus("chicken.djvu");
2035 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2036
2037 let mut meta = DjVuMetadata::default();
2039 meta.title = Some("X".into());
2040 doc.page_mut(0).unwrap().set_metadata(&meta);
2041 doc.page_mut(0)
2042 .unwrap()
2043 .set_metadata(&DjVuMetadata::default());
2044
2045 let edited = doc.into_bytes();
2046 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2047 let any_meta = reparsed
2048 .file
2049 .root
2050 .children()
2051 .iter()
2052 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2053 assert!(!any_meta, "set_metadata(empty) should remove any METa/METz");
2054 }
2055
2056 #[test]
2057 fn set_metadata_replaces_existing_chunk_in_place() {
2058 let original = read_corpus("chicken.djvu");
2059 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2060
2061 let mut m1 = DjVuMetadata::default();
2062 m1.title = Some("First".into());
2063 doc.page_mut(0).unwrap().set_metadata(&m1);
2064
2065 let mut m2 = DjVuMetadata::default();
2066 m2.title = Some("Second".into());
2067 doc.page_mut(0).unwrap().set_metadata(&m2);
2068
2069 let edited = doc.into_bytes();
2070 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2071 let metz_count = reparsed
2072 .file
2073 .root
2074 .children()
2075 .iter()
2076 .filter(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"))
2077 .count();
2078 assert_eq!(metz_count, 1, "should not duplicate METz on repeat set");
2079 }
2080
2081 fn dirm_offsets_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
2086 let form = crate::iff::parse_form(data).expect("parse_form");
2088 assert_eq!(&form.form_type, b"DJVM");
2089
2090 let dirm = form
2091 .chunks
2092 .iter()
2093 .find(|c| &c.id == b"DIRM")
2094 .expect("DIRM present");
2095 let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
2097 let declared = payload.offsets;
2098 let nfiles = declared.len();
2099
2100 let mut actual = Vec::with_capacity(nfiles);
2103 let mut pos = 16usize;
2104 let body_end = 8 + u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2105 while pos < body_end {
2106 let id = &data[pos..pos + 4];
2107 let len =
2108 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
2109 as usize;
2110 if id == b"FORM" {
2111 actual.push(pos as u32);
2112 }
2113 let mut next = pos + 8 + len;
2114 if next & 1 == 1 {
2115 next += 1;
2116 }
2117 pos = next;
2118 }
2119 (declared, actual)
2120 }
2121
2122 #[test]
2123 fn dirm_offsets_match_actual_after_no_edit() {
2124 let original = read_corpus("DjVu3Spec_bundled.djvu");
2127 let (declared, actual) = dirm_offsets_and_actual(&original);
2128 assert_eq!(declared, actual);
2129 }
2130
2131 #[test]
2132 fn dirm_offsets_recomputed_after_page_metadata_edit() {
2133 let original = read_corpus("DjVu3Spec_bundled.djvu");
2134 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2135
2136 let mut meta = DjVuMetadata::default();
2138 meta.title = Some("PR3 DJVM bundled mutation".into());
2139 meta.author = Some("djvu-rs PR3 tests".into());
2140 doc.page_mut(0).unwrap().set_metadata(&meta);
2141 assert!(doc.is_dirty());
2142
2143 let edited = doc.into_bytes();
2144 assert_ne!(edited.len(), original.len());
2146
2147 let (declared, actual) = dirm_offsets_and_actual(&edited);
2150 assert_eq!(
2151 declared, actual,
2152 "DIRM offsets must point at the new FORM positions after edit"
2153 );
2154
2155 let reparsed =
2158 crate::djvu_document::DjVuDocument::parse(&edited).expect("edited bundle must parse");
2159 let original_doc =
2160 crate::djvu_document::DjVuDocument::parse(&original).expect("original bundle parses");
2161 assert_eq!(reparsed.page_count(), original_doc.page_count());
2162 }
2163
2164 #[test]
2165 fn dirm_offsets_recomputed_after_middle_page_edit() {
2166 let original = read_corpus("DjVu3Spec_bundled.djvu");
2168 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2169 let count = doc.page_count();
2170 assert!(count >= 3);
2171
2172 let mid = count / 2;
2173 let mut meta = DjVuMetadata::default();
2174 meta.title = Some("PR3 mid-page edit".into());
2175 doc.page_mut(mid).unwrap().set_metadata(&meta);
2176
2177 let edited = doc.into_bytes();
2178 let (declared, actual) = dirm_offsets_and_actual(&edited);
2179 assert_eq!(declared, actual);
2180
2181 let (orig_declared, _) = dirm_offsets_and_actual(&original);
2183 for i in 0..mid {
2184 assert_eq!(
2185 declared[i], orig_declared[i],
2186 "offset for page {i} (before edit) must be unchanged"
2187 );
2188 }
2189 }
2190
2191 #[test]
2192 fn set_bookmarks_replaces_navm_in_bundle() {
2193 use crate::djvu_document::DjVuBookmark;
2194
2195 let original = read_corpus("DjVu3Spec_bundled.djvu");
2196 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2197
2198 let bookmarks = vec![
2199 DjVuBookmark {
2200 title: "Front matter".into(),
2201 url: "#1".into(),
2202 children: vec![DjVuBookmark {
2203 title: "Acknowledgments".into(),
2204 url: "#3".into(),
2205 children: vec![],
2206 }],
2207 },
2208 DjVuBookmark {
2209 title: "Body".into(),
2210 url: "#10".into(),
2211 children: vec![],
2212 },
2213 ];
2214 doc.set_bookmarks(&bookmarks).unwrap();
2215 assert!(doc.is_dirty());
2216 let edited = doc.into_bytes();
2217
2218 let (declared, actual) = dirm_offsets_and_actual(&edited);
2220 assert_eq!(declared, actual);
2221
2222 let reparsed = crate::djvu_document::DjVuDocument::parse(&edited)
2224 .expect("bundle with new bookmarks parses");
2225 let parsed_bms = reparsed.bookmarks();
2226 assert_eq!(parsed_bms.len(), 2);
2227 assert_eq!(parsed_bms[0].title, "Front matter");
2228 assert_eq!(parsed_bms[0].children.len(), 1);
2229 assert_eq!(parsed_bms[0].children[0].title, "Acknowledgments");
2230 assert_eq!(parsed_bms[1].title, "Body");
2231 }
2232
2233 #[test]
2234 fn set_bookmarks_empty_removes_navm() {
2235 let original = read_corpus("DjVu3Spec_bundled.djvu");
2236 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2237 doc.set_bookmarks(&[]).unwrap();
2240 let edited = doc.into_bytes();
2241
2242 let form = crate::iff::parse_form(&edited).unwrap();
2243 let has_navm = form.chunks.iter().any(|c| &c.id == b"NAVM");
2244 assert!(!has_navm, "set_bookmarks(&[]) must remove NAVM");
2245
2246 let (declared, actual) = dirm_offsets_and_actual(&edited);
2248 assert_eq!(declared, actual);
2249 }
2250
2251 #[test]
2252 fn set_bookmarks_inserts_navm_when_absent() {
2253 use crate::djvu_document::DjVuBookmark;
2254
2255 let original = read_corpus("DjVu3Spec_bundled.djvu");
2258 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2259 doc.set_bookmarks(&[]).unwrap();
2260 let stripped = doc.into_bytes();
2261
2262 let mut doc = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2263 let bms = vec![DjVuBookmark {
2264 title: "Re-added".into(),
2265 url: "#1".into(),
2266 children: vec![],
2267 }];
2268 doc.set_bookmarks(&bms).unwrap();
2269 let edited = doc.into_bytes();
2270
2271 let form = crate::iff::parse_form(&edited).unwrap();
2272 let navm_pos = form
2273 .chunks
2274 .iter()
2275 .position(|c| &c.id == b"NAVM")
2276 .expect("NAVM should be inserted");
2277 let dirm_pos = form.chunks.iter().position(|c| &c.id == b"DIRM").unwrap();
2278 assert_eq!(
2279 navm_pos,
2280 dirm_pos + 1,
2281 "NAVM should be placed immediately after DIRM"
2282 );
2283
2284 let (declared, actual) = dirm_offsets_and_actual(&edited);
2285 assert_eq!(declared, actual);
2286 }
2287
2288 #[test]
2289 fn set_bookmarks_on_single_page_djvu_errors() {
2290 let original = read_corpus("chicken.djvu");
2291 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2292 let err = doc.set_bookmarks(&[]).err().unwrap();
2293 assert!(matches!(err, MutError::BookmarksRequireDjvm));
2294 }
2295
2296 #[test]
2297 fn page_mut_djvm_text_layer_roundtrip() {
2298 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2299
2300 let original = read_corpus("DjVu3Spec_bundled.djvu");
2301 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2302 let layer = TextLayer {
2303 text: "djvm page-3 text".into(),
2304 zones: vec![TextZone {
2305 kind: TextZoneKind::Page,
2306 rect: Rect {
2307 x: 0,
2308 y: 0,
2309 width: 100,
2310 height: 50,
2311 },
2312 text: "djvm page-3 text".into(),
2313 children: vec![],
2314 }],
2315 };
2316 doc.page_mut(2).unwrap().set_text_layer(&layer).unwrap();
2317 let edited = doc.into_bytes();
2318
2319 let (declared, actual) = dirm_offsets_and_actual(&edited);
2320 assert_eq!(declared, actual);
2321
2322 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2324 let mut djvu_seen = 0usize;
2326 let mut found_txtz = false;
2327 for child in reparsed.file.root.children() {
2328 if let Chunk::Form {
2329 secondary_id,
2330 children,
2331 ..
2332 } = child
2333 && secondary_id == b"DJVU"
2334 {
2335 if djvu_seen == 2 {
2336 found_txtz = children
2337 .iter()
2338 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
2339 break;
2340 }
2341 djvu_seen += 1;
2342 }
2343 }
2344 assert!(
2345 found_txtz,
2346 "TXTz chunk should be present on page 2 after set_text_layer"
2347 );
2348 }
2349
2350 #[test]
2355 fn unmutated_pages_byte_identical_after_metadata_edit() {
2356 use crate::metadata::DjVuMetadata;
2357
2358 let original = read_corpus("DjVu3Spec_bundled.djvu");
2359
2360 let orig_ranges = top_form_ranges(&original);
2361
2362 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2363 let meta = DjVuMetadata {
2364 title: Some("PR4 byte-identical probe".into()),
2365 ..Default::default()
2366 };
2367 doc.page_mut(0).unwrap().set_metadata(&meta);
2368 let edited = doc.into_bytes();
2369
2370 let edited_ranges = top_form_ranges(&edited);
2371 assert_eq!(orig_ranges.len(), edited_ranges.len());
2372
2373 let mut djvu_idx = 0usize;
2376 for (i, (or, er)) in orig_ranges.iter().zip(edited_ranges.iter()).enumerate() {
2377 let is_form_djvu = &original[or.start..or.start + 4] == b"FORM"
2380 && (&original[or.start + 8..or.start + 12] == b"DJVU"
2381 || &original[or.start + 8..or.start + 12] == b"DJVI");
2382 if !is_form_djvu {
2383 continue;
2384 }
2385 let is_edited_page = djvu_idx == 0;
2386 djvu_idx += 1;
2387 if is_edited_page {
2388 continue;
2389 }
2390 assert_eq!(
2391 &original[or.clone()],
2392 &edited[er.clone()],
2393 "FORM at top-level child #{i} must be byte-identical after edit"
2394 );
2395 }
2396 }
2397
2398 fn indirect_over_fixtures(
2403 page_names: &[&str],
2404 ) -> (
2405 Vec<u8>,
2406 impl Fn(&str) -> Result<Vec<u8>, std::io::Error> + use<>,
2407 ) {
2408 let index = crate::djvm::create_indirect(page_names).expect("create_indirect");
2409 let map: std::collections::HashMap<String, Vec<u8>> = page_names
2411 .iter()
2412 .map(|n| (n.to_string(), read_corpus(n)))
2413 .collect();
2414 let resolver = move |name: &str| -> Result<Vec<u8>, std::io::Error> {
2415 map.get(name).cloned().ok_or_else(|| {
2416 std::io::Error::new(std::io::ErrorKind::NotFound, "no such component")
2417 })
2418 };
2419 (index, resolver)
2420 }
2421
2422 #[test]
2423 fn from_indirect_resolved_rebundles_single_page() {
2424 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2425 let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2426 assert_eq!(doc.root_form_type(), Some(b"DJVM"));
2427 assert_eq!(doc.page_count(), 1);
2428 assert!(!doc.is_dirty());
2429
2430 let bundled = doc.try_into_bytes().unwrap();
2432 let reparsed =
2433 crate::djvu_document::DjVuDocument::parse(&bundled).expect("bundled output parses");
2434 assert_eq!(reparsed.page_count(), 1);
2435 assert_eq!(reparsed.page(0).unwrap().width(), 181);
2437 assert_eq!(reparsed.page(0).unwrap().height(), 240);
2438
2439 let (declared, actual) = dirm_offsets_and_actual(&bundled);
2441 assert_eq!(declared, actual);
2442 }
2443
2444 #[test]
2445 fn from_indirect_resolved_multi_page_preserves_order() {
2446 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2447 let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2448 assert_eq!(doc.page_count(), 2);
2449 let bundled = doc.try_into_bytes().unwrap();
2450
2451 let reparsed = crate::djvu_document::DjVuDocument::parse(&bundled).expect("parses");
2452 assert_eq!(reparsed.page_count(), 2);
2453 assert_eq!(reparsed.page(0).unwrap().width(), 181);
2455 let irish_doc = crate::djvu_document::DjVuDocument::parse(&read_corpus("irish.djvu"))
2456 .expect("irish parses standalone");
2457 assert_eq!(
2458 reparsed.page(1).unwrap().dimensions(),
2459 irish_doc.page(0).unwrap().dimensions()
2460 );
2461
2462 let (declared, actual) = dirm_offsets_and_actual(&bundled);
2463 assert_eq!(declared, actual);
2464 }
2465
2466 #[test]
2467 fn from_indirect_resolved_then_metadata_edit_roundtrips() {
2468 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2469 let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2470
2471 let meta = DjVuMetadata {
2472 title: Some("rebundled indirect".into()),
2473 author: Some("djvu-rs #325".into()),
2474 ..Default::default()
2475 };
2476 doc.page_mut(1).unwrap().set_metadata(&meta);
2477 assert!(doc.is_dirty());
2478 let edited = doc.into_bytes();
2479
2480 let (declared, actual) = dirm_offsets_and_actual(&edited);
2482 assert_eq!(declared, actual);
2483
2484 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2486 let mut djvu_seen = 0usize;
2487 let mut found = None;
2488 for child in reparsed.file.root.children() {
2489 if let Chunk::Form {
2490 secondary_id,
2491 children,
2492 ..
2493 } = child
2494 && secondary_id == b"DJVU"
2495 {
2496 if djvu_seen == 1 {
2497 found = children
2498 .iter()
2499 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2500 .map(|c| c.data().to_vec());
2501 break;
2502 }
2503 djvu_seen += 1;
2504 }
2505 }
2506 let metz = found.expect("page 1 should have METz after edit");
2507 let decoded = crate::bzz::bzz_decode(&metz).unwrap();
2508 let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2509 assert_eq!(parsed.title.as_deref(), Some("rebundled indirect"));
2510 }
2511
2512 #[test]
2513 fn from_indirect_resolved_then_text_layer_edit() {
2514 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2515
2516 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2517 let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2518 let layer = TextLayer {
2519 text: "rebundled text".into(),
2520 zones: vec![TextZone {
2521 kind: TextZoneKind::Page,
2522 rect: Rect {
2523 x: 0,
2524 y: 0,
2525 width: 100,
2526 height: 50,
2527 },
2528 text: "rebundled text".into(),
2529 children: vec![],
2530 }],
2531 };
2532 doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
2533 let edited = doc.into_bytes();
2534
2535 let reparsed = crate::djvu_document::DjVuDocument::parse(&edited).expect("parses");
2536 let text = reparsed.page(0).unwrap().text_layer().unwrap();
2537 assert!(text.is_some(), "edited page should expose a text layer");
2538 assert_eq!(text.unwrap().text, "rebundled text");
2539 }
2540
2541 #[test]
2542 fn from_indirect_resolved_missing_component_errors() {
2543 let index = crate::djvm::create_indirect(&["missing.djvu"]).expect("create_indirect");
2545 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2546 Err::<Vec<u8>, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"))
2547 })
2548 .unwrap_err();
2549 match err {
2550 MutError::ComponentResolve { name } => assert_eq!(name, "missing.djvu"),
2551 other => panic!("expected ComponentResolve, got {other:?}"),
2552 }
2553 }
2554
2555 #[test]
2556 fn from_indirect_resolved_malformed_component_errors() {
2557 let index = crate::djvm::create_indirect(&["garbage.djvu"]).expect("create_indirect");
2558 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2559 Ok::<Vec<u8>, std::io::Error>(b"not an iff document".to_vec())
2560 })
2561 .unwrap_err();
2562 assert!(
2563 matches!(err, MutError::ComponentMalformed { .. }),
2564 "{err:?}"
2565 );
2566 }
2567
2568 #[test]
2570 fn from_indirect_resolved_wrong_form_type_errors() {
2571 let index = crate::djvm::create_indirect(&["fake.djvu"]).expect("create_indirect");
2572 let fake = iff::emit(&DjvuFile {
2573 root: Chunk::Form {
2574 secondary_id: *b"FAKE",
2575 length: 0,
2576 children: vec![],
2577 },
2578 });
2579 let err = DjVuDocumentMut::from_indirect_resolved(&index, move |_name: &str| {
2580 Ok::<Vec<u8>, std::io::Error>(fake.clone())
2581 })
2582 .unwrap_err();
2583 assert!(
2584 matches!(err, MutError::ComponentMalformed { .. }),
2585 "{err:?}"
2586 );
2587 }
2588
2589 #[test]
2590 fn from_indirect_resolved_rejects_bundled_input() {
2591 let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2593 let err = DjVuDocumentMut::from_indirect_resolved(&bundled, |_n: &str| {
2594 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2595 })
2596 .unwrap_err();
2597 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2598 }
2599
2600 #[test]
2601 fn from_indirect_resolved_rejects_single_page_djvu() {
2602 let chicken = read_corpus("chicken.djvu");
2603 let err = DjVuDocumentMut::from_indirect_resolved(&chicken, |_n: &str| {
2604 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2605 })
2606 .unwrap_err();
2607 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2608 }
2609
2610 #[test]
2613 fn from_indirect_resolved_no_page_component_returns_dirm_malformed() {
2614 use crate::dirm::DirmPayload;
2615 let dirm_payload = DirmPayload::build_indirect(1, &[0x00], &["shared.djvi".to_string()]);
2617 let dirm_chunk = iff::Chunk::Leaf {
2618 id: *b"DIRM",
2619 data: dirm_payload.encode(),
2620 };
2621 let index =
2622 iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm_chunk)]).expect("fits");
2623
2624 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2625 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2626 })
2627 .unwrap_err();
2628 assert!(
2629 matches!(err, MutError::DirmMalformed(_)),
2630 "expected DirmMalformed, got {err:?}"
2631 );
2632 }
2633
2634 #[test]
2635 fn from_bytes_on_indirect_still_unsupported_for_page_mut() {
2636 let index = crate::djvm::create_indirect(&["chicken.djvu"]).expect("create_indirect");
2638 let mut doc = DjVuDocumentMut::from_bytes(&index).unwrap();
2639 let err = doc.page_mut(0).err().unwrap();
2640 assert!(matches!(err, MutError::IndirectDjvmUnsupported), "{err:?}");
2641 }
2642
2643 fn fresh_temp_dir(tag: &str) -> PathBuf {
2647 let dir = std::env::temp_dir().join(format!("djvu_rs_rewrite_{tag}"));
2648 let _ = std::fs::remove_dir_all(&dir);
2649 std::fs::create_dir_all(&dir).unwrap();
2650 dir
2651 }
2652
2653 #[test]
2654 fn rewrite_plan_commits_full_set_to_dir() {
2655 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2656 let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2657 assert_eq!(plan.page_count(), 2);
2658
2659 plan.edit_page(0, |doc| {
2661 let meta = DjVuMetadata {
2662 title: Some("rewrite path".into()),
2663 ..Default::default()
2664 };
2665 doc.page_mut(0)?.set_metadata(&meta);
2666 Ok(())
2667 })
2668 .unwrap();
2669
2670 let preview = plan.plan("index.djvu");
2672 assert_eq!(preview.len(), 3);
2673 assert_eq!(preview[0].name, "chicken.djvu");
2674 assert!(preview[0].changed, "edited page must show changed");
2675 assert_eq!(preview[1].name, "irish.djvu");
2676 assert!(!preview[1].changed, "untouched page must be unchanged");
2677 assert!(preview[2].is_root);
2678 assert!(!preview[2].changed, "root unchanged for a page-only edit");
2679
2680 let dir = fresh_temp_dir("commit_full_set");
2681 let written = plan.commit_to_dir(&dir, "index.djvu").unwrap();
2682 assert_eq!(written.len(), 3);
2683 for p in &written {
2684 assert!(p.exists(), "committed file {p:?} must exist");
2685 }
2686 let leftovers: Vec<_> = std::fs::read_dir(&dir)
2688 .unwrap()
2689 .filter_map(|e| e.ok())
2690 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
2691 .collect();
2692 assert!(leftovers.is_empty(), "temp files must be renamed away");
2693
2694 let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2697 let doc = crate::djvu_document::DjVuDocument::parse_from_dir(&index_bytes, &dir).unwrap();
2698 assert_eq!(doc.page_count(), 2);
2699 let meta_page0 = doc.page(0).unwrap();
2700 let edited_comp = std::fs::read(dir.join("chicken.djvu")).unwrap();
2703 let reparsed = DjVuDocumentMut::from_bytes(&edited_comp).unwrap();
2704 let has_metz = reparsed
2705 .file
2706 .root
2707 .children()
2708 .iter()
2709 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"));
2710 assert!(has_metz, "edited component file must contain METz");
2711 let irish_src = read_corpus("irish.djvu");
2713 let irish_out = std::fs::read(dir.join("irish.djvu")).unwrap();
2714 assert_eq!(irish_out, irish_src, "unedited component copied verbatim");
2715 let _ = meta_page0;
2716
2717 let _ = std::fs::remove_dir_all(&dir);
2718 }
2719
2720 #[test]
2721 fn rewrite_plan_rejects_duplicate_dirm_names() {
2722 let index = crate::djvm::create_indirect(&["dup.djvu", "dup.djvu"]).expect("create");
2724 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2725 Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2726 })
2727 .unwrap_err();
2728 match err {
2729 MutError::DuplicateComponentName { name } => assert_eq!(name, "dup.djvu"),
2730 other => panic!("expected DuplicateComponentName, got {other:?}"),
2731 }
2732 }
2733
2734 #[test]
2735 fn rewrite_plan_rejects_unsafe_dirm_names() {
2736 for bad in [
2737 "../evil.djvu",
2738 "/abs.djvu",
2739 "sub/page.djvu",
2740 "..",
2741 "a:b.djvu",
2742 ] {
2743 let index = crate::djvm::create_indirect(&[bad]).expect("create");
2744 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2745 Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2746 })
2747 .unwrap_err();
2748 assert!(
2749 matches!(err, MutError::UnsafeComponentName { .. }),
2750 "name {bad:?} should be rejected, got {err:?}"
2751 );
2752 }
2753 }
2754
2755 #[test]
2756 fn rewrite_plan_unsafe_root_name_leaves_dir_unchanged() {
2757 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2758 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2759
2760 let dir = fresh_temp_dir("unsafe_root");
2761 std::fs::write(dir.join("sentinel"), b"keep me").unwrap();
2763
2764 let err = plan.commit_to_dir(&dir, "../escape.djvu").unwrap_err();
2765 assert!(
2766 matches!(err, MutError::UnsafeComponentName { .. }),
2767 "{err:?}"
2768 );
2769
2770 let entries: Vec<String> = std::fs::read_dir(&dir)
2772 .unwrap()
2773 .filter_map(|e| e.ok())
2774 .map(|e| e.file_name().to_string_lossy().into_owned())
2775 .collect();
2776 assert_eq!(entries, vec!["sentinel".to_string()]);
2777 assert_eq!(std::fs::read(dir.join("sentinel")).unwrap(), b"keep me");
2778
2779 let _ = std::fs::remove_dir_all(&dir);
2780 }
2781
2782 #[test]
2783 fn rewrite_plan_root_name_collision_rejected() {
2784 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2785 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2786 let dir = fresh_temp_dir("root_collision");
2787 let err = plan.commit_to_dir(&dir, "chicken.djvu").unwrap_err();
2789 assert!(
2790 matches!(err, MutError::DuplicateComponentName { .. }),
2791 "{err:?}"
2792 );
2793 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
2795 let _ = std::fs::remove_dir_all(&dir);
2796 }
2797
2798 #[test]
2799 fn rewrite_plan_set_bookmarks_marks_root_changed() {
2800 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2801 let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2802 plan.set_bookmarks(&[DjVuBookmark {
2803 title: "Top".into(),
2804 url: "#1".into(),
2805 children: vec![],
2806 }])
2807 .unwrap();
2808
2809 let preview = plan.plan("index.djvu");
2810 let root = preview.iter().find(|i| i.is_root).unwrap();
2811 assert!(root.changed, "root index must be marked changed");
2812
2813 let dir = fresh_temp_dir("bookmarks");
2815 plan.commit_to_dir(&dir, "index.djvu").unwrap();
2816 let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2817 let form = crate::iff::parse_form(&index_bytes).unwrap();
2818 assert!(
2819 form.chunks.iter().any(|c| &c.id == b"NAVM"),
2820 "committed index must contain NAVM"
2821 );
2822 let _ = std::fs::remove_dir_all(&dir);
2823 }
2824
2825 #[test]
2826 fn rewrite_plan_rejects_bundled_input() {
2827 let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2828 let err = IndirectRewritePlan::from_indirect_resolved(&bundled, |_n: &str| {
2829 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2830 })
2831 .unwrap_err();
2832 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2833 }
2834
2835 #[test]
2836 fn chunk_at_path_rejects_empty_path() {
2837 let original = read_corpus("chicken.djvu");
2838 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2839 let err = doc.chunk_at_path(&[]).unwrap_err();
2840 assert!(matches!(err, MutError::EmptyPath));
2841 }
2842
2843 #[test]
2844 fn root_form_type_returns_some_for_form_root() {
2845 let original = read_corpus("chicken.djvu");
2846 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2847 let t = doc.root_form_type();
2848 assert!(t.is_some());
2849 }
2850
2851 #[test]
2854 fn set_bookmarks_empty_on_djvm_without_navm_is_noop() {
2855 let original = read_corpus("DjVu3Spec_bundled.djvu");
2857 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2858 doc.set_bookmarks(&[]).unwrap(); let stripped = doc.into_bytes();
2860
2861 let mut doc2 = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2863 doc2.set_bookmarks(&[]).unwrap();
2864 assert_eq!(
2865 doc2.into_bytes(),
2866 stripped,
2867 "no-op set_bookmarks should not change bytes"
2868 );
2869 }
2870
2871 #[test]
2874 fn set_metadata_empty_on_page_without_meta_is_noop() {
2875 let original = read_corpus("chicken.djvu"); let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2877 doc.page_mut(0)
2879 .unwrap()
2880 .set_metadata(&DjVuMetadata::default());
2881 let bytes = doc.into_bytes();
2883 let reparsed = DjVuDocumentMut::from_bytes(&bytes).unwrap();
2884 let has_meta = reparsed
2885 .file
2886 .root
2887 .children()
2888 .iter()
2889 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2890 assert!(
2891 !has_meta,
2892 "empty set_metadata should not insert a METa chunk"
2893 );
2894 }
2895
2896 #[test]
2899 fn chunk_at_path_traverses_leaf_first_branch() {
2900 let original = read_corpus("chicken.djvu");
2901 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2902 let err = doc.chunk_at_path(&[0, 0, 0]).unwrap_err();
2903 assert!(
2904 matches!(err, MutError::PathTraversesLeaf { depth: 1, len: 3 }),
2905 "{err:?}"
2906 );
2907 }
2908
2909 #[test]
2911 fn rewrite_plan_resolver_failure_returns_component_resolve_error() {
2912 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2913 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2914 Err::<Vec<u8>, std::io::Error>(std::io::Error::new(
2915 std::io::ErrorKind::NotFound,
2916 "nope",
2917 ))
2918 })
2919 .unwrap_err();
2920 assert!(matches!(err, MutError::ComponentResolve { .. }), "{err:?}");
2921 }
2922
2923 #[test]
2924 fn rewrite_plan_non_iff_component_returns_malformed_error() {
2925 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2926 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2927 Ok::<Vec<u8>, std::io::Error>(b"not iff".to_vec())
2928 })
2929 .unwrap_err();
2930 assert!(
2931 matches!(err, MutError::ComponentMalformed { .. }),
2932 "{err:?}"
2933 );
2934 }
2935
2936 #[test]
2938 fn rewrite_plan_wrong_form_type_returns_malformed_error() {
2939 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2940 let fake = iff::emit(&DjvuFile {
2941 root: Chunk::Form {
2942 secondary_id: *b"FAKE",
2943 length: 0,
2944 children: vec![],
2945 },
2946 });
2947 let err = IndirectRewritePlan::from_indirect_resolved(&index, move |_name: &str| {
2948 Ok::<Vec<u8>, std::io::Error>(fake.clone())
2949 })
2950 .unwrap_err();
2951 assert!(
2952 matches!(err, MutError::ComponentMalformed { .. }),
2953 "{err:?}"
2954 );
2955 }
2956
2957 #[test]
2959 fn rewrite_plan_component_count() {
2960 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2961 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2962 assert_eq!(plan.component_count(), 2);
2963 assert_eq!(plan.page_count(), 2);
2964 }
2965
2966 #[cfg(feature = "std")]
2967 #[test]
2968 fn validate_safe_component_name_rejects_empty() {
2969 let err = validate_safe_component_name("").unwrap_err();
2970 assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2971 }
2972
2973 #[cfg(feature = "std")]
2974 #[test]
2975 fn validate_safe_component_name_rejects_nul() {
2976 let err = validate_safe_component_name("a\0b").unwrap_err();
2977 assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2978 }
2979
2980 fn top_form_ranges(data: &[u8]) -> Vec<core::ops::Range<usize>> {
2983 assert_eq!(&data[..4], b"AT&T");
2984 let form_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2985 let body_end = 12 + form_len;
2986 let mut pos = 16usize; let mut out = Vec::new();
2988 while pos + 8 <= body_end {
2989 let len =
2990 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
2991 as usize;
2992 let mut next = pos + 8 + len;
2993 if next & 1 == 1 && next < body_end {
2994 next += 1;
2995 }
2996 out.push(pos..next);
2997 pos = next;
2998 }
2999 out
3000 }
3001
3002 #[test]
3006 fn is_bundled_djvm_leaf_returns_false() {
3007 let leaf = Chunk::Leaf {
3008 id: *b"INFO",
3009 data: vec![],
3010 };
3011 assert!(!is_bundled_djvm(&leaf));
3012 }
3013
3014 #[test]
3016 fn is_bundled_djvm_non_djvm_form_returns_false() {
3017 let form = Chunk::Form {
3018 secondary_id: *b"DJVU",
3019 length: 0,
3020 children: vec![],
3021 };
3022 assert!(!is_bundled_djvm(&form));
3023 }
3024
3025 #[test]
3029 fn from_indirect_resolved_empty_dirm_returns_dirm_malformed() {
3030 let dirm_payload = DirmPayload::build_indirect(0, &[], &[]);
3031 let dirm = Chunk::Leaf {
3032 id: *b"DIRM",
3033 data: dirm_payload.encode(),
3034 };
3035 let index = iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits");
3036 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_n: &str| {
3037 Ok::<Vec<u8>, std::io::Error>(Vec::new())
3038 })
3039 .unwrap_err();
3040 assert!(
3041 matches!(err, MutError::DirmMalformed(_)),
3042 "expected DirmMalformed, got {err:?}"
3043 );
3044 }
3045
3046 #[test]
3049 fn set_text_layer_missing_info_chunk_returns_missing_page_info() {
3050 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
3051
3052 let bytes = iff::emit(&iff::DjvuFile {
3053 root: Chunk::Form {
3054 secondary_id: *b"DJVU",
3055 length: 0,
3056 children: vec![Chunk::Leaf {
3058 id: *b"ANTz",
3059 data: vec![0u8; 4],
3060 }],
3061 },
3062 });
3063 let mut doc = DjVuDocumentMut::from_bytes(&bytes).expect("no-INFO DJVU must parse");
3064 let layer = TextLayer {
3065 text: "hello".to_string(),
3066 zones: vec![TextZone {
3067 kind: TextZoneKind::Page,
3068 rect: Rect {
3069 x: 0,
3070 y: 0,
3071 width: 10,
3072 height: 10,
3073 },
3074 text: "hello".to_string(),
3075 children: vec![],
3076 }],
3077 };
3078 let err = doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap_err();
3079 assert!(matches!(err, MutError::MissingPageInfo), "{err:?}");
3080 }
3081
3082 #[test]
3086 fn emit_patched_leaf_root_returns_none() {
3087 let leaf = Chunk::Leaf {
3088 id: *b"INFO",
3089 data: vec![0u8; 4],
3090 };
3091 assert!(emit_patched_single_page(&leaf, &[]).is_none());
3092 }
3093
3094 #[test]
3096 fn emit_patched_form_child_in_djvu_returns_none() {
3097 let original = iff::partial_emit(
3100 *b"DJVU",
3101 &[iff::EmitPart::Chunk(&Chunk::Leaf {
3102 id: *b"INFO",
3103 data: vec![0u8; 4],
3104 })],
3105 )
3106 .unwrap();
3107
3108 let root = Chunk::Form {
3110 secondary_id: *b"DJVU",
3111 length: 0,
3112 children: vec![Chunk::Form {
3113 secondary_id: *b"INFO",
3114 length: 0,
3115 children: vec![],
3116 }],
3117 };
3118 assert!(emit_patched_single_page(&root, &original).is_none());
3119 }
3120
3121 #[test]
3123 fn original_child_ranges_too_short_returns_none() {
3124 assert!(original_single_page_child_ranges(b"AT&TFORM").is_none());
3125 }
3126
3127 #[test]
3129 fn original_child_ranges_not_djvu_returns_none() {
3130 let bytes = iff::partial_emit(*b"DJVI", &[]).unwrap();
3131 assert!(original_single_page_child_ranges(&bytes).is_none());
3132 }
3133
3134 #[test]
3136 fn original_child_ranges_nested_form_tag_returns_none() {
3137 let inner: &[u8] = b"FORM\x00\x00\x00\x04\x00\x00\x00\x00";
3142 let bytes = iff::partial_emit(*b"DJVU", &[iff::EmitPart::Verbatim(inner)]).unwrap();
3143 assert!(original_single_page_child_ranges(&bytes).is_none());
3144 }
3145
3146 #[test]
3148 fn original_child_ranges_odd_length_at_body_end_returns_none() {
3149 let form_tag: [u8; 4] = *b"FORM";
3153 let mut bytes: Vec<u8> = Vec::new();
3154 bytes.extend_from_slice(&iff::MAGIC);
3155 bytes.extend_from_slice(&form_tag);
3156 bytes.extend_from_slice(&15u32.to_be_bytes()); bytes.extend_from_slice(b"DJVU");
3158 bytes.extend_from_slice(b"INFO");
3159 bytes.extend_from_slice(&3u32.to_be_bytes());
3160 bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
3161 assert!(original_single_page_child_ranges(&bytes).is_none());
3162 }
3163
3164 #[test]
3166 fn original_child_ranges_short_tail_returns_none() {
3167 let form_tag: [u8; 4] = *b"FORM";
3170 let mut bytes: Vec<u8> = Vec::new();
3171 bytes.extend_from_slice(&iff::MAGIC);
3172 bytes.extend_from_slice(&form_tag);
3173 bytes.extend_from_slice(&17u32.to_be_bytes()); bytes.extend_from_slice(b"DJVU");
3175 bytes.extend_from_slice(b"INFO");
3176 bytes.extend_from_slice(&4u32.to_be_bytes());
3177 bytes.extend_from_slice(&[0u8; 4]);
3178 bytes.push(0x00); assert!(original_single_page_child_ranges(&bytes).is_none());
3180 }
3181
3182 #[test]
3186 fn recompute_dirm_offsets_leaf_root_is_noop() {
3187 let mut leaf = Chunk::Leaf {
3188 id: *b"INFO",
3189 data: vec![0u8; 4],
3190 };
3191 assert!(recompute_dirm_offsets(&mut leaf).is_ok());
3192 }
3193
3194 #[test]
3196 fn recompute_dirm_offsets_djvm_no_dirm_is_noop() {
3197 let mut root = Chunk::Form {
3198 secondary_id: *b"DJVM",
3199 length: 0,
3200 children: vec![Chunk::Form {
3201 secondary_id: *b"DJVU",
3202 length: 0,
3203 children: vec![],
3204 }],
3205 };
3206 assert!(recompute_dirm_offsets(&mut root).is_ok());
3207 }
3208
3209 #[test]
3211 fn recompute_dirm_offsets_count_mismatch_errors() {
3212 let dirm_payload = DirmPayload::build_bundled(
3214 2,
3215 &[0x01, 0x01],
3216 &["p1.djvu".to_string(), "p2.djvu".to_string()],
3217 &[],
3218 );
3219 let dirm_data = dirm_payload.encode();
3220 let mut root = Chunk::Form {
3221 secondary_id: *b"DJVM",
3222 length: 0,
3223 children: vec![
3224 Chunk::Leaf {
3225 id: *b"DIRM",
3226 data: dirm_data,
3227 },
3228 Chunk::Form {
3229 secondary_id: *b"DJVU",
3230 length: 0,
3231 children: vec![],
3232 },
3233 ],
3235 };
3236 let err = recompute_dirm_offsets(&mut root).unwrap_err();
3237 assert!(
3238 matches!(err, MutError::DirmComponentCountMismatch { .. }),
3239 "{err:?}"
3240 );
3241 }
3242}