Skip to main content

djvu_rs/
djvm.rs

1//! DJVM document merge and split operations.
2//!
3//! Provides [`merge`] to combine multiple DjVu documents into a single
4//! bundled DJVM, and [`split`] to extract page ranges from a document.
5//!
6//! [`merge`]: crate::djvm::merge
7//! [`split`]: crate::djvm::split
8
9#[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/// Error type for DJVM merge, split, and conversion operations.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum DjvmError {
31    /// IFF container parse error.
32    #[error("IFF parse error: {0}")]
33    Iff(#[from] IffError),
34
35    /// Document model error.
36    #[error("document error: {0}")]
37    Doc(#[from] crate::djvu_document::DocError),
38
39    /// No pages to merge.
40    #[error("no pages to merge")]
41    EmptyMerge,
42
43    /// Page range is out of bounds.
44    #[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    /// A page-removal index is out of bounds.
52    #[error("page index {index} is out of bounds (document has {count} pages)")]
53    PageIndexOutOfBounds {
54        /// The requested page index.
55        index: usize,
56        /// Number of pages in the document.
57        count: usize,
58    },
59
60    /// A page-removal index was supplied more than once.
61    #[error("page index {index} was specified more than once")]
62    DuplicatePageIndex {
63        /// The duplicate page index.
64        index: usize,
65    },
66
67    /// Removing the requested pages would leave the document empty.
68    #[error("cannot remove all {count} pages from a document")]
69    AllPagesRemoved {
70        /// Number of pages in the document.
71        count: usize,
72    },
73
74    /// The bundled component graph could not be built.
75    #[error("component graph error: {0}")]
76    ComponentGraph(String),
77
78    /// The assembled document's FORM payload would exceed `u32::MAX` (4 GiB).
79    #[error("merged document exceeds the 4 GiB IFF FORM limit")]
80    OutputTooLarge,
81
82    /// The input is not a bundled `FORM:DJVM` document.
83    #[error("to_indirect requires a bundled FORM:DJVM document")]
84    NotBundledDjvm,
85
86    /// The `DIRM` payload is malformed or missing a required field.
87    #[error("DIRM chunk is malformed: {0}")]
88    DirmMalformed(&'static str),
89
90    /// The bundled `DIRM` and embedded component count disagree.
91    #[error("DIRM component count {dirm} does not match bundle child count {children}")]
92    DirmComponentCountMismatch {
93        /// Component count declared by `DIRM`.
94        dirm: usize,
95        /// Direct `FORM` children in the bundle.
96        children: usize,
97    },
98
99    /// A streaming sink or temporary spool could not be read or written.
100    #[error("stream I/O error: {0}")]
101    Io(#[from] io::Error),
102
103    /// The component, id, and flag slices passed to a convenience builder disagree.
104    #[error(
105        "component descriptor count mismatch (components: {components}, ids: {ids}, flags: {flags})"
106    )]
107    ComponentDescriptorCountMismatch {
108        /// Number of component byte slices.
109        components: usize,
110        /// Number of component ids.
111        ids: usize,
112        /// Number of component flags.
113        flags: usize,
114    },
115
116    /// More than `u16::MAX` components were supplied for one bundled DIRM.
117    #[error("bundled DIRM supports at most 65535 components (got {count})")]
118    TooManyComponents {
119        /// Number of requested components.
120        count: usize,
121    },
122}
123
124/// Storage policy for [`DjvmStreamWriter`] component bytes.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum DjvmSpool {
127    /// Spool into an in-memory buffer (bounded by total component bytes; use
128    /// only for modest documents).
129    Memory,
130    /// Spool into a temporary file in [`std::env::temp_dir`]. The writer holds
131    /// only the component currently passed to [`DjvmStreamWriter::add_component`]
132    /// in RAM; the file is removed when the writer finishes or is dropped.
133    TempFile,
134}
135
136struct SpoolComponent {
137    id: String,
138    flag: u8,
139    /// Length of the embedded component before its enclosing-DJVM alignment pad.
140    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
187/// A temporary component spool which is removed on every exit path.
188///
189/// The path remains linked while the writer is active so creation failures and
190/// cleanup are observable on every supported platform. Drop closes the file
191/// first, then removes the path; that is the Windows-compatible fallback for
192/// platforms which cannot unlink an open file.
193struct 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        // Windows cannot remove an open file. Taking it here closes the handle
250        // before the best-effort deletion; Unix follows the same cleanup path.
251        drop(self.file.take());
252        let _ = std::fs::remove_file(&self.path);
253    }
254}
255
256/// Incrementally builds a bundled `FORM:DJVM` document into a [`Write`] sink.
257///
258/// [`Self::add_component`] accepts either a complete standalone `AT&T`-prefixed
259/// component file or the same component with only that four-byte `AT&T` prefix
260/// removed (a bare `FORM` sub-FORM). Components are embedded unchanged after
261/// stripping only the optional magic. `flag` is the DIRM kind: `0` shared,
262/// `1` page, or `2` thumbnail.
263pub 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    /// Start a bundled DJVM writer using the chosen component spool policy.
272    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    /// Append one standalone `AT&T` component or bare `FORM` sub-FORM.
282    ///
283    /// The supplied bytes are spooled immediately. In [`DjvmSpool::TempFile`]
284    /// mode, the writer retains only this borrowed component while this call is
285    /// running; the recorded directory data is just id, flag, and byte length.
286    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    /// Append a document-level leaf chunk (for example `NAVM`) after `DIRM`
305    /// and before the bundled component FORMs.
306    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    /// Add an already-parsed document chunk for the vector convenience API.
315    ///
316    /// This retains the canonical IFF re-framing behavior for unusual document
317    /// chunks which are themselves `FORM`s. The public API intentionally
318    /// exposes only leaf chunks because DJVM document chunks such as `NAVM`
319    /// are leaf payloads.
320    fn add_document_iff_chunk(&mut self, chunk: &iff::Chunk) {
321        self.document_chunks.push(chunk.clone());
322    }
323
324    /// Write the final header, DIRM, document chunks, and spooled components,
325    /// returning the sink.
326    ///
327    /// On error the sink may contain a partial DJVM. The library does not
328    /// clean it up or provide atomic replacement (that policy belongs to the
329    /// CLI/application layer).
330    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        // The offset table is fixed-width and comes before the BZZ metadata.
353        // Its final contents cannot affect the DIRM chunk's framed size, so all
354        // component starts are known before any component is copied to `sink`.
355        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        // `partial_emit_with_offsets` starts every part after AT&T + FORM +
393        // length + DJVM (16 bytes). `offset` is therefore exactly the final
394        // outer FORM payload length plus its 12-byte prologue.
395        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        // Obtain the canonical AT&T/FORM/DJVM prologue from the IFF emission
400        // seam, patch only its already-reserved length field, then stream each
401        // child. This avoids hand-rolling IFF framing outside `djvu-iff`.
402        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
416/// Write one child chunk using the IFF emission seam, omitting its temporary
417/// root prologue. The remaining bytes are exactly the child framing that
418/// `iff::partial_emit_with_offsets` would place in a DJVM payload.
419fn 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
427/// An indirect `FORM:DJVM` index and the external component files it resolves.
428pub struct IndirectDocument {
429    /// The indirect `FORM:DJVM` index file bytes (`DIRM` has no bundled bit or
430    /// offset table; document-level chunks such as `NAVM` are retained).
431    pub index: Vec<u8>,
432    /// One resolver-keyed standalone component file per `DIRM` entry, in
433    /// directory order.
434    pub components: Vec<(String, Vec<u8>)>,
435}
436
437/// The result of [`dedup_shared_components`].
438pub struct ComponentDedup {
439    /// The deduplicated bundled document.
440    pub document: Vec<u8>,
441    /// `(dropped_id, surviving_id)` for every merged duplicate, in DIRM order.
442    pub merged: Vec<(String, String)>,
443}
444
445/// Policy for shared components that become unreachable after page removal.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum UnreachablePolicy {
448    /// Keep unreachable shared components in the output.
449    Preserve,
450    /// Drop shared components no longer reachable from any surviving page.
451    GarbageCollect,
452}
453
454/// Result of removing pages from a bundled document.
455pub struct PageRemoval {
456    /// The rebuilt bundled document.
457    pub document: Vec<u8>,
458    /// Ids of shared components that became unreachable from the surviving
459    /// pages, in DIRM order. These are dropped from `document` iff the policy
460    /// was `GarbageCollect`; otherwise they are reported but retained.
461    pub unreachable: Vec<String>,
462}
463
464/// Remove the pages at the given 0-based page indices (page order = DIRM order
465/// of `Page` components) from a bundled `FORM:DJVM`, applying `policy` to shared
466/// components that no longer have any including page.
467pub 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            // Thumbnail-to-page association is not represented by INCL, so this
571            // slice deliberately retains all thumbnails under both policies.
572            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
599/// Merge byte-identical shared `FORM:DJVI` components in a bundled document,
600/// redirecting `INCL` references to the surviving component. Pages and
601/// thumbnails are never merged; only exact byte-for-byte duplicate shared
602/// components are.
603pub 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    // A BTreeMap makes this grouping deterministic, while the first entry seen
634    // for each byte payload is necessarily its lowest DIRM index.
635    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        // Do not infer shareability from the FORM type alone: a malformed DIRM
642        // could label a page or thumbnail as DJVI. Only a directory-declared
643        // shared component with a DJVI body is eligible.
644        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    // Besides avoiding unnecessary DIRM metadata rewrites, this preserves the
659    // source byte-for-byte when no duplicate is found.
660    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
699/// Rewrite INCL leaf payloads that name dropped components and return the
700/// component FORM body. Unchanged forms retain their original body verbatim.
701fn 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
758/// Re-serialize a sub-FORM child — the raw `data` of a `FORM` chunk, which
759/// begins with its 4-byte form type — back into a standalone `AT&T`-prefixed
760/// FORM document. Inverse of [`strip_att`].
761fn wrap_sub_form(form_data: &[u8]) -> Vec<u8> {
762    // `form_data` is a FORM body: it begins with the 4-byte secondary id
763    // (DJVU/DJVI/…) followed by the chunks. Route the AT&T/FORM/length framing
764    // through the emission seam rather than hand-assembling it. A well-formed
765    // FORM body is even-length (every inner chunk is word-aligned), so the seam
766    // reproduces the original bytes exactly; a malformed odd body merely gains a
767    // trailing pad, which re-parses identically.
768    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
776/// Strip a leading `AT&T` magic from a standalone FORM document, yielding the
777/// `FORM`-chunk bytes to embed inside a DJVM bundle. Inverse of [`wrap_sub_form`].
778fn 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
786/// Convert a bundled `FORM:DJVM` into its indirect index and standalone
787/// component files.
788///
789/// The returned index retains each document-level non-`FORM` chunk (including
790/// `NAVM`). Its `DIRM` is the source directory with only the bundled bit and
791/// offset table removed: the BZZ-compressed metadata tail is retained verbatim,
792/// so component ids, names, titles, and flags remain stable. Each returned
793/// component is a complete `AT&T`-prefixed `FORM:DJVU`, `FORM:DJVI`, or
794/// `FORM:THUM` file suitable for [`crate::djvu_document::ComponentResolver`].
795pub 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    // The BZZ metadata tail is opaque here. Decoding it only supplies the
826    // resolver keys; the re-emitted DIRM carries the original metadata bytes.
827    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    // Bundled DIRM layout is [flags][nfiles][offset table][BZZ metadata].
835    // Clear only the bit that selects that layout; `encode` then omits the
836    // table while preserving the metadata blob byte-for-byte.
837    dirm.flags &= !BUNDLED_FLAG;
838    dirm.offsets.clear();
839    let indirect_dirm = dirm.encode();
840
841    // Preserve document-level chunks (NAVM and any extensions) in their
842    // original order while removing all embedded component FORMs. Re-frame
843    // leaves through the IFF emission seam so length and padding are correct.
844    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
873/// Merge multiple DjVu documents (raw bytes) into a single bundled DJVM.
874///
875/// Each input document contributes all its pages to the output.
876/// Shared dictionaries (DJVI components) are included and INCL
877/// references are preserved within each source document's pages.
878pub 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            // Single-page document — the whole file is one page
892            components.push(doc_data.to_vec());
893            component_ids.push(format!("p{:04}.djvu", components.len()));
894            component_flags.push(1); // page
895        } else if &form.form_type == b"DJVM" {
896            // Multi-page bundled document — extract each FORM child
897            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 }; // 0 = shared, 1 = page
902
903                    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
918/// Split a document, extracting pages in the given range (0-based, exclusive end).
919///
920/// Returns raw DjVu bytes for a new document containing only the requested pages.
921pub fn split(doc_data: &[u8], start: usize, end: usize) -> Result<Vec<u8>, DjvmError> {
922    let form = iff::parse_form(doc_data)?;
923
924    // Page count derived from the same FORM walk used for extraction below, so
925    // the bounds check can never disagree with what is actually present (a
926    // DIRM-based page count and the FORM:DJVU children can diverge).
927    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    // Single-page document: just return the whole thing
942    if &form.form_type == b"DJVU" && start == 0 && end == 1 {
943        return Ok(doc_data.to_vec());
944    }
945
946    // For a single page extraction from a multi-page document with no shared
947    // dependencies, return the standalone `FORM:DJVU`. If the page INCLs shared
948    // components, fall through to the graph closure path below so it is bundled
949    // with its dependencies — a bare page would carry dangling INCL references.
950    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                // Only trust the graph when its page count agrees with the FORM
960                // walk; otherwise keep the historical standalone behaviour.
961                (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    // Multiple pages: when the bundled component graph is available, retain
981    // just the selected pages and their transitive INCL dependencies.  DIRM
982    // identities must survive this rewrite: INCL chunks name those ids.
983    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        // `count` is intentionally derived from the FORM walk above for
991        // compatibility.  If a graph that otherwise parses has a different
992        // page count, keep the established extraction path below.
993        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                // Thumbnails are deliberately excluded from split output.
1003                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            // The graph and reader both correlate DIRM entry i with embedded
1018            // FORM child i.  Iterating nodes keeps the output in DIRM order.
1019            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    // Fallback for indirect, malformed, and otherwise non-graph DJVMs: keep
1033    // the historical FORM-based extraction behaviour.
1034    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    // First pass: collect shared components (DJVI) that might be needed
1039    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); // shared
1044        }
1045    }
1046
1047    // Second pass: collect pages in the requested range
1048    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); // page
1055            }
1056            page_idx += 1;
1057        }
1058    }
1059
1060    build_djvm(&components, &component_ids, &component_flags)
1061}
1062
1063/// Build a bundled DJVM file from components.
1064///
1065/// The IFF framing — `FORM:DJVM` header, the `DIRM` chunk header, and the
1066/// even-byte padding between components — is delegated to [`iff::partial_emit`]
1067/// so this writer shares the one emission seam (#367). The DIRM goes through as
1068/// a re-framed [`iff::Chunk`]; each component is copied verbatim (its AT&T magic
1069/// stripped, since it is embedded, not a standalone file).
1070fn build_djvm(components: &[Vec<u8>], ids: &[String], flags: &[u8]) -> Result<Vec<u8>, DjvmError> {
1071    build_djvm_with_document_chunks(components, ids, flags, &[])
1072}
1073
1074/// Build a bundled DJVM, retaining the supplied document-level chunks between
1075/// the rebuilt DIRM and embedded component FORMs.
1076fn 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    // Keep every convenience API on the streaming implementation. The memory
1091    // spool preserves the Vec-returning surface while the TempFile spool is
1092    // available to callers whose documents cannot fit in a component Vec.
1093    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
1103/// Create an indirect (non-bundled) DJVM index file that references pages as
1104/// separate files.
1105///
1106/// The returned bytes are a valid `FORM:DJVM` with a DIRM directory chunk whose
1107/// `is_bundled` flag is **not** set.  Each entry in `page_names` becomes one
1108/// `Page` component; there are no embedded `FORM:DJVU` sub-forms — the component
1109/// data lives in separate files that must be passed to a resolver when parsing.
1110///
1111/// Shared-dictionary (DJVI) components are not supported by this helper; use
1112/// [`merge`] to build a bundled document that includes them.
1113///
1114/// # Errors
1115///
1116/// Returns [`DjvmError::EmptyMerge`] if `page_names` is empty.
1117pub 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    // All entries are pages (flag = 1)
1125    let flags: Vec<u8> = vec![1u8; count];
1126
1127    // Indirect: a single DIRM chunk, no embedded component FORMs. Route the
1128    // DJVM framing through the emission seam (same path as the bundled build).
1129    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    /// Reference the established `partial_emit_with_offsets` implementation so
1274    /// the streaming path is checked against the old canonical framing rather
1275    /// than merely against its Vec convenience wrapper.
1276    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                // The public writer accepts both forms. Use a bare `FORM` for
1337                // the shared component and standalone AT&T files for the rest.
1338                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    /// #657: merged bundles must carry a DjVuLibre-acceptable DIRM — version
1971    /// byte 0x81 (bundled, directory version 1), every offset non-zero and
1972    /// pointing at a component `FORM` tag, and the 24-bit size table matching
1973    /// each component's actual byte span. A zeroed offset table or version 0
1974    /// is rejected by DjVmDir ("no indirect entries allowed in bundled
1975    /// document").
1976    #[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            // Skip if fixture not available
2010            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        // Split out page 0
2018        let page0 = split(&data, 0, 1).expect("split page 0");
2019        // Verify the result is parseable
2020        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        // Verify the result has the right FORM type
2033        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        // Build an indirect DJVM that references "chicken.djvu"
2057        let indirect_bytes = create_indirect(&["chicken.djvu"]).expect("create_indirect");
2058
2059        // Verify it parses as FORM:DJVM
2060        let form = iff::parse_form(&indirect_bytes).expect("parse form");
2061        assert_eq!(&form.form_type, b"DJVM");
2062
2063        // Verify DIRM chunk has is_bundled = 0
2064        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        // Parse with a resolver that supplies chicken.djvu
2072        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        // 3-page indirect document
2102        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        // Component count = 3 in DIRM
2108        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        // merge(&[djvm]) should expand the DJVM into its component pages
2124        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        // A valid AT&T FORM with an unknown form type has 0 pages → always OOB
2152        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        // Extract pages 1..3 — a multi-page range → hits build_djvm path
2173        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        // page0 INCLs dictA, so extracting it alone must produce a self-contained
2218        // bundle (page0 + dictA) rather than a bare page with a dangling INCL.
2219        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        // A page that references no shared component keeps the standalone fast
2242        // path. Extracting index 1 also covers the `page_idx += 1` skip.
2243        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        // All docs are unknown type → components stays empty → EmptyMerge
2277        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        // Extracting page at index 1 forces page_idx to increment past index 0,
2285        // covering the page_idx += 1 path in the single-page DJVM loop.
2286        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        // Page index 1 (p0002) INCLs the shared dict0020.iff, so its standalone
2298        // extraction is now a self-contained bundle rather than a bare page with
2299        // a dangling INCL. Its INCL must resolve within the extracted bundle.
2300        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        // Write an indirect DJVM index and chicken.djvu to a temp directory,
2314        // then open it via parse_from_dir.
2315        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        // Copy chicken.djvu as the component
2323        let component_name = "p0001.djvu";
2324        std::fs::copy(&chicken_path, tmp.join(component_name)).unwrap();
2325
2326        // Build indirect index
2327        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        // Open via parse_from_dir
2332        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}