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)]
67pub enum MutError {
68 #[error("IFF parse error: {0}")]
70 Parse(#[from] LegacyError),
71
72 #[error("chunk path out of range: index {index} at depth {depth} (form has {len} children)")]
74 PathOutOfRange {
75 index: usize,
76 depth: usize,
77 len: usize,
78 },
79
80 #[error("chunk path enters a leaf at depth {depth} but is {len} levels long")]
82 PathTraversesLeaf { depth: usize, len: usize },
83
84 #[error("path ends on a FORM, not a leaf chunk")]
87 NotALeaf,
88
89 #[error("path must not be empty")]
91 EmptyPath,
92
93 #[error("page index {index} out of range (document has {count} pages)")]
95 PageOutOfRange {
96 index: usize,
98 count: usize,
100 },
101
102 #[error("page has no INFO chunk; cannot encode height-dependent chunk")]
105 MissingPageInfo,
106
107 #[error("INFO chunk parse error: {0}")]
109 InfoParse(#[from] IffError),
110
111 #[error("mutation of indirect DJVM documents is not supported")]
118 IndirectDjvmUnsupported,
119
120 #[error("DIRM chunk is malformed: {0}")]
124 DirmMalformed(&'static str),
125
126 #[error("DIRM component count {dirm} does not match bundle child count {children}")]
130 DirmComponentCountMismatch {
131 dirm: usize,
133 children: usize,
135 },
136
137 #[error("set_bookmarks requires a FORM:DJVM bundle (this document is FORM:DJVU)")]
140 BookmarksRequireDjvm,
141
142 #[error("chunk encode error: {0}")]
145 Encode(#[from] crate::chunk_encode::EncodeError),
146
147 #[error("from_indirect_resolved requires an indirect FORM:DJVM document")]
152 NotIndirectDjvm,
153
154 #[error("resolver did not supply DIRM component {name:?}")]
157 ComponentResolve {
158 name: String,
160 },
161
162 #[error("DIRM component {name:?} is malformed: {reason}")]
165 ComponentMalformed {
166 name: String,
168 reason: &'static str,
170 },
171
172 #[error("unsafe component file name {name:?}: {reason}")]
178 UnsafeComponentName {
179 name: String,
181 reason: &'static str,
183 },
184
185 #[error("duplicate DIRM component file name {name:?}")]
188 DuplicateComponentName {
189 name: String,
191 },
192
193 #[error("rewrite I/O error for {name:?}: {message}")]
195 RewriteIo {
196 name: String,
198 message: String,
200 },
201}
202
203#[derive(Debug, Clone)]
211pub struct DjVuDocumentMut {
212 file: DjvuFile,
213 original_bytes: Vec<u8>,
218 dirty: bool,
219}
220
221impl DjVuDocumentMut {
222 pub fn from_bytes(data: &[u8]) -> Result<Self, MutError> {
227 let file = iff::parse(data)?;
228 Ok(Self {
229 file,
230 original_bytes: data.to_vec(),
231 dirty: false,
232 })
233 }
234
235 pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
269 where
270 R: Fn(&str) -> Result<Vec<u8>, E>,
271 {
272 let (dirm_data, components) = resolve_indirect_components(root_bytes)?;
273 if !components.iter().any(|c| c.kind == DirmComponentKind::Page) {
274 return Err(MutError::DirmMalformed(
275 "indirect DIRM lists no page component",
276 ));
277 }
278
279 let mut component_forms: Vec<Chunk> = Vec::with_capacity(components.len());
282 for comp in &components {
283 let bytes = resolver(&comp.id).map_err(|_| MutError::ComponentResolve {
284 name: comp.id.clone(),
285 })?;
286 let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
287 name: comp.id.clone(),
288 reason: "not a parseable IFF document",
289 })?;
290 match &parsed.root {
291 Chunk::Form { secondary_id, .. }
292 if secondary_id == b"DJVU"
293 || secondary_id == b"DJVI"
294 || secondary_id == b"THUM" => {}
295 _ => {
296 return Err(MutError::ComponentMalformed {
297 name: comp.id.clone(),
298 reason: "root is not a FORM:DJVU/DJVI/THUM",
299 });
300 }
301 }
302 component_forms.push(parsed.root);
303 }
304
305 let bundled_dirm = bundled_dirm_from_indirect(dirm_data, component_forms.len())?;
310
311 let mut children: Vec<Chunk> = Vec::with_capacity(1 + component_forms.len());
314 children.push(Chunk::Leaf {
315 id: *b"DIRM",
316 data: bundled_dirm,
317 });
318 children.extend(component_forms);
319 let mut file = DjvuFile {
320 root: Chunk::Form {
321 secondary_id: *b"DJVM",
322 length: 0,
323 children,
324 },
325 };
326
327 recompute_dirm_offsets(&mut file.root)?;
330 let bundled_bytes = iff::emit(&file);
331 Ok(Self {
332 file,
333 original_bytes: bundled_bytes,
334 dirty: false,
335 })
336 }
337
338 pub fn root_child_count(&self) -> usize {
344 self.file.root.children().len()
345 }
346
347 pub fn root_form_type(&self) -> Option<&[u8; 4]> {
351 match &self.file.root {
352 Chunk::Form { secondary_id, .. } => Some(secondary_id),
353 Chunk::Leaf { .. } => None,
354 }
355 }
356
357 pub fn replace_leaf(&mut self, path: &[usize], new_data: Vec<u8>) -> Result<(), MutError> {
370 let chunk = self.chunk_at_path_mut(path)?;
371 match chunk {
372 Chunk::Leaf { data, .. } => {
373 *data = new_data;
374 self.dirty = true;
375 Ok(())
376 }
377 Chunk::Form { .. } => Err(MutError::NotALeaf),
378 }
379 }
380
381 pub fn chunk_at_path(&self, path: &[usize]) -> Result<&Chunk, MutError> {
383 if path.is_empty() {
384 return Err(MutError::EmptyPath);
385 }
386 let mut current = &self.file.root;
387 for (depth, &idx) in path.iter().enumerate() {
388 let children = current.children();
389 if children.is_empty() && depth < path.len() - 1 {
390 return Err(MutError::PathTraversesLeaf {
392 depth,
393 len: path.len(),
394 });
395 }
396 if let Chunk::Leaf { .. } = current {
397 return Err(MutError::PathTraversesLeaf {
398 depth,
399 len: path.len(),
400 });
401 }
402 if idx >= children.len() {
403 return Err(MutError::PathOutOfRange {
404 index: idx,
405 depth,
406 len: children.len(),
407 });
408 }
409 current = &children[idx];
410 }
411 Ok(current)
412 }
413
414 fn chunk_at_path_mut(&mut self, path: &[usize]) -> Result<&mut Chunk, MutError> {
415 if path.is_empty() {
416 return Err(MutError::EmptyPath);
417 }
418 let _ = self.chunk_at_path(path)?;
421 let mut current = &mut self.file.root;
423 for &idx in path {
424 match current {
427 Chunk::Form { children, .. } => {
428 current = &mut children[idx];
429 }
430 Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
431 }
432 }
433 Ok(current)
434 }
435
436 pub fn is_dirty(&self) -> bool {
438 self.dirty
439 }
440
441 pub fn into_bytes(self) -> Vec<u8> {
457 self.try_into_bytes()
458 .expect("DIRM recomputation failed — inconsistent document")
459 }
460
461 pub fn try_into_bytes(mut self) -> Result<Vec<u8>, MutError> {
464 if !self.dirty {
465 return Ok(self.original_bytes);
466 }
467 recompute_dirm_offsets(&mut self.file.root)?;
468 Ok(
469 emit_patched_single_page(&self.file.root, &self.original_bytes)
470 .unwrap_or_else(|| iff::emit(&self.file)),
471 )
472 }
473
474 pub fn page_count(&self) -> usize {
481 match self.root_form_type() {
482 Some(b"DJVM") => self
483 .file
484 .root
485 .children()
486 .iter()
487 .filter(
488 |c| matches!(c, Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU"),
489 )
490 .count(),
491 _ => 1,
492 }
493 }
494
495 pub fn page_mut(&mut self, index: usize) -> Result<PageMut<'_>, MutError> {
511 let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
512 if &root_form_type == b"DJVU" {
513 let count = self.page_count();
514 if index >= count {
515 return Err(MutError::PageOutOfRange { index, count });
516 }
517 debug_assert_eq!(index, 0);
518 return Ok(PageMut {
519 form: &mut self.file.root,
520 dirty: &mut self.dirty,
521 });
522 }
523 debug_assert_eq!(&root_form_type, b"DJVM");
524 if !is_bundled_djvm(&self.file.root) {
525 return Err(MutError::IndirectDjvmUnsupported);
526 }
527 let count = self.page_count();
528 if index >= count {
529 return Err(MutError::PageOutOfRange { index, count });
530 }
531 let children = match &mut self.file.root {
533 Chunk::Form { children, .. } => children,
534 Chunk::Leaf { .. } => unreachable!("validated FORM root"),
535 };
536 let mut seen = 0usize;
537 for child in children.iter_mut() {
538 if let Chunk::Form { secondary_id, .. } = child
539 && secondary_id == b"DJVU"
540 {
541 if seen == index {
542 return Ok(PageMut {
543 form: child,
544 dirty: &mut self.dirty,
545 });
546 }
547 seen += 1;
548 }
549 }
550 unreachable!("page_count agreed with bundle but iteration disagreed")
551 }
552
553 pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
567 let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
568 if &root_form_type != b"DJVM" {
569 return Err(MutError::BookmarksRequireDjvm);
570 }
571 let children = match &mut self.file.root {
572 Chunk::Form { children, .. } => children,
573 Chunk::Leaf { .. } => unreachable!("validated FORM root"),
574 };
575 let pos = children
576 .iter()
577 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"NAVM"));
578 match (pos, bookmarks.is_empty()) {
579 (Some(i), true) => {
580 children.remove(i);
581 }
582 (Some(i), false) => {
583 children[i] = NavmChunk(bookmarks).encode_chunk()?.into_leaf();
584 }
585 (None, true) => { }
586 (None, false) => {
587 let dirm_pos = children
591 .iter()
592 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"DIRM"));
593 let insert_at = dirm_pos.map(|i| i + 1).unwrap_or(0);
594 children.insert(insert_at, NavmChunk(bookmarks).encode_chunk()?.into_leaf());
595 }
596 }
597 self.dirty = true;
598 Ok(())
599 }
600}
601
602#[cfg(feature = "std")]
617fn resolve_indirect_components(root_bytes: &[u8]) -> Result<(&[u8], Vec<DirmComponent>), MutError> {
618 let form = iff::parse_form(root_bytes)?;
619 if &form.form_type != b"DJVM" {
620 return Err(MutError::NotIndirectDjvm);
621 }
622 let dirm_data: &[u8] = form
623 .chunks
624 .iter()
625 .find(|c| &c.id == b"DIRM")
626 .ok_or(MutError::DirmMalformed("indirect DJVM has no DIRM chunk"))?
627 .data;
628
629 let payload = DirmPayload::decode(dirm_data)
630 .map_err(|_| MutError::DirmMalformed("DIRM directory could not be parsed"))?;
631 if payload.is_bundled() {
632 return Err(MutError::NotIndirectDjvm);
634 }
635 let components = payload.components();
636 if components.is_empty() {
637 return Err(MutError::DirmMalformed("indirect DIRM lists no components"));
638 }
639 Ok((dirm_data, components))
640}
641
642fn bundled_dirm_from_indirect(indirect: &[u8], nfiles: usize) -> Result<Vec<u8>, MutError> {
652 let mut payload = DirmPayload::decode(indirect).map_err(MutError::DirmMalformed)?;
653 payload.flags |= 0x80;
658 payload.offsets = core::iter::repeat_n(0u32, nfiles).collect();
659 Ok(payload.encode())
660}
661
662fn is_bundled_djvm(chunk: &Chunk) -> bool {
666 let Chunk::Form {
667 secondary_id,
668 children,
669 ..
670 } = chunk
671 else {
672 return false;
673 };
674 if secondary_id != b"DJVM" {
675 return false;
676 }
677 children.iter().any(|c| {
678 matches!(c, Chunk::Leaf { id, data } if id == b"DIRM" && crate::dirm::DirmPayload::peek_bundled(data))
679 })
680}
681
682#[derive(Debug, Clone, PartialEq, Eq)]
684struct OriginalChildRange {
685 id: [u8; 4],
686 data: Vec<u8>,
687 range: Range<usize>,
688}
689
690fn emit_patched_single_page(root: &Chunk, original: &[u8]) -> Option<Vec<u8>> {
694 let Chunk::Form {
695 secondary_id,
696 children,
697 ..
698 } = root
699 else {
700 return None;
701 };
702 if secondary_id != b"DJVU" {
703 return None;
704 }
705 let original_children = original_single_page_child_ranges(original)?;
706 if original_children.len() != children.len() {
707 return None;
708 }
709
710 let mut parts: Vec<iff::EmitPart> = Vec::with_capacity(children.len());
715 for (child, original_child) in children.iter().zip(original_children.iter()) {
716 match child {
717 Chunk::Leaf { id, data }
718 if id == &original_child.id && data == &original_child.data =>
719 {
720 parts.push(iff::EmitPart::Verbatim(
721 &original[original_child.range.clone()],
722 ));
723 }
724 Chunk::Leaf { .. } => parts.push(iff::EmitPart::Chunk(child)),
725 Chunk::Form { .. } => return None,
726 }
727 }
728
729 iff::partial_emit(*secondary_id, &parts)
730}
731
732fn original_single_page_child_ranges(original: &[u8]) -> Option<Vec<OriginalChildRange>> {
733 if original.len() < 16 || &original[..4] != b"AT&T" || &original[4..8] != b"FORM" {
734 return None;
735 }
736 let form_len = u32::from_be_bytes(original[8..12].try_into().ok()?) as usize;
737 let body_end = 12usize.checked_add(form_len)?;
738 if body_end > original.len() || &original[12..16] != b"DJVU" {
739 return None;
740 }
741
742 let chunks = parse_form_body(original.get(16..body_end)?).ok()?;
747
748 let mut ranges = Vec::with_capacity(chunks.len());
749 let mut pos = 16usize;
750 for chunk in &chunks {
751 if &chunk.id == b"FORM" {
753 return None;
754 }
755 let data_end = pos + 8 + chunk.data.len();
756 let mut next = data_end;
757 if next & 1 == 1 {
758 if next >= body_end {
761 return None;
762 }
763 next += 1;
764 }
765 ranges.push(OriginalChildRange {
766 id: chunk.id,
767 data: chunk.data.to_vec(),
768 range: pos..next,
769 });
770 pos = next;
771 }
772
773 if pos != body_end {
776 return None;
777 }
778 Some(ranges)
779}
780
781fn recompute_dirm_offsets(root: &mut Chunk) -> Result<(), MutError> {
792 let Chunk::Form {
793 secondary_id,
794 children,
795 ..
796 } = root
797 else {
798 return Ok(());
799 };
800 if secondary_id != b"DJVM" {
801 return Ok(());
802 }
803
804 let mut pos: usize = 16;
807 let mut new_offsets: Vec<u32> = Vec::new();
808 let mut dirm_idx: Option<usize> = None;
809
810 #[allow(clippy::redundant_guards)]
814 for (i, child) in children.iter().enumerate() {
815 match child {
816 Chunk::Leaf { id, .. } if id == b"DIRM" => {
817 dirm_idx = Some(i);
818 }
819 Chunk::Form {
820 secondary_id: sid, ..
821 } if sid == b"DJVU" || sid == b"DJVI" || sid == b"THUM" => {
822 new_offsets.push(u32::try_from(pos).map_err(|_| {
823 MutError::DirmMalformed("component offset exceeds u32 (file > 4 GiB)")
824 })?);
825 }
826 _ => {}
827 }
828 pos += iff::emitted_size(child);
829 }
830
831 let Some(dirm_idx) = dirm_idx else {
832 return Ok(());
835 };
836
837 let dirm = &mut children[dirm_idx];
838 let Chunk::Leaf { data, .. } = dirm else {
839 return Err(MutError::DirmMalformed("DIRM is not a leaf chunk"));
840 };
841
842 let mut payload = DirmPayload::decode(data).map_err(MutError::DirmMalformed)?;
846 if !payload.is_bundled() {
847 return Ok(());
849 }
850 if payload.nfiles as usize != new_offsets.len() {
851 return Err(MutError::DirmComponentCountMismatch {
852 dirm: payload.nfiles as usize,
853 children: new_offsets.len(),
854 });
855 }
856 payload.offsets = new_offsets;
857 *data = payload.encode();
858 Ok(())
859}
860
861pub struct PageMut<'doc> {
870 form: &'doc mut Chunk,
871 dirty: &'doc mut bool,
872}
873
874impl PageMut<'_> {
875 pub fn set_text_layer(&mut self, layer: &TextLayer) -> Result<(), MutError> {
879 let info_data = self
880 .find_leaf_data(b"INFO")
881 .ok_or(MutError::MissingPageInfo)?;
882 let info = PageInfo::parse(info_data)?;
883 let plain = encode_text_layer(layer, info.height as u32);
884 let compressed = crate::bzz_encode::bzz_encode(&plain);
885 self.replace_or_insert_text(compressed);
886 *self.dirty = true;
887 Ok(())
888 }
889
890 pub fn set_annotations(&mut self, annotation: &Annotation, areas: &[MapArea]) {
893 let bytes = encode_annotations_bzz(annotation, areas);
894 self.replace_or_insert(b"ANTa", b"ANTz", bytes);
895 *self.dirty = true;
896 }
897
898 pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
902 let bytes = encode_metadata_bzz(meta);
903 self.replace_or_insert(b"METa", b"METz", bytes);
904 *self.dirty = true;
905 }
906
907 fn find_leaf_data(&self, id: &[u8; 4]) -> Option<&[u8]> {
908 for child in self.form.children() {
909 if let Chunk::Leaf { id: cid, data } = child
910 && cid == id
911 {
912 return Some(data);
913 }
914 }
915 None
916 }
917
918 fn replace_or_insert(&mut self, id_a: &[u8; 4], id_z: &[u8; 4], data: Vec<u8>) {
922 let children = match self.form {
923 Chunk::Form { children, .. } => children,
924 Chunk::Leaf { .. } => unreachable!("PageMut wraps a FORM"),
925 };
926 let pos = children
927 .iter()
928 .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z));
929 match (pos, data.is_empty()) {
930 (Some(i), true) => {
931 children.remove(i);
932 }
933 (Some(i), false) => {
934 children[i] = Chunk::Leaf { id: *id_z, data };
935 }
936 (None, true) => { }
937 (None, false) => {
938 children.push(Chunk::Leaf { id: *id_z, data });
939 }
940 }
941 }
942
943 fn replace_or_insert_text(&mut self, data: Vec<u8>) {
945 self.replace_or_insert(b"TXTa", b"TXTz", data);
946 }
947}
948
949#[cfg(feature = "std")]
954#[derive(Debug, Clone, PartialEq, Eq)]
955pub struct RewriteItem {
956 pub name: String,
959 pub is_root: bool,
962 pub changed: bool,
966}
967
968#[cfg(feature = "std")]
970#[derive(Debug, Clone)]
971struct PlannedComponent {
972 name: String,
975 is_page: bool,
977 original: Vec<u8>,
979 edited: Option<Vec<u8>>,
981}
982
983#[cfg(feature = "std")]
1036#[derive(Debug, Clone)]
1037pub struct IndirectRewritePlan {
1038 root_bytes: Vec<u8>,
1040 root_changed: bool,
1042 components: Vec<PlannedComponent>,
1043}
1044
1045#[cfg(feature = "std")]
1046impl IndirectRewritePlan {
1047 pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
1066 where
1067 R: Fn(&str) -> Result<Vec<u8>, E>,
1068 {
1069 let (_dirm_data, infos) = resolve_indirect_components(root_bytes)?;
1072
1073 let mut seen = std::collections::HashSet::new();
1077 for info in &infos {
1078 validate_safe_component_name(&info.id)?;
1079 if !seen.insert(info.id.clone()) {
1080 return Err(MutError::DuplicateComponentName {
1081 name: info.id.clone(),
1082 });
1083 }
1084 }
1085
1086 let mut components = Vec::with_capacity(infos.len());
1087 for info in &infos {
1088 let bytes = resolver(&info.id).map_err(|_| MutError::ComponentResolve {
1089 name: info.id.clone(),
1090 })?;
1091 let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
1094 name: info.id.clone(),
1095 reason: "not a parseable IFF document",
1096 })?;
1097 match &parsed.root {
1098 Chunk::Form { secondary_id, .. }
1099 if secondary_id == b"DJVU"
1100 || secondary_id == b"DJVI"
1101 || secondary_id == b"THUM" => {}
1102 _ => {
1103 return Err(MutError::ComponentMalformed {
1104 name: info.id.clone(),
1105 reason: "root is not a FORM:DJVU/DJVI/THUM",
1106 });
1107 }
1108 }
1109 components.push(PlannedComponent {
1110 name: info.id.clone(),
1111 is_page: info.kind == DirmComponentKind::Page,
1112 original: bytes,
1113 edited: None,
1114 });
1115 }
1116
1117 Ok(Self {
1118 root_bytes: root_bytes.to_vec(),
1119 root_changed: false,
1120 components,
1121 })
1122 }
1123
1124 pub fn page_count(&self) -> usize {
1127 self.components.iter().filter(|c| c.is_page).count()
1128 }
1129
1130 pub fn component_count(&self) -> usize {
1132 self.components.len()
1133 }
1134
1135 pub fn edit_page<F>(&mut self, index: usize, edit: F) -> Result<(), MutError>
1148 where
1149 F: FnOnce(&mut DjVuDocumentMut) -> Result<(), MutError>,
1150 {
1151 let count = self.page_count();
1152 let comp = self
1153 .components
1154 .iter_mut()
1155 .filter(|c| c.is_page)
1156 .nth(index)
1157 .ok_or(MutError::PageOutOfRange { index, count })?;
1158 let current: &[u8] = comp.edited.as_deref().unwrap_or(&comp.original);
1159 let mut doc = DjVuDocumentMut::from_bytes(current)?;
1160 edit(&mut doc)?;
1161 if doc.is_dirty() {
1162 comp.edited = Some(doc.try_into_bytes()?);
1163 }
1164 Ok(())
1165 }
1166
1167 pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
1171 let mut root = DjVuDocumentMut::from_bytes(&self.root_bytes)?;
1172 root.set_bookmarks(bookmarks)?;
1173 if root.is_dirty() {
1174 self.root_bytes = root.try_into_bytes()?;
1175 self.root_changed = true;
1176 }
1177 Ok(())
1178 }
1179
1180 pub fn plan(&self, root_name: &str) -> Vec<RewriteItem> {
1188 let mut items: Vec<RewriteItem> = self
1189 .components
1190 .iter()
1191 .map(|c| RewriteItem {
1192 name: c.name.clone(),
1193 is_root: false,
1194 changed: c.edited.is_some(),
1195 })
1196 .collect();
1197 items.push(RewriteItem {
1198 name: root_name.to_string(),
1199 is_root: true,
1200 changed: self.root_changed,
1201 });
1202 items
1203 }
1204
1205 pub fn commit_to_dir(
1216 &self,
1217 dir: impl AsRef<std::path::Path>,
1218 root_name: &str,
1219 ) -> Result<Vec<std::path::PathBuf>, MutError> {
1220 let dir = dir.as_ref();
1221
1222 validate_safe_component_name(root_name)?;
1224 if self.components.iter().any(|c| c.name == root_name) {
1227 return Err(MutError::DuplicateComponentName {
1228 name: root_name.to_string(),
1229 });
1230 }
1231
1232 std::fs::create_dir_all(dir).map_err(|e| MutError::RewriteIo {
1233 name: dir.display().to_string(),
1234 message: e.to_string(),
1235 })?;
1236
1237 let mut written = Vec::with_capacity(self.components.len() + 1);
1239 for comp in &self.components {
1240 let bytes = comp.edited.as_deref().unwrap_or(&comp.original);
1241 written.push(stage_and_rename(dir, &comp.name, bytes)?);
1242 }
1243 written.push(stage_and_rename(dir, root_name, &self.root_bytes)?);
1244 Ok(written)
1245 }
1246}
1247
1248#[cfg(feature = "std")]
1254fn validate_safe_component_name(name: &str) -> Result<(), MutError> {
1255 let reject = |reason: &'static str| {
1256 Err(MutError::UnsafeComponentName {
1257 name: name.to_string(),
1258 reason,
1259 })
1260 };
1261 if name.is_empty() {
1262 return reject("name is empty");
1263 }
1264 if name.contains('\0') {
1265 return reject("name contains a NUL byte");
1266 }
1267 if name.contains('/') || name.contains('\\') {
1268 return reject("name contains a path separator");
1269 }
1270 if name.contains(':') {
1271 return reject("name contains a drive-letter / stream colon");
1272 }
1273 if name == "." || name == ".." {
1274 return reject("name is a relative directory reference");
1275 }
1276 Ok(())
1277}
1278
1279#[cfg(feature = "std")]
1282fn stage_and_rename(
1283 dir: &std::path::Path,
1284 name: &str,
1285 bytes: &[u8],
1286) -> Result<std::path::PathBuf, MutError> {
1287 use std::io::Write;
1288
1289 let final_path = dir.join(name);
1290 let tmp_path = dir.join(format!(".{name}.djvu-rs.tmp"));
1293
1294 let io_err = |path: &std::path::Path, e: std::io::Error| MutError::RewriteIo {
1295 name: path.display().to_string(),
1296 message: e.to_string(),
1297 };
1298
1299 {
1300 let mut f = std::fs::File::create(&tmp_path).map_err(|e| io_err(&tmp_path, e))?;
1301 f.write_all(bytes).map_err(|e| io_err(&tmp_path, e))?;
1302 f.sync_all().map_err(|e| io_err(&tmp_path, e))?;
1303 }
1304 std::fs::rename(&tmp_path, &final_path).map_err(|e| {
1305 let _ = std::fs::remove_file(&tmp_path);
1307 io_err(&final_path, e)
1308 })?;
1309 Ok(final_path)
1310}
1311
1312#[cfg(test)]
1313#[allow(clippy::field_reassign_with_default)]
1314mod tests {
1315 use super::*;
1316 use std::path::PathBuf;
1317
1318 fn corpus_path(name: &str) -> PathBuf {
1319 let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1320 p.push("tests/fixtures");
1321 p.push(name);
1322 p
1323 }
1324
1325 fn read_corpus(name: &str) -> Vec<u8> {
1326 std::fs::read(corpus_path(name)).expect("corpus fixture missing")
1327 }
1328
1329 #[test]
1331 fn roundtrip_byte_identical_chicken() {
1332 let original = read_corpus("chicken.djvu");
1333 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1334 assert!(!doc.is_dirty());
1335 assert_eq!(doc.into_bytes(), original);
1336 }
1337
1338 #[test]
1340 fn roundtrip_byte_identical_boy_jb2() {
1341 let original = read_corpus("boy_jb2.djvu");
1342 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1343 assert_eq!(doc.into_bytes(), original);
1344 }
1345
1346 #[test]
1348 fn roundtrip_byte_identical_djvm_bundle() {
1349 let original = read_corpus("DjVu3Spec_bundled.djvu");
1350 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1351 assert_eq!(doc.root_form_type(), Some(b"DJVM"));
1352 assert_eq!(doc.into_bytes(), original);
1353 }
1354
1355 #[test]
1357 fn roundtrip_byte_identical_navm() {
1358 let original = read_corpus("navm_fgbz.djvu");
1359 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1360 assert_eq!(doc.into_bytes(), original);
1361 }
1362
1363 #[test]
1365 fn replace_leaf_changes_emitted_bytes() {
1366 let original = read_corpus("chicken.djvu");
1367 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1368
1369 let first = doc.chunk_at_path(&[0]).unwrap();
1371 let original_first_data = first.data().to_vec();
1372 assert!(!original_first_data.is_empty());
1373
1374 let marker = b"PR1_TEST_MARKER".to_vec();
1376 doc.replace_leaf(&[0], marker.clone()).unwrap();
1377 assert!(doc.is_dirty());
1378
1379 let edited = doc.into_bytes();
1380
1381 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1383 let new_first = reparsed.chunk_at_path(&[0]).unwrap();
1384 assert_eq!(new_first.data(), marker.as_slice());
1385 }
1386
1387 #[test]
1388 fn single_page_patch_preserves_unedited_child_bytes() {
1389 let original = read_corpus("chicken.djvu");
1390 let original_ranges =
1391 original_single_page_child_ranges(&original).expect("single-page child ranges");
1392 assert!(
1393 original_ranges.len() > 2,
1394 "fixture must have unrelated chunks to preserve"
1395 );
1396
1397 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1398 doc.replace_leaf(&[0], b"PATCHED_INFO".to_vec()).unwrap();
1399 let edited = doc.try_into_bytes().unwrap();
1400 let edited_ranges =
1401 original_single_page_child_ranges(&edited).expect("edited child ranges");
1402 assert_eq!(edited_ranges.len(), original_ranges.len());
1403
1404 for (idx, (before, after)) in original_ranges.iter().zip(edited_ranges.iter()).enumerate() {
1405 if idx == 0 {
1406 assert_ne!(
1407 &original[before.range.clone()],
1408 &edited[after.range.clone()]
1409 );
1410 continue;
1411 }
1412 assert_eq!(before.id, after.id);
1413 assert_eq!(
1414 &original[before.range.clone()],
1415 &edited[after.range.clone()],
1416 "unchanged child #{idx} must be copied byte-for-byte"
1417 );
1418 }
1419 }
1420
1421 #[test]
1422 fn single_page_patch_falls_back_for_bundled_djvm() {
1423 let original = read_corpus("DjVu3Spec_bundled.djvu");
1424 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1425 assert!(
1426 emit_patched_single_page(&doc.file.root, &original).is_none(),
1427 "single-page patch path must decline bundled DJVM layouts"
1428 );
1429 }
1430
1431 #[test]
1432 fn replace_leaf_rejects_empty_path() {
1433 let original = read_corpus("chicken.djvu");
1434 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1435 let err = doc.replace_leaf(&[], vec![]).unwrap_err();
1436 assert!(matches!(err, MutError::EmptyPath));
1437 }
1438
1439 #[test]
1440 fn replace_leaf_rejects_out_of_range() {
1441 let original = read_corpus("chicken.djvu");
1442 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1443 let err = doc.replace_leaf(&[9999], vec![]).unwrap_err();
1444 assert!(matches!(err, MutError::PathOutOfRange { .. }));
1445 }
1446
1447 #[test]
1448 fn replace_leaf_rejects_traversing_leaf() {
1449 let original = read_corpus("chicken.djvu");
1450 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1451 let err = doc.replace_leaf(&[0, 0], vec![]).unwrap_err();
1453 assert!(matches!(err, MutError::PathTraversesLeaf { .. }));
1454 }
1455
1456 #[test]
1457 fn replace_leaf_rejects_form_target() {
1458 let original = read_corpus("DjVu3Spec_bundled.djvu");
1462 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1463 let last_idx = doc.root_child_count() - 1;
1464 let err = doc.replace_leaf(&[last_idx], vec![]).unwrap_err();
1465 assert!(matches!(err, MutError::NotALeaf));
1466 }
1467
1468 #[test]
1469 fn root_form_type_djvu_single_page() {
1470 let original = read_corpus("chicken.djvu");
1471 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1472 assert_eq!(doc.root_form_type(), Some(b"DJVU"));
1473 }
1474
1475 #[test]
1478 fn page_count_single_page_djvu_is_one() {
1479 let original = read_corpus("chicken.djvu");
1480 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1481 assert_eq!(doc.page_count(), 1);
1482 }
1483
1484 #[test]
1485 fn page_count_djvm_bundle_counts_djvu_components_only() {
1486 let original = read_corpus("DjVu3Spec_bundled.djvu");
1487 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1488 let direct: usize = doc
1491 .file
1492 .root
1493 .children()
1494 .iter()
1495 .filter(|c| {
1496 matches!(c, crate::iff::Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU")
1497 })
1498 .count();
1499 assert!(direct >= 2, "expected multi-page bundle, got {direct}");
1500 assert_eq!(doc.page_count(), direct);
1501 }
1502
1503 #[test]
1504 fn page_mut_out_of_range_errors() {
1505 let original = read_corpus("chicken.djvu");
1506 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1507 let err = doc.page_mut(1).err().unwrap();
1508 assert!(matches!(
1509 err,
1510 MutError::PageOutOfRange { index: 1, count: 1 }
1511 ));
1512 }
1513
1514 #[test]
1515 fn page_mut_djvm_bundle_succeeds_after_pr3() {
1516 let original = read_corpus("DjVu3Spec_bundled.djvu");
1519 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1520 assert!(doc.page_mut(0).is_ok());
1521 let count = doc.page_count();
1522 let err = doc.page_mut(count).err().unwrap();
1523 assert!(matches!(err, MutError::PageOutOfRange { .. }));
1524 }
1525
1526 #[test]
1527 fn page_mut_indirect_djvm_returns_unsupported_before_range_check() {
1528 let mut doc = DjVuDocumentMut::from_bytes(&indirect_djvm_bytes()).unwrap();
1529 let err = doc.page_mut(0).err().unwrap();
1530 assert!(matches!(err, MutError::IndirectDjvmUnsupported));
1531 }
1532
1533 fn indirect_djvm_bytes() -> Vec<u8> {
1534 let bzz_meta: &[u8] = &[
1535 0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
1536 0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
1537 ];
1538
1539 let mut dirm_data = Vec::new();
1540 dirm_data.push(0x00);
1541 dirm_data.push(0x00);
1542 dirm_data.push(0x01);
1543 dirm_data.extend_from_slice(bzz_meta);
1544
1545 let dirm = Chunk::Leaf {
1548 id: *b"DIRM",
1549 data: dirm_data,
1550 };
1551 iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits within u32")
1552 }
1553
1554 #[test]
1555 fn set_text_layer_roundtrip_chicken() {
1556 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1557
1558 let original = read_corpus("chicken.djvu");
1559 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1560
1561 let layer = TextLayer {
1562 text: "hello world".to_string(),
1563 zones: vec![TextZone {
1564 kind: TextZoneKind::Page,
1565 rect: Rect {
1566 x: 0,
1567 y: 0,
1568 width: 100,
1569 height: 50,
1570 },
1571 text: "hello world".to_string(),
1572 children: vec![],
1573 }],
1574 };
1575 doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
1576 assert!(doc.is_dirty());
1577 let edited = doc.into_bytes();
1578
1579 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1581 let has_txtz = reparsed
1582 .file
1583 .root
1584 .children()
1585 .iter()
1586 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1587 assert!(
1588 has_txtz,
1589 "TXTz chunk should be present after set_text_layer"
1590 );
1591 }
1592
1593 #[test]
1594 fn set_annotations_roundtrip_chicken() {
1595 use crate::annotation::{Annotation, Color};
1596
1597 let original = read_corpus("chicken.djvu");
1598 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1599
1600 let mut ann = Annotation::default();
1601 ann.background = Some(Color {
1602 r: 0xFF,
1603 g: 0xFF,
1604 b: 0xFF,
1605 });
1606 ann.mode = Some("color".to_string());
1607 doc.page_mut(0).unwrap().set_annotations(&ann, &[]);
1608 let edited = doc.into_bytes();
1609
1610 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1611 let antz = reparsed
1612 .file
1613 .root
1614 .children()
1615 .iter()
1616 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"ANTz"));
1617 assert!(antz.is_some(), "ANTz should be inserted");
1618 let data = antz.unwrap().data();
1619 let decoded = crate::bzz::bzz_decode(data).expect("ANTz must decompress");
1620 let (parsed_ann, _areas) =
1621 crate::annotation::parse_annotations(&decoded).expect("ANTz must round-trip");
1622 assert_eq!(parsed_ann.mode.as_deref(), Some("color"));
1623 assert_eq!(
1624 parsed_ann.background,
1625 Some(Color {
1626 r: 0xFF,
1627 g: 0xFF,
1628 b: 0xFF
1629 })
1630 );
1631 }
1632
1633 #[test]
1634 fn set_metadata_roundtrip_chicken() {
1635 let original = read_corpus("chicken.djvu");
1636 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1637
1638 let mut meta = DjVuMetadata::default();
1639 meta.title = Some("Test Title".into());
1640 meta.author = Some("Tester".into());
1641 doc.page_mut(0).unwrap().set_metadata(&meta);
1642 let edited = doc.into_bytes();
1643
1644 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1645 let metz = reparsed
1646 .file
1647 .root
1648 .children()
1649 .iter()
1650 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
1651 .expect("METz should be inserted");
1652 let decoded = crate::bzz::bzz_decode(metz.data()).unwrap();
1653 let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
1654 assert_eq!(parsed, meta);
1655 }
1656
1657 #[test]
1658 fn set_metadata_empty_removes_existing_chunk() {
1659 let original = read_corpus("chicken.djvu");
1660 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1661
1662 let mut meta = DjVuMetadata::default();
1664 meta.title = Some("X".into());
1665 doc.page_mut(0).unwrap().set_metadata(&meta);
1666 doc.page_mut(0)
1667 .unwrap()
1668 .set_metadata(&DjVuMetadata::default());
1669
1670 let edited = doc.into_bytes();
1671 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1672 let any_meta = reparsed
1673 .file
1674 .root
1675 .children()
1676 .iter()
1677 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
1678 assert!(!any_meta, "set_metadata(empty) should remove any METa/METz");
1679 }
1680
1681 #[test]
1682 fn set_metadata_replaces_existing_chunk_in_place() {
1683 let original = read_corpus("chicken.djvu");
1684 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1685
1686 let mut m1 = DjVuMetadata::default();
1687 m1.title = Some("First".into());
1688 doc.page_mut(0).unwrap().set_metadata(&m1);
1689
1690 let mut m2 = DjVuMetadata::default();
1691 m2.title = Some("Second".into());
1692 doc.page_mut(0).unwrap().set_metadata(&m2);
1693
1694 let edited = doc.into_bytes();
1695 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1696 let metz_count = reparsed
1697 .file
1698 .root
1699 .children()
1700 .iter()
1701 .filter(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"))
1702 .count();
1703 assert_eq!(metz_count, 1, "should not duplicate METz on repeat set");
1704 }
1705
1706 fn dirm_offsets_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
1711 let form = crate::iff::parse_form(data).expect("parse_form");
1713 assert_eq!(&form.form_type, b"DJVM");
1714
1715 let dirm = form
1716 .chunks
1717 .iter()
1718 .find(|c| &c.id == b"DIRM")
1719 .expect("DIRM present");
1720 let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
1722 let declared = payload.offsets;
1723 let nfiles = declared.len();
1724
1725 let mut actual = Vec::with_capacity(nfiles);
1728 let mut pos = 16usize;
1729 let body_end = 8 + u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
1730 while pos < body_end {
1731 let id = &data[pos..pos + 4];
1732 let len =
1733 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
1734 as usize;
1735 if id == b"FORM" {
1736 actual.push(pos as u32);
1737 }
1738 let mut next = pos + 8 + len;
1739 if next & 1 == 1 {
1740 next += 1;
1741 }
1742 pos = next;
1743 }
1744 (declared, actual)
1745 }
1746
1747 #[test]
1748 fn dirm_offsets_match_actual_after_no_edit() {
1749 let original = read_corpus("DjVu3Spec_bundled.djvu");
1752 let (declared, actual) = dirm_offsets_and_actual(&original);
1753 assert_eq!(declared, actual);
1754 }
1755
1756 #[test]
1757 fn dirm_offsets_recomputed_after_page_metadata_edit() {
1758 let original = read_corpus("DjVu3Spec_bundled.djvu");
1759 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1760
1761 let mut meta = DjVuMetadata::default();
1763 meta.title = Some("PR3 DJVM bundled mutation".into());
1764 meta.author = Some("djvu-rs PR3 tests".into());
1765 doc.page_mut(0).unwrap().set_metadata(&meta);
1766 assert!(doc.is_dirty());
1767
1768 let edited = doc.into_bytes();
1769 assert_ne!(edited.len(), original.len());
1771
1772 let (declared, actual) = dirm_offsets_and_actual(&edited);
1775 assert_eq!(
1776 declared, actual,
1777 "DIRM offsets must point at the new FORM positions after edit"
1778 );
1779
1780 let reparsed =
1783 crate::djvu_document::DjVuDocument::parse(&edited).expect("edited bundle must parse");
1784 let original_doc =
1785 crate::djvu_document::DjVuDocument::parse(&original).expect("original bundle parses");
1786 assert_eq!(reparsed.page_count(), original_doc.page_count());
1787 }
1788
1789 #[test]
1790 fn dirm_offsets_recomputed_after_middle_page_edit() {
1791 let original = read_corpus("DjVu3Spec_bundled.djvu");
1793 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1794 let count = doc.page_count();
1795 assert!(count >= 3);
1796
1797 let mid = count / 2;
1798 let mut meta = DjVuMetadata::default();
1799 meta.title = Some("PR3 mid-page edit".into());
1800 doc.page_mut(mid).unwrap().set_metadata(&meta);
1801
1802 let edited = doc.into_bytes();
1803 let (declared, actual) = dirm_offsets_and_actual(&edited);
1804 assert_eq!(declared, actual);
1805
1806 let (orig_declared, _) = dirm_offsets_and_actual(&original);
1808 for i in 0..mid {
1809 assert_eq!(
1810 declared[i], orig_declared[i],
1811 "offset for page {i} (before edit) must be unchanged"
1812 );
1813 }
1814 }
1815
1816 #[test]
1817 fn set_bookmarks_replaces_navm_in_bundle() {
1818 use crate::djvu_document::DjVuBookmark;
1819
1820 let original = read_corpus("DjVu3Spec_bundled.djvu");
1821 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1822
1823 let bookmarks = vec![
1824 DjVuBookmark {
1825 title: "Front matter".into(),
1826 url: "#1".into(),
1827 children: vec![DjVuBookmark {
1828 title: "Acknowledgments".into(),
1829 url: "#3".into(),
1830 children: vec![],
1831 }],
1832 },
1833 DjVuBookmark {
1834 title: "Body".into(),
1835 url: "#10".into(),
1836 children: vec![],
1837 },
1838 ];
1839 doc.set_bookmarks(&bookmarks).unwrap();
1840 assert!(doc.is_dirty());
1841 let edited = doc.into_bytes();
1842
1843 let (declared, actual) = dirm_offsets_and_actual(&edited);
1845 assert_eq!(declared, actual);
1846
1847 let reparsed = crate::djvu_document::DjVuDocument::parse(&edited)
1849 .expect("bundle with new bookmarks parses");
1850 let parsed_bms = reparsed.bookmarks();
1851 assert_eq!(parsed_bms.len(), 2);
1852 assert_eq!(parsed_bms[0].title, "Front matter");
1853 assert_eq!(parsed_bms[0].children.len(), 1);
1854 assert_eq!(parsed_bms[0].children[0].title, "Acknowledgments");
1855 assert_eq!(parsed_bms[1].title, "Body");
1856 }
1857
1858 #[test]
1859 fn set_bookmarks_empty_removes_navm() {
1860 let original = read_corpus("DjVu3Spec_bundled.djvu");
1861 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1862 doc.set_bookmarks(&[]).unwrap();
1865 let edited = doc.into_bytes();
1866
1867 let form = crate::iff::parse_form(&edited).unwrap();
1868 let has_navm = form.chunks.iter().any(|c| &c.id == b"NAVM");
1869 assert!(!has_navm, "set_bookmarks(&[]) must remove NAVM");
1870
1871 let (declared, actual) = dirm_offsets_and_actual(&edited);
1873 assert_eq!(declared, actual);
1874 }
1875
1876 #[test]
1877 fn set_bookmarks_inserts_navm_when_absent() {
1878 use crate::djvu_document::DjVuBookmark;
1879
1880 let original = read_corpus("DjVu3Spec_bundled.djvu");
1883 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1884 doc.set_bookmarks(&[]).unwrap();
1885 let stripped = doc.into_bytes();
1886
1887 let mut doc = DjVuDocumentMut::from_bytes(&stripped).unwrap();
1888 let bms = vec![DjVuBookmark {
1889 title: "Re-added".into(),
1890 url: "#1".into(),
1891 children: vec![],
1892 }];
1893 doc.set_bookmarks(&bms).unwrap();
1894 let edited = doc.into_bytes();
1895
1896 let form = crate::iff::parse_form(&edited).unwrap();
1897 let navm_pos = form
1898 .chunks
1899 .iter()
1900 .position(|c| &c.id == b"NAVM")
1901 .expect("NAVM should be inserted");
1902 let dirm_pos = form.chunks.iter().position(|c| &c.id == b"DIRM").unwrap();
1903 assert_eq!(
1904 navm_pos,
1905 dirm_pos + 1,
1906 "NAVM should be placed immediately after DIRM"
1907 );
1908
1909 let (declared, actual) = dirm_offsets_and_actual(&edited);
1910 assert_eq!(declared, actual);
1911 }
1912
1913 #[test]
1914 fn set_bookmarks_on_single_page_djvu_errors() {
1915 let original = read_corpus("chicken.djvu");
1916 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1917 let err = doc.set_bookmarks(&[]).err().unwrap();
1918 assert!(matches!(err, MutError::BookmarksRequireDjvm));
1919 }
1920
1921 #[test]
1922 fn page_mut_djvm_text_layer_roundtrip() {
1923 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1924
1925 let original = read_corpus("DjVu3Spec_bundled.djvu");
1926 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1927 let layer = TextLayer {
1928 text: "djvm page-3 text".into(),
1929 zones: vec![TextZone {
1930 kind: TextZoneKind::Page,
1931 rect: Rect {
1932 x: 0,
1933 y: 0,
1934 width: 100,
1935 height: 50,
1936 },
1937 text: "djvm page-3 text".into(),
1938 children: vec![],
1939 }],
1940 };
1941 doc.page_mut(2).unwrap().set_text_layer(&layer).unwrap();
1942 let edited = doc.into_bytes();
1943
1944 let (declared, actual) = dirm_offsets_and_actual(&edited);
1945 assert_eq!(declared, actual);
1946
1947 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1949 let mut djvu_seen = 0usize;
1951 let mut found_txtz = false;
1952 for child in reparsed.file.root.children() {
1953 if let Chunk::Form {
1954 secondary_id,
1955 children,
1956 ..
1957 } = child
1958 && secondary_id == b"DJVU"
1959 {
1960 if djvu_seen == 2 {
1961 found_txtz = children
1962 .iter()
1963 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1964 break;
1965 }
1966 djvu_seen += 1;
1967 }
1968 }
1969 assert!(
1970 found_txtz,
1971 "TXTz chunk should be present on page 2 after set_text_layer"
1972 );
1973 }
1974
1975 #[test]
1980 fn unmutated_pages_byte_identical_after_metadata_edit() {
1981 use crate::metadata::DjVuMetadata;
1982
1983 let original = read_corpus("DjVu3Spec_bundled.djvu");
1984
1985 let orig_ranges = top_form_ranges(&original);
1986
1987 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1988 let meta = DjVuMetadata {
1989 title: Some("PR4 byte-identical probe".into()),
1990 ..Default::default()
1991 };
1992 doc.page_mut(0).unwrap().set_metadata(&meta);
1993 let edited = doc.into_bytes();
1994
1995 let edited_ranges = top_form_ranges(&edited);
1996 assert_eq!(orig_ranges.len(), edited_ranges.len());
1997
1998 let mut djvu_idx = 0usize;
2001 for (i, (or, er)) in orig_ranges.iter().zip(edited_ranges.iter()).enumerate() {
2002 let is_form_djvu = &original[or.start..or.start + 4] == b"FORM"
2005 && (&original[or.start + 8..or.start + 12] == b"DJVU"
2006 || &original[or.start + 8..or.start + 12] == b"DJVI");
2007 if !is_form_djvu {
2008 continue;
2009 }
2010 let is_edited_page = djvu_idx == 0;
2011 djvu_idx += 1;
2012 if is_edited_page {
2013 continue;
2014 }
2015 assert_eq!(
2016 &original[or.clone()],
2017 &edited[er.clone()],
2018 "FORM at top-level child #{i} must be byte-identical after edit"
2019 );
2020 }
2021 }
2022
2023 fn indirect_over_fixtures(
2028 page_names: &[&str],
2029 ) -> (
2030 Vec<u8>,
2031 impl Fn(&str) -> Result<Vec<u8>, std::io::Error> + use<>,
2032 ) {
2033 let index = crate::djvm::create_indirect(page_names).expect("create_indirect");
2034 let map: std::collections::HashMap<String, Vec<u8>> = page_names
2036 .iter()
2037 .map(|n| (n.to_string(), read_corpus(n)))
2038 .collect();
2039 let resolver = move |name: &str| -> Result<Vec<u8>, std::io::Error> {
2040 map.get(name).cloned().ok_or_else(|| {
2041 std::io::Error::new(std::io::ErrorKind::NotFound, "no such component")
2042 })
2043 };
2044 (index, resolver)
2045 }
2046
2047 #[test]
2048 fn from_indirect_resolved_rebundles_single_page() {
2049 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2050 let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2051 assert_eq!(doc.root_form_type(), Some(b"DJVM"));
2052 assert_eq!(doc.page_count(), 1);
2053 assert!(!doc.is_dirty());
2054
2055 let bundled = doc.try_into_bytes().unwrap();
2057 let reparsed =
2058 crate::djvu_document::DjVuDocument::parse(&bundled).expect("bundled output parses");
2059 assert_eq!(reparsed.page_count(), 1);
2060 assert_eq!(reparsed.page(0).unwrap().width(), 181);
2062 assert_eq!(reparsed.page(0).unwrap().height(), 240);
2063
2064 let (declared, actual) = dirm_offsets_and_actual(&bundled);
2066 assert_eq!(declared, actual);
2067 }
2068
2069 #[test]
2070 fn from_indirect_resolved_multi_page_preserves_order() {
2071 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2072 let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2073 assert_eq!(doc.page_count(), 2);
2074 let bundled = doc.try_into_bytes().unwrap();
2075
2076 let reparsed = crate::djvu_document::DjVuDocument::parse(&bundled).expect("parses");
2077 assert_eq!(reparsed.page_count(), 2);
2078 assert_eq!(reparsed.page(0).unwrap().width(), 181);
2080 let irish_doc = crate::djvu_document::DjVuDocument::parse(&read_corpus("irish.djvu"))
2081 .expect("irish parses standalone");
2082 assert_eq!(
2083 reparsed.page(1).unwrap().dimensions(),
2084 irish_doc.page(0).unwrap().dimensions()
2085 );
2086
2087 let (declared, actual) = dirm_offsets_and_actual(&bundled);
2088 assert_eq!(declared, actual);
2089 }
2090
2091 #[test]
2092 fn from_indirect_resolved_then_metadata_edit_roundtrips() {
2093 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2094 let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2095
2096 let meta = DjVuMetadata {
2097 title: Some("rebundled indirect".into()),
2098 author: Some("djvu-rs #325".into()),
2099 ..Default::default()
2100 };
2101 doc.page_mut(1).unwrap().set_metadata(&meta);
2102 assert!(doc.is_dirty());
2103 let edited = doc.into_bytes();
2104
2105 let (declared, actual) = dirm_offsets_and_actual(&edited);
2107 assert_eq!(declared, actual);
2108
2109 let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2111 let mut djvu_seen = 0usize;
2112 let mut found = None;
2113 for child in reparsed.file.root.children() {
2114 if let Chunk::Form {
2115 secondary_id,
2116 children,
2117 ..
2118 } = child
2119 && secondary_id == b"DJVU"
2120 {
2121 if djvu_seen == 1 {
2122 found = children
2123 .iter()
2124 .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2125 .map(|c| c.data().to_vec());
2126 break;
2127 }
2128 djvu_seen += 1;
2129 }
2130 }
2131 let metz = found.expect("page 1 should have METz after edit");
2132 let decoded = crate::bzz::bzz_decode(&metz).unwrap();
2133 let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2134 assert_eq!(parsed.title.as_deref(), Some("rebundled indirect"));
2135 }
2136
2137 #[test]
2138 fn from_indirect_resolved_then_text_layer_edit() {
2139 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2140
2141 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2142 let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2143 let layer = TextLayer {
2144 text: "rebundled text".into(),
2145 zones: vec![TextZone {
2146 kind: TextZoneKind::Page,
2147 rect: Rect {
2148 x: 0,
2149 y: 0,
2150 width: 100,
2151 height: 50,
2152 },
2153 text: "rebundled text".into(),
2154 children: vec![],
2155 }],
2156 };
2157 doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
2158 let edited = doc.into_bytes();
2159
2160 let reparsed = crate::djvu_document::DjVuDocument::parse(&edited).expect("parses");
2161 let text = reparsed.page(0).unwrap().text_layer().unwrap();
2162 assert!(text.is_some(), "edited page should expose a text layer");
2163 assert_eq!(text.unwrap().text, "rebundled text");
2164 }
2165
2166 #[test]
2167 fn from_indirect_resolved_missing_component_errors() {
2168 let index = crate::djvm::create_indirect(&["missing.djvu"]).expect("create_indirect");
2170 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2171 Err::<Vec<u8>, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"))
2172 })
2173 .unwrap_err();
2174 match err {
2175 MutError::ComponentResolve { name } => assert_eq!(name, "missing.djvu"),
2176 other => panic!("expected ComponentResolve, got {other:?}"),
2177 }
2178 }
2179
2180 #[test]
2181 fn from_indirect_resolved_malformed_component_errors() {
2182 let index = crate::djvm::create_indirect(&["garbage.djvu"]).expect("create_indirect");
2183 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2184 Ok::<Vec<u8>, std::io::Error>(b"not an iff document".to_vec())
2185 })
2186 .unwrap_err();
2187 assert!(
2188 matches!(err, MutError::ComponentMalformed { .. }),
2189 "{err:?}"
2190 );
2191 }
2192
2193 #[test]
2195 fn from_indirect_resolved_wrong_form_type_errors() {
2196 let index = crate::djvm::create_indirect(&["fake.djvu"]).expect("create_indirect");
2197 let fake = iff::emit(&DjvuFile {
2198 root: Chunk::Form {
2199 secondary_id: *b"FAKE",
2200 length: 0,
2201 children: vec![],
2202 },
2203 });
2204 let err = DjVuDocumentMut::from_indirect_resolved(&index, move |_name: &str| {
2205 Ok::<Vec<u8>, std::io::Error>(fake.clone())
2206 })
2207 .unwrap_err();
2208 assert!(
2209 matches!(err, MutError::ComponentMalformed { .. }),
2210 "{err:?}"
2211 );
2212 }
2213
2214 #[test]
2215 fn from_indirect_resolved_rejects_bundled_input() {
2216 let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2218 let err = DjVuDocumentMut::from_indirect_resolved(&bundled, |_n: &str| {
2219 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2220 })
2221 .unwrap_err();
2222 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2223 }
2224
2225 #[test]
2226 fn from_indirect_resolved_rejects_single_page_djvu() {
2227 let chicken = read_corpus("chicken.djvu");
2228 let err = DjVuDocumentMut::from_indirect_resolved(&chicken, |_n: &str| {
2229 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2230 })
2231 .unwrap_err();
2232 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2233 }
2234
2235 #[test]
2238 fn from_indirect_resolved_no_page_component_returns_dirm_malformed() {
2239 use crate::dirm::DirmPayload;
2240 let dirm_payload = DirmPayload::build_indirect(1, &[0x00], &["shared.djvi".to_string()]);
2242 let dirm_chunk = iff::Chunk::Leaf {
2243 id: *b"DIRM",
2244 data: dirm_payload.encode(),
2245 };
2246 let index =
2247 iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm_chunk)]).expect("fits");
2248
2249 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2250 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2251 })
2252 .unwrap_err();
2253 assert!(
2254 matches!(err, MutError::DirmMalformed(_)),
2255 "expected DirmMalformed, got {err:?}"
2256 );
2257 }
2258
2259 #[test]
2260 fn from_bytes_on_indirect_still_unsupported_for_page_mut() {
2261 let index = crate::djvm::create_indirect(&["chicken.djvu"]).expect("create_indirect");
2263 let mut doc = DjVuDocumentMut::from_bytes(&index).unwrap();
2264 let err = doc.page_mut(0).err().unwrap();
2265 assert!(matches!(err, MutError::IndirectDjvmUnsupported), "{err:?}");
2266 }
2267
2268 fn fresh_temp_dir(tag: &str) -> PathBuf {
2272 let dir = std::env::temp_dir().join(format!("djvu_rs_rewrite_{tag}"));
2273 let _ = std::fs::remove_dir_all(&dir);
2274 std::fs::create_dir_all(&dir).unwrap();
2275 dir
2276 }
2277
2278 #[test]
2279 fn rewrite_plan_commits_full_set_to_dir() {
2280 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2281 let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2282 assert_eq!(plan.page_count(), 2);
2283
2284 plan.edit_page(0, |doc| {
2286 let meta = DjVuMetadata {
2287 title: Some("rewrite path".into()),
2288 ..Default::default()
2289 };
2290 doc.page_mut(0)?.set_metadata(&meta);
2291 Ok(())
2292 })
2293 .unwrap();
2294
2295 let preview = plan.plan("index.djvu");
2297 assert_eq!(preview.len(), 3);
2298 assert_eq!(preview[0].name, "chicken.djvu");
2299 assert!(preview[0].changed, "edited page must show changed");
2300 assert_eq!(preview[1].name, "irish.djvu");
2301 assert!(!preview[1].changed, "untouched page must be unchanged");
2302 assert!(preview[2].is_root);
2303 assert!(!preview[2].changed, "root unchanged for a page-only edit");
2304
2305 let dir = fresh_temp_dir("commit_full_set");
2306 let written = plan.commit_to_dir(&dir, "index.djvu").unwrap();
2307 assert_eq!(written.len(), 3);
2308 for p in &written {
2309 assert!(p.exists(), "committed file {p:?} must exist");
2310 }
2311 let leftovers: Vec<_> = std::fs::read_dir(&dir)
2313 .unwrap()
2314 .filter_map(|e| e.ok())
2315 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
2316 .collect();
2317 assert!(leftovers.is_empty(), "temp files must be renamed away");
2318
2319 let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2322 let doc = crate::djvu_document::DjVuDocument::parse_from_dir(&index_bytes, &dir).unwrap();
2323 assert_eq!(doc.page_count(), 2);
2324 let meta_page0 = doc.page(0).unwrap();
2325 let edited_comp = std::fs::read(dir.join("chicken.djvu")).unwrap();
2328 let reparsed = DjVuDocumentMut::from_bytes(&edited_comp).unwrap();
2329 let has_metz = reparsed
2330 .file
2331 .root
2332 .children()
2333 .iter()
2334 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"));
2335 assert!(has_metz, "edited component file must contain METz");
2336 let irish_src = read_corpus("irish.djvu");
2338 let irish_out = std::fs::read(dir.join("irish.djvu")).unwrap();
2339 assert_eq!(irish_out, irish_src, "unedited component copied verbatim");
2340 let _ = meta_page0;
2341
2342 let _ = std::fs::remove_dir_all(&dir);
2343 }
2344
2345 #[test]
2346 fn rewrite_plan_rejects_duplicate_dirm_names() {
2347 let index = crate::djvm::create_indirect(&["dup.djvu", "dup.djvu"]).expect("create");
2349 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2350 Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2351 })
2352 .unwrap_err();
2353 match err {
2354 MutError::DuplicateComponentName { name } => assert_eq!(name, "dup.djvu"),
2355 other => panic!("expected DuplicateComponentName, got {other:?}"),
2356 }
2357 }
2358
2359 #[test]
2360 fn rewrite_plan_rejects_unsafe_dirm_names() {
2361 for bad in [
2362 "../evil.djvu",
2363 "/abs.djvu",
2364 "sub/page.djvu",
2365 "..",
2366 "a:b.djvu",
2367 ] {
2368 let index = crate::djvm::create_indirect(&[bad]).expect("create");
2369 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2370 Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2371 })
2372 .unwrap_err();
2373 assert!(
2374 matches!(err, MutError::UnsafeComponentName { .. }),
2375 "name {bad:?} should be rejected, got {err:?}"
2376 );
2377 }
2378 }
2379
2380 #[test]
2381 fn rewrite_plan_unsafe_root_name_leaves_dir_unchanged() {
2382 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2383 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2384
2385 let dir = fresh_temp_dir("unsafe_root");
2386 std::fs::write(dir.join("sentinel"), b"keep me").unwrap();
2388
2389 let err = plan.commit_to_dir(&dir, "../escape.djvu").unwrap_err();
2390 assert!(
2391 matches!(err, MutError::UnsafeComponentName { .. }),
2392 "{err:?}"
2393 );
2394
2395 let entries: Vec<String> = std::fs::read_dir(&dir)
2397 .unwrap()
2398 .filter_map(|e| e.ok())
2399 .map(|e| e.file_name().to_string_lossy().into_owned())
2400 .collect();
2401 assert_eq!(entries, vec!["sentinel".to_string()]);
2402 assert_eq!(std::fs::read(dir.join("sentinel")).unwrap(), b"keep me");
2403
2404 let _ = std::fs::remove_dir_all(&dir);
2405 }
2406
2407 #[test]
2408 fn rewrite_plan_root_name_collision_rejected() {
2409 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2410 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2411 let dir = fresh_temp_dir("root_collision");
2412 let err = plan.commit_to_dir(&dir, "chicken.djvu").unwrap_err();
2414 assert!(
2415 matches!(err, MutError::DuplicateComponentName { .. }),
2416 "{err:?}"
2417 );
2418 assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
2420 let _ = std::fs::remove_dir_all(&dir);
2421 }
2422
2423 #[test]
2424 fn rewrite_plan_set_bookmarks_marks_root_changed() {
2425 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2426 let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2427 plan.set_bookmarks(&[DjVuBookmark {
2428 title: "Top".into(),
2429 url: "#1".into(),
2430 children: vec![],
2431 }])
2432 .unwrap();
2433
2434 let preview = plan.plan("index.djvu");
2435 let root = preview.iter().find(|i| i.is_root).unwrap();
2436 assert!(root.changed, "root index must be marked changed");
2437
2438 let dir = fresh_temp_dir("bookmarks");
2440 plan.commit_to_dir(&dir, "index.djvu").unwrap();
2441 let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2442 let form = crate::iff::parse_form(&index_bytes).unwrap();
2443 assert!(
2444 form.chunks.iter().any(|c| &c.id == b"NAVM"),
2445 "committed index must contain NAVM"
2446 );
2447 let _ = std::fs::remove_dir_all(&dir);
2448 }
2449
2450 #[test]
2451 fn rewrite_plan_rejects_bundled_input() {
2452 let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2453 let err = IndirectRewritePlan::from_indirect_resolved(&bundled, |_n: &str| {
2454 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2455 })
2456 .unwrap_err();
2457 assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2458 }
2459
2460 #[test]
2461 fn chunk_at_path_rejects_empty_path() {
2462 let original = read_corpus("chicken.djvu");
2463 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2464 let err = doc.chunk_at_path(&[]).unwrap_err();
2465 assert!(matches!(err, MutError::EmptyPath));
2466 }
2467
2468 #[test]
2469 fn root_form_type_returns_some_for_form_root() {
2470 let original = read_corpus("chicken.djvu");
2471 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2472 let t = doc.root_form_type();
2473 assert!(t.is_some());
2474 }
2475
2476 #[test]
2479 fn set_bookmarks_empty_on_djvm_without_navm_is_noop() {
2480 let original = read_corpus("DjVu3Spec_bundled.djvu");
2482 let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2483 doc.set_bookmarks(&[]).unwrap(); let stripped = doc.into_bytes();
2485
2486 let mut doc2 = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2488 doc2.set_bookmarks(&[]).unwrap();
2489 assert_eq!(
2490 doc2.into_bytes(),
2491 stripped,
2492 "no-op set_bookmarks should not change bytes"
2493 );
2494 }
2495
2496 #[test]
2499 fn set_metadata_empty_on_page_without_meta_is_noop() {
2500 let original = read_corpus("chicken.djvu"); let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2502 doc.page_mut(0)
2504 .unwrap()
2505 .set_metadata(&DjVuMetadata::default());
2506 let bytes = doc.into_bytes();
2508 let reparsed = DjVuDocumentMut::from_bytes(&bytes).unwrap();
2509 let has_meta = reparsed
2510 .file
2511 .root
2512 .children()
2513 .iter()
2514 .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2515 assert!(
2516 !has_meta,
2517 "empty set_metadata should not insert a METa chunk"
2518 );
2519 }
2520
2521 #[test]
2524 fn chunk_at_path_traverses_leaf_first_branch() {
2525 let original = read_corpus("chicken.djvu");
2526 let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2527 let err = doc.chunk_at_path(&[0, 0, 0]).unwrap_err();
2528 assert!(
2529 matches!(err, MutError::PathTraversesLeaf { depth: 1, len: 3 }),
2530 "{err:?}"
2531 );
2532 }
2533
2534 #[test]
2536 fn rewrite_plan_resolver_failure_returns_component_resolve_error() {
2537 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2538 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2539 Err::<Vec<u8>, std::io::Error>(std::io::Error::new(
2540 std::io::ErrorKind::NotFound,
2541 "nope",
2542 ))
2543 })
2544 .unwrap_err();
2545 assert!(matches!(err, MutError::ComponentResolve { .. }), "{err:?}");
2546 }
2547
2548 #[test]
2549 fn rewrite_plan_non_iff_component_returns_malformed_error() {
2550 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2551 let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2552 Ok::<Vec<u8>, std::io::Error>(b"not iff".to_vec())
2553 })
2554 .unwrap_err();
2555 assert!(
2556 matches!(err, MutError::ComponentMalformed { .. }),
2557 "{err:?}"
2558 );
2559 }
2560
2561 #[test]
2563 fn rewrite_plan_wrong_form_type_returns_malformed_error() {
2564 let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2565 let fake = iff::emit(&DjvuFile {
2566 root: Chunk::Form {
2567 secondary_id: *b"FAKE",
2568 length: 0,
2569 children: vec![],
2570 },
2571 });
2572 let err = IndirectRewritePlan::from_indirect_resolved(&index, move |_name: &str| {
2573 Ok::<Vec<u8>, std::io::Error>(fake.clone())
2574 })
2575 .unwrap_err();
2576 assert!(
2577 matches!(err, MutError::ComponentMalformed { .. }),
2578 "{err:?}"
2579 );
2580 }
2581
2582 #[test]
2584 fn rewrite_plan_component_count() {
2585 let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2586 let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2587 assert_eq!(plan.component_count(), 2);
2588 assert_eq!(plan.page_count(), 2);
2589 }
2590
2591 #[cfg(feature = "std")]
2592 #[test]
2593 fn validate_safe_component_name_rejects_empty() {
2594 let err = validate_safe_component_name("").unwrap_err();
2595 assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2596 }
2597
2598 #[cfg(feature = "std")]
2599 #[test]
2600 fn validate_safe_component_name_rejects_nul() {
2601 let err = validate_safe_component_name("a\0b").unwrap_err();
2602 assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2603 }
2604
2605 fn top_form_ranges(data: &[u8]) -> Vec<core::ops::Range<usize>> {
2608 assert_eq!(&data[..4], b"AT&T");
2609 let form_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2610 let body_end = 12 + form_len;
2611 let mut pos = 16usize; let mut out = Vec::new();
2613 while pos + 8 <= body_end {
2614 let len =
2615 u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
2616 as usize;
2617 let mut next = pos + 8 + len;
2618 if next & 1 == 1 && next < body_end {
2619 next += 1;
2620 }
2621 out.push(pos..next);
2622 pos = next;
2623 }
2624 out
2625 }
2626
2627 #[test]
2631 fn is_bundled_djvm_leaf_returns_false() {
2632 let leaf = Chunk::Leaf {
2633 id: *b"INFO",
2634 data: vec![],
2635 };
2636 assert!(!is_bundled_djvm(&leaf));
2637 }
2638
2639 #[test]
2641 fn is_bundled_djvm_non_djvm_form_returns_false() {
2642 let form = Chunk::Form {
2643 secondary_id: *b"DJVU",
2644 length: 0,
2645 children: vec![],
2646 };
2647 assert!(!is_bundled_djvm(&form));
2648 }
2649
2650 #[test]
2654 fn from_indirect_resolved_empty_dirm_returns_dirm_malformed() {
2655 let dirm_payload = DirmPayload::build_indirect(0, &[], &[]);
2656 let dirm = Chunk::Leaf {
2657 id: *b"DIRM",
2658 data: dirm_payload.encode(),
2659 };
2660 let index = iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits");
2661 let err = DjVuDocumentMut::from_indirect_resolved(&index, |_n: &str| {
2662 Ok::<Vec<u8>, std::io::Error>(Vec::new())
2663 })
2664 .unwrap_err();
2665 assert!(
2666 matches!(err, MutError::DirmMalformed(_)),
2667 "expected DirmMalformed, got {err:?}"
2668 );
2669 }
2670
2671 #[test]
2674 fn set_text_layer_missing_info_chunk_returns_missing_page_info() {
2675 use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2676
2677 let bytes = iff::emit(&iff::DjvuFile {
2678 root: Chunk::Form {
2679 secondary_id: *b"DJVU",
2680 length: 0,
2681 children: vec![Chunk::Leaf {
2683 id: *b"ANTz",
2684 data: vec![0u8; 4],
2685 }],
2686 },
2687 });
2688 let mut doc = DjVuDocumentMut::from_bytes(&bytes).expect("no-INFO DJVU must parse");
2689 let layer = TextLayer {
2690 text: "hello".to_string(),
2691 zones: vec![TextZone {
2692 kind: TextZoneKind::Page,
2693 rect: Rect {
2694 x: 0,
2695 y: 0,
2696 width: 10,
2697 height: 10,
2698 },
2699 text: "hello".to_string(),
2700 children: vec![],
2701 }],
2702 };
2703 let err = doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap_err();
2704 assert!(matches!(err, MutError::MissingPageInfo), "{err:?}");
2705 }
2706
2707 #[test]
2711 fn emit_patched_leaf_root_returns_none() {
2712 let leaf = Chunk::Leaf {
2713 id: *b"INFO",
2714 data: vec![0u8; 4],
2715 };
2716 assert!(emit_patched_single_page(&leaf, &[]).is_none());
2717 }
2718
2719 #[test]
2721 fn emit_patched_form_child_in_djvu_returns_none() {
2722 let original = iff::partial_emit(
2725 *b"DJVU",
2726 &[iff::EmitPart::Chunk(&Chunk::Leaf {
2727 id: *b"INFO",
2728 data: vec![0u8; 4],
2729 })],
2730 )
2731 .unwrap();
2732
2733 let root = Chunk::Form {
2735 secondary_id: *b"DJVU",
2736 length: 0,
2737 children: vec![Chunk::Form {
2738 secondary_id: *b"INFO",
2739 length: 0,
2740 children: vec![],
2741 }],
2742 };
2743 assert!(emit_patched_single_page(&root, &original).is_none());
2744 }
2745
2746 #[test]
2748 fn original_child_ranges_too_short_returns_none() {
2749 assert!(original_single_page_child_ranges(b"AT&TFORM").is_none());
2750 }
2751
2752 #[test]
2754 fn original_child_ranges_not_djvu_returns_none() {
2755 let bytes = iff::partial_emit(*b"DJVI", &[]).unwrap();
2756 assert!(original_single_page_child_ranges(&bytes).is_none());
2757 }
2758
2759 #[test]
2761 fn original_child_ranges_nested_form_tag_returns_none() {
2762 let inner: &[u8] = b"FORM\x00\x00\x00\x04\x00\x00\x00\x00";
2767 let bytes = iff::partial_emit(*b"DJVU", &[iff::EmitPart::Verbatim(inner)]).unwrap();
2768 assert!(original_single_page_child_ranges(&bytes).is_none());
2769 }
2770
2771 #[test]
2773 fn original_child_ranges_odd_length_at_body_end_returns_none() {
2774 let form_tag: [u8; 4] = *b"FORM";
2778 let mut bytes: Vec<u8> = Vec::new();
2779 bytes.extend_from_slice(&iff::MAGIC);
2780 bytes.extend_from_slice(&form_tag);
2781 bytes.extend_from_slice(&15u32.to_be_bytes()); bytes.extend_from_slice(b"DJVU");
2783 bytes.extend_from_slice(b"INFO");
2784 bytes.extend_from_slice(&3u32.to_be_bytes());
2785 bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
2786 assert!(original_single_page_child_ranges(&bytes).is_none());
2787 }
2788
2789 #[test]
2791 fn original_child_ranges_short_tail_returns_none() {
2792 let form_tag: [u8; 4] = *b"FORM";
2795 let mut bytes: Vec<u8> = Vec::new();
2796 bytes.extend_from_slice(&iff::MAGIC);
2797 bytes.extend_from_slice(&form_tag);
2798 bytes.extend_from_slice(&17u32.to_be_bytes()); bytes.extend_from_slice(b"DJVU");
2800 bytes.extend_from_slice(b"INFO");
2801 bytes.extend_from_slice(&4u32.to_be_bytes());
2802 bytes.extend_from_slice(&[0u8; 4]);
2803 bytes.push(0x00); assert!(original_single_page_child_ranges(&bytes).is_none());
2805 }
2806
2807 #[test]
2811 fn recompute_dirm_offsets_leaf_root_is_noop() {
2812 let mut leaf = Chunk::Leaf {
2813 id: *b"INFO",
2814 data: vec![0u8; 4],
2815 };
2816 assert!(recompute_dirm_offsets(&mut leaf).is_ok());
2817 }
2818
2819 #[test]
2821 fn recompute_dirm_offsets_djvm_no_dirm_is_noop() {
2822 let mut root = Chunk::Form {
2823 secondary_id: *b"DJVM",
2824 length: 0,
2825 children: vec![Chunk::Form {
2826 secondary_id: *b"DJVU",
2827 length: 0,
2828 children: vec![],
2829 }],
2830 };
2831 assert!(recompute_dirm_offsets(&mut root).is_ok());
2832 }
2833
2834 #[test]
2836 fn recompute_dirm_offsets_count_mismatch_errors() {
2837 let dirm_payload = DirmPayload::build_bundled(
2839 2,
2840 &[0x01, 0x01],
2841 &["p1.djvu".to_string(), "p2.djvu".to_string()],
2842 );
2843 let dirm_data = dirm_payload.encode();
2844 let mut root = Chunk::Form {
2845 secondary_id: *b"DJVM",
2846 length: 0,
2847 children: vec![
2848 Chunk::Leaf {
2849 id: *b"DIRM",
2850 data: dirm_data,
2851 },
2852 Chunk::Form {
2853 secondary_id: *b"DJVU",
2854 length: 0,
2855 children: vec![],
2856 },
2857 ],
2859 };
2860 let err = recompute_dirm_offsets(&mut root).unwrap_err();
2861 assert!(
2862 matches!(err, MutError::DirmComponentCountMismatch { .. }),
2863 "{err:?}"
2864 );
2865 }
2866}