1#[cfg(not(feature = "std"))]
10use alloc::{format, string::String, vec, vec::Vec};
11
12use crate::dirm::{BUNDLED_FLAG, DirmComponentKind, DirmPayload};
13use crate::error::IffError;
14use crate::iff;
15use crate::{ComponentGraph, ComponentNodeKind};
16
17#[cfg(test)]
18use crate::djvu_document::DjVuDocument;
19
20use std::fs::{File, OpenOptions};
21use std::io::{self, Seek, SeekFrom, Write};
22use std::path::PathBuf;
23use std::sync::atomic::{AtomicU64, Ordering};
24
25static SPOOL_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
26
27#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum DjvmError {
31 #[error("IFF parse error: {0}")]
33 Iff(#[from] IffError),
34
35 #[error("document error: {0}")]
37 Doc(#[from] crate::djvu_document::DocError),
38
39 #[error("no pages to merge")]
41 EmptyMerge,
42
43 #[error("page range {start}..{end} is out of bounds (document has {count} pages)")]
45 PageRangeOutOfBounds {
46 start: usize,
47 end: usize,
48 count: usize,
49 },
50
51 #[error("page index {index} is out of bounds (document has {count} pages)")]
53 PageIndexOutOfBounds {
54 index: usize,
56 count: usize,
58 },
59
60 #[error("page index {index} was specified more than once")]
62 DuplicatePageIndex {
63 index: usize,
65 },
66
67 #[error("cannot remove all {count} pages from a document")]
69 AllPagesRemoved {
70 count: usize,
72 },
73
74 #[error("component graph error: {0}")]
76 ComponentGraph(String),
77
78 #[error("merged document exceeds the 4 GiB IFF FORM limit")]
80 OutputTooLarge,
81
82 #[error("to_indirect requires a bundled FORM:DJVM document")]
84 NotBundledDjvm,
85
86 #[error("DIRM chunk is malformed: {0}")]
88 DirmMalformed(&'static str),
89
90 #[error("DIRM component count {dirm} does not match bundle child count {children}")]
92 DirmComponentCountMismatch {
93 dirm: usize,
95 children: usize,
97 },
98
99 #[error("stream I/O error: {0}")]
101 Io(#[from] io::Error),
102
103 #[error(
105 "component descriptor count mismatch (components: {components}, ids: {ids}, flags: {flags})"
106 )]
107 ComponentDescriptorCountMismatch {
108 components: usize,
110 ids: usize,
112 flags: usize,
114 },
115
116 #[error("bundled DIRM supports at most 65535 components (got {count})")]
118 TooManyComponents {
119 count: usize,
121 },
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum DjvmSpool {
127 Memory,
130 TempFile,
134}
135
136struct SpoolComponent {
137 id: String,
138 flag: u8,
139 size: u32,
141}
142
143enum SpoolStorage {
144 Memory(Vec<u8>),
145 TempFile(TempFileSpool),
146}
147
148impl SpoolStorage {
149 fn new(spool: DjvmSpool) -> Result<Self, DjvmError> {
150 match spool {
151 DjvmSpool::Memory => Ok(Self::Memory(Vec::new())),
152 DjvmSpool::TempFile => Ok(Self::TempFile(TempFileSpool::create()?)),
153 }
154 }
155
156 fn write_component(&mut self, bytes: &[u8]) -> Result<(), DjvmError> {
157 match self {
158 Self::Memory(buffer) => {
159 buffer.extend_from_slice(bytes);
160 if bytes.len() % 2 == 1 {
161 buffer.push(0);
162 }
163 }
164 Self::TempFile(spool) => {
165 spool.file_mut()?.write_all(bytes)?;
166 if bytes.len() % 2 == 1 {
167 spool.file_mut()?.write_all(&[0])?;
168 }
169 }
170 }
171 Ok(())
172 }
173
174 fn write_to<W: Write>(&mut self, sink: &mut W) -> Result<(), DjvmError> {
175 match self {
176 Self::Memory(buffer) => sink.write_all(buffer)?,
177 Self::TempFile(spool) => {
178 let file = spool.file_mut()?;
179 file.seek(SeekFrom::Start(0))?;
180 io::copy(file, sink)?;
181 }
182 }
183 Ok(())
184 }
185}
186
187struct TempFileSpool {
194 file: Option<File>,
195 path: PathBuf,
196}
197
198impl TempFileSpool {
199 fn create() -> Result<Self, DjvmError> {
200 let directory = std::env::temp_dir();
201 let timestamp = std::time::SystemTime::now()
202 .duration_since(std::time::UNIX_EPOCH)
203 .unwrap_or_default()
204 .as_nanos();
205
206 for _ in 0..128 {
207 let counter = SPOOL_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
208 let path = directory.join(format!(
209 "djvu-rs-djvm-spool-{}-{timestamp}-{counter}",
210 std::process::id()
211 ));
212 match OpenOptions::new()
213 .read(true)
214 .write(true)
215 .create_new(true)
216 .open(&path)
217 {
218 Ok(file) => {
219 return Ok(Self {
220 file: Some(file),
221 path,
222 });
223 }
224 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
225 Err(error) => return Err(error.into()),
226 }
227 }
228
229 Err(io::Error::new(
230 io::ErrorKind::AlreadyExists,
231 "could not create a unique DJVM spool file",
232 )
233 .into())
234 }
235
236 fn file_mut(&mut self) -> Result<&mut File, DjvmError> {
237 self.file.as_mut().ok_or_else(|| {
238 io::Error::new(
239 io::ErrorKind::BrokenPipe,
240 "DJVM spool file was closed before streaming completed",
241 )
242 .into()
243 })
244 }
245}
246
247impl Drop for TempFileSpool {
248 fn drop(&mut self) {
249 drop(self.file.take());
252 let _ = std::fs::remove_file(&self.path);
253 }
254}
255
256pub struct DjvmStreamWriter<W: Write> {
264 sink: W,
265 spool: SpoolStorage,
266 components: Vec<SpoolComponent>,
267 document_chunks: Vec<iff::Chunk>,
268}
269
270impl<W: Write> DjvmStreamWriter<W> {
271 pub fn new(sink: W, spool: DjvmSpool) -> Result<Self, DjvmError> {
273 Ok(Self {
274 sink,
275 spool: SpoolStorage::new(spool)?,
276 components: Vec::new(),
277 document_chunks: Vec::new(),
278 })
279 }
280
281 pub fn add_component(&mut self, id: &str, flag: u8, bytes: &[u8]) -> Result<(), DjvmError> {
287 if self.components.len() == usize::from(u16::MAX) {
288 return Err(DjvmError::TooManyComponents {
289 count: self.components.len() + 1,
290 });
291 }
292
293 let component = strip_att(bytes);
294 let size = u32::try_from(component.len()).map_err(|_| DjvmError::OutputTooLarge)?;
295 self.spool.write_component(component)?;
296 self.components.push(SpoolComponent {
297 id: id.to_string(),
298 flag,
299 size,
300 });
301 Ok(())
302 }
303
304 pub fn add_document_chunk(&mut self, chunk_id: [u8; 4], data: &[u8]) -> Result<(), DjvmError> {
307 self.document_chunks.push(iff::Chunk::Leaf {
308 id: chunk_id,
309 data: data.to_vec(),
310 });
311 Ok(())
312 }
313
314 fn add_document_iff_chunk(&mut self, chunk: &iff::Chunk) {
321 self.document_chunks.push(chunk.clone());
322 }
323
324 pub fn finish(self) -> Result<W, DjvmError> {
331 let Self {
332 mut sink,
333 mut spool,
334 components,
335 document_chunks,
336 } = self;
337 let component_count = components.len();
338 let ids = components
339 .iter()
340 .map(|component| component.id.clone())
341 .collect::<Vec<_>>();
342 let flags = components
343 .iter()
344 .map(|component| component.flag)
345 .collect::<Vec<_>>();
346 let sizes = components
347 .iter()
348 .map(|component| component.size)
349 .collect::<Vec<_>>();
350 let mut dirm = DirmPayload::build_bundled(component_count, &flags, &ids, &sizes);
351
352 let provisional_dirm_chunk = iff::Chunk::Leaf {
356 id: *b"DIRM",
357 data: dirm.encode(),
358 };
359 let dirm_size = iff::emitted_size(&provisional_dirm_chunk);
360 let document_chunk_size = document_chunks.iter().try_fold(0usize, |total, chunk| {
361 total
362 .checked_add(iff::emitted_size(chunk))
363 .ok_or(DjvmError::OutputTooLarge)
364 })?;
365 let mut offset = 16usize
366 .checked_add(dirm_size)
367 .and_then(|total| total.checked_add(document_chunk_size))
368 .ok_or(DjvmError::OutputTooLarge)?;
369 dirm.offsets = components
370 .iter()
371 .map(|component| {
372 let current = u32::try_from(offset).map_err(|_| DjvmError::OutputTooLarge)?;
373 let component_size =
374 usize::try_from(component.size).map_err(|_| DjvmError::OutputTooLarge)?;
375 offset = offset
376 .checked_add(component_size)
377 .and_then(|total| total.checked_add(component_size % 2))
378 .ok_or(DjvmError::OutputTooLarge)?;
379 Ok(current)
380 })
381 .collect::<Result<Vec<_>, DjvmError>>()?;
382 let dirm_chunk = iff::Chunk::Leaf {
383 id: *b"DIRM",
384 data: dirm.encode(),
385 };
386 debug_assert_eq!(
387 iff::emitted_size(&dirm_chunk),
388 dirm_size,
389 "fixed-width DIRM offsets must not change the layout"
390 );
391
392 let form_payload_length = offset.checked_sub(12).ok_or(DjvmError::OutputTooLarge)?;
396 let form_payload_length =
397 u32::try_from(form_payload_length).map_err(|_| DjvmError::OutputTooLarge)?;
398
399 let mut header = iff::partial_emit(*b"DJVM", &[]).ok_or(DjvmError::OutputTooLarge)?;
403 debug_assert_eq!(header.len(), 16, "empty DJVM emission is its prologue");
404 header[8..12].copy_from_slice(&form_payload_length.to_be_bytes());
405 sink.write_all(&header)?;
406 write_emitted_chunk(&mut sink, &dirm_chunk)?;
407 for chunk in &document_chunks {
408 write_emitted_chunk(&mut sink, chunk)?;
409 }
410 spool.write_to(&mut sink)?;
411 drop(spool);
412 Ok(sink)
413 }
414}
415
416fn write_emitted_chunk<W: Write>(sink: &mut W, chunk: &iff::Chunk) -> Result<(), DjvmError> {
420 let emitted = iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(chunk)])
421 .ok_or(DjvmError::OutputTooLarge)?;
422 debug_assert_eq!(emitted.len() - 16, iff::emitted_size(chunk));
423 sink.write_all(&emitted[16..])?;
424 Ok(())
425}
426
427pub struct IndirectDocument {
429 pub index: Vec<u8>,
432 pub components: Vec<(String, Vec<u8>)>,
435}
436
437pub struct ComponentDedup {
439 pub document: Vec<u8>,
441 pub merged: Vec<(String, String)>,
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum UnreachablePolicy {
448 Preserve,
450 GarbageCollect,
452}
453
454pub struct PageRemoval {
456 pub document: Vec<u8>,
458 pub unreachable: Vec<String>,
462}
463
464pub fn remove_pages(
468 bundled: &[u8],
469 pages_to_remove: &[usize],
470 policy: UnreachablePolicy,
471) -> Result<PageRemoval, DjvmError> {
472 let form = iff::parse_form(bundled)?;
473 if form.form_type != *b"DJVM" {
474 return Err(DjvmError::NotBundledDjvm);
475 }
476
477 let dirm_data = form
478 .chunks
479 .iter()
480 .find(|chunk| chunk.id == *b"DIRM")
481 .ok_or(DjvmError::DirmMalformed("bundled DJVM has no DIRM chunk"))?
482 .data;
483 let dirm = DirmPayload::decode(dirm_data).map_err(DjvmError::DirmMalformed)?;
484 if !dirm.is_bundled() {
485 return Err(DjvmError::NotBundledDjvm);
486 }
487
488 let graph = ComponentGraph::parse(bundled)
489 .map_err(|error| DjvmError::ComponentGraph(format!("{error:?}")))?;
490 let directory = dirm.components();
491 let component_forms = form
492 .chunks
493 .iter()
494 .filter(|chunk| chunk.id == *b"FORM")
495 .collect::<Vec<_>>();
496 if component_forms.len() != directory.len() {
497 return Err(DjvmError::DirmComponentCountMismatch {
498 dirm: directory.len(),
499 children: component_forms.len(),
500 });
501 }
502
503 let pages = graph
504 .nodes()
505 .iter()
506 .filter(|node| node.kind == ComponentNodeKind::Page)
507 .collect::<Vec<_>>();
508 let mut removed = vec![false; pages.len()];
509 for &index in pages_to_remove {
510 if index >= pages.len() {
511 return Err(DjvmError::PageIndexOutOfBounds {
512 index,
513 count: pages.len(),
514 });
515 }
516 if removed[index] {
517 return Err(DjvmError::DuplicatePageIndex { index });
518 }
519 removed[index] = true;
520 }
521 if removed.iter().all(|removed| *removed) {
522 return Err(DjvmError::AllPagesRemoved { count: pages.len() });
523 }
524
525 let surviving_pages = pages
526 .iter()
527 .enumerate()
528 .filter_map(|(index, page)| (!removed[index]).then_some(*page))
529 .collect::<Vec<_>>();
530 let roots = surviving_pages
531 .iter()
532 .map(|page| page.id.as_str())
533 .collect::<Vec<_>>();
534 let closure = graph.transitive_closure(&roots);
535 let mut reachable = vec![false; graph.nodes().len()];
536 for index in closure {
537 reachable[index] = true;
538 }
539
540 let unreachable = graph
541 .nodes()
542 .iter()
543 .filter(|node| {
544 matches!(
545 node.kind,
546 ComponentNodeKind::Dictionary
547 | ComponentNodeKind::Annotation
548 | ComponentNodeKind::SharedOther
549 ) && !reachable[node.dirm_index]
550 })
551 .map(|node| node.id.clone())
552 .collect::<Vec<_>>();
553
554 let mut removed_dirm_entries = vec![false; graph.nodes().len()];
555 for (index, page) in pages.iter().enumerate() {
556 removed_dirm_entries[page.dirm_index] = removed[index];
557 }
558
559 let mut components = Vec::new();
560 let mut ids = Vec::new();
561 let mut flags = Vec::new();
562 for node in graph.nodes() {
563 let keep = match node.kind {
564 ComponentNodeKind::Page => !removed_dirm_entries[node.dirm_index],
565 ComponentNodeKind::Dictionary
566 | ComponentNodeKind::Annotation
567 | ComponentNodeKind::SharedOther => {
568 policy == UnreachablePolicy::Preserve || reachable[node.dirm_index]
569 }
570 ComponentNodeKind::Thumbnail => true,
573 };
574 if keep {
575 let component = component_forms[node.dirm_index];
576 components.push(wrap_sub_form(component.data));
577 ids.push(directory[node.dirm_index].id.clone());
578 flags.push(dirm_kind_flag(directory[node.dirm_index].kind));
579 }
580 }
581
582 let document_chunks = form
583 .chunks
584 .iter()
585 .filter(|chunk| chunk.id != *b"DIRM" && chunk.id != *b"FORM")
586 .map(|chunk| iff::Chunk::Leaf {
587 id: chunk.id,
588 data: chunk.data.to_vec(),
589 })
590 .collect::<Vec<_>>();
591 let document = build_djvm_with_document_chunks(&components, &ids, &flags, &document_chunks)?;
592
593 Ok(PageRemoval {
594 document,
595 unreachable,
596 })
597}
598
599pub fn dedup_shared_components(bundled: &[u8]) -> Result<ComponentDedup, DjvmError> {
604 let form = iff::parse_form(bundled)?;
605 if form.form_type != *b"DJVM" {
606 return Err(DjvmError::NotBundledDjvm);
607 }
608
609 let dirm_data = form
610 .chunks
611 .iter()
612 .find(|chunk| chunk.id == *b"DIRM")
613 .ok_or(DjvmError::DirmMalformed("bundled DJVM has no DIRM chunk"))?
614 .data;
615 let dirm = DirmPayload::decode(dirm_data).map_err(DjvmError::DirmMalformed)?;
616 if !dirm.is_bundled() {
617 return Err(DjvmError::NotBundledDjvm);
618 }
619
620 let directory = dirm.components();
621 let component_forms = form
622 .chunks
623 .iter()
624 .filter(|chunk| chunk.id == *b"FORM")
625 .collect::<Vec<_>>();
626 if component_forms.len() != directory.len() {
627 return Err(DjvmError::DirmComponentCountMismatch {
628 dirm: directory.len(),
629 children: component_forms.len(),
630 });
631 }
632
633 let mut survivor_by_payload = std::collections::BTreeMap::<Vec<u8>, usize>::new();
636 let mut keep = vec![true; directory.len()];
637 let mut merged = Vec::new();
638 let mut dropped_to_survivor = std::collections::BTreeMap::new();
639
640 for (index, (entry, component)) in directory.iter().zip(&component_forms).enumerate() {
641 if entry.kind != DirmComponentKind::Shared || !component.data.starts_with(b"DJVI") {
645 continue;
646 }
647
648 if let Some(&survivor) = survivor_by_payload.get(component.data) {
649 keep[index] = false;
650 let surviving_id = directory[survivor].id.clone();
651 merged.push((entry.id.clone(), surviving_id.clone()));
652 dropped_to_survivor.insert(entry.id.clone(), surviving_id);
653 } else {
654 survivor_by_payload.insert(component.data.to_vec(), index);
655 }
656 }
657
658 if merged.is_empty() {
661 return Ok(ComponentDedup {
662 document: bundled.to_vec(),
663 merged,
664 });
665 }
666
667 let mut components = Vec::new();
668 let mut ids = Vec::new();
669 let mut flags = Vec::new();
670 for (index, (entry, component)) in directory.iter().zip(component_forms).enumerate() {
671 if !keep[index] {
672 continue;
673 }
674
675 let body = if component.data.starts_with(b"DJVU") || component.data.starts_with(b"DJVI") {
676 rewrite_component_incls(component.data, &dropped_to_survivor)?
677 } else {
678 component.data.to_vec()
679 };
680 components.push(wrap_sub_form(&body));
681 ids.push(entry.id.clone());
682 flags.push(dirm_kind_flag(entry.kind));
683 }
684
685 let document_chunks = form
686 .chunks
687 .iter()
688 .filter(|chunk| chunk.id != *b"DIRM" && chunk.id != *b"FORM")
689 .map(|chunk| iff::Chunk::Leaf {
690 id: chunk.id,
691 data: chunk.data.to_vec(),
692 })
693 .collect::<Vec<_>>();
694 let document = build_djvm_with_document_chunks(&components, &ids, &flags, &document_chunks)?;
695
696 Ok(ComponentDedup { document, merged })
697}
698
699fn rewrite_component_incls(
702 form_data: &[u8],
703 dropped_to_survivor: &std::collections::BTreeMap<String, String>,
704) -> Result<Vec<u8>, DjvmError> {
705 let form_type = form_data
706 .get(..4)
707 .and_then(|bytes| bytes.try_into().ok())
708 .ok_or(DjvmError::DirmMalformed("component FORM body is too short"))?;
709 let body = &form_data[4..];
710 let chunks = iff::parse_form_body(body)?;
711 let mut changed = false;
712 let mut emitted_chunks = Vec::with_capacity(chunks.len());
713
714 for chunk in chunks {
715 let mut data = chunk.data.to_vec();
716 if chunk.id == *b"INCL" {
717 let id_end = data
718 .iter()
719 .rposition(|byte| *byte != 0 && !byte.is_ascii_whitespace())
720 .map_or(0, |index| index + 1);
721 if let Ok(id) = core::str::from_utf8(&data[..id_end])
722 && let Some(survivor) = dropped_to_survivor.get(id)
723 {
724 let mut rewritten = survivor.as_bytes().to_vec();
725 rewritten.extend_from_slice(&data[id_end..]);
726 data = rewritten;
727 changed = true;
728 }
729 }
730 emitted_chunks.push(iff::Chunk::Leaf { id: chunk.id, data });
731 }
732
733 if !changed {
734 return Ok(form_data.to_vec());
735 }
736
737 let parts = emitted_chunks
738 .iter()
739 .map(iff::EmitPart::Chunk)
740 .collect::<Vec<_>>();
741 let emitted = iff::partial_emit(form_type, &parts).ok_or(DjvmError::OutputTooLarge)?;
742 let length = u32::from_be_bytes(
743 emitted[8..12]
744 .try_into()
745 .expect("IFF emitter always writes a FORM length"),
746 ) as usize;
747 Ok(emitted[12..12 + length].to_vec())
748}
749
750fn dirm_kind_flag(kind: DirmComponentKind) -> u8 {
751 match kind {
752 DirmComponentKind::Shared => 0,
753 DirmComponentKind::Page => 1,
754 DirmComponentKind::Thumbnail => 2,
755 }
756}
757
758fn wrap_sub_form(form_data: &[u8]) -> Vec<u8> {
762 let split = form_data.len().min(4);
769 let (id_bytes, body) = form_data.split_at(split);
770 let mut secondary_id = *b" ";
771 secondary_id[..id_bytes.len()].copy_from_slice(id_bytes);
772 iff::partial_emit(secondary_id, &[iff::EmitPart::Verbatim(body)])
773 .expect("sub-FORM fits within the 4 GiB IFF FORM limit")
774}
775
776fn strip_att(form: &[u8]) -> &[u8] {
779 if form.len() >= 4 && &form[..4] == b"AT&T" {
780 &form[4..]
781 } else {
782 form
783 }
784}
785
786pub fn to_indirect(bundled: &[u8]) -> Result<IndirectDocument, DjvmError> {
796 let form = iff::parse_form(bundled)?;
797 if form.form_type != *b"DJVM" {
798 return Err(DjvmError::NotBundledDjvm);
799 }
800
801 let dirm_data = form
802 .chunks
803 .iter()
804 .find(|chunk| chunk.id == *b"DIRM")
805 .ok_or(DjvmError::DirmMalformed("bundled DJVM has no DIRM chunk"))?
806 .data;
807 let mut dirm = DirmPayload::decode(dirm_data).map_err(DjvmError::DirmMalformed)?;
808 if !dirm.is_bundled() {
809 return Err(DjvmError::NotBundledDjvm);
810 }
811
812 let component_forms = form
813 .chunks
814 .iter()
815 .filter(|chunk| chunk.id == *b"FORM")
816 .collect::<Vec<_>>();
817 let expected_count = dirm.nfiles as usize;
818 if component_forms.len() != expected_count {
819 return Err(DjvmError::DirmComponentCountMismatch {
820 dirm: expected_count,
821 children: component_forms.len(),
822 });
823 }
824
825 let components = dirm
828 .components()
829 .into_iter()
830 .zip(component_forms)
831 .map(|(component, form)| (component.id, wrap_sub_form(form.data)))
832 .collect();
833
834 dirm.flags &= !BUNDLED_FLAG;
838 dirm.offsets.clear();
839 let indirect_dirm = dirm.encode();
840
841 let mut index_chunks = Vec::with_capacity(form.chunks.len() - expected_count);
845 let mut replaced_dirm = false;
846 for chunk in &form.chunks {
847 match chunk.id {
848 id if id == *b"FORM" => {}
849 id if id == *b"DIRM" && !replaced_dirm => {
850 index_chunks.push(iff::Chunk::Leaf {
851 id: *b"DIRM",
852 data: indirect_dirm.clone(),
853 });
854 replaced_dirm = true;
855 }
856 id if id == *b"DIRM" => {}
857 id => index_chunks.push(iff::Chunk::Leaf {
858 id,
859 data: chunk.data.to_vec(),
860 }),
861 }
862 }
863 debug_assert!(replaced_dirm, "the DIRM was found above");
864 let index_parts = index_chunks
865 .iter()
866 .map(iff::EmitPart::Chunk)
867 .collect::<Vec<_>>();
868 let index = iff::partial_emit(*b"DJVM", &index_parts).ok_or(DjvmError::OutputTooLarge)?;
869
870 Ok(IndirectDocument { index, components })
871}
872
873pub fn merge(documents: &[&[u8]]) -> Result<Vec<u8>, DjvmError> {
879 if documents.is_empty() {
880 return Err(DjvmError::EmptyMerge);
881 }
882
883 let mut components: Vec<Vec<u8>> = Vec::new();
884 let mut component_ids: Vec<String> = Vec::new();
885 let mut component_flags: Vec<u8> = Vec::new();
886
887 for (doc_idx, &doc_data) in documents.iter().enumerate() {
888 let form = iff::parse_form(doc_data)?;
889
890 if &form.form_type == b"DJVU" {
891 components.push(doc_data.to_vec());
893 component_ids.push(format!("p{:04}.djvu", components.len()));
894 component_flags.push(1); } else if &form.form_type == b"DJVM" {
896 for chunk in &form.chunks {
898 if &chunk.id == b"FORM" && chunk.data.len() >= 4 {
899 let child_form_type = &chunk.data[..4];
900
901 let flag = if child_form_type == b"DJVI" { 0 } else { 1 }; components.push(wrap_sub_form(chunk.data));
904 component_ids.push(format!("d{}p{:04}.djvu", doc_idx, components.len()));
905 component_flags.push(flag);
906 }
907 }
908 }
909 }
910
911 if components.is_empty() {
912 return Err(DjvmError::EmptyMerge);
913 }
914
915 build_djvm(&components, &component_ids, &component_flags)
916}
917
918pub fn split(doc_data: &[u8], start: usize, end: usize) -> Result<Vec<u8>, DjvmError> {
922 let form = iff::parse_form(doc_data)?;
923
924 let count = match &form.form_type {
928 b"DJVU" => 1,
929 b"DJVM" => form
930 .chunks
931 .iter()
932 .filter(|c| &c.id == b"FORM" && c.data.len() >= 4 && &c.data[..4] == b"DJVU")
933 .count(),
934 _ => 0,
935 };
936
937 if start >= count || end > count || start >= end {
938 return Err(DjvmError::PageRangeOutOfBounds { start, end, count });
939 }
940
941 if &form.form_type == b"DJVU" && start == 0 && end == 1 {
943 return Ok(doc_data.to_vec());
944 }
945
946 if end - start == 1 && &form.form_type == b"DJVM" {
951 let standalone = ComponentGraph::parse(doc_data)
952 .ok()
953 .and_then(|graph| {
954 let pages = graph
955 .nodes()
956 .iter()
957 .filter(|node| node.kind == ComponentNodeKind::Page)
958 .collect::<Vec<_>>();
959 (pages.len() == count)
962 .then(|| pages.get(start).map(|page| page.includes.is_empty()))
963 .flatten()
964 })
965 .unwrap_or(true);
966
967 if standalone {
968 let mut page_idx = 0;
969 for chunk in &form.chunks {
970 if &chunk.id == b"FORM" && chunk.data.len() >= 4 && &chunk.data[..4] == b"DJVU" {
971 if page_idx == start {
972 return Ok(wrap_sub_form(chunk.data));
973 }
974 page_idx += 1;
975 }
976 }
977 }
978 }
979
980 if let Ok(graph) = ComponentGraph::parse(doc_data) {
984 let pages = graph
985 .nodes()
986 .iter()
987 .filter(|node| node.kind == ComponentNodeKind::Page)
988 .collect::<Vec<_>>();
989
990 if pages.len() == count {
994 let roots = pages[start..end]
995 .iter()
996 .map(|node| node.id.as_str())
997 .collect::<Vec<_>>();
998 let closure = graph.transitive_closure(&roots);
999 let mut selected = vec![false; graph.nodes().len()];
1000 for node_index in closure {
1001 let node = &graph.nodes()[node_index];
1002 if node.kind != ComponentNodeKind::Thumbnail {
1004 selected[node_index] = true;
1005 }
1006 }
1007
1008 let component_forms = form
1009 .chunks
1010 .iter()
1011 .filter(|chunk| chunk.id == *b"FORM")
1012 .collect::<Vec<_>>();
1013 let mut components = Vec::new();
1014 let mut component_ids = Vec::new();
1015 let mut component_flags = Vec::new();
1016
1017 for node in graph.nodes() {
1020 if selected[node.dirm_index] {
1021 let component = component_forms[node.dirm_index];
1022 components.push(wrap_sub_form(component.data));
1023 component_ids.push(node.id.clone());
1024 component_flags.push(u8::from(node.kind == ComponentNodeKind::Page));
1025 }
1026 }
1027
1028 return build_djvm(&components, &component_ids, &component_flags);
1029 }
1030 }
1031
1032 let mut components: Vec<Vec<u8>> = Vec::new();
1035 let mut component_ids: Vec<String> = Vec::new();
1036 let mut component_flags: Vec<u8> = Vec::new();
1037
1038 for chunk in &form.chunks {
1040 if &chunk.id == b"FORM" && chunk.data.len() >= 4 && &chunk.data[..4] == b"DJVI" {
1041 components.push(wrap_sub_form(chunk.data));
1042 component_ids.push(format!("shared{}.djvi", components.len()));
1043 component_flags.push(0); }
1045 }
1046
1047 let mut page_idx = 0;
1049 for chunk in &form.chunks {
1050 if &chunk.id == b"FORM" && chunk.data.len() >= 4 && &chunk.data[..4] == b"DJVU" {
1051 if page_idx >= start && page_idx < end {
1052 components.push(wrap_sub_form(chunk.data));
1053 component_ids.push(format!("p{:04}.djvu", page_idx + 1));
1054 component_flags.push(1); }
1056 page_idx += 1;
1057 }
1058 }
1059
1060 build_djvm(&components, &component_ids, &component_flags)
1061}
1062
1063fn build_djvm(components: &[Vec<u8>], ids: &[String], flags: &[u8]) -> Result<Vec<u8>, DjvmError> {
1071 build_djvm_with_document_chunks(components, ids, flags, &[])
1072}
1073
1074fn build_djvm_with_document_chunks(
1077 components: &[Vec<u8>],
1078 ids: &[String],
1079 flags: &[u8],
1080 document_chunks: &[iff::Chunk],
1081) -> Result<Vec<u8>, DjvmError> {
1082 if components.len() != ids.len() || components.len() != flags.len() {
1083 return Err(DjvmError::ComponentDescriptorCountMismatch {
1084 components: components.len(),
1085 ids: ids.len(),
1086 flags: flags.len(),
1087 });
1088 }
1089
1090 let mut writer = DjvmStreamWriter::new(Vec::new(), DjvmSpool::Memory)?;
1094 for ((component, id), &flag) in components.iter().zip(ids).zip(flags) {
1095 writer.add_component(id, flag, component)?;
1096 }
1097 for chunk in document_chunks {
1098 writer.add_document_iff_chunk(chunk);
1099 }
1100 writer.finish()
1101}
1102
1103pub fn create_indirect(page_names: &[&str]) -> Result<Vec<u8>, DjvmError> {
1118 if page_names.is_empty() {
1119 return Err(DjvmError::EmptyMerge);
1120 }
1121
1122 let count = page_names.len();
1123 let ids: Vec<String> = page_names.iter().map(|s| s.to_string()).collect();
1124 let flags: Vec<u8> = vec![1u8; count];
1126
1127 let dirm = iff::Chunk::Leaf {
1130 id: *b"DIRM",
1131 data: DirmPayload::build_indirect(count, &flags, &ids).encode(),
1132 };
1133 iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).ok_or(DjvmError::OutputTooLarge)
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139
1140 fn fixture_path(name: &str) -> std::path::PathBuf {
1141 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1142 .join("tests/fixtures")
1143 .join(name)
1144 }
1145
1146 struct SplitFixtureComponent {
1147 id: &'static str,
1148 dirm_flag: u8,
1149 form: [u8; 4],
1150 chunks: Vec<([u8; 4], Vec<u8>)>,
1151 }
1152
1153 fn split_component(
1154 id: &'static str,
1155 dirm_flag: u8,
1156 form: [u8; 4],
1157 chunks: Vec<([u8; 4], Vec<u8>)>,
1158 ) -> SplitFixtureComponent {
1159 SplitFixtureComponent {
1160 id,
1161 dirm_flag,
1162 form,
1163 chunks,
1164 }
1165 }
1166
1167 fn split_incl(id: &[u8]) -> ([u8; 4], Vec<u8>) {
1168 (*b"INCL", id.to_vec())
1169 }
1170
1171 fn split_component_body(component: &SplitFixtureComponent) -> Vec<u8> {
1172 let chunks = component
1173 .chunks
1174 .iter()
1175 .map(|(id, data)| iff::Chunk::Leaf {
1176 id: *id,
1177 data: data.clone(),
1178 })
1179 .collect::<Vec<_>>();
1180 let parts = chunks.iter().map(iff::EmitPart::Chunk).collect::<Vec<_>>();
1181 let bytes = iff::partial_emit(component.form, &parts).expect("small fixture FORM");
1182 let length = u32::from_be_bytes(bytes[8..12].try_into().unwrap()) as usize;
1183 bytes[12..12 + length].to_vec()
1184 }
1185
1186 fn split_bundled_fixture(components: Vec<SplitFixtureComponent>) -> Vec<u8> {
1187 split_bundled_fixture_with_document_chunks(components, vec![])
1188 }
1189
1190 fn split_bundled_fixture_with_document_chunks(
1191 components: Vec<SplitFixtureComponent>,
1192 document_chunks: Vec<([u8; 4], Vec<u8>)>,
1193 ) -> Vec<u8> {
1194 let bodies = components
1195 .iter()
1196 .map(split_component_body)
1197 .collect::<Vec<_>>();
1198 let ids = components
1199 .iter()
1200 .map(|component| component.id.to_string())
1201 .collect::<Vec<_>>();
1202 let flags = components
1203 .iter()
1204 .map(|component| component.dirm_flag)
1205 .collect::<Vec<_>>();
1206 let sizes = bodies
1207 .iter()
1208 .map(|body| u32::try_from(8 + body.len()).unwrap())
1209 .collect::<Vec<_>>();
1210 let mut dirm = DirmPayload::build_bundled(components.len(), &flags, &ids, &sizes);
1211 let document_chunks = document_chunks
1212 .into_iter()
1213 .map(|(id, data)| iff::Chunk::Leaf { id, data })
1214 .collect::<Vec<_>>();
1215
1216 let emit = |dirm: &DirmPayload| {
1217 let dirm_chunk = iff::Chunk::Leaf {
1218 id: *b"DIRM",
1219 data: dirm.encode(),
1220 };
1221 let mut parts = vec![iff::EmitPart::Chunk(&dirm_chunk)];
1222 parts.extend(document_chunks.iter().map(iff::EmitPart::Chunk));
1223 parts.extend(bodies.iter().map(|body| iff::EmitPart::Form(body)));
1224 iff::partial_emit_with_offsets(*b"DJVM", &parts).expect("small bundled fixture")
1225 };
1226
1227 let (_, offsets) = emit(&dirm);
1228 dirm.offsets = offsets[1 + document_chunks.len()..]
1229 .iter()
1230 .map(|&offset| u32::try_from(offset).unwrap())
1231 .collect();
1232 emit(&dirm).0
1233 }
1234
1235 fn stream_writer_fixture() -> (Vec<Vec<u8>>, Vec<String>, Vec<u8>, Vec<iff::Chunk>) {
1236 let page = std::fs::read(fixture_path("chicken.djvu")).expect("read page fixture");
1237 let navm_source = std::fs::read(fixture_path("navm_fgbz.djvu")).expect("read NAVM fixture");
1238 let navm = iff::parse_form(&navm_source)
1239 .expect("parse NAVM fixture")
1240 .chunks
1241 .iter()
1242 .find(|chunk| chunk.id == *b"NAVM")
1243 .expect("NAVM fixture contains NAVM")
1244 .data
1245 .to_vec();
1246 let shared = wrap_sub_form(&split_component_body(&split_component(
1247 "dict.djvi",
1248 0,
1249 *b"DJVI",
1250 vec![(*b"Djbz", vec![1, 2, 3])],
1251 )));
1252 let thumbnail = wrap_sub_form(&split_component_body(&split_component(
1253 "page.thum",
1254 2,
1255 *b"THUM",
1256 vec![],
1257 )));
1258 (
1259 vec![page, shared, thumbnail],
1260 vec![
1261 "page.djvu".to_string(),
1262 "dict.djvi".to_string(),
1263 "page.thum".to_string(),
1264 ],
1265 vec![1, 0, 2],
1266 vec![iff::Chunk::Leaf {
1267 id: *b"NAVM",
1268 data: navm,
1269 }],
1270 )
1271 }
1272
1273 fn two_pass_djvm_reference(
1277 components: &[Vec<u8>],
1278 ids: &[String],
1279 flags: &[u8],
1280 document_chunks: &[iff::Chunk],
1281 ) -> Vec<u8> {
1282 let stripped = components
1283 .iter()
1284 .map(|component| strip_att(component))
1285 .collect::<Vec<_>>();
1286 let sizes = stripped
1287 .iter()
1288 .map(|component| u32::try_from(component.len()).expect("small fixture component"))
1289 .collect::<Vec<_>>();
1290 let mut dirm = DirmPayload::build_bundled(components.len(), flags, ids, &sizes);
1291 let emit = |dirm: &DirmPayload| {
1292 let dirm_chunk = iff::Chunk::Leaf {
1293 id: *b"DIRM",
1294 data: dirm.encode(),
1295 };
1296 let mut parts = Vec::with_capacity(1 + document_chunks.len() + stripped.len());
1297 parts.push(iff::EmitPart::Chunk(&dirm_chunk));
1298 parts.extend(document_chunks.iter().map(iff::EmitPart::Chunk));
1299 parts.extend(
1300 stripped
1301 .iter()
1302 .map(|component| iff::EmitPart::Verbatim(component)),
1303 );
1304 iff::partial_emit_with_offsets(*b"DJVM", &parts).expect("small reference DJVM")
1305 };
1306
1307 let (_, offsets) = emit(&dirm);
1308 dirm.offsets = offsets[1 + document_chunks.len()..]
1309 .iter()
1310 .map(|&offset| u32::try_from(offset).expect("small fixture offset"))
1311 .collect();
1312 emit(&dirm).0
1313 }
1314
1315 fn temp_spool_path<W: Write>(writer: &DjvmStreamWriter<W>) -> PathBuf {
1316 match &writer.spool {
1317 SpoolStorage::TempFile(spool) => spool.path.clone(),
1318 SpoolStorage::Memory(_) => panic!("expected a tempfile spool"),
1319 }
1320 }
1321
1322 #[test]
1323 fn stream_writer_matches_vec_builder_and_parses_for_both_spools() {
1324 let (components, ids, flags, document_chunks) = stream_writer_fixture();
1325 let reference = two_pass_djvm_reference(&components, &ids, &flags, &document_chunks);
1326 let expected = build_djvm_with_document_chunks(&components, &ids, &flags, &document_chunks)
1327 .expect("build through vector convenience API");
1328 assert_eq!(expected, reference, "Vec API must preserve old IFF framing");
1329
1330 for spool in [DjvmSpool::Memory, DjvmSpool::TempFile] {
1331 let mut writer = DjvmStreamWriter::new(std::io::Cursor::new(Vec::new()), spool)
1332 .expect("create stream writer");
1333 for (index, ((component, id), &flag)) in
1334 components.iter().zip(&ids).zip(&flags).enumerate()
1335 {
1336 let bytes = if index == 1 {
1339 &component[4..]
1340 } else {
1341 component
1342 };
1343 writer
1344 .add_component(id, flag, bytes)
1345 .expect("spool component");
1346 }
1347 for chunk in &document_chunks {
1348 let iff::Chunk::Leaf { id, data } = chunk else {
1349 panic!("fixture document chunks are leaves");
1350 };
1351 writer
1352 .add_document_chunk(*id, data)
1353 .expect("add NAVM chunk");
1354 }
1355 let actual = writer.finish().expect("finish stream writer").into_inner();
1356
1357 assert_eq!(actual, expected, "{spool:?} output must be byte-identical");
1358 assert_eq!(actual, reference, "{spool:?} must match two-pass framing");
1359 let document = DjVuDocument::parse(&actual).expect("parse streamed DJVM");
1360 assert_eq!(document.page_count(), 1);
1361 let graph = ComponentGraph::parse(&actual).expect("parse streamed component graph");
1362 assert!(graph.validate().is_empty(), "streamed graph must validate");
1363 }
1364 }
1365
1366 #[test]
1367 fn tempfile_spool_is_removed_after_finish_and_drop() {
1368 let component = std::fs::read(fixture_path("chicken.djvu")).expect("read component");
1369
1370 let mut writer = DjvmStreamWriter::new(std::io::sink(), DjvmSpool::TempFile)
1371 .expect("create tempfile writer");
1372 let finished_path = temp_spool_path(&writer);
1373 assert!(finished_path.exists(), "tempfile spool must be created");
1374 writer
1375 .add_component("page.djvu", 1, &component)
1376 .expect("spool component");
1377 writer.finish().expect("finish tempfile writer");
1378 assert!(
1379 !finished_path.exists(),
1380 "finishing must close and remove the tempfile spool"
1381 );
1382
1383 let dropped_path = {
1384 let mut writer = DjvmStreamWriter::new(std::io::sink(), DjvmSpool::TempFile)
1385 .expect("create tempfile writer");
1386 let path = temp_spool_path(&writer);
1387 writer
1388 .add_component("page.djvu", 1, &component)
1389 .expect("spool component");
1390 assert!(path.exists(), "tempfile spool must remain until drop");
1391 path
1392 };
1393 assert!(
1394 !dropped_path.exists(),
1395 "dropping an unfinished writer must remove the tempfile spool"
1396 );
1397 }
1398
1399 #[test]
1400 fn tempfile_spool_keeps_large_component_stream_out_of_memory() {
1401 let mut writer = DjvmStreamWriter::new(std::io::sink(), DjvmSpool::TempFile)
1402 .expect("create tempfile writer");
1403 let path = temp_spool_path(&writer);
1404 let mut component = vec![0x5a; 100_000];
1405 component[..4].copy_from_slice(b"FORM");
1406
1407 for index in 0..200 {
1408 writer
1409 .add_component(&format!("page-{index:04}.djvu"), 1, &component)
1410 .expect("spool synthetic component");
1411 }
1412
1413 assert!(matches!(&writer.spool, SpoolStorage::TempFile(_)));
1414 assert_eq!(writer.components.len(), 200);
1415 assert_eq!(
1416 std::fs::metadata(&path).expect("inspect spool file").len(),
1417 20_000_000,
1418 "all synthetic component bytes reside in the tempfile spool"
1419 );
1420 writer.finish().expect("stream synthetic document to sink");
1421 assert!(!path.exists(), "finishing removes the large spool file");
1422 }
1423
1424 #[test]
1425 fn djvm_stream_writer_failing_sink_returns_io_error() {
1426 let component = std::fs::read(fixture_path("chicken.djvu")).expect("read component");
1427 let mut writer = DjvmStreamWriter::new(
1428 crate::export_test_support::FailingWriter::after(2),
1429 DjvmSpool::Memory,
1430 )
1431 .expect("construct stream writer");
1432 writer
1433 .add_component("page.djvu", 1, &component)
1434 .expect("spool component before sink writes");
1435
1436 let error = writer
1437 .finish()
1438 .expect_err("injected sink failure must be returned");
1439 assert!(matches!(error, DjvmError::Io(error) if error.kind() == io::ErrorKind::Other));
1440 }
1441
1442 fn split_dependency_fixture() -> Vec<u8> {
1443 split_bundled_fixture(vec![
1444 split_component("page0.djvu", 1, *b"DJVU", vec![split_incl(b"dictA.djvi")]),
1445 split_component("dictA.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![1])]),
1446 split_component("page1.djvu", 1, *b"DJVU", vec![split_incl(b"dictB.djvi")]),
1447 split_component("dictB.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![2])]),
1448 split_component("dictC.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![3])]),
1449 ])
1450 }
1451
1452 #[test]
1453 fn remove_pages_garbage_collects_newly_and_already_unreachable_shared_components() {
1454 let bundled = split_bundled_fixture_with_document_chunks(
1455 vec![
1456 split_component("page0.djvu", 1, *b"DJVU", vec![split_incl(b"dictA.djvi")]),
1457 split_component("dictA.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![1])]),
1458 split_component("page1.djvu", 1, *b"DJVU", vec![split_incl(b"dictB.djvi")]),
1459 split_component("dictB.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![2])]),
1460 split_component("dictC.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![3])]),
1461 ],
1462 vec![(*b"NAVM", vec![1, 2, 3])],
1463 );
1464
1465 let result = remove_pages(&bundled, &[1], UnreachablePolicy::GarbageCollect)
1466 .expect("remove second page and garbage collect");
1467 assert_eq!(
1468 result.unreachable,
1469 vec!["dictB.djvi".to_string(), "dictC.djvi".to_string()]
1470 );
1471
1472 let graph = ComponentGraph::parse(&result.document).expect("parse result graph");
1473 assert_eq!(
1474 graph
1475 .nodes()
1476 .iter()
1477 .map(|node| node.id.as_str())
1478 .collect::<Vec<_>>(),
1479 vec!["page0.djvu", "dictA.djvi"]
1480 );
1481 assert_eq!(
1482 graph
1483 .includes("page0.djvu")
1484 .into_iter()
1485 .map(|node| node.id.as_str())
1486 .collect::<Vec<_>>(),
1487 vec!["dictA.djvi"],
1488 "the surviving page's INCL still resolves"
1489 );
1490 assert!(
1491 graph
1492 .validate()
1493 .iter()
1494 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
1495 "the result has no dangling INCL targets"
1496 );
1497
1498 let document_chunks = iff::parse_form(&result.document)
1499 .expect("parse result document")
1500 .chunks
1501 .into_iter()
1502 .filter(|chunk| chunk.id != *b"DIRM" && chunk.id != *b"FORM")
1503 .map(|chunk| (chunk.id, chunk.data.to_vec()))
1504 .collect::<Vec<_>>();
1505 assert_eq!(document_chunks, vec![(*b"NAVM", vec![1, 2, 3])]);
1506 }
1507
1508 #[test]
1509 fn remove_pages_preserves_unreachable_shared_components_when_requested() {
1510 let result = remove_pages(
1511 &split_dependency_fixture(),
1512 &[1],
1513 UnreachablePolicy::Preserve,
1514 )
1515 .expect("remove second page while preserving shared components");
1516 assert_eq!(
1517 result.unreachable,
1518 vec!["dictB.djvi".to_string(), "dictC.djvi".to_string()]
1519 );
1520
1521 let graph = ComponentGraph::parse(&result.document).expect("parse result graph");
1522 assert_eq!(
1523 graph
1524 .nodes()
1525 .iter()
1526 .map(|node| node.id.as_str())
1527 .collect::<Vec<_>>(),
1528 vec!["page0.djvu", "dictA.djvi", "dictB.djvi", "dictC.djvi"]
1529 );
1530 assert!(
1531 graph
1532 .validate()
1533 .iter()
1534 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
1535 "preserving unreachable components keeps all INCL targets valid"
1536 );
1537 }
1538
1539 #[test]
1540 fn remove_pages_can_garbage_collect_orphans_without_removing_pages() {
1541 let bundled = split_dependency_fixture();
1542 let result = remove_pages(&bundled, &[], UnreachablePolicy::GarbageCollect)
1543 .expect("garbage collect without removing pages");
1544 assert_eq!(result.unreachable, vec!["dictC.djvi".to_string()]);
1545
1546 let graph = ComponentGraph::parse(&result.document).expect("parse result graph");
1547 assert_eq!(
1548 graph
1549 .nodes()
1550 .iter()
1551 .map(|node| node.id.as_str())
1552 .collect::<Vec<_>>(),
1553 vec!["page0.djvu", "dictA.djvi", "page1.djvu", "dictB.djvi"]
1554 );
1555 assert_eq!(
1556 graph
1557 .nodes()
1558 .iter()
1559 .filter(|node| node.kind == ComponentNodeKind::Page)
1560 .count(),
1561 2,
1562 "every page survives when no page index is removed"
1563 );
1564 }
1565
1566 #[test]
1567 fn remove_pages_rejects_removing_every_page() {
1568 let result = remove_pages(
1569 &split_dependency_fixture(),
1570 &[0, 1],
1571 UnreachablePolicy::GarbageCollect,
1572 );
1573 assert!(matches!(
1574 result,
1575 Err(DjvmError::AllPagesRemoved { count: 2 })
1576 ));
1577 }
1578
1579 #[test]
1580 fn remove_pages_rejects_out_of_range_indices() {
1581 let result = remove_pages(
1582 &split_dependency_fixture(),
1583 &[2],
1584 UnreachablePolicy::GarbageCollect,
1585 );
1586 assert!(matches!(
1587 result,
1588 Err(DjvmError::PageIndexOutOfBounds { index: 2, count: 2 })
1589 ));
1590 }
1591
1592 #[test]
1593 fn remove_pages_rejects_duplicate_indices() {
1594 let result = remove_pages(
1595 &split_dependency_fixture(),
1596 &[0, 0],
1597 UnreachablePolicy::GarbageCollect,
1598 );
1599 assert!(matches!(
1600 result,
1601 Err(DjvmError::DuplicatePageIndex { index: 0 })
1602 ));
1603 }
1604
1605 #[test]
1606 fn remove_pages_garbage_collect_round_trips_real_bundled_fixture() {
1607 let bundled =
1608 std::fs::read(fixture_path("DjVu3Spec_bundled.djvu")).expect("read bundled fixture");
1609 let original = ComponentGraph::parse(&bundled).expect("parse source graph");
1610 let original_page_count = original
1611 .nodes()
1612 .iter()
1613 .filter(|node| node.kind == ComponentNodeKind::Page)
1614 .count();
1615 assert!(
1616 original_page_count > 1,
1617 "fixture must contain multiple pages"
1618 );
1619
1620 let result = remove_pages(&bundled, &[0], UnreachablePolicy::GarbageCollect)
1621 .expect("remove one fixture page");
1622 let graph = ComponentGraph::parse(&result.document).expect("parse result graph");
1623 assert_eq!(
1624 graph
1625 .nodes()
1626 .iter()
1627 .filter(|node| node.kind == ComponentNodeKind::Page)
1628 .count(),
1629 original_page_count - 1
1630 );
1631 assert!(
1632 graph.validate().is_empty(),
1633 "the rebuilt fixture graph validates"
1634 );
1635 }
1636
1637 #[test]
1638 fn dedup_shared_components_merges_identical_dicts_and_redirects_incls() {
1639 let bundled = split_bundled_fixture_with_document_chunks(
1640 vec![
1641 split_component("page0.djvu", 1, *b"DJVU", vec![split_incl(b"dictA.djvi")]),
1642 split_component("dictA.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![1])]),
1643 split_component("page1.djvu", 1, *b"DJVU", vec![split_incl(b"dictB.djvi")]),
1644 split_component("dictB.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![1])]),
1645 split_component("dictC.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![2])]),
1646 ],
1647 vec![(*b"NAVM", vec![1, 2, 3])],
1648 );
1649 let original_graph = ComponentGraph::parse(&bundled).expect("parse source graph");
1650
1651 let result = dedup_shared_components(&bundled).expect("deduplicate bundled fixture");
1652 assert_eq!(
1653 result.merged,
1654 vec![("dictB.djvi".to_string(), "dictA.djvi".to_string())],
1655 "the first matching DIRM component survives"
1656 );
1657
1658 let graph = ComponentGraph::parse(&result.document).expect("parse deduplicated graph");
1659 assert!(graph.node("dictA.djvi").is_some());
1660 assert!(graph.node("dictB.djvi").is_none());
1661 assert!(graph.node("dictC.djvi").is_some());
1662 for page in ["page0.djvu", "page1.djvu"] {
1663 assert_eq!(
1664 graph
1665 .includes(page)
1666 .into_iter()
1667 .map(|node| node.id.as_str())
1668 .collect::<Vec<_>>(),
1669 vec!["dictA.djvi"],
1670 "{page} now includes the surviving dictionary"
1671 );
1672 }
1673 assert!(
1674 graph
1675 .validate()
1676 .iter()
1677 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
1678 "redirected INCL edges have no missing targets"
1679 );
1680 assert_eq!(
1681 graph
1682 .nodes()
1683 .iter()
1684 .filter(|node| node.kind == ComponentNodeKind::Page)
1685 .count(),
1686 original_graph
1687 .nodes()
1688 .iter()
1689 .filter(|node| node.kind == ComponentNodeKind::Page)
1690 .count(),
1691 "deduplication does not change the page count"
1692 );
1693
1694 let document_chunks = iff::parse_form(&result.document)
1695 .expect("parse deduplicated document")
1696 .chunks
1697 .into_iter()
1698 .filter(|chunk| chunk.id != *b"DIRM" && chunk.id != *b"FORM")
1699 .map(|chunk| (chunk.id, chunk.data.to_vec()))
1700 .collect::<Vec<_>>();
1701 assert_eq!(document_chunks, vec![(*b"NAVM", vec![1, 2, 3])]);
1702 }
1703
1704 #[test]
1705 fn dedup_shared_components_never_merges_different_dicts() {
1706 let bundled = split_bundled_fixture(vec![
1707 split_component("page0.djvu", 1, *b"DJVU", vec![split_incl(b"dictA.djvi")]),
1708 split_component("dictA.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![1])]),
1709 split_component("page1.djvu", 1, *b"DJVU", vec![split_incl(b"dictB.djvi")]),
1710 split_component("dictB.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![2])]),
1711 ]);
1712
1713 let result = dedup_shared_components(&bundled).expect("deduplicate bundled fixture");
1714 assert!(result.merged.is_empty());
1715 let graph = ComponentGraph::parse(&result.document).expect("parse result graph");
1716 assert!(graph.node("dictA.djvi").is_some());
1717 assert!(graph.node("dictB.djvi").is_some());
1718 assert_eq!(
1719 graph
1720 .includes("page0.djvu")
1721 .into_iter()
1722 .map(|node| node.id.as_str())
1723 .collect::<Vec<_>>(),
1724 vec!["dictA.djvi"]
1725 );
1726 assert_eq!(
1727 graph
1728 .includes("page1.djvu")
1729 .into_iter()
1730 .map(|node| node.id.as_str())
1731 .collect::<Vec<_>>(),
1732 vec!["dictB.djvi"]
1733 );
1734 }
1735
1736 #[test]
1737 fn dedup_shared_components_is_a_byte_preserving_no_op_without_duplicates() {
1738 let bundled = split_bundled_fixture(vec![
1739 split_component("page0.djvu", 1, *b"DJVU", vec![split_incl(b"dictA.djvi")]),
1740 split_component("dictA.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![1])]),
1741 split_component("dictB.djvi", 0, *b"DJVI", vec![(*b"Djbz", vec![2])]),
1742 ]);
1743
1744 let result = dedup_shared_components(&bundled).expect("deduplicate bundled fixture");
1745 assert!(result.merged.is_empty());
1746 assert_eq!(
1747 result.document, bundled,
1748 "duplicate-free bundles are unchanged"
1749 );
1750 let graph = ComponentGraph::parse(&result.document).expect("parse result graph");
1751 assert!(
1752 graph
1753 .validate()
1754 .iter()
1755 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. }))
1756 );
1757 }
1758
1759 #[test]
1760 fn dedup_shared_components_round_trips_bundled_fixture() {
1761 let bundled =
1762 std::fs::read(fixture_path("DjVu3Spec_bundled.djvu")).expect("bundled fixture exists");
1763 let original = ComponentGraph::parse(&bundled).expect("parse source graph");
1764
1765 let result = dedup_shared_components(&bundled).expect("deduplicate fixture");
1766 let rewritten = ComponentGraph::parse(&result.document).expect("parse result graph");
1767 assert_eq!(
1768 rewritten
1769 .nodes()
1770 .iter()
1771 .filter(|node| node.kind == ComponentNodeKind::Page)
1772 .count(),
1773 original
1774 .nodes()
1775 .iter()
1776 .filter(|node| node.kind == ComponentNodeKind::Page)
1777 .count(),
1778 "deduplication preserves fixture page count"
1779 );
1780 assert!(
1781 rewritten
1782 .validate()
1783 .iter()
1784 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
1785 "deduplicated fixture has no dangling INCL edges"
1786 );
1787 }
1788
1789 #[test]
1790 fn to_indirect_round_trips_graph_dirm_metadata_and_shared_dictionaries() {
1791 use crate::djvu_document::{ComponentId, ComponentResolveError};
1792
1793 let bundled = std::fs::read(fixture_path("DjVu3Spec_bundled.djvu"))
1794 .expect("DjVu3Spec_bundled fixture exists");
1795 let original_form = iff::parse_form(&bundled).expect("parse bundled fixture");
1796 let original_dirm = DirmPayload::decode(
1797 original_form
1798 .chunks
1799 .iter()
1800 .find(|chunk| chunk.id == *b"DIRM")
1801 .expect("bundled fixture has DIRM")
1802 .data,
1803 )
1804 .expect("decode bundled DIRM");
1805 let original_ids = original_dirm
1806 .components()
1807 .into_iter()
1808 .map(|component| component.id)
1809 .collect::<Vec<_>>();
1810 let original_graph =
1811 ComponentGraph::parse(&bundled).expect("build bundled component graph");
1812 assert_eq!(
1813 original_graph
1814 .nodes()
1815 .iter()
1816 .map(|node| node.id.as_str())
1817 .collect::<Vec<_>>(),
1818 original_ids.iter().map(String::as_str).collect::<Vec<_>>(),
1819 "the graph follows DIRM order"
1820 );
1821 assert!(
1822 original_graph
1823 .validate()
1824 .iter()
1825 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
1826 "the source bundle has no dangling INCL edges"
1827 );
1828
1829 let original_document = DjVuDocument::parse(&bundled).expect("parse bundled fixture");
1830 let pages_with_shared_dict = (0..original_document.page_count())
1831 .filter(|&index| {
1832 original_document
1833 .page(index)
1834 .expect("valid source page")
1835 .decoded_shared_dict()
1836 .is_some()
1837 })
1838 .collect::<Vec<_>>();
1839 assert!(
1840 !pages_with_shared_dict.is_empty(),
1841 "fixture must exercise shared DJVI resolution"
1842 );
1843
1844 let indirect = to_indirect(&bundled).expect("convert bundled fixture");
1845 let index_form = iff::parse_form(&indirect.index).expect("parse indirect index");
1846 assert_eq!(&index_form.form_type, b"DJVM");
1847 assert!(
1848 index_form.chunks.iter().all(|chunk| chunk.id != *b"FORM"),
1849 "indirect index contains no embedded component forms"
1850 );
1851 let index_dirm = DirmPayload::decode(
1852 index_form
1853 .chunks
1854 .iter()
1855 .find(|chunk| chunk.id == *b"DIRM")
1856 .expect("indirect index has DIRM")
1857 .data,
1858 )
1859 .expect("decode indirect DIRM");
1860 assert!(!index_dirm.is_bundled(), "bundled bit is cleared");
1861 assert!(index_dirm.offsets.is_empty(), "offset table is removed");
1862 assert_eq!(index_dirm.nfiles, original_dirm.nfiles);
1863 assert_eq!(
1864 index_dirm.flags,
1865 original_dirm.flags & !BUNDLED_FLAG,
1866 "only the bundled bit changes"
1867 );
1868 assert_eq!(
1869 index_dirm.metadata, original_dirm.metadata,
1870 "the BZZ metadata blob, including ids/names/titles/component flags, is verbatim"
1871 );
1872 assert_eq!(
1873 indirect
1874 .components
1875 .iter()
1876 .map(|(name, _)| name.as_str())
1877 .collect::<Vec<_>>(),
1878 original_ids.iter().map(String::as_str).collect::<Vec<_>>(),
1879 "one resolver-keyed file per DIRM entry, in DIRM order"
1880 );
1881 assert_eq!(indirect.components.len(), original_dirm.nfiles as usize);
1882
1883 let original_document_chunks = original_form
1884 .chunks
1885 .iter()
1886 .filter(|chunk| chunk.id != *b"DIRM" && chunk.id != *b"FORM")
1887 .map(|chunk| (chunk.id, chunk.data))
1888 .collect::<Vec<_>>();
1889 let index_document_chunks = index_form
1890 .chunks
1891 .iter()
1892 .filter(|chunk| chunk.id != *b"DIRM" && chunk.id != *b"FORM")
1893 .map(|chunk| (chunk.id, chunk.data))
1894 .collect::<Vec<_>>();
1895 assert_eq!(
1896 index_document_chunks, original_document_chunks,
1897 "NAVM and every other document-level chunk survive in the index"
1898 );
1899
1900 let component_map = indirect
1901 .components
1902 .iter()
1903 .cloned()
1904 .collect::<std::collections::BTreeMap<_, _>>();
1905 for node in original_graph.nodes() {
1906 let component = component_map
1907 .get(&node.id)
1908 .expect("every graph node has an extracted component");
1909 assert!(component.starts_with(b"AT&T"));
1910 let component_form =
1911 iff::parse_form(component).expect("component is a standalone FORM");
1912 let includes = component_form
1913 .chunks
1914 .iter()
1915 .filter(|chunk| chunk.id == *b"INCL")
1916 .map(|chunk| {
1917 core::str::from_utf8(chunk.data.trim_ascii_end())
1918 .expect("fixture INCL ids are UTF-8")
1919 })
1920 .collect::<Vec<_>>();
1921 let expected_includes = node
1922 .includes
1923 .iter()
1924 .map(|&target| original_graph.nodes()[target].id.as_str())
1925 .collect::<Vec<_>>();
1926 assert_eq!(
1927 includes, expected_includes,
1928 "INCL edges survive for {}",
1929 node.id
1930 );
1931 }
1932
1933 let resolver = |component: &ComponentId| {
1934 component_map.get(&component.name).cloned().ok_or_else(|| {
1935 ComponentResolveError::Missing {
1936 component: component.clone(),
1937 }
1938 })
1939 };
1940 let resolved = DjVuDocument::parse_with_component_resolver(&indirect.index, &resolver)
1941 .expect("parse converted indirect document");
1942 assert_eq!(resolved.page_count(), original_document.page_count());
1943 for index in pages_with_shared_dict {
1944 assert!(
1945 resolved
1946 .page(index)
1947 .expect("valid resolved page")
1948 .decoded_shared_dict()
1949 .is_some(),
1950 "page {index}'s INCL still resolves its shared dictionary"
1951 );
1952 }
1953 }
1954
1955 #[test]
1956 fn to_indirect_rejects_an_indirect_djvm() {
1957 let indirect = create_indirect(&["page.djvu"]).expect("build indirect index");
1958 assert!(matches!(
1959 to_indirect(&indirect),
1960 Err(DjvmError::NotBundledDjvm)
1961 ));
1962 }
1963
1964 #[test]
1965 fn merge_empty_returns_error() {
1966 let result = merge(&[]);
1967 assert!(result.is_err());
1968 }
1969
1970 #[test]
1977 fn merge_dirm_offsets_sizes_and_version_are_djvulibre_clean() {
1978 let a = std::fs::read(fixture_path("navm_fgbz.djvu")).unwrap();
1979 let bytes = merge(&[&a, &a]).unwrap();
1980
1981 assert_eq!(&bytes[16..20], b"DIRM");
1982 let dirm_len = u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as usize;
1983 let payload = &bytes[24..24 + dirm_len];
1984 assert_eq!(payload[0], 0x81, "bundled bit + directory version 1");
1985
1986 let nfiles = u16::from_be_bytes(payload[1..3].try_into().unwrap()) as usize;
1987 assert!(nfiles > 0);
1988 let dirm = DirmPayload::decode(payload).unwrap();
1989 let components = dirm.components();
1990 assert_eq!(components.len(), nfiles);
1991 for (c, &off) in components.iter().zip(&dirm.offsets) {
1992 assert_ne!(off, 0, "component {} has a zeroed offset", c.id);
1993 let off = off as usize;
1994 assert_eq!(&bytes[off..off + 4], b"FORM", "offset must hit a FORM tag");
1995 let form_len = u32::from_be_bytes(bytes[off + 4..off + 8].try_into().unwrap()) as u64;
1996 assert_eq!(
1997 c.size as u64,
1998 form_len + 8,
1999 "size table must match component {}'s FORM span",
2000 c.id
2001 );
2002 }
2003 }
2004
2005 #[test]
2006 fn split_single_page_from_multipage() {
2007 let path = fixture_path("DjVu3Spec_bundled.djvu");
2008 if !path.exists() {
2009 return;
2011 }
2012 let data = std::fs::read(&path).expect("read fixture");
2013 let doc = DjVuDocument::parse(&data).expect("parse");
2014 let count = doc.page_count();
2015 assert!(count > 1, "need multipage fixture");
2016
2017 let page0 = split(&data, 0, 1).expect("split page 0");
2019 let form = iff::parse_form(&page0).expect("parse split page");
2021 assert_eq!(&form.form_type, b"DJVU");
2022 }
2023
2024 #[test]
2025 fn merge_two_single_page_files() {
2026 let path = fixture_path("irish.djvu");
2027 if !path.exists() {
2028 return;
2029 }
2030 let irish = std::fs::read(&path).expect("read fixture");
2031 let data = merge(&[&irish, &irish]).expect("merge");
2032 let form = iff::parse_form(&data).expect("parse merged");
2034 assert_eq!(&form.form_type, b"DJVM");
2035 }
2036
2037 #[test]
2038 fn split_out_of_bounds() {
2039 let path = fixture_path("irish.djvu");
2040 if !path.exists() {
2041 return;
2042 }
2043 let data = std::fs::read(&path).expect("read fixture");
2044 let result = split(&data, 0, 5);
2045 assert!(result.is_err());
2046 }
2047
2048 #[test]
2049 fn create_indirect_empty_returns_error() {
2050 let result = create_indirect(&[]);
2051 assert!(result.is_err());
2052 }
2053
2054 #[test]
2055 fn create_indirect_parses_with_resolver() {
2056 let indirect_bytes = create_indirect(&["chicken.djvu"]).expect("create_indirect");
2058
2059 let form = iff::parse_form(&indirect_bytes).expect("parse form");
2061 assert_eq!(&form.form_type, b"DJVM");
2062
2063 let dirm = form.chunks.iter().find(|c| &c.id == b"DIRM").expect("DIRM");
2065 let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
2066 assert!(
2067 !payload.is_bundled(),
2068 "indirect DIRM must not have bundled bit set"
2069 );
2070
2071 let chicken_path = fixture_path("chicken.djvu");
2073 if !chicken_path.exists() {
2074 return;
2075 }
2076 let chicken_data = std::fs::read(&chicken_path).expect("read chicken.djvu");
2077 let doc = DjVuDocument::parse_with_resolver(
2078 &indirect_bytes,
2079 Some(
2080 move |name: &str| -> Result<Vec<u8>, crate::djvu_document::DocError> {
2081 if name == "chicken.djvu" {
2082 Ok(chicken_data.clone())
2083 } else {
2084 Err(crate::djvu_document::DocError::IndirectResolve(
2085 name.to_string(),
2086 ))
2087 }
2088 },
2089 ),
2090 )
2091 .expect("parse indirect with resolver");
2092
2093 assert_eq!(doc.page_count(), 1);
2094 let page = doc.page(0).unwrap();
2095 assert_eq!(page.width(), 181);
2096 assert_eq!(page.height(), 240);
2097 }
2098
2099 #[test]
2100 fn create_indirect_multipage() {
2101 let indirect_bytes =
2103 create_indirect(&["page1.djvu", "page2.djvu", "page3.djvu"]).expect("create_indirect");
2104 let form = iff::parse_form(&indirect_bytes).expect("parse");
2105 assert_eq!(&form.form_type, b"DJVM");
2106
2107 let dirm = form.chunks.iter().find(|c| &c.id == b"DIRM").expect("DIRM");
2109 let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
2110 assert_eq!(payload.nfiles, 3);
2111 }
2112
2113 #[test]
2114 fn merge_with_djvm_input_extracts_all_pages() {
2115 let path = fixture_path("DjVu3Spec_bundled.djvu");
2116 if !path.exists() {
2117 return;
2118 }
2119 let data = std::fs::read(&path).expect("read");
2120 let doc = DjVuDocument::parse(&data).expect("parse");
2121 let expected_pages = doc.page_count();
2122
2123 let merged = merge(&[&data]).expect("merge DJVM");
2125 let form = iff::parse_form(&merged).expect("parse merged DJVM");
2126 assert_eq!(&form.form_type, b"DJVM");
2127 let page_count = form
2128 .chunks
2129 .iter()
2130 .filter(|c| &c.id == b"FORM" && c.data.len() >= 4 && &c.data[..4] == b"DJVU")
2131 .count();
2132 assert_eq!(page_count, expected_pages);
2133 }
2134
2135 #[test]
2136 fn split_single_page_djvu_returns_original_bytes() {
2137 let path = fixture_path("chicken.djvu");
2138 if !path.exists() {
2139 return;
2140 }
2141 let data = std::fs::read(&path).expect("read");
2142 let result = split(&data, 0, 1).expect("split single-page");
2143 assert_eq!(
2144 result, data,
2145 "splitting a single-page doc must return original bytes"
2146 );
2147 }
2148
2149 #[test]
2150 fn split_unknown_form_type_is_out_of_bounds() {
2151 let fake = iff::partial_emit(*b"UNKN", &[]).unwrap();
2153 let result = split(&fake, 0, 1);
2154 assert!(
2155 result.is_err(),
2156 "unknown form type must yield PageRangeOutOfBounds"
2157 );
2158 }
2159
2160 #[test]
2161 fn split_range_from_multipage_djvm_builds_new_djvm() {
2162 let path = fixture_path("DjVu3Spec_bundled.djvu");
2163 if !path.exists() {
2164 return;
2165 }
2166 let data = std::fs::read(&path).expect("read");
2167 let doc = DjVuDocument::parse(&data).expect("parse");
2168 let count = doc.page_count();
2169 if count < 3 {
2170 return;
2171 }
2172 let extracted = split(&data, 1, 3).expect("split range");
2174 let form = iff::parse_form(&extracted).expect("parse extracted");
2175 assert_eq!(&form.form_type, b"DJVM");
2176 let page_count = form
2177 .chunks
2178 .iter()
2179 .filter(|c| &c.id == b"FORM" && c.data.len() >= 4 && &c.data[..4] == b"DJVU")
2180 .count();
2181 assert_eq!(page_count, 2);
2182 }
2183
2184 #[test]
2185 fn split_bundled_djvm_keeps_transitive_dependencies_and_dirm_ids() {
2186 let extracted = split(&split_dependency_fixture(), 0, 2).expect("split range");
2187 let graph = ComponentGraph::parse(&extracted).expect("parse extracted graph");
2188 let ids = graph
2189 .nodes()
2190 .iter()
2191 .map(|node| node.id.as_str())
2192 .collect::<Vec<_>>();
2193
2194 assert_eq!(
2195 ids,
2196 vec!["page0.djvu", "dictA.djvi", "page1.djvu", "dictB.djvi"]
2197 );
2198 assert!(
2199 graph.node("dictA.djvi").is_some(),
2200 "original id is retained"
2201 );
2202 assert!(
2203 graph.node("dictC.djvi").is_none(),
2204 "unreferenced shared component is omitted"
2205 );
2206 assert!(
2207 graph
2208 .validate()
2209 .iter()
2210 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
2211 "the retained page INCLs resolve within the extracted bundle"
2212 );
2213 }
2214
2215 #[test]
2216 fn split_single_page_with_dependencies_bundles_its_closure() {
2217 let extracted = split(&split_dependency_fixture(), 0, 1).expect("split page");
2220 let form = iff::parse_form(&extracted).expect("parse extracted bundle");
2221 assert_eq!(&form.form_type, b"DJVM");
2222
2223 let graph = ComponentGraph::parse(&extracted).expect("parse extracted graph");
2224 let ids = graph
2225 .nodes()
2226 .iter()
2227 .map(|node| node.id.as_str())
2228 .collect::<Vec<_>>();
2229 assert_eq!(ids, vec!["page0.djvu", "dictA.djvi"]);
2230 assert!(
2231 graph
2232 .validate()
2233 .iter()
2234 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
2235 "the retained page INCL resolves within the extracted bundle"
2236 );
2237 }
2238
2239 #[test]
2240 fn split_single_page_without_dependencies_returns_standalone_form_djvu() {
2241 let doc = split_bundled_fixture(vec![
2244 split_component("page0.djvu", 1, *b"DJVU", vec![]),
2245 split_component("page1.djvu", 1, *b"DJVU", vec![]),
2246 ]);
2247 let extracted = split(&doc, 1, 2).expect("split page");
2248 let form = iff::parse_form(&extracted).expect("parse extracted page");
2249 assert_eq!(&form.form_type, b"DJVU");
2250 }
2251
2252 #[test]
2253 fn split_djvm_without_a_component_graph_uses_legacy_fallback() {
2254 let page0 = split_component_body(&split_component("page0.djvu", 1, *b"DJVU", vec![]));
2255 let page1 = split_component_body(&split_component("page1.djvu", 1, *b"DJVU", vec![]));
2256 let doc = iff::partial_emit(
2257 *b"DJVM",
2258 &[iff::EmitPart::Form(&page0), iff::EmitPart::Form(&page1)],
2259 )
2260 .expect("small DIRM-less fixture");
2261
2262 let extracted = split(&doc, 0, 2).expect("split through fallback");
2263 let form = iff::parse_form(&extracted).expect("parse fallback output");
2264 assert_eq!(&form.form_type, b"DJVM");
2265 assert_eq!(
2266 form.chunks
2267 .iter()
2268 .filter(|chunk| chunk.id == *b"FORM" && chunk.data.starts_with(b"DJVU"))
2269 .count(),
2270 2
2271 );
2272 }
2273
2274 #[test]
2275 fn merge_unknown_form_type_returns_empty_merge_error() {
2276 let fake = iff::partial_emit(*b"UNKN", &[]).unwrap();
2278 let result = merge(&[&fake]);
2279 assert!(matches!(result, Err(DjvmError::EmptyMerge)));
2280 }
2281
2282 #[test]
2283 fn split_second_page_from_djvm_skips_first() {
2284 let path = fixture_path("DjVu3Spec_bundled.djvu");
2287 if !path.exists() {
2288 return;
2289 }
2290 let data = std::fs::read(&path).expect("read");
2291 let doc = DjVuDocument::parse(&data).expect("parse");
2292 if doc.page_count() < 2 {
2293 return;
2294 }
2295 let result = split(&data, 1, 2).expect("split page 1");
2296 let form = iff::parse_form(&result).expect("parse split page");
2297 assert_eq!(&form.form_type, b"DJVM");
2301 let graph = ComponentGraph::parse(&result).expect("parse extracted graph");
2302 assert!(
2303 graph
2304 .validate()
2305 .iter()
2306 .all(|error| !matches!(error, crate::GraphError::MissingTarget { .. })),
2307 "the extracted page's INCL resolves within its bundle"
2308 );
2309 }
2310
2311 #[test]
2312 fn parse_from_dir_indirect() {
2313 let chicken_path = fixture_path("chicken.djvu");
2316 if !chicken_path.exists() {
2317 return;
2318 }
2319 let tmp = std::env::temp_dir().join("djvu_indirect_test");
2320 std::fs::create_dir_all(&tmp).unwrap();
2321
2322 let component_name = "p0001.djvu";
2324 std::fs::copy(&chicken_path, tmp.join(component_name)).unwrap();
2325
2326 let index_bytes = create_indirect(&[component_name]).expect("create_indirect");
2328 let index_path = tmp.join("index.djvu");
2329 std::fs::write(&index_path, &index_bytes).unwrap();
2330
2331 let index_data = std::fs::read(&index_path).unwrap();
2333 let doc = DjVuDocument::parse_from_dir(&index_data, &tmp).expect("parse_from_dir");
2334 assert_eq!(doc.page_count(), 1);
2335 assert_eq!(doc.page(0).unwrap().width(), 181);
2336 }
2337}