Skip to main content

djvu_rs/
djvu_mut.rs

1//! In-place DjVu document mutation — byte-preserving rewrite of the IFF tree.
2//!
3//! Originated in [#222](https://github.com/matyushkin/djvu-rs/issues/222).
4//! This module parses a document into an editable tree, can walk to a leaf
5//! chunk by path, replace its data, and serialise back. When no mutations have
6//! happened, [`into_bytes`](crate::djvu_mut::DjVuDocumentMut::into_bytes)
7//! returns the original bytes verbatim (byte-identical round-trip). High-level
8//! setters are available for page text, annotations, metadata, and bundled-DJVM
9//! bookmarks.
10//!
11//! Indirect `FORM:DJVM` mutation via the plain
12//! [`from_bytes`](crate::djvu_mut::DjVuDocumentMut::from_bytes) entry point
13//! remains unsupported ([`page_mut`](crate::djvu_mut::DjVuDocumentMut::page_mut)
14//! returns [`MutError::IndirectDjvmUnsupported`]). To edit an indirect document,
15//! use [`from_indirect_resolved`](crate::djvu_mut::DjVuDocumentMut::from_indirect_resolved),
16//! which resolves the external components and rebundles them into an owned
17//! bundled `FORM:DJVM` tree; see
18//! [`docs/indirect-djvm-mutation.md`](../docs/indirect-djvm-mutation.md). The
19//! explicit external-file rewrite path is provided separately by
20//! [`IndirectRewritePlan`](crate::djvu_mut::IndirectRewritePlan).
21//!
22//! ## Example
23//!
24//! ```no_run
25//! use djvu_rs::djvu_mut::DjVuDocumentMut;
26//!
27//! let original = std::fs::read("doc.djvu").unwrap();
28//! let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
29//!
30//! // Round-trip byte-identical without edits:
31//! assert_eq!(doc.clone().into_bytes(), original);
32//!
33//! // Replace a leaf chunk's payload by path:
34//! doc.replace_leaf(&[0], b"new payload".to_vec()).unwrap();
35//! let edited = doc.into_bytes();
36//! ```
37//!
38//! ## Path format
39//!
40//! A `path: &[usize]` is a sequence of child indices to walk from the root
41//! `FORM` chunk. The root itself is never indexed — `[0]` selects the first
42//! child of the root.
43//!
44//! For a single-page `FORM:DJVU`: `[i]` selects the i-th leaf chunk
45//! (e.g. `INFO`, `Sjbz`, `BG44`). For a bundled `FORM:DJVM`:
46//! `[0]` selects the `DIRM` chunk, `[1]` selects the `NAVM` chunk (if
47//! present), `[i]` thereafter selects the i-th component `FORM:DJVU`. To
48//! reach a leaf inside that component: `[i, j]`.
49
50#[cfg(not(feature = "std"))]
51use alloc::vec::Vec;
52use core::ops::Range;
53
54use crate::annotation::{Annotation, MapArea, encode_annotations_bzz};
55use crate::chunk_encode::{ChunkEncoder, NavmChunk};
56use crate::dirm::{DirmComponent, DirmComponentKind, DirmPayload};
57use crate::djvu_document::DjVuBookmark;
58use crate::error::{IffError, LegacyError};
59use crate::iff::{self, Chunk, DjvuFile, parse_form_body};
60use crate::info::PageInfo;
61use crate::metadata::{DjVuMetadata, encode_metadata_bzz};
62use crate::text::TextLayer;
63use crate::text_encode::encode_text_layer;
64
65/// Errors produced by [`DjVuDocumentMut`] operations.
66#[derive(Debug, thiserror::Error)]
67pub enum MutError {
68    /// IFF parse error during [`DjVuDocumentMut::from_bytes`].
69    #[error("IFF parse error: {0}")]
70    Parse(#[from] LegacyError),
71
72    /// The path indexed past the end of a FORM's children.
73    #[error("chunk path out of range: index {index} at depth {depth} (form has {len} children)")]
74    PathOutOfRange {
75        index: usize,
76        depth: usize,
77        len: usize,
78    },
79
80    /// The path traversed into a leaf chunk and tried to keep going.
81    #[error("chunk path enters a leaf at depth {depth} but is {len} levels long")]
82    PathTraversesLeaf { depth: usize, len: usize },
83
84    /// `replace_leaf` was called with a path that ends on a `FORM` chunk
85    /// rather than a leaf.
86    #[error("path ends on a FORM, not a leaf chunk")]
87    NotALeaf,
88
89    /// The path is empty — must contain at least one index.
90    #[error("path must not be empty")]
91    EmptyPath,
92
93    /// `page_mut` was called with an index past the document's page count.
94    #[error("page index {index} out of range (document has {count} pages)")]
95    PageOutOfRange {
96        /// Requested page index.
97        index: usize,
98        /// Number of pages in the document.
99        count: usize,
100    },
101
102    /// The page has no INFO chunk, which is required to encode chunks whose
103    /// payload depends on page height (currently `set_text_layer`).
104    #[error("page has no INFO chunk; cannot encode height-dependent chunk")]
105    MissingPageInfo,
106
107    /// The page's INFO chunk failed to parse.
108    #[error("INFO chunk parse error: {0}")]
109    InfoParse(#[from] IffError),
110
111    /// The operation requires DIRM offset recomputation, which is not
112    /// implemented for indirect (non-bundled) `FORM:DJVM` documents — those
113    /// reference page bytes in external files via a resolver, so editing them
114    /// in place would also need the external files rewritten. The current
115    /// decision record is
116    /// [`docs/indirect-djvm-mutation.md`](../docs/indirect-djvm-mutation.md).
117    #[error("mutation of indirect DJVM documents is not supported")]
118    IndirectDjvmUnsupported,
119
120    /// The DIRM chunk was malformed in a way that prevents offset
121    /// recomputation. Should not occur after a successful
122    /// [`DjVuDocumentMut::from_bytes`] on a well-formed DJVM document.
123    #[error("DIRM chunk is malformed: {0}")]
124    DirmMalformed(&'static str),
125
126    /// The number of `FORM:DJVU`/`FORM:DJVI` components in the bundle does
127    /// not match the count recorded in DIRM. Indicates a structurally
128    /// inconsistent document.
129    #[error("DIRM component count {dirm} does not match bundle child count {children}")]
130    DirmComponentCountMismatch {
131        /// Component count read from DIRM (`nfiles`).
132        dirm: usize,
133        /// Actual count of `FORM:DJVU`/`FORM:DJVI` children in the root.
134        children: usize,
135    },
136
137    /// `set_bookmarks` was called on a `FORM:DJVU` (single-page) document.
138    /// NAVM bookmarks live in `FORM:DJVM` bundles only.
139    #[error("set_bookmarks requires a FORM:DJVM bundle (this document is FORM:DJVU)")]
140    BookmarksRequireDjvm,
141
142    /// A chunk encoder rejected its input because a count exceeds the wire
143    /// format's fixed-width field (e.g. a bookmark node with > 255 children).
144    #[error("chunk encode error: {0}")]
145    Encode(#[from] crate::chunk_encode::EncodeError),
146
147    /// [`DjVuDocumentMut::from_indirect_resolved`] was called on a document
148    /// that is not an indirect `FORM:DJVM` (it is single-page `FORM:DJVU` or an
149    /// already-bundled `FORM:DJVM`). Use [`DjVuDocumentMut::from_bytes`] for
150    /// those — only indirect bundles need resolver-backed rebundling.
151    #[error("from_indirect_resolved requires an indirect FORM:DJVM document")]
152    NotIndirectDjvm,
153
154    /// A DIRM component could not be obtained from the caller-provided resolver
155    /// (the resolver returned an error or no bytes for this component name).
156    #[error("resolver did not supply DIRM component {name:?}")]
157    ComponentResolve {
158        /// The DIRM component id passed to the resolver.
159        name: String,
160    },
161
162    /// A resolved DIRM component did not parse as a `FORM:DJVU`/`FORM:DJVI`/
163    /// `FORM:THUM` chunk, so it cannot be embedded in a bundled output.
164    #[error("DIRM component {name:?} is malformed: {reason}")]
165    ComponentMalformed {
166        /// The DIRM component id that failed to parse.
167        name: String,
168        /// Why the component bytes were rejected.
169        reason: &'static str,
170    },
171
172    /// A DIRM component name (or the root index name) is not a safe relative
173    /// file name and was rejected by the external-file rewrite path. Absolute
174    /// paths, names with path separators, drive letters, `.`/`..`, and embedded
175    /// NUL bytes are all rejected so a rewrite can never escape the destination
176    /// directory.
177    #[error("unsafe component file name {name:?}: {reason}")]
178    UnsafeComponentName {
179        /// The offending name.
180        name: String,
181        /// Why it was rejected.
182        reason: &'static str,
183    },
184
185    /// Two DIRM entries resolve to the same component file name, which would
186    /// make an external-file rewrite ambiguous (one file would shadow another).
187    #[error("duplicate DIRM component file name {name:?}")]
188    DuplicateComponentName {
189        /// The duplicated name.
190        name: String,
191    },
192
193    /// A filesystem error occurred while committing an external-file rewrite.
194    #[error("rewrite I/O error for {name:?}: {message}")]
195    RewriteIo {
196        /// The file the error is associated with.
197        name: String,
198        /// The underlying error message.
199        message: String,
200    },
201}
202
203/// A DjVu document opened for in-place mutation.
204///
205/// Holds a parsed [`DjvuFile`] tree plus the original byte buffer, so that
206/// [`Self::into_bytes`] returns a byte-identical copy when no edits have been
207/// made. After any mutation the dirty flag is set and serialisation falls
208/// through to [`iff::emit`], which reconstructs the IFF stream from the tree
209/// (see the parser/emitter contract in `src/iff.rs`).
210#[derive(Debug, Clone)]
211pub struct DjVuDocumentMut {
212    file: DjvuFile,
213    /// Original bytes of the document.  Held so an unedited round-trip is
214    /// byte-identical without re-emitting through `iff::emit` (which
215    /// recomputes FORM lengths and would not necessarily match the original
216    /// byte layout for documents with inconsistent headers).
217    original_bytes: Vec<u8>,
218    dirty: bool,
219}
220
221impl DjVuDocumentMut {
222    /// Parse a DjVu document for mutation. Validates the IFF tree.
223    ///
224    /// The original bytes are retained so that a no-edit round-trip via
225    /// [`Self::into_bytes`] is byte-identical to the input.
226    pub fn from_bytes(data: &[u8]) -> Result<Self, MutError> {
227        let file = iff::parse(data)?;
228        Ok(Self {
229            file,
230            original_bytes: data.to_vec(),
231            dirty: false,
232        })
233    }
234
235    /// Resolve an indirect `FORM:DJVM` document into an owned **bundled**
236    /// mutation tree, fetching every external component through `resolver`.
237    ///
238    /// An indirect DJVM stores only a `DIRM` directory in `root_bytes`; the
239    /// page (and shared-dictionary / thumbnail) component bytes live in
240    /// separate files. [`Self::from_bytes`] keeps indirect documents
241    /// unsupported because in-place editing would also need those external
242    /// files rewritten. This constructor instead implements the *rebundling*
243    /// strategy from [`docs/indirect-djvm-mutation.md`](../docs/indirect-djvm-mutation.md):
244    /// it resolves each `DIRM` component (in declaration order), embeds them
245    /// into a single bundled `FORM:DJVM`, and returns a [`DjVuDocumentMut`]
246    /// whose [`Self::try_into_bytes`] yields one self-contained bundled byte
247    /// stream that no longer needs a resolver.
248    ///
249    /// The resolver is called once per `DIRM` entry with that entry's id (the
250    /// same key [`crate::djvu_document::DjVuDocument::parse_with_resolver`]
251    /// uses) and must return the raw bytes of that component file. Returning an
252    /// error for any component aborts the whole construction.
253    ///
254    /// After construction the returned handle behaves like any bundled
255    /// `FORM:DJVM`: [`Self::page_mut`], [`Self::set_bookmarks`], and
256    /// [`Self::try_into_bytes`] all work, with `DIRM` offsets recomputed on
257    /// serialisation.
258    ///
259    /// # Errors
260    ///
261    /// - [`MutError::NotIndirectDjvm`] if `root_bytes` is not an indirect
262    ///   `FORM:DJVM` (single-page `FORM:DJVU` or an already-bundled bundle).
263    /// - [`MutError::ComponentResolve`] if the resolver fails for a component.
264    /// - [`MutError::ComponentMalformed`] if a resolved component does not parse
265    ///   as a `FORM:DJVU`/`DJVI`/`THUM`.
266    /// - [`MutError::DirmMalformed`] if the `DIRM` chunk cannot be read.
267    /// - [`MutError::InfoParse`] if `root_bytes` is not a parseable IFF FORM.
268    pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
269    where
270        R: Fn(&str) -> Result<Vec<u8>, E>,
271    {
272        let (dirm_data, components) = resolve_indirect_components(root_bytes)?;
273        if !components.iter().any(|c| c.kind == DirmComponentKind::Page) {
274            return Err(MutError::DirmMalformed(
275                "indirect DIRM lists no page component",
276            ));
277        }
278
279        // Resolve every component (page + shared + thumbnail) in DIRM order and
280        // parse each into its owned FORM subtree.
281        let mut component_forms: Vec<Chunk> = Vec::with_capacity(components.len());
282        for comp in &components {
283            let bytes = resolver(&comp.id).map_err(|_| MutError::ComponentResolve {
284                name: comp.id.clone(),
285            })?;
286            let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
287                name: comp.id.clone(),
288                reason: "not a parseable IFF document",
289            })?;
290            match &parsed.root {
291                Chunk::Form { secondary_id, .. }
292                    if secondary_id == b"DJVU"
293                        || secondary_id == b"DJVI"
294                        || secondary_id == b"THUM" => {}
295                _ => {
296                    return Err(MutError::ComponentMalformed {
297                        name: comp.id.clone(),
298                        reason: "root is not a FORM:DJVU/DJVI/THUM",
299                    });
300                }
301            }
302            component_forms.push(parsed.root);
303        }
304
305        // Convert the indirect DIRM into a bundled one: flip the bundled bit,
306        // splice in a zeroed offset table (recomputed below), and keep the
307        // BZZ-compressed metadata tail verbatim so component ids / names /
308        // flags survive the round-trip.
309        let bundled_dirm = bundled_dirm_from_indirect(dirm_data, component_forms.len())?;
310
311        // Assemble the bundled FORM:DJVM tree: DIRM first, then components in
312        // DIRM order. `length` is recomputed by `iff::emit`.
313        let mut children: Vec<Chunk> = Vec::with_capacity(1 + component_forms.len());
314        children.push(Chunk::Leaf {
315            id: *b"DIRM",
316            data: bundled_dirm,
317        });
318        children.extend(component_forms);
319        let mut file = DjvuFile {
320            root: Chunk::Form {
321                secondary_id: *b"DJVM",
322                length: 0,
323                children,
324            },
325        };
326
327        // Fill the DIRM offset table for the about-to-be-emitted layout, then
328        // freeze the bundled bytes as this document's canonical (unedited) form.
329        recompute_dirm_offsets(&mut file.root)?;
330        let bundled_bytes = iff::emit(&file);
331        Ok(Self {
332            file,
333            original_bytes: bundled_bytes,
334            dirty: false,
335        })
336    }
337
338    /// Number of direct children of the root FORM chunk.
339    ///
340    /// For a single-page `FORM:DJVU` this is the number of leaf chunks
341    /// (`INFO`, `Sjbz`, …). For a bundled `FORM:DJVM` it is `DIRM` + optional
342    /// `NAVM` + per-page component `FORM`s.
343    pub fn root_child_count(&self) -> usize {
344        self.file.root.children().len()
345    }
346
347    /// Return the 4-byte FORM type of the root (e.g. `b"DJVU"`, `b"DJVM"`).
348    /// Returns `None` if the root is somehow a leaf — should never happen on
349    /// a well-formed input that survived `from_bytes`.
350    pub fn root_form_type(&self) -> Option<&[u8; 4]> {
351        match &self.file.root {
352            Chunk::Form { secondary_id, .. } => Some(secondary_id),
353            Chunk::Leaf { .. } => None,
354        }
355    }
356
357    /// Replace the data of the leaf chunk reached by `path`.
358    ///
359    /// `path` is a sequence of child indices walked from the root FORM's
360    /// children. The walk descends into any FORM it encounters at an
361    /// intermediate index; the final index must address a leaf.
362    ///
363    /// # Errors
364    ///
365    /// - [`MutError::EmptyPath`] if `path.is_empty()`.
366    /// - [`MutError::PathOutOfRange`] if any index exceeds a FORM's child count.
367    /// - [`MutError::PathTraversesLeaf`] if the path tries to descend past a leaf.
368    /// - [`MutError::NotALeaf`] if the final chunk is a FORM rather than a leaf.
369    pub fn replace_leaf(&mut self, path: &[usize], new_data: Vec<u8>) -> Result<(), MutError> {
370        let chunk = self.chunk_at_path_mut(path)?;
371        match chunk {
372            Chunk::Leaf { data, .. } => {
373                *data = new_data;
374                self.dirty = true;
375                Ok(())
376            }
377            Chunk::Form { .. } => Err(MutError::NotALeaf),
378        }
379    }
380
381    /// Return the chunk at `path` for inspection (without mutation).
382    pub fn chunk_at_path(&self, path: &[usize]) -> Result<&Chunk, MutError> {
383        if path.is_empty() {
384            return Err(MutError::EmptyPath);
385        }
386        let mut current = &self.file.root;
387        for (depth, &idx) in path.iter().enumerate() {
388            let children = current.children();
389            if children.is_empty() && depth < path.len() - 1 {
390                // We're inside a leaf but the path keeps going.
391                return Err(MutError::PathTraversesLeaf {
392                    depth,
393                    len: path.len(),
394                });
395            }
396            if let Chunk::Leaf { .. } = current {
397                return Err(MutError::PathTraversesLeaf {
398                    depth,
399                    len: path.len(),
400                });
401            }
402            if idx >= children.len() {
403                return Err(MutError::PathOutOfRange {
404                    index: idx,
405                    depth,
406                    len: children.len(),
407                });
408            }
409            current = &children[idx];
410        }
411        Ok(current)
412    }
413
414    fn chunk_at_path_mut(&mut self, path: &[usize]) -> Result<&mut Chunk, MutError> {
415        if path.is_empty() {
416            return Err(MutError::EmptyPath);
417        }
418        // Validate path first using the immutable walk.  This avoids the
419        // borrow-checker dance of validating during a mutable walk.
420        let _ = self.chunk_at_path(path)?;
421        // Now walk for real with `&mut`.
422        let mut current = &mut self.file.root;
423        for &idx in path {
424            // Validation above guarantees the indices are in range and that
425            // we never index into a leaf, so this match is total.
426            match current {
427                Chunk::Form { children, .. } => {
428                    current = &mut children[idx];
429                }
430                Chunk::Leaf { .. } => unreachable!("validated by chunk_at_path"),
431            }
432        }
433        Ok(current)
434    }
435
436    /// Whether any mutation has been applied since `from_bytes`.
437    pub fn is_dirty(&self) -> bool {
438        self.dirty
439    }
440
441    /// Serialise the document back to bytes.
442    ///
443    /// When [`Self::is_dirty`] is `false`, this returns the bytes passed to
444    /// [`Self::from_bytes`] verbatim. After any mutation it falls through to
445    /// [`iff::emit`] which reconstructs the IFF stream from the parsed tree;
446    /// for `FORM:DJVM` bundles the `DIRM` offsets are recomputed first so
447    /// they point at the correct component positions in the new output.
448    ///
449    /// # Panics
450    ///
451    /// Panics if `DIRM` offset recomputation fails — this only happens on a
452    /// structurally inconsistent document (DIRM `nfiles` not matching the
453    /// bundle's child count, etc.) which a successful [`Self::from_bytes`]
454    /// would already have rejected. Use [`Self::try_into_bytes`] to recover
455    /// the error without panicking.
456    pub fn into_bytes(self) -> Vec<u8> {
457        self.try_into_bytes()
458            .expect("DIRM recomputation failed — inconsistent document")
459    }
460
461    /// Like [`Self::into_bytes`] but returns the [`MutError`] from `DIRM`
462    /// offset recomputation rather than panicking.
463    pub fn try_into_bytes(mut self) -> Result<Vec<u8>, MutError> {
464        if !self.dirty {
465            return Ok(self.original_bytes);
466        }
467        recompute_dirm_offsets(&mut self.file.root)?;
468        Ok(
469            emit_patched_single_page(&self.file.root, &self.original_bytes)
470                .unwrap_or_else(|| iff::emit(&self.file)),
471        )
472    }
473
474    // ---- High-level setters (PR2 of #222) ----------------------------------
475
476    /// Number of editable pages in the document.
477    ///
478    /// `1` for `FORM:DJVU`, the count of `FORM:DJVU` children for `FORM:DJVM`
479    /// (shared-dictionary `FORM:DJVI` components are not counted as pages).
480    pub fn page_count(&self) -> usize {
481        match self.root_form_type() {
482            Some(b"DJVM") => self
483                .file
484                .root
485                .children()
486                .iter()
487                .filter(
488                    |c| matches!(c, Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU"),
489                )
490                .count(),
491            _ => 1,
492        }
493    }
494
495    /// Borrow the i-th page's `FORM:DJVU` for high-level mutation.
496    ///
497    /// For single-page `FORM:DJVU` only `index == 0` is valid. For bundled
498    /// `FORM:DJVM` the index walks `FORM:DJVU` direct children in order
499    /// (shared-dictionary `FORM:DJVI` components are skipped).
500    ///
501    /// On serialisation, [`Self::into_bytes`] rewrites DIRM offsets to
502    /// reflect any size changes from page mutations.
503    ///
504    /// # Errors
505    ///
506    /// - [`MutError::PageOutOfRange`] if `index >= self.page_count()`.
507    /// - [`MutError::IndirectDjvmUnsupported`] if the document is an
508    ///   indirect (non-bundled) `FORM:DJVM` — page bytes live in external
509    ///   files, so editing in place is not supported by this primitive.
510    pub fn page_mut(&mut self, index: usize) -> Result<PageMut<'_>, MutError> {
511        let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
512        if &root_form_type == b"DJVU" {
513            let count = self.page_count();
514            if index >= count {
515                return Err(MutError::PageOutOfRange { index, count });
516            }
517            debug_assert_eq!(index, 0);
518            return Ok(PageMut {
519                form: &mut self.file.root,
520                dirty: &mut self.dirty,
521            });
522        }
523        debug_assert_eq!(&root_form_type, b"DJVM");
524        if !is_bundled_djvm(&self.file.root) {
525            return Err(MutError::IndirectDjvmUnsupported);
526        }
527        let count = self.page_count();
528        if index >= count {
529            return Err(MutError::PageOutOfRange { index, count });
530        }
531        // Walk the root's children, returning the index-th FORM:DJVU.
532        let children = match &mut self.file.root {
533            Chunk::Form { children, .. } => children,
534            Chunk::Leaf { .. } => unreachable!("validated FORM root"),
535        };
536        let mut seen = 0usize;
537        for child in children.iter_mut() {
538            if let Chunk::Form { secondary_id, .. } = child
539                && secondary_id == b"DJVU"
540            {
541                if seen == index {
542                    return Ok(PageMut {
543                        form: child,
544                        dirty: &mut self.dirty,
545                    });
546                }
547                seen += 1;
548            }
549        }
550        unreachable!("page_count agreed with bundle but iteration disagreed")
551    }
552
553    /// Replace, insert, or remove the document's `NAVM` bookmark chunk.
554    ///
555    /// Empty `bookmarks` removes any existing NAVM. The chunk lives at the
556    /// `FORM:DJVM` bundle root, between `DIRM` and the per-page components,
557    /// and the payload is built through the chunk-encoder seam
558    /// ([`NavmChunk`]).
559    ///
560    /// # Errors
561    ///
562    /// - [`MutError::BookmarksRequireDjvm`] if the document is a single-page
563    ///   `FORM:DJVU` (no NAVM in non-bundled documents per the DjVu spec).
564    /// - [`MutError::Encode`] if the bookmark tree exceeds a NAVM wire limit
565    ///   (> 255 children on a node, or > 65 535 nodes total).
566    pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
567        let root_form_type = *self.root_form_type().expect("from_bytes validated FORM");
568        if &root_form_type != b"DJVM" {
569            return Err(MutError::BookmarksRequireDjvm);
570        }
571        let children = match &mut self.file.root {
572            Chunk::Form { children, .. } => children,
573            Chunk::Leaf { .. } => unreachable!("validated FORM root"),
574        };
575        let pos = children
576            .iter()
577            .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"NAVM"));
578        match (pos, bookmarks.is_empty()) {
579            (Some(i), true) => {
580                children.remove(i);
581            }
582            (Some(i), false) => {
583                children[i] = NavmChunk(bookmarks).encode_chunk()?.into_leaf();
584            }
585            (None, true) => { /* nothing to remove and nothing to insert */ }
586            (None, false) => {
587                // Insert NAVM right after DIRM if present, else right after
588                // the secondary id (i.e. as the first child). DIRM is the
589                // first chunk in a well-formed bundle.
590                let dirm_pos = children
591                    .iter()
592                    .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"DIRM"));
593                let insert_at = dirm_pos.map(|i| i + 1).unwrap_or(0);
594                children.insert(insert_at, NavmChunk(bookmarks).encode_chunk()?.into_leaf());
595            }
596        }
597        self.dirty = true;
598        Ok(())
599    }
600}
601
602/// Shared prologue for the two indirect-DJVM resolvers
603/// ([`DjVuDocumentMut::from_indirect_resolved`] and
604/// [`IndirectRewritePlan::from_indirect_resolved`]): parse the index FORM,
605/// confirm it is an *indirect* `FORM:DJVM` carrying a non-empty component
606/// directory, and return the raw `DIRM` bytes alongside the decoded component
607/// list (in DIRM order). Resolving the external component files and the
608/// per-caller assembly (rebundled tree vs. rewrite plan) stay with the callers.
609///
610/// # Errors
611///
612/// - [`MutError::NotIndirectDjvm`] if the root is not a `FORM:DJVM`, or is an
613///   already-bundled one.
614/// - [`MutError::DirmMalformed`] if the `DIRM` chunk is missing, unparseable, or
615///   lists no components.
616#[cfg(feature = "std")]
617fn resolve_indirect_components(root_bytes: &[u8]) -> Result<(&[u8], Vec<DirmComponent>), MutError> {
618    let form = iff::parse_form(root_bytes)?;
619    if &form.form_type != b"DJVM" {
620        return Err(MutError::NotIndirectDjvm);
621    }
622    let dirm_data: &[u8] = form
623        .chunks
624        .iter()
625        .find(|c| &c.id == b"DIRM")
626        .ok_or(MutError::DirmMalformed("indirect DJVM has no DIRM chunk"))?
627        .data;
628
629    let payload = DirmPayload::decode(dirm_data)
630        .map_err(|_| MutError::DirmMalformed("DIRM directory could not be parsed"))?;
631    if payload.is_bundled() {
632        // Already bundled — no external files to resolve.
633        return Err(MutError::NotIndirectDjvm);
634    }
635    let components = payload.components();
636    if components.is_empty() {
637        return Err(MutError::DirmMalformed("indirect DIRM lists no components"));
638    }
639    Ok((dirm_data, components))
640}
641
642/// Convert an indirect `DIRM` payload into the bundled form expected by a
643/// rebundled `FORM:DJVM`.
644///
645/// Indirect and bundled DIRM share the same `[flags][nfiles:u16][BZZ meta]`
646/// framing; bundled documents additionally carry a `4 × nfiles` offset table
647/// between `nfiles` and the BZZ metadata. This sets the bundled flag bit, splices
648/// a zeroed offset table (filled later by [`recompute_dirm_offsets`]), and keeps
649/// the original BZZ metadata tail verbatim — preserving component ids, names,
650/// titles, and per-component flags.
651fn bundled_dirm_from_indirect(indirect: &[u8], nfiles: usize) -> Result<Vec<u8>, MutError> {
652    let mut payload = DirmPayload::decode(indirect).map_err(MutError::DirmMalformed)?;
653    // Flip the bundled bit and splice in a zeroed offset table (one slot per
654    // component); the real positions are filled by `recompute_dirm_offsets`
655    // for the about-to-be-emitted layout. The BZZ metadata tail is carried
656    // through verbatim by `DirmPayload`, preserving ids / names / titles / flags.
657    payload.flags |= 0x80;
658    payload.offsets = core::iter::repeat_n(0u32, nfiles).collect();
659    Ok(payload.encode())
660}
661
662/// Whether `chunk` is a bundled (rather than indirect) `FORM:DJVM`.
663///
664/// Returns `false` for any non-DJVM chunk.
665fn is_bundled_djvm(chunk: &Chunk) -> bool {
666    let Chunk::Form {
667        secondary_id,
668        children,
669        ..
670    } = chunk
671    else {
672        return false;
673    };
674    if secondary_id != b"DJVM" {
675        return false;
676    }
677    children.iter().any(|c| {
678        matches!(c, Chunk::Leaf { id, data } if id == b"DIRM" && crate::dirm::DirmPayload::peek_bundled(data))
679    })
680}
681
682/// Original byte range for one direct child of a single-page FORM:DJVU.
683#[derive(Debug, Clone, PartialEq, Eq)]
684struct OriginalChildRange {
685    id: [u8; 4],
686    data: Vec<u8>,
687    range: Range<usize>,
688}
689
690/// Emit an edited single-page FORM:DJVU while copying unchanged child chunks
691/// from the original byte buffer. Returns `None` when the original layout is
692/// outside the narrow, safely-patchable shape; callers then use full-tree emit.
693fn emit_patched_single_page(root: &Chunk, original: &[u8]) -> Option<Vec<u8>> {
694    let Chunk::Form {
695        secondary_id,
696        children,
697        ..
698    } = root
699    else {
700        return None;
701    };
702    if secondary_id != b"DJVU" {
703        return None;
704    }
705    let original_children = original_single_page_child_ranges(original)?;
706    if original_children.len() != children.len() {
707        return None;
708    }
709
710    // Untouched children pass through verbatim (their original padded bytes);
711    // edited leaves are re-framed. The IFF framing — header, padding, FORM
712    // length — lives in `iff::partial_emit`, so this path can't drift from the
713    // canonical emitter.
714    let mut parts: Vec<iff::EmitPart> = Vec::with_capacity(children.len());
715    for (child, original_child) in children.iter().zip(original_children.iter()) {
716        match child {
717            Chunk::Leaf { id, data }
718                if id == &original_child.id && data == &original_child.data =>
719            {
720                parts.push(iff::EmitPart::Verbatim(
721                    &original[original_child.range.clone()],
722                ));
723            }
724            Chunk::Leaf { .. } => parts.push(iff::EmitPart::Chunk(child)),
725            Chunk::Form { .. } => return None,
726        }
727    }
728
729    iff::partial_emit(*secondary_id, &parts)
730}
731
732fn original_single_page_child_ranges(original: &[u8]) -> Option<Vec<OriginalChildRange>> {
733    if original.len() < 16 || &original[..4] != b"AT&T" || &original[4..8] != b"FORM" {
734        return None;
735    }
736    let form_len = u32::from_be_bytes(original[8..12].try_into().ok()?) as usize;
737    let body_end = 12usize.checked_add(form_len)?;
738    if body_end > original.len() || &original[12..16] != b"DJVU" {
739        return None;
740    }
741
742    // Walk the FORM body (bytes after the 4-byte form type) with the shared
743    // `djvu-iff` chunk walker. It advances by `8 + data_len + (data_len & 1)`
744    // per chunk, so we can re-derive each child's absolute byte span in
745    // `original` by replaying the same contiguous tiling from offset 16.
746    let chunks = parse_form_body(original.get(16..body_end)?).ok()?;
747
748    let mut ranges = Vec::with_capacity(chunks.len());
749    let mut pos = 16usize;
750    for chunk in &chunks {
751        // The narrow single-page shape we patch in place has no nested FORMs.
752        if &chunk.id == b"FORM" {
753            return None;
754        }
755        let data_end = pos + 8 + chunk.data.len();
756        let mut next = data_end;
757        if next & 1 == 1 {
758            // Odd-length tail chunk with no room for its pad byte: bail to a
759            // full-tree emit rather than fabricate alignment bytes.
760            if next >= body_end {
761                return None;
762            }
763            next += 1;
764        }
765        ranges.push(OriginalChildRange {
766            id: chunk.id,
767            data: chunk.data.to_vec(),
768            range: pos..next,
769        });
770        pos = next;
771    }
772
773    // The chunks must tile the body exactly; a short tail (the walker stops on
774    // fewer than 8 remaining bytes) means a malformed layout we won't patch.
775    if pos != body_end {
776        return None;
777    }
778    Some(ranges)
779}
780
781/// Recompute the absolute byte offsets stored in the `DIRM` chunk so they
782/// point at each `FORM:DJVU`/`FORM:DJVI` component in the about-to-be-emitted
783/// document.
784///
785/// Offsets in DIRM are absolute file-byte positions (from the leading
786/// `b"AT&T"` magic) of each component's outer `b"FORM"` chunk header. After a
787/// page-chunk mutation those positions shift, and viewers that use DIRM for
788/// page navigation see the wrong bytes if the table is not refreshed.
789///
790/// No-op for non-DJVM roots and for indirect DIRM (no offset table).
791fn recompute_dirm_offsets(root: &mut Chunk) -> Result<(), MutError> {
792    let Chunk::Form {
793        secondary_id,
794        children,
795        ..
796    } = root
797    else {
798        return Ok(());
799    };
800    if secondary_id != b"DJVM" {
801        return Ok(());
802    }
803
804    // Absolute byte position of the next chunk inside the FORM:DJVM body:
805    // AT&T(4) + FORM(4) + length(4) + secondary_id "DJVM"(4) = 16.
806    let mut pos: usize = 16;
807    let mut new_offsets: Vec<u32> = Vec::new();
808    let mut dirm_idx: Option<usize> = None;
809
810    // The `id == b"DIRM"` guard form is needed: `id` is `[u8; 4]` reached
811    // through a `&` reference, so a by-value pattern would require `*b"DIRM"`
812    // which clippy's redundant-guards autofix doesn't propose.
813    #[allow(clippy::redundant_guards)]
814    for (i, child) in children.iter().enumerate() {
815        match child {
816            Chunk::Leaf { id, .. } if id == b"DIRM" => {
817                dirm_idx = Some(i);
818            }
819            Chunk::Form {
820                secondary_id: sid, ..
821            } if sid == b"DJVU" || sid == b"DJVI" || sid == b"THUM" => {
822                new_offsets.push(u32::try_from(pos).map_err(|_| {
823                    MutError::DirmMalformed("component offset exceeds u32 (file > 4 GiB)")
824                })?);
825            }
826            _ => {}
827        }
828        pos += iff::emitted_size(child);
829    }
830
831    let Some(dirm_idx) = dirm_idx else {
832        // Bundled DJVM with no DIRM is malformed by spec, but tolerate it
833        // (parse_dirm would have failed during from_bytes if it mattered).
834        return Ok(());
835    };
836
837    let dirm = &mut children[dirm_idx];
838    let Chunk::Leaf { data, .. } = dirm else {
839        return Err(MutError::DirmMalformed("DIRM is not a leaf chunk"));
840    };
841
842    // Decode through the shared DIRM model, swap in the recomputed offsets, and
843    // re-encode. The metadata tail is preserved verbatim, so only the 4-byte
844    // offset slots change — the rewrite stays byte-preserving everywhere else.
845    let mut payload = DirmPayload::decode(data).map_err(MutError::DirmMalformed)?;
846    if !payload.is_bundled() {
847        // Indirect DIRM has no offset table to update.
848        return Ok(());
849    }
850    if payload.nfiles as usize != new_offsets.len() {
851        return Err(MutError::DirmComponentCountMismatch {
852            dirm: payload.nfiles as usize,
853            children: new_offsets.len(),
854        });
855    }
856    payload.offsets = new_offsets;
857    *data = payload.encode();
858    Ok(())
859}
860
861/// A mutable handle to one page's `FORM:DJVU` chunk inside a
862/// [`DjVuDocumentMut`]. Returned by [`DjVuDocumentMut::page_mut`].
863///
864/// Each setter replaces the corresponding chunk in place, or appends a new
865/// chunk if the page does not have one yet. The compressed `*z` chunk variant
866/// is preferred on insert (TXTz / ANTz / METz) for size; if an existing
867/// uncompressed `*a` chunk is present, the setter replaces *that* chunk and
868/// upgrades its identifier to the `*z` form.
869pub struct PageMut<'doc> {
870    form: &'doc mut Chunk,
871    dirty: &'doc mut bool,
872}
873
874impl PageMut<'_> {
875    /// Replace (or insert) the page's text layer with the BZZ-compressed
876    /// `TXTz` form of `layer`. Page height is read from the page's `INFO`
877    /// chunk; missing INFO yields [`MutError::MissingPageInfo`].
878    pub fn set_text_layer(&mut self, layer: &TextLayer) -> Result<(), MutError> {
879        let info_data = self
880            .find_leaf_data(b"INFO")
881            .ok_or(MutError::MissingPageInfo)?;
882        let info = PageInfo::parse(info_data)?;
883        let plain = encode_text_layer(layer, info.height as u32);
884        let compressed = crate::bzz_encode::bzz_encode(&plain);
885        self.replace_or_insert_text(compressed);
886        *self.dirty = true;
887        Ok(())
888    }
889
890    /// Replace (or insert) the page's annotation chunk with the
891    /// BZZ-compressed `ANTz` form of `(annotation, areas)`.
892    pub fn set_annotations(&mut self, annotation: &Annotation, areas: &[MapArea]) {
893        let bytes = encode_annotations_bzz(annotation, areas);
894        self.replace_or_insert(b"ANTa", b"ANTz", bytes);
895        *self.dirty = true;
896    }
897
898    /// Replace (or insert) the page's metadata chunk with the
899    /// BZZ-compressed `METz` form of `meta`. An empty `meta` value removes
900    /// any existing METa/METz chunk.
901    pub fn set_metadata(&mut self, meta: &DjVuMetadata) {
902        let bytes = encode_metadata_bzz(meta);
903        self.replace_or_insert(b"METa", b"METz", bytes);
904        *self.dirty = true;
905    }
906
907    fn find_leaf_data(&self, id: &[u8; 4]) -> Option<&[u8]> {
908        for child in self.form.children() {
909            if let Chunk::Leaf { id: cid, data } = child
910                && cid == id
911            {
912                return Some(data);
913            }
914        }
915        None
916    }
917
918    /// Replace either the `*a` or `*z` variant of a chunk pair, picking `*z`
919    /// (compressed) for any newly inserted chunk. If `data` is empty, removes
920    /// the existing chunk (whichever variant is present) and does not insert.
921    fn replace_or_insert(&mut self, id_a: &[u8; 4], id_z: &[u8; 4], data: Vec<u8>) {
922        let children = match self.form {
923            Chunk::Form { children, .. } => children,
924            Chunk::Leaf { .. } => unreachable!("PageMut wraps a FORM"),
925        };
926        let pos = children
927            .iter()
928            .position(|c| matches!(c, Chunk::Leaf { id, .. } if id == id_a || id == id_z));
929        match (pos, data.is_empty()) {
930            (Some(i), true) => {
931                children.remove(i);
932            }
933            (Some(i), false) => {
934                children[i] = Chunk::Leaf { id: *id_z, data };
935            }
936            (None, true) => { /* nothing to remove and nothing to insert */ }
937            (None, false) => {
938                children.push(Chunk::Leaf { id: *id_z, data });
939            }
940        }
941    }
942
943    /// TXTa / TXTz variant of `replace_or_insert` (kept separate for clarity).
944    fn replace_or_insert_text(&mut self, data: Vec<u8>) {
945        self.replace_or_insert(b"TXTa", b"TXTz", data);
946    }
947}
948
949// ---- #326: explicit external-file rewrite plan for indirect DJVM -----------
950
951/// One entry in an [`IndirectRewritePlan`] preview, describing a file the plan
952/// will touch on commit.
953#[cfg(feature = "std")]
954#[derive(Debug, Clone, PartialEq, Eq)]
955pub struct RewriteItem {
956    /// The file name (relative to the destination directory). For the root
957    /// index this is the name passed to [`IndirectRewritePlan::commit_to_dir`].
958    pub name: String,
959    /// Whether this is the root DJVM index file (`true`) or a page/shared
960    /// component (`false`).
961    pub is_root: bool,
962    /// Whether this file's bytes differ from the resolved original — i.e.
963    /// whether an edit changed it. Unchanged files are still (re)written on
964    /// commit so the destination directory holds a complete component set.
965    pub changed: bool,
966}
967
968/// One resolved component staged inside an [`IndirectRewritePlan`].
969#[cfg(feature = "std")]
970#[derive(Debug, Clone)]
971struct PlannedComponent {
972    /// DIRM component id, used both as the resolver key and the external file
973    /// name. Validated to be a safe relative file name at construction.
974    name: String,
975    /// Whether this component is a page (vs. shared dictionary / thumbnail).
976    is_page: bool,
977    /// The bytes originally returned by the resolver.
978    original: Vec<u8>,
979    /// Edited bytes, if a page edit changed this component.
980    edited: Option<Vec<u8>>,
981}
982
983/// A staged, side-effect-free plan to rewrite an **indirect** `FORM:DJVM`
984/// document across its external component files.
985///
986/// This is the explicit multi-file counterpart to
987/// [`DjVuDocumentMut::from_indirect_resolved`]. Where `from_indirect_resolved`
988/// collapses an indirect document into a single self-contained **bundled**
989/// byte stream (no destination policy needed), `IndirectRewritePlan` keeps the
990/// document **indirect**: each page stays in its own external file, and edits
991/// are written back to per-component files in a destination directory.
992///
993/// The two paths differ deliberately:
994///
995/// | | `from_indirect_resolved` | `IndirectRewritePlan` |
996/// |---|---|---|
997/// | Output | one bundled DJVM byte stream | a directory of component files + index |
998/// | Side effects | none (`try_into_bytes` returns bytes) | files written on `commit_to_dir` |
999/// | Caller policy | none | destination dir, file names, atomicity |
1000/// | Document shape | becomes bundled | stays indirect |
1001///
1002/// # Mutation model
1003///
1004/// Edits never touch the filesystem. They are staged in memory via
1005/// [`Self::edit_page`] / [`Self::set_bookmarks`] and only written when
1006/// [`Self::commit_to_dir`] is called. Call [`Self::plan`] at any time to
1007/// preview exactly which files a commit will write and which have changed.
1008///
1009/// # Name safety
1010///
1011/// Every DIRM component id (and the root index name supplied at commit) must be
1012/// a safe *flat* relative file name: no path separators, no `.`/`..`, no
1013/// drive-letter `:`/absolute path, no embedded NUL. Names that could escape the
1014/// destination directory are rejected with [`MutError::UnsafeComponentName`];
1015/// two entries mapping to one file name are rejected with
1016/// [`MutError::DuplicateComponentName`]. Both checks run at construction, so an
1017/// invalid directory can never reach the write phase. Nested component
1018/// sub-directories are intentionally not supported by this path.
1019///
1020/// # Atomicity
1021///
1022/// Each file is written by staging a sibling temporary file in the destination
1023/// directory and atomically renaming it over the target, so a reader never sees
1024/// a half-written component file (on platforms where same-directory rename is
1025/// atomic — POSIX and modern Windows `ReplaceFile`/`rename`). The root index is
1026/// written **last**.
1027///
1028/// What is **not** guaranteed: the multi-file commit is not transactional. A
1029/// crash partway through can leave some component files updated and others not.
1030/// Because indirect components are independent, self-describing page files
1031/// (the index lists names, not byte offsets), every individual file remains a
1032/// valid DjVu page either way — but the document set as a whole may be a mix of
1033/// old and new pages until the commit finishes. Callers needing cross-file
1034/// atomicity should commit to a fresh directory and swap it in themselves.
1035#[cfg(feature = "std")]
1036#[derive(Debug, Clone)]
1037pub struct IndirectRewritePlan {
1038    /// The current (possibly edited) root index bytes.
1039    root_bytes: Vec<u8>,
1040    /// Whether the root index has been edited since construction.
1041    root_changed: bool,
1042    components: Vec<PlannedComponent>,
1043}
1044
1045#[cfg(feature = "std")]
1046impl IndirectRewritePlan {
1047    /// Resolve an indirect `FORM:DJVM` document into a rewrite plan, fetching
1048    /// every external component through `resolver`.
1049    ///
1050    /// The resolver is called once per `DIRM` entry with that entry's id (the
1051    /// same key [`DjVuDocumentMut::from_indirect_resolved`] uses), which is also
1052    /// the external file name the component will be written back to.
1053    ///
1054    /// # Errors
1055    ///
1056    /// - [`MutError::NotIndirectDjvm`] if `root_bytes` is not an indirect
1057    ///   `FORM:DJVM`.
1058    /// - [`MutError::UnsafeComponentName`] / [`MutError::DuplicateComponentName`]
1059    ///   if a DIRM component id is not a safe, unique flat file name.
1060    /// - [`MutError::ComponentResolve`] if the resolver fails for a component.
1061    /// - [`MutError::ComponentMalformed`] if a resolved component does not parse
1062    ///   as a `FORM:DJVU`/`DJVI`/`THUM`.
1063    /// - [`MutError::DirmMalformed`] / [`MutError::InfoParse`] if the index or its
1064    ///   `DIRM` chunk cannot be read.
1065    pub fn from_indirect_resolved<R, E>(root_bytes: &[u8], resolver: R) -> Result<Self, MutError>
1066    where
1067        R: Fn(&str) -> Result<Vec<u8>, E>,
1068    {
1069        // The rewrite plan keeps the original index bytes verbatim, so the DIRM
1070        // bytes returned by the shared prologue are not needed here.
1071        let (_dirm_data, infos) = resolve_indirect_components(root_bytes)?;
1072
1073        // Validate every component file name up front: safe + unique. This runs
1074        // before any resolution or write, so an invalid directory is rejected
1075        // without side effects.
1076        let mut seen = std::collections::HashSet::new();
1077        for info in &infos {
1078            validate_safe_component_name(&info.id)?;
1079            if !seen.insert(info.id.clone()) {
1080                return Err(MutError::DuplicateComponentName {
1081                    name: info.id.clone(),
1082                });
1083            }
1084        }
1085
1086        let mut components = Vec::with_capacity(infos.len());
1087        for info in &infos {
1088            let bytes = resolver(&info.id).map_err(|_| MutError::ComponentResolve {
1089                name: info.id.clone(),
1090            })?;
1091            // Validate the bytes parse as a component FORM so later commits never
1092            // write a file we already know is malformed.
1093            let parsed = iff::parse(&bytes).map_err(|_| MutError::ComponentMalformed {
1094                name: info.id.clone(),
1095                reason: "not a parseable IFF document",
1096            })?;
1097            match &parsed.root {
1098                Chunk::Form { secondary_id, .. }
1099                    if secondary_id == b"DJVU"
1100                        || secondary_id == b"DJVI"
1101                        || secondary_id == b"THUM" => {}
1102                _ => {
1103                    return Err(MutError::ComponentMalformed {
1104                        name: info.id.clone(),
1105                        reason: "root is not a FORM:DJVU/DJVI/THUM",
1106                    });
1107                }
1108            }
1109            components.push(PlannedComponent {
1110                name: info.id.clone(),
1111                is_page: info.kind == DirmComponentKind::Page,
1112                original: bytes,
1113                edited: None,
1114            });
1115        }
1116
1117        Ok(Self {
1118            root_bytes: root_bytes.to_vec(),
1119            root_changed: false,
1120            components,
1121        })
1122    }
1123
1124    /// Number of page components in the document (shared dictionaries and
1125    /// thumbnails are not counted).
1126    pub fn page_count(&self) -> usize {
1127        self.components.iter().filter(|c| c.is_page).count()
1128    }
1129
1130    /// Total number of components (pages + shared dictionaries + thumbnails).
1131    pub fn component_count(&self) -> usize {
1132        self.components.len()
1133    }
1134
1135    /// Edit the `index`-th page component in memory.
1136    ///
1137    /// The closure receives a [`DjVuDocumentMut`] opened on that page's current
1138    /// (possibly already-edited) bytes — a single-page `FORM:DJVU`, so
1139    /// `doc.page_mut(0)` exposes the usual `set_text_layer` / `set_metadata` /
1140    /// `set_annotations` setters. Nothing is written to disk; the resulting
1141    /// bytes are staged for the next [`Self::commit_to_dir`].
1142    ///
1143    /// # Errors
1144    ///
1145    /// - [`MutError::PageOutOfRange`] if `index >= self.page_count()`.
1146    /// - Any [`MutError`] returned by the closure or by re-serialising the page.
1147    pub fn edit_page<F>(&mut self, index: usize, edit: F) -> Result<(), MutError>
1148    where
1149        F: FnOnce(&mut DjVuDocumentMut) -> Result<(), MutError>,
1150    {
1151        let count = self.page_count();
1152        let comp = self
1153            .components
1154            .iter_mut()
1155            .filter(|c| c.is_page)
1156            .nth(index)
1157            .ok_or(MutError::PageOutOfRange { index, count })?;
1158        let current: &[u8] = comp.edited.as_deref().unwrap_or(&comp.original);
1159        let mut doc = DjVuDocumentMut::from_bytes(current)?;
1160        edit(&mut doc)?;
1161        if doc.is_dirty() {
1162            comp.edited = Some(doc.try_into_bytes()?);
1163        }
1164        Ok(())
1165    }
1166
1167    /// Replace, insert, or remove the document's `NAVM` bookmarks in the root
1168    /// index file. The edit is staged in memory and written on commit; only the
1169    /// root index file changes (bookmarks live in the index, not page files).
1170    pub fn set_bookmarks(&mut self, bookmarks: &[DjVuBookmark]) -> Result<(), MutError> {
1171        let mut root = DjVuDocumentMut::from_bytes(&self.root_bytes)?;
1172        root.set_bookmarks(bookmarks)?;
1173        if root.is_dirty() {
1174            self.root_bytes = root.try_into_bytes()?;
1175            self.root_changed = true;
1176        }
1177        Ok(())
1178    }
1179
1180    /// Preview the files a [`Self::commit_to_dir`] will write, in commit order
1181    /// (every component, then the root index). `changed` flags which files
1182    /// differ from their resolved originals.
1183    ///
1184    /// `root_name` is the file name the root index will be written under; it is
1185    /// reported as the final, `is_root` item but is **not** validated here (that
1186    /// happens at commit).
1187    pub fn plan(&self, root_name: &str) -> Vec<RewriteItem> {
1188        let mut items: Vec<RewriteItem> = self
1189            .components
1190            .iter()
1191            .map(|c| RewriteItem {
1192                name: c.name.clone(),
1193                is_root: false,
1194                changed: c.edited.is_some(),
1195            })
1196            .collect();
1197        items.push(RewriteItem {
1198            name: root_name.to_string(),
1199            is_root: true,
1200            changed: self.root_changed,
1201        });
1202        items
1203    }
1204
1205    /// Commit the plan: write the full indirect document set (every component
1206    /// plus the root index) into `dir`, staging each file as a sibling temporary
1207    /// file and atomically renaming it into place. The root index is written
1208    /// last.
1209    ///
1210    /// All name validation happens before the first byte is written, so a
1211    /// validation failure (e.g. an unsafe `root_name`) leaves `dir` untouched.
1212    /// Returns the absolute paths written, in the same order as [`Self::plan`].
1213    ///
1214    /// See the type-level docs for the atomicity guarantees and their limits.
1215    pub fn commit_to_dir(
1216        &self,
1217        dir: impl AsRef<std::path::Path>,
1218        root_name: &str,
1219    ) -> Result<Vec<std::path::PathBuf>, MutError> {
1220        let dir = dir.as_ref();
1221
1222        // ---- Validate everything before writing anything --------------------
1223        validate_safe_component_name(root_name)?;
1224        // Component names were validated at construction, but the root name must
1225        // also not collide with a component file.
1226        if self.components.iter().any(|c| c.name == root_name) {
1227            return Err(MutError::DuplicateComponentName {
1228                name: root_name.to_string(),
1229            });
1230        }
1231
1232        std::fs::create_dir_all(dir).map_err(|e| MutError::RewriteIo {
1233            name: dir.display().to_string(),
1234            message: e.to_string(),
1235        })?;
1236
1237        // ---- Write component files, then the root index ---------------------
1238        let mut written = Vec::with_capacity(self.components.len() + 1);
1239        for comp in &self.components {
1240            let bytes = comp.edited.as_deref().unwrap_or(&comp.original);
1241            written.push(stage_and_rename(dir, &comp.name, bytes)?);
1242        }
1243        written.push(stage_and_rename(dir, root_name, &self.root_bytes)?);
1244        Ok(written)
1245    }
1246}
1247
1248/// Reject any component / index name that is not a safe flat relative file name.
1249///
1250/// Permitted names are non-empty, contain no path separator (`/` or `\\`), no
1251/// drive/ADS colon, no NUL, and are not `.` or `..`. This guarantees a write can
1252/// never escape the destination directory.
1253#[cfg(feature = "std")]
1254fn validate_safe_component_name(name: &str) -> Result<(), MutError> {
1255    let reject = |reason: &'static str| {
1256        Err(MutError::UnsafeComponentName {
1257            name: name.to_string(),
1258            reason,
1259        })
1260    };
1261    if name.is_empty() {
1262        return reject("name is empty");
1263    }
1264    if name.contains('\0') {
1265        return reject("name contains a NUL byte");
1266    }
1267    if name.contains('/') || name.contains('\\') {
1268        return reject("name contains a path separator");
1269    }
1270    if name.contains(':') {
1271        return reject("name contains a drive-letter / stream colon");
1272    }
1273    if name == "." || name == ".." {
1274        return reject("name is a relative directory reference");
1275    }
1276    Ok(())
1277}
1278
1279/// Write `bytes` to `dir/name` by staging a sibling temp file and atomically
1280/// renaming it over the target. Returns the final path.
1281#[cfg(feature = "std")]
1282fn stage_and_rename(
1283    dir: &std::path::Path,
1284    name: &str,
1285    bytes: &[u8],
1286) -> Result<std::path::PathBuf, MutError> {
1287    use std::io::Write;
1288
1289    let final_path = dir.join(name);
1290    // A stable, collision-resistant-enough temp name in the same directory so
1291    // the rename stays on one filesystem (and is therefore atomic).
1292    let tmp_path = dir.join(format!(".{name}.djvu-rs.tmp"));
1293
1294    let io_err = |path: &std::path::Path, e: std::io::Error| MutError::RewriteIo {
1295        name: path.display().to_string(),
1296        message: e.to_string(),
1297    };
1298
1299    {
1300        let mut f = std::fs::File::create(&tmp_path).map_err(|e| io_err(&tmp_path, e))?;
1301        f.write_all(bytes).map_err(|e| io_err(&tmp_path, e))?;
1302        f.sync_all().map_err(|e| io_err(&tmp_path, e))?;
1303    }
1304    std::fs::rename(&tmp_path, &final_path).map_err(|e| {
1305        // Best-effort cleanup of the temp file on rename failure.
1306        let _ = std::fs::remove_file(&tmp_path);
1307        io_err(&final_path, e)
1308    })?;
1309    Ok(final_path)
1310}
1311
1312#[cfg(test)]
1313#[allow(clippy::field_reassign_with_default)]
1314mod tests {
1315    use super::*;
1316    use std::path::PathBuf;
1317
1318    fn corpus_path(name: &str) -> PathBuf {
1319        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1320        p.push("tests/fixtures");
1321        p.push(name);
1322        p
1323    }
1324
1325    fn read_corpus(name: &str) -> Vec<u8> {
1326        std::fs::read(corpus_path(name)).expect("corpus fixture missing")
1327    }
1328
1329    /// Round-trip without edits is byte-identical on a single-page document.
1330    #[test]
1331    fn roundtrip_byte_identical_chicken() {
1332        let original = read_corpus("chicken.djvu");
1333        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1334        assert!(!doc.is_dirty());
1335        assert_eq!(doc.into_bytes(), original);
1336    }
1337
1338    /// Round-trip without edits is byte-identical on a bilevel JB2 document.
1339    #[test]
1340    fn roundtrip_byte_identical_boy_jb2() {
1341        let original = read_corpus("boy_jb2.djvu");
1342        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1343        assert_eq!(doc.into_bytes(), original);
1344    }
1345
1346    /// Round-trip without edits is byte-identical on a multi-page DJVM bundle.
1347    #[test]
1348    fn roundtrip_byte_identical_djvm_bundle() {
1349        let original = read_corpus("DjVu3Spec_bundled.djvu");
1350        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1351        assert_eq!(doc.root_form_type(), Some(b"DJVM"));
1352        assert_eq!(doc.into_bytes(), original);
1353    }
1354
1355    /// Round-trip without edits is byte-identical on a navm/fgbz document.
1356    #[test]
1357    fn roundtrip_byte_identical_navm() {
1358        let original = read_corpus("navm_fgbz.djvu");
1359        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1360        assert_eq!(doc.into_bytes(), original);
1361    }
1362
1363    /// `replace_leaf` mutates in place and the serialised output reflects it.
1364    #[test]
1365    fn replace_leaf_changes_emitted_bytes() {
1366        let original = read_corpus("chicken.djvu");
1367        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1368
1369        // Walk to the first leaf — for chicken.djvu (FORM:DJVU) this is INFO.
1370        let first = doc.chunk_at_path(&[0]).unwrap();
1371        let original_first_data = first.data().to_vec();
1372        assert!(!original_first_data.is_empty());
1373
1374        // Replace with a marker and serialise.
1375        let marker = b"PR1_TEST_MARKER".to_vec();
1376        doc.replace_leaf(&[0], marker.clone()).unwrap();
1377        assert!(doc.is_dirty());
1378
1379        let edited = doc.into_bytes();
1380
1381        // Re-parse the edited bytes and confirm the leaf payload changed.
1382        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1383        let new_first = reparsed.chunk_at_path(&[0]).unwrap();
1384        assert_eq!(new_first.data(), marker.as_slice());
1385    }
1386
1387    #[test]
1388    fn single_page_patch_preserves_unedited_child_bytes() {
1389        let original = read_corpus("chicken.djvu");
1390        let original_ranges =
1391            original_single_page_child_ranges(&original).expect("single-page child ranges");
1392        assert!(
1393            original_ranges.len() > 2,
1394            "fixture must have unrelated chunks to preserve"
1395        );
1396
1397        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1398        doc.replace_leaf(&[0], b"PATCHED_INFO".to_vec()).unwrap();
1399        let edited = doc.try_into_bytes().unwrap();
1400        let edited_ranges =
1401            original_single_page_child_ranges(&edited).expect("edited child ranges");
1402        assert_eq!(edited_ranges.len(), original_ranges.len());
1403
1404        for (idx, (before, after)) in original_ranges.iter().zip(edited_ranges.iter()).enumerate() {
1405            if idx == 0 {
1406                assert_ne!(
1407                    &original[before.range.clone()],
1408                    &edited[after.range.clone()]
1409                );
1410                continue;
1411            }
1412            assert_eq!(before.id, after.id);
1413            assert_eq!(
1414                &original[before.range.clone()],
1415                &edited[after.range.clone()],
1416                "unchanged child #{idx} must be copied byte-for-byte"
1417            );
1418        }
1419    }
1420
1421    #[test]
1422    fn single_page_patch_falls_back_for_bundled_djvm() {
1423        let original = read_corpus("DjVu3Spec_bundled.djvu");
1424        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1425        assert!(
1426            emit_patched_single_page(&doc.file.root, &original).is_none(),
1427            "single-page patch path must decline bundled DJVM layouts"
1428        );
1429    }
1430
1431    #[test]
1432    fn replace_leaf_rejects_empty_path() {
1433        let original = read_corpus("chicken.djvu");
1434        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1435        let err = doc.replace_leaf(&[], vec![]).unwrap_err();
1436        assert!(matches!(err, MutError::EmptyPath));
1437    }
1438
1439    #[test]
1440    fn replace_leaf_rejects_out_of_range() {
1441        let original = read_corpus("chicken.djvu");
1442        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1443        let err = doc.replace_leaf(&[9999], vec![]).unwrap_err();
1444        assert!(matches!(err, MutError::PathOutOfRange { .. }));
1445    }
1446
1447    #[test]
1448    fn replace_leaf_rejects_traversing_leaf() {
1449        let original = read_corpus("chicken.djvu");
1450        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1451        // [0] is a leaf (INFO).  [0, 0] tries to descend past it.
1452        let err = doc.replace_leaf(&[0, 0], vec![]).unwrap_err();
1453        assert!(matches!(err, MutError::PathTraversesLeaf { .. }));
1454    }
1455
1456    #[test]
1457    fn replace_leaf_rejects_form_target() {
1458        // For a DJVM bundle, [N] for some N points at a FORM:DJVU page,
1459        // not a leaf.  Picking the last child of DjVu3Spec_bundled (which
1460        // is a page FORM) demonstrates NotALeaf.
1461        let original = read_corpus("DjVu3Spec_bundled.djvu");
1462        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1463        let last_idx = doc.root_child_count() - 1;
1464        let err = doc.replace_leaf(&[last_idx], vec![]).unwrap_err();
1465        assert!(matches!(err, MutError::NotALeaf));
1466    }
1467
1468    #[test]
1469    fn root_form_type_djvu_single_page() {
1470        let original = read_corpus("chicken.djvu");
1471        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1472        assert_eq!(doc.root_form_type(), Some(b"DJVU"));
1473    }
1474
1475    // ---- PR2 setters ------------------------------------------------------
1476
1477    #[test]
1478    fn page_count_single_page_djvu_is_one() {
1479        let original = read_corpus("chicken.djvu");
1480        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1481        assert_eq!(doc.page_count(), 1);
1482    }
1483
1484    #[test]
1485    fn page_count_djvm_bundle_counts_djvu_components_only() {
1486        let original = read_corpus("DjVu3Spec_bundled.djvu");
1487        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1488        // The bundle has multiple FORM:DJVU pages; assert it's > 1 and matches
1489        // the count of DJVU children at the root.
1490        let direct: usize = doc
1491            .file
1492            .root
1493            .children()
1494            .iter()
1495            .filter(|c| {
1496                matches!(c, crate::iff::Chunk::Form { secondary_id, .. } if secondary_id == b"DJVU")
1497            })
1498            .count();
1499        assert!(direct >= 2, "expected multi-page bundle, got {direct}");
1500        assert_eq!(doc.page_count(), direct);
1501    }
1502
1503    #[test]
1504    fn page_mut_out_of_range_errors() {
1505        let original = read_corpus("chicken.djvu");
1506        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1507        let err = doc.page_mut(1).err().unwrap();
1508        assert!(matches!(
1509            err,
1510            MutError::PageOutOfRange { index: 1, count: 1 }
1511        ));
1512    }
1513
1514    #[test]
1515    fn page_mut_djvm_bundle_succeeds_after_pr3() {
1516        // PR3 enables page_mut on bundled FORM:DJVM. Verify it returns a
1517        // valid handle for index 0 and rejects out-of-range indices.
1518        let original = read_corpus("DjVu3Spec_bundled.djvu");
1519        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1520        assert!(doc.page_mut(0).is_ok());
1521        let count = doc.page_count();
1522        let err = doc.page_mut(count).err().unwrap();
1523        assert!(matches!(err, MutError::PageOutOfRange { .. }));
1524    }
1525
1526    #[test]
1527    fn page_mut_indirect_djvm_returns_unsupported_before_range_check() {
1528        let mut doc = DjVuDocumentMut::from_bytes(&indirect_djvm_bytes()).unwrap();
1529        let err = doc.page_mut(0).err().unwrap();
1530        assert!(matches!(err, MutError::IndirectDjvmUnsupported));
1531    }
1532
1533    fn indirect_djvm_bytes() -> Vec<u8> {
1534        let bzz_meta: &[u8] = &[
1535            0xff, 0xff, 0xed, 0xbf, 0x8a, 0x1f, 0xbe, 0xad, 0x14, 0x57, 0x10, 0xc9, 0x63, 0x19,
1536            0x11, 0xf0, 0x85, 0x28, 0x12, 0x8a, 0xbf,
1537        ];
1538
1539        let mut dirm_data = Vec::new();
1540        dirm_data.push(0x00);
1541        dirm_data.push(0x00);
1542        dirm_data.push(0x01);
1543        dirm_data.extend_from_slice(bzz_meta);
1544
1545        // FORM:DJVM carrying a single (indirect) DIRM chunk, built through the
1546        // emission seam rather than hand-assembled framing.
1547        let dirm = Chunk::Leaf {
1548            id: *b"DIRM",
1549            data: dirm_data,
1550        };
1551        iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits within u32")
1552    }
1553
1554    #[test]
1555    fn set_text_layer_roundtrip_chicken() {
1556        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1557
1558        let original = read_corpus("chicken.djvu");
1559        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1560
1561        let layer = TextLayer {
1562            text: "hello world".to_string(),
1563            zones: vec![TextZone {
1564                kind: TextZoneKind::Page,
1565                rect: Rect {
1566                    x: 0,
1567                    y: 0,
1568                    width: 100,
1569                    height: 50,
1570                },
1571                text: "hello world".to_string(),
1572                children: vec![],
1573            }],
1574        };
1575        doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
1576        assert!(doc.is_dirty());
1577        let edited = doc.into_bytes();
1578
1579        // Re-parse and confirm a TXTz chunk now exists.
1580        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1581        let has_txtz = reparsed
1582            .file
1583            .root
1584            .children()
1585            .iter()
1586            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1587        assert!(
1588            has_txtz,
1589            "TXTz chunk should be present after set_text_layer"
1590        );
1591    }
1592
1593    #[test]
1594    fn set_annotations_roundtrip_chicken() {
1595        use crate::annotation::{Annotation, Color};
1596
1597        let original = read_corpus("chicken.djvu");
1598        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1599
1600        let mut ann = Annotation::default();
1601        ann.background = Some(Color {
1602            r: 0xFF,
1603            g: 0xFF,
1604            b: 0xFF,
1605        });
1606        ann.mode = Some("color".to_string());
1607        doc.page_mut(0).unwrap().set_annotations(&ann, &[]);
1608        let edited = doc.into_bytes();
1609
1610        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1611        let antz = reparsed
1612            .file
1613            .root
1614            .children()
1615            .iter()
1616            .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"ANTz"));
1617        assert!(antz.is_some(), "ANTz should be inserted");
1618        let data = antz.unwrap().data();
1619        let decoded = crate::bzz::bzz_decode(data).expect("ANTz must decompress");
1620        let (parsed_ann, _areas) =
1621            crate::annotation::parse_annotations(&decoded).expect("ANTz must round-trip");
1622        assert_eq!(parsed_ann.mode.as_deref(), Some("color"));
1623        assert_eq!(
1624            parsed_ann.background,
1625            Some(Color {
1626                r: 0xFF,
1627                g: 0xFF,
1628                b: 0xFF
1629            })
1630        );
1631    }
1632
1633    #[test]
1634    fn set_metadata_roundtrip_chicken() {
1635        let original = read_corpus("chicken.djvu");
1636        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1637
1638        let mut meta = DjVuMetadata::default();
1639        meta.title = Some("Test Title".into());
1640        meta.author = Some("Tester".into());
1641        doc.page_mut(0).unwrap().set_metadata(&meta);
1642        let edited = doc.into_bytes();
1643
1644        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1645        let metz = reparsed
1646            .file
1647            .root
1648            .children()
1649            .iter()
1650            .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
1651            .expect("METz should be inserted");
1652        let decoded = crate::bzz::bzz_decode(metz.data()).unwrap();
1653        let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
1654        assert_eq!(parsed, meta);
1655    }
1656
1657    #[test]
1658    fn set_metadata_empty_removes_existing_chunk() {
1659        let original = read_corpus("chicken.djvu");
1660        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1661
1662        // Insert one, then clear.
1663        let mut meta = DjVuMetadata::default();
1664        meta.title = Some("X".into());
1665        doc.page_mut(0).unwrap().set_metadata(&meta);
1666        doc.page_mut(0)
1667            .unwrap()
1668            .set_metadata(&DjVuMetadata::default());
1669
1670        let edited = doc.into_bytes();
1671        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1672        let any_meta = reparsed
1673            .file
1674            .root
1675            .children()
1676            .iter()
1677            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
1678        assert!(!any_meta, "set_metadata(empty) should remove any METa/METz");
1679    }
1680
1681    #[test]
1682    fn set_metadata_replaces_existing_chunk_in_place() {
1683        let original = read_corpus("chicken.djvu");
1684        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1685
1686        let mut m1 = DjVuMetadata::default();
1687        m1.title = Some("First".into());
1688        doc.page_mut(0).unwrap().set_metadata(&m1);
1689
1690        let mut m2 = DjVuMetadata::default();
1691        m2.title = Some("Second".into());
1692        doc.page_mut(0).unwrap().set_metadata(&m2);
1693
1694        let edited = doc.into_bytes();
1695        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1696        let metz_count = reparsed
1697            .file
1698            .root
1699            .children()
1700            .iter()
1701            .filter(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"))
1702            .count();
1703        assert_eq!(metz_count, 1, "should not duplicate METz on repeat set");
1704    }
1705
1706    // ---- PR3: bundled DJVM mutation + set_bookmarks -----------------------
1707
1708    /// Helper: parse the FORM:DJVM body, return the DIRM chunk's offset table
1709    /// and the actual file offsets where each component FORM header sits.
1710    fn dirm_offsets_and_actual(data: &[u8]) -> (Vec<u32>, Vec<u32>) {
1711        // Parse top-level FORM
1712        let form = crate::iff::parse_form(data).expect("parse_form");
1713        assert_eq!(&form.form_type, b"DJVM");
1714
1715        let dirm = form
1716            .chunks
1717            .iter()
1718            .find(|c| &c.id == b"DIRM")
1719            .expect("DIRM present");
1720        // Decode through the canonical owner instead of hand-parsing bytes.
1721        let payload = crate::dirm::DirmPayload::decode(dirm.data).expect("decode DIRM");
1722        let declared = payload.offsets;
1723        let nfiles = declared.len();
1724
1725        // Walk the file to find each FORM child's absolute byte offset.
1726        // Layout: AT&T(4) FORM(4) length(4) DJVM(4) chunks…
1727        let mut actual = Vec::with_capacity(nfiles);
1728        let mut pos = 16usize;
1729        let body_end = 8 + u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
1730        while pos < body_end {
1731            let id = &data[pos..pos + 4];
1732            let len =
1733                u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
1734                    as usize;
1735            if id == b"FORM" {
1736                actual.push(pos as u32);
1737            }
1738            let mut next = pos + 8 + len;
1739            if next & 1 == 1 {
1740                next += 1;
1741            }
1742            pos = next;
1743        }
1744        (declared, actual)
1745    }
1746
1747    #[test]
1748    fn dirm_offsets_match_actual_after_no_edit() {
1749        // Sanity: even without edits, the recompute path agrees with the
1750        // original document layout on a real bundle.
1751        let original = read_corpus("DjVu3Spec_bundled.djvu");
1752        let (declared, actual) = dirm_offsets_and_actual(&original);
1753        assert_eq!(declared, actual);
1754    }
1755
1756    #[test]
1757    fn dirm_offsets_recomputed_after_page_metadata_edit() {
1758        let original = read_corpus("DjVu3Spec_bundled.djvu");
1759        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1760
1761        // Edit page 0's metadata so the page FORM grows.
1762        let mut meta = DjVuMetadata::default();
1763        meta.title = Some("PR3 DJVM bundled mutation".into());
1764        meta.author = Some("djvu-rs PR3 tests".into());
1765        doc.page_mut(0).unwrap().set_metadata(&meta);
1766        assert!(doc.is_dirty());
1767
1768        let edited = doc.into_bytes();
1769        // Sizes must have changed (metadata chunk was inserted).
1770        assert_ne!(edited.len(), original.len());
1771
1772        // DIRM offsets in the new bytes must match where the FORM headers
1773        // actually live.
1774        let (declared, actual) = dirm_offsets_and_actual(&edited);
1775        assert_eq!(
1776            declared, actual,
1777            "DIRM offsets must point at the new FORM positions after edit"
1778        );
1779
1780        // The full document must still parse via DjVuDocument and expose the
1781        // expected page count.
1782        let reparsed =
1783            crate::djvu_document::DjVuDocument::parse(&edited).expect("edited bundle must parse");
1784        let original_doc =
1785            crate::djvu_document::DjVuDocument::parse(&original).expect("original bundle parses");
1786        assert_eq!(reparsed.page_count(), original_doc.page_count());
1787    }
1788
1789    #[test]
1790    fn dirm_offsets_recomputed_after_middle_page_edit() {
1791        // Editing a non-first page must shift only the trailing offsets.
1792        let original = read_corpus("DjVu3Spec_bundled.djvu");
1793        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1794        let count = doc.page_count();
1795        assert!(count >= 3);
1796
1797        let mid = count / 2;
1798        let mut meta = DjVuMetadata::default();
1799        meta.title = Some("PR3 mid-page edit".into());
1800        doc.page_mut(mid).unwrap().set_metadata(&meta);
1801
1802        let edited = doc.into_bytes();
1803        let (declared, actual) = dirm_offsets_and_actual(&edited);
1804        assert_eq!(declared, actual);
1805
1806        // Pages before `mid` should have unchanged offsets vs. the original.
1807        let (orig_declared, _) = dirm_offsets_and_actual(&original);
1808        for i in 0..mid {
1809            assert_eq!(
1810                declared[i], orig_declared[i],
1811                "offset for page {i} (before edit) must be unchanged"
1812            );
1813        }
1814    }
1815
1816    #[test]
1817    fn set_bookmarks_replaces_navm_in_bundle() {
1818        use crate::djvu_document::DjVuBookmark;
1819
1820        let original = read_corpus("DjVu3Spec_bundled.djvu");
1821        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1822
1823        let bookmarks = vec![
1824            DjVuBookmark {
1825                title: "Front matter".into(),
1826                url: "#1".into(),
1827                children: vec![DjVuBookmark {
1828                    title: "Acknowledgments".into(),
1829                    url: "#3".into(),
1830                    children: vec![],
1831                }],
1832            },
1833            DjVuBookmark {
1834                title: "Body".into(),
1835                url: "#10".into(),
1836                children: vec![],
1837            },
1838        ];
1839        doc.set_bookmarks(&bookmarks).unwrap();
1840        assert!(doc.is_dirty());
1841        let edited = doc.into_bytes();
1842
1843        // DIRM offsets must still be correct after the NAVM size change.
1844        let (declared, actual) = dirm_offsets_and_actual(&edited);
1845        assert_eq!(declared, actual);
1846
1847        // Round-trip the bookmarks via the high-level DjVuDocument parser.
1848        let reparsed = crate::djvu_document::DjVuDocument::parse(&edited)
1849            .expect("bundle with new bookmarks parses");
1850        let parsed_bms = reparsed.bookmarks();
1851        assert_eq!(parsed_bms.len(), 2);
1852        assert_eq!(parsed_bms[0].title, "Front matter");
1853        assert_eq!(parsed_bms[0].children.len(), 1);
1854        assert_eq!(parsed_bms[0].children[0].title, "Acknowledgments");
1855        assert_eq!(parsed_bms[1].title, "Body");
1856    }
1857
1858    #[test]
1859    fn set_bookmarks_empty_removes_navm() {
1860        let original = read_corpus("DjVu3Spec_bundled.djvu");
1861        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1862        // The fixture might or might not have NAVM; either way, calling with
1863        // an empty slice should result in no NAVM in the output.
1864        doc.set_bookmarks(&[]).unwrap();
1865        let edited = doc.into_bytes();
1866
1867        let form = crate::iff::parse_form(&edited).unwrap();
1868        let has_navm = form.chunks.iter().any(|c| &c.id == b"NAVM");
1869        assert!(!has_navm, "set_bookmarks(&[]) must remove NAVM");
1870
1871        // DIRM offsets still match.
1872        let (declared, actual) = dirm_offsets_and_actual(&edited);
1873        assert_eq!(declared, actual);
1874    }
1875
1876    #[test]
1877    fn set_bookmarks_inserts_navm_when_absent() {
1878        use crate::djvu_document::DjVuBookmark;
1879
1880        // Build a bundle that has no NAVM by first stripping it, then
1881        // re-add bookmarks via set_bookmarks.
1882        let original = read_corpus("DjVu3Spec_bundled.djvu");
1883        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1884        doc.set_bookmarks(&[]).unwrap();
1885        let stripped = doc.into_bytes();
1886
1887        let mut doc = DjVuDocumentMut::from_bytes(&stripped).unwrap();
1888        let bms = vec![DjVuBookmark {
1889            title: "Re-added".into(),
1890            url: "#1".into(),
1891            children: vec![],
1892        }];
1893        doc.set_bookmarks(&bms).unwrap();
1894        let edited = doc.into_bytes();
1895
1896        let form = crate::iff::parse_form(&edited).unwrap();
1897        let navm_pos = form
1898            .chunks
1899            .iter()
1900            .position(|c| &c.id == b"NAVM")
1901            .expect("NAVM should be inserted");
1902        let dirm_pos = form.chunks.iter().position(|c| &c.id == b"DIRM").unwrap();
1903        assert_eq!(
1904            navm_pos,
1905            dirm_pos + 1,
1906            "NAVM should be placed immediately after DIRM"
1907        );
1908
1909        let (declared, actual) = dirm_offsets_and_actual(&edited);
1910        assert_eq!(declared, actual);
1911    }
1912
1913    #[test]
1914    fn set_bookmarks_on_single_page_djvu_errors() {
1915        let original = read_corpus("chicken.djvu");
1916        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1917        let err = doc.set_bookmarks(&[]).err().unwrap();
1918        assert!(matches!(err, MutError::BookmarksRequireDjvm));
1919    }
1920
1921    #[test]
1922    fn page_mut_djvm_text_layer_roundtrip() {
1923        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
1924
1925        let original = read_corpus("DjVu3Spec_bundled.djvu");
1926        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1927        let layer = TextLayer {
1928            text: "djvm page-3 text".into(),
1929            zones: vec![TextZone {
1930                kind: TextZoneKind::Page,
1931                rect: Rect {
1932                    x: 0,
1933                    y: 0,
1934                    width: 100,
1935                    height: 50,
1936                },
1937                text: "djvm page-3 text".into(),
1938                children: vec![],
1939            }],
1940        };
1941        doc.page_mut(2).unwrap().set_text_layer(&layer).unwrap();
1942        let edited = doc.into_bytes();
1943
1944        let (declared, actual) = dirm_offsets_and_actual(&edited);
1945        assert_eq!(declared, actual);
1946
1947        // Re-open and confirm the targeted page now has a TXTz chunk.
1948        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
1949        // The third FORM:DJVU child should have a TXTz leaf.
1950        let mut djvu_seen = 0usize;
1951        let mut found_txtz = false;
1952        for child in reparsed.file.root.children() {
1953            if let Chunk::Form {
1954                secondary_id,
1955                children,
1956                ..
1957            } = child
1958                && secondary_id == b"DJVU"
1959            {
1960                if djvu_seen == 2 {
1961                    found_txtz = children
1962                        .iter()
1963                        .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"TXTz"));
1964                    break;
1965                }
1966                djvu_seen += 1;
1967            }
1968        }
1969        assert!(
1970            found_txtz,
1971            "TXTz chunk should be present on page 2 after set_text_layer"
1972        );
1973    }
1974
1975    /// PR4 of #222: editing one page in a bundled DJVM must leave every
1976    /// other page's bytes unchanged. The mutated page itself may grow
1977    /// (e.g. a new METz chunk), but unmutated FORM:DJVU/DJVI components
1978    /// must round-trip byte-identical.
1979    #[test]
1980    fn unmutated_pages_byte_identical_after_metadata_edit() {
1981        use crate::metadata::DjVuMetadata;
1982
1983        let original = read_corpus("DjVu3Spec_bundled.djvu");
1984
1985        let orig_ranges = top_form_ranges(&original);
1986
1987        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
1988        let meta = DjVuMetadata {
1989            title: Some("PR4 byte-identical probe".into()),
1990            ..Default::default()
1991        };
1992        doc.page_mut(0).unwrap().set_metadata(&meta);
1993        let edited = doc.into_bytes();
1994
1995        let edited_ranges = top_form_ranges(&edited);
1996        assert_eq!(orig_ranges.len(), edited_ranges.len());
1997
1998        // The first FORM:DJVU child corresponds to page 0 (the one we edited);
1999        // it is allowed to differ. All others must be byte-identical.
2000        let mut djvu_idx = 0usize;
2001        for (i, (or, er)) in orig_ranges.iter().zip(edited_ranges.iter()).enumerate() {
2002            // Only enforce identity on FORM:DJVU/DJVI components — bare leaves
2003            // (DIRM, NAVM) legitimately change when offsets shift.
2004            let is_form_djvu = &original[or.start..or.start + 4] == b"FORM"
2005                && (&original[or.start + 8..or.start + 12] == b"DJVU"
2006                    || &original[or.start + 8..or.start + 12] == b"DJVI");
2007            if !is_form_djvu {
2008                continue;
2009            }
2010            let is_edited_page = djvu_idx == 0;
2011            djvu_idx += 1;
2012            if is_edited_page {
2013                continue;
2014            }
2015            assert_eq!(
2016                &original[or.clone()],
2017                &edited[er.clone()],
2018                "FORM at top-level child #{i} must be byte-identical after edit"
2019            );
2020        }
2021    }
2022
2023    // ---- #325: resolver-backed indirect DJVM rebundling -------------------
2024
2025    /// Build an indirect FORM:DJVM index over `page_names` and a resolver that
2026    /// serves each named fixture from `tests/fixtures`.
2027    fn indirect_over_fixtures(
2028        page_names: &[&str],
2029    ) -> (
2030        Vec<u8>,
2031        impl Fn(&str) -> Result<Vec<u8>, std::io::Error> + use<>,
2032    ) {
2033        let index = crate::djvm::create_indirect(page_names).expect("create_indirect");
2034        // Snapshot the fixture bytes keyed by name so the resolver is owned.
2035        let map: std::collections::HashMap<String, Vec<u8>> = page_names
2036            .iter()
2037            .map(|n| (n.to_string(), read_corpus(n)))
2038            .collect();
2039        let resolver = move |name: &str| -> Result<Vec<u8>, std::io::Error> {
2040            map.get(name).cloned().ok_or_else(|| {
2041                std::io::Error::new(std::io::ErrorKind::NotFound, "no such component")
2042            })
2043        };
2044        (index, resolver)
2045    }
2046
2047    #[test]
2048    fn from_indirect_resolved_rebundles_single_page() {
2049        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2050        let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2051        assert_eq!(doc.root_form_type(), Some(b"DJVM"));
2052        assert_eq!(doc.page_count(), 1);
2053        assert!(!doc.is_dirty());
2054
2055        // Output must parse as a bundled DJVM without any resolver.
2056        let bundled = doc.try_into_bytes().unwrap();
2057        let reparsed =
2058            crate::djvu_document::DjVuDocument::parse(&bundled).expect("bundled output parses");
2059        assert_eq!(reparsed.page_count(), 1);
2060        // The single page's pixel dimensions come from the resolved chicken.djvu.
2061        assert_eq!(reparsed.page(0).unwrap().width(), 181);
2062        assert_eq!(reparsed.page(0).unwrap().height(), 240);
2063
2064        // DIRM offsets must point at the actual component FORM positions.
2065        let (declared, actual) = dirm_offsets_and_actual(&bundled);
2066        assert_eq!(declared, actual);
2067    }
2068
2069    #[test]
2070    fn from_indirect_resolved_multi_page_preserves_order() {
2071        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2072        let doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2073        assert_eq!(doc.page_count(), 2);
2074        let bundled = doc.try_into_bytes().unwrap();
2075
2076        let reparsed = crate::djvu_document::DjVuDocument::parse(&bundled).expect("parses");
2077        assert_eq!(reparsed.page_count(), 2);
2078        // Page 0 == chicken (181x240), page 1 == irish (different size).
2079        assert_eq!(reparsed.page(0).unwrap().width(), 181);
2080        let irish_doc = crate::djvu_document::DjVuDocument::parse(&read_corpus("irish.djvu"))
2081            .expect("irish parses standalone");
2082        assert_eq!(
2083            reparsed.page(1).unwrap().dimensions(),
2084            irish_doc.page(0).unwrap().dimensions()
2085        );
2086
2087        let (declared, actual) = dirm_offsets_and_actual(&bundled);
2088        assert_eq!(declared, actual);
2089    }
2090
2091    #[test]
2092    fn from_indirect_resolved_then_metadata_edit_roundtrips() {
2093        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2094        let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2095
2096        let meta = DjVuMetadata {
2097            title: Some("rebundled indirect".into()),
2098            author: Some("djvu-rs #325".into()),
2099            ..Default::default()
2100        };
2101        doc.page_mut(1).unwrap().set_metadata(&meta);
2102        assert!(doc.is_dirty());
2103        let edited = doc.into_bytes();
2104
2105        // Offsets stay consistent after the page-1 metadata grows.
2106        let (declared, actual) = dirm_offsets_and_actual(&edited);
2107        assert_eq!(declared, actual);
2108
2109        // Metadata round-trips through the high-level parser on the edited page.
2110        let reparsed = DjVuDocumentMut::from_bytes(&edited).unwrap();
2111        let mut djvu_seen = 0usize;
2112        let mut found = None;
2113        for child in reparsed.file.root.children() {
2114            if let Chunk::Form {
2115                secondary_id,
2116                children,
2117                ..
2118            } = child
2119                && secondary_id == b"DJVU"
2120            {
2121                if djvu_seen == 1 {
2122                    found = children
2123                        .iter()
2124                        .find(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"))
2125                        .map(|c| c.data().to_vec());
2126                    break;
2127                }
2128                djvu_seen += 1;
2129            }
2130        }
2131        let metz = found.expect("page 1 should have METz after edit");
2132        let decoded = crate::bzz::bzz_decode(&metz).unwrap();
2133        let parsed = crate::metadata::parse_metadata(&decoded).unwrap();
2134        assert_eq!(parsed.title.as_deref(), Some("rebundled indirect"));
2135    }
2136
2137    #[test]
2138    fn from_indirect_resolved_then_text_layer_edit() {
2139        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2140
2141        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2142        let mut doc = DjVuDocumentMut::from_indirect_resolved(&index, resolver).unwrap();
2143        let layer = TextLayer {
2144            text: "rebundled text".into(),
2145            zones: vec![TextZone {
2146                kind: TextZoneKind::Page,
2147                rect: Rect {
2148                    x: 0,
2149                    y: 0,
2150                    width: 100,
2151                    height: 50,
2152                },
2153                text: "rebundled text".into(),
2154                children: vec![],
2155            }],
2156        };
2157        doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap();
2158        let edited = doc.into_bytes();
2159
2160        let reparsed = crate::djvu_document::DjVuDocument::parse(&edited).expect("parses");
2161        let text = reparsed.page(0).unwrap().text_layer().unwrap();
2162        assert!(text.is_some(), "edited page should expose a text layer");
2163        assert_eq!(text.unwrap().text, "rebundled text");
2164    }
2165
2166    #[test]
2167    fn from_indirect_resolved_missing_component_errors() {
2168        // Resolver that never produces bytes ⇒ ComponentResolve.
2169        let index = crate::djvm::create_indirect(&["missing.djvu"]).expect("create_indirect");
2170        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2171            Err::<Vec<u8>, _>(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"))
2172        })
2173        .unwrap_err();
2174        match err {
2175            MutError::ComponentResolve { name } => assert_eq!(name, "missing.djvu"),
2176            other => panic!("expected ComponentResolve, got {other:?}"),
2177        }
2178    }
2179
2180    #[test]
2181    fn from_indirect_resolved_malformed_component_errors() {
2182        let index = crate::djvm::create_indirect(&["garbage.djvu"]).expect("create_indirect");
2183        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2184            Ok::<Vec<u8>, std::io::Error>(b"not an iff document".to_vec())
2185        })
2186        .unwrap_err();
2187        assert!(
2188            matches!(err, MutError::ComponentMalformed { .. }),
2189            "{err:?}"
2190        );
2191    }
2192
2193    // Lines 293-298: DjVuDocumentMut::from_indirect_resolved with FORM:FAKE component.
2194    #[test]
2195    fn from_indirect_resolved_wrong_form_type_errors() {
2196        let index = crate::djvm::create_indirect(&["fake.djvu"]).expect("create_indirect");
2197        let fake = iff::emit(&DjvuFile {
2198            root: Chunk::Form {
2199                secondary_id: *b"FAKE",
2200                length: 0,
2201                children: vec![],
2202            },
2203        });
2204        let err = DjVuDocumentMut::from_indirect_resolved(&index, move |_name: &str| {
2205            Ok::<Vec<u8>, std::io::Error>(fake.clone())
2206        })
2207        .unwrap_err();
2208        assert!(
2209            matches!(err, MutError::ComponentMalformed { .. }),
2210            "{err:?}"
2211        );
2212    }
2213
2214    #[test]
2215    fn from_indirect_resolved_rejects_bundled_input() {
2216        // A genuinely bundled DJVM is not indirect ⇒ NotIndirectDjvm.
2217        let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2218        let err = DjVuDocumentMut::from_indirect_resolved(&bundled, |_n: &str| {
2219            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2220        })
2221        .unwrap_err();
2222        assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2223    }
2224
2225    #[test]
2226    fn from_indirect_resolved_rejects_single_page_djvu() {
2227        let chicken = read_corpus("chicken.djvu");
2228        let err = DjVuDocumentMut::from_indirect_resolved(&chicken, |_n: &str| {
2229            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2230        })
2231        .unwrap_err();
2232        assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2233    }
2234
2235    /// Indirect DJVM whose DIRM lists only Shared entries (no Page) fires
2236    /// lines 274-275: DirmMalformed "indirect DIRM lists no page component".
2237    #[test]
2238    fn from_indirect_resolved_no_page_component_returns_dirm_malformed() {
2239        use crate::dirm::DirmPayload;
2240        // Build indirect DJVM with 1 Shared entry (flag=0x00)
2241        let dirm_payload = DirmPayload::build_indirect(1, &[0x00], &["shared.djvi".to_string()]);
2242        let dirm_chunk = iff::Chunk::Leaf {
2243            id: *b"DIRM",
2244            data: dirm_payload.encode(),
2245        };
2246        let index =
2247            iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm_chunk)]).expect("fits");
2248
2249        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_name: &str| {
2250            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2251        })
2252        .unwrap_err();
2253        assert!(
2254            matches!(err, MutError::DirmMalformed(_)),
2255            "expected DirmMalformed, got {err:?}"
2256        );
2257    }
2258
2259    #[test]
2260    fn from_bytes_on_indirect_still_unsupported_for_page_mut() {
2261        // The plain entry point keeps the documented unsupported behavior.
2262        let index = crate::djvm::create_indirect(&["chicken.djvu"]).expect("create_indirect");
2263        let mut doc = DjVuDocumentMut::from_bytes(&index).unwrap();
2264        let err = doc.page_mut(0).err().unwrap();
2265        assert!(matches!(err, MutError::IndirectDjvmUnsupported), "{err:?}");
2266    }
2267
2268    // ---- #326: explicit external-file rewrite plan ------------------------
2269
2270    /// A fresh, empty temp directory unique to `tag` (cleared if it exists).
2271    fn fresh_temp_dir(tag: &str) -> PathBuf {
2272        let dir = std::env::temp_dir().join(format!("djvu_rs_rewrite_{tag}"));
2273        let _ = std::fs::remove_dir_all(&dir);
2274        std::fs::create_dir_all(&dir).unwrap();
2275        dir
2276    }
2277
2278    #[test]
2279    fn rewrite_plan_commits_full_set_to_dir() {
2280        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2281        let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2282        assert_eq!(plan.page_count(), 2);
2283
2284        // Edit page 0's metadata in memory only.
2285        plan.edit_page(0, |doc| {
2286            let meta = DjVuMetadata {
2287                title: Some("rewrite path".into()),
2288                ..Default::default()
2289            };
2290            doc.page_mut(0)?.set_metadata(&meta);
2291            Ok(())
2292        })
2293        .unwrap();
2294
2295        // The preview marks page 0 changed, page 1 and root unchanged.
2296        let preview = plan.plan("index.djvu");
2297        assert_eq!(preview.len(), 3);
2298        assert_eq!(preview[0].name, "chicken.djvu");
2299        assert!(preview[0].changed, "edited page must show changed");
2300        assert_eq!(preview[1].name, "irish.djvu");
2301        assert!(!preview[1].changed, "untouched page must be unchanged");
2302        assert!(preview[2].is_root);
2303        assert!(!preview[2].changed, "root unchanged for a page-only edit");
2304
2305        let dir = fresh_temp_dir("commit_full_set");
2306        let written = plan.commit_to_dir(&dir, "index.djvu").unwrap();
2307        assert_eq!(written.len(), 3);
2308        for p in &written {
2309            assert!(p.exists(), "committed file {p:?} must exist");
2310        }
2311        // No stray temp files left behind.
2312        let leftovers: Vec<_> = std::fs::read_dir(&dir)
2313            .unwrap()
2314            .filter_map(|e| e.ok())
2315            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
2316            .collect();
2317        assert!(leftovers.is_empty(), "temp files must be renamed away");
2318
2319        // The rewritten directory parses as an indirect document and the edit
2320        // landed on page 0.
2321        let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2322        let doc = crate::djvu_document::DjVuDocument::parse_from_dir(&index_bytes, &dir).unwrap();
2323        assert_eq!(doc.page_count(), 2);
2324        let meta_page0 = doc.page(0).unwrap();
2325        // metadata is read at the document level; confirm the edited component
2326        // round-trips through the single-page parser.
2327        let edited_comp = std::fs::read(dir.join("chicken.djvu")).unwrap();
2328        let reparsed = DjVuDocumentMut::from_bytes(&edited_comp).unwrap();
2329        let has_metz = reparsed
2330            .file
2331            .root
2332            .children()
2333            .iter()
2334            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METz"));
2335        assert!(has_metz, "edited component file must contain METz");
2336        // The unedited component is byte-identical to the source fixture.
2337        let irish_src = read_corpus("irish.djvu");
2338        let irish_out = std::fs::read(dir.join("irish.djvu")).unwrap();
2339        assert_eq!(irish_out, irish_src, "unedited component copied verbatim");
2340        let _ = meta_page0;
2341
2342        let _ = std::fs::remove_dir_all(&dir);
2343    }
2344
2345    #[test]
2346    fn rewrite_plan_rejects_duplicate_dirm_names() {
2347        // Two DIRM entries with the same id ⇒ DuplicateComponentName.
2348        let index = crate::djvm::create_indirect(&["dup.djvu", "dup.djvu"]).expect("create");
2349        let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2350            Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2351        })
2352        .unwrap_err();
2353        match err {
2354            MutError::DuplicateComponentName { name } => assert_eq!(name, "dup.djvu"),
2355            other => panic!("expected DuplicateComponentName, got {other:?}"),
2356        }
2357    }
2358
2359    #[test]
2360    fn rewrite_plan_rejects_unsafe_dirm_names() {
2361        for bad in [
2362            "../evil.djvu",
2363            "/abs.djvu",
2364            "sub/page.djvu",
2365            "..",
2366            "a:b.djvu",
2367        ] {
2368            let index = crate::djvm::create_indirect(&[bad]).expect("create");
2369            let err = IndirectRewritePlan::from_indirect_resolved(&index, |_n: &str| {
2370                Ok::<Vec<u8>, std::io::Error>(read_corpus("chicken.djvu"))
2371            })
2372            .unwrap_err();
2373            assert!(
2374                matches!(err, MutError::UnsafeComponentName { .. }),
2375                "name {bad:?} should be rejected, got {err:?}"
2376            );
2377        }
2378    }
2379
2380    #[test]
2381    fn rewrite_plan_unsafe_root_name_leaves_dir_unchanged() {
2382        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2383        let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2384
2385        let dir = fresh_temp_dir("unsafe_root");
2386        // Drop a sentinel file that must survive a failed commit.
2387        std::fs::write(dir.join("sentinel"), b"keep me").unwrap();
2388
2389        let err = plan.commit_to_dir(&dir, "../escape.djvu").unwrap_err();
2390        assert!(
2391            matches!(err, MutError::UnsafeComponentName { .. }),
2392            "{err:?}"
2393        );
2394
2395        // Nothing was written: only the sentinel remains.
2396        let entries: Vec<String> = std::fs::read_dir(&dir)
2397            .unwrap()
2398            .filter_map(|e| e.ok())
2399            .map(|e| e.file_name().to_string_lossy().into_owned())
2400            .collect();
2401        assert_eq!(entries, vec!["sentinel".to_string()]);
2402        assert_eq!(std::fs::read(dir.join("sentinel")).unwrap(), b"keep me");
2403
2404        let _ = std::fs::remove_dir_all(&dir);
2405    }
2406
2407    #[test]
2408    fn rewrite_plan_root_name_collision_rejected() {
2409        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2410        let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2411        let dir = fresh_temp_dir("root_collision");
2412        // Root name equals a component name — would shadow the page file.
2413        let err = plan.commit_to_dir(&dir, "chicken.djvu").unwrap_err();
2414        assert!(
2415            matches!(err, MutError::DuplicateComponentName { .. }),
2416            "{err:?}"
2417        );
2418        // Validation failed before writing: directory is still empty.
2419        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
2420        let _ = std::fs::remove_dir_all(&dir);
2421    }
2422
2423    #[test]
2424    fn rewrite_plan_set_bookmarks_marks_root_changed() {
2425        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu"]);
2426        let mut plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2427        plan.set_bookmarks(&[DjVuBookmark {
2428            title: "Top".into(),
2429            url: "#1".into(),
2430            children: vec![],
2431        }])
2432        .unwrap();
2433
2434        let preview = plan.plan("index.djvu");
2435        let root = preview.iter().find(|i| i.is_root).unwrap();
2436        assert!(root.changed, "root index must be marked changed");
2437
2438        // Commit and confirm the index file carries NAVM bookmarks.
2439        let dir = fresh_temp_dir("bookmarks");
2440        plan.commit_to_dir(&dir, "index.djvu").unwrap();
2441        let index_bytes = std::fs::read(dir.join("index.djvu")).unwrap();
2442        let form = crate::iff::parse_form(&index_bytes).unwrap();
2443        assert!(
2444            form.chunks.iter().any(|c| &c.id == b"NAVM"),
2445            "committed index must contain NAVM"
2446        );
2447        let _ = std::fs::remove_dir_all(&dir);
2448    }
2449
2450    #[test]
2451    fn rewrite_plan_rejects_bundled_input() {
2452        let bundled = read_corpus("DjVu3Spec_bundled.djvu");
2453        let err = IndirectRewritePlan::from_indirect_resolved(&bundled, |_n: &str| {
2454            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2455        })
2456        .unwrap_err();
2457        assert!(matches!(err, MutError::NotIndirectDjvm), "{err:?}");
2458    }
2459
2460    #[test]
2461    fn chunk_at_path_rejects_empty_path() {
2462        let original = read_corpus("chicken.djvu");
2463        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2464        let err = doc.chunk_at_path(&[]).unwrap_err();
2465        assert!(matches!(err, MutError::EmptyPath));
2466    }
2467
2468    #[test]
2469    fn root_form_type_returns_some_for_form_root() {
2470        let original = read_corpus("chicken.djvu");
2471        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2472        let t = doc.root_form_type();
2473        assert!(t.is_some());
2474    }
2475
2476    // Line 585: (None, true) branch of set_bookmarks — no-op when DJVM has no NAVM and
2477    // we try to set empty bookmarks.
2478    #[test]
2479    fn set_bookmarks_empty_on_djvm_without_navm_is_noop() {
2480        // Strip NAVM from a bundled doc, then call set_bookmarks(&[]) on the stripped doc.
2481        let original = read_corpus("DjVu3Spec_bundled.djvu");
2482        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2483        doc.set_bookmarks(&[]).unwrap(); // removes NAVM if present
2484        let stripped = doc.into_bytes();
2485
2486        // Now stripped has no NAVM; set_bookmarks(&[]) is a true no-op (None, true).
2487        let mut doc2 = DjVuDocumentMut::from_bytes(&stripped).unwrap();
2488        doc2.set_bookmarks(&[]).unwrap();
2489        assert_eq!(
2490            doc2.into_bytes(),
2491            stripped,
2492            "no-op set_bookmarks should not change bytes"
2493        );
2494    }
2495
2496    // Line 936: (None, true) branch of replace_or_insert — set_metadata with default
2497    // (empty) on a page that has no existing METa/METz chunk.
2498    #[test]
2499    fn set_metadata_empty_on_page_without_meta_is_noop() {
2500        let original = read_corpus("chicken.djvu"); // known: no METa chunk
2501        let mut doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2502        // Default metadata → encode_metadata returns empty → (None, true) no-op path.
2503        doc.page_mut(0)
2504            .unwrap()
2505            .set_metadata(&DjVuMetadata::default());
2506        // Dirty is still set (set_metadata always marks dirty), but no METa was inserted.
2507        let bytes = doc.into_bytes();
2508        let reparsed = DjVuDocumentMut::from_bytes(&bytes).unwrap();
2509        let has_meta = reparsed
2510            .file
2511            .root
2512            .children()
2513            .iter()
2514            .any(|c| matches!(c, Chunk::Leaf { id, .. } if id == b"METa" || id == b"METz"));
2515        assert!(
2516            !has_meta,
2517            "empty set_metadata should not insert a METa chunk"
2518        );
2519    }
2520
2521    // Lines 391-393: PathTraversesLeaf first branch (children.is_empty && depth < len-1).
2522    // A 3-deep path where [0] reaches INFO (Leaf): depth=1 triggers the first check.
2523    #[test]
2524    fn chunk_at_path_traverses_leaf_first_branch() {
2525        let original = read_corpus("chicken.djvu");
2526        let doc = DjVuDocumentMut::from_bytes(&original).unwrap();
2527        let err = doc.chunk_at_path(&[0, 0, 0]).unwrap_err();
2528        assert!(
2529            matches!(err, MutError::PathTraversesLeaf { depth: 1, len: 3 }),
2530            "{err:?}"
2531        );
2532    }
2533
2534    // Lines 1089, 1094-1095: IndirectRewritePlan::from_indirect_resolved error paths.
2535    #[test]
2536    fn rewrite_plan_resolver_failure_returns_component_resolve_error() {
2537        let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2538        let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2539            Err::<Vec<u8>, std::io::Error>(std::io::Error::new(
2540                std::io::ErrorKind::NotFound,
2541                "nope",
2542            ))
2543        })
2544        .unwrap_err();
2545        assert!(matches!(err, MutError::ComponentResolve { .. }), "{err:?}");
2546    }
2547
2548    #[test]
2549    fn rewrite_plan_non_iff_component_returns_malformed_error() {
2550        let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2551        let err = IndirectRewritePlan::from_indirect_resolved(&index, |_name: &str| {
2552            Ok::<Vec<u8>, std::io::Error>(b"not iff".to_vec())
2553        })
2554        .unwrap_err();
2555        assert!(
2556            matches!(err, MutError::ComponentMalformed { .. }),
2557            "{err:?}"
2558        );
2559    }
2560
2561    // Lines 1100-1105: wrong FORM type (not DJVU/DJVI/THUM) → ComponentMalformed.
2562    #[test]
2563    fn rewrite_plan_wrong_form_type_returns_malformed_error() {
2564        let (index, _) = indirect_over_fixtures(&["chicken.djvu"]);
2565        let fake = iff::emit(&DjvuFile {
2566            root: Chunk::Form {
2567                secondary_id: *b"FAKE",
2568                length: 0,
2569                children: vec![],
2570            },
2571        });
2572        let err = IndirectRewritePlan::from_indirect_resolved(&index, move |_name: &str| {
2573            Ok::<Vec<u8>, std::io::Error>(fake.clone())
2574        })
2575        .unwrap_err();
2576        assert!(
2577            matches!(err, MutError::ComponentMalformed { .. }),
2578            "{err:?}"
2579        );
2580    }
2581
2582    // Lines 1131-1132: component_count() on IndirectRewritePlan.
2583    #[test]
2584    fn rewrite_plan_component_count() {
2585        let (index, resolver) = indirect_over_fixtures(&["chicken.djvu", "irish.djvu"]);
2586        let plan = IndirectRewritePlan::from_indirect_resolved(&index, resolver).unwrap();
2587        assert_eq!(plan.component_count(), 2);
2588        assert_eq!(plan.page_count(), 2);
2589    }
2590
2591    #[cfg(feature = "std")]
2592    #[test]
2593    fn validate_safe_component_name_rejects_empty() {
2594        let err = validate_safe_component_name("").unwrap_err();
2595        assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2596    }
2597
2598    #[cfg(feature = "std")]
2599    #[test]
2600    fn validate_safe_component_name_rejects_nul() {
2601        let err = validate_safe_component_name("a\0b").unwrap_err();
2602        assert!(matches!(err, MutError::UnsafeComponentName { .. }));
2603    }
2604
2605    /// Walk top-level children of the outer FORM and return their absolute
2606    /// byte ranges (header+payload+pad).
2607    fn top_form_ranges(data: &[u8]) -> Vec<core::ops::Range<usize>> {
2608        assert_eq!(&data[..4], b"AT&T");
2609        let form_len = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
2610        let body_end = 12 + form_len;
2611        let mut pos = 16usize; // skip AT&T(4) + FORM(4) + len(4) + secondary_id(4)
2612        let mut out = Vec::new();
2613        while pos + 8 <= body_end {
2614            let len =
2615                u32::from_be_bytes([data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]])
2616                    as usize;
2617            let mut next = pos + 8 + len;
2618            if next & 1 == 1 && next < body_end {
2619                next += 1;
2620            }
2621            out.push(pos..next);
2622            pos = next;
2623        }
2624        out
2625    }
2626
2627    // ---- is_bundled_djvm edge cases -----------------------------------------
2628
2629    // Line 672: root is a Leaf → returns false immediately.
2630    #[test]
2631    fn is_bundled_djvm_leaf_returns_false() {
2632        let leaf = Chunk::Leaf {
2633            id: *b"INFO",
2634            data: vec![],
2635        };
2636        assert!(!is_bundled_djvm(&leaf));
2637    }
2638
2639    // Line 675: FORM with secondary_id != DJVM → returns false.
2640    #[test]
2641    fn is_bundled_djvm_non_djvm_form_returns_false() {
2642        let form = Chunk::Form {
2643            secondary_id: *b"DJVU",
2644            length: 0,
2645            children: vec![],
2646        };
2647        assert!(!is_bundled_djvm(&form));
2648    }
2649
2650    // ---- resolve_indirect_components / find_leaf_data edge cases ------------
2651
2652    // Line 637: indirect DJVM with nfiles=0 → DirmMalformed("indirect DIRM lists no components").
2653    #[test]
2654    fn from_indirect_resolved_empty_dirm_returns_dirm_malformed() {
2655        let dirm_payload = DirmPayload::build_indirect(0, &[], &[]);
2656        let dirm = Chunk::Leaf {
2657            id: *b"DIRM",
2658            data: dirm_payload.encode(),
2659        };
2660        let index = iff::partial_emit(*b"DJVM", &[iff::EmitPart::Chunk(&dirm)]).expect("fits");
2661        let err = DjVuDocumentMut::from_indirect_resolved(&index, |_n: &str| {
2662            Ok::<Vec<u8>, std::io::Error>(Vec::new())
2663        })
2664        .unwrap_err();
2665        assert!(
2666            matches!(err, MutError::DirmMalformed(_)),
2667            "expected DirmMalformed, got {err:?}"
2668        );
2669    }
2670
2671    // Line 915: `find_leaf_data` returns None when the page has no INFO chunk.
2672    // Triggered by calling `set_text_layer` on a FORM:DJVU without an INFO chunk.
2673    #[test]
2674    fn set_text_layer_missing_info_chunk_returns_missing_page_info() {
2675        use crate::text::{Rect, TextLayer, TextZone, TextZoneKind};
2676
2677        let bytes = iff::emit(&iff::DjvuFile {
2678            root: Chunk::Form {
2679                secondary_id: *b"DJVU",
2680                length: 0,
2681                // No INFO chunk
2682                children: vec![Chunk::Leaf {
2683                    id: *b"ANTz",
2684                    data: vec![0u8; 4],
2685                }],
2686            },
2687        });
2688        let mut doc = DjVuDocumentMut::from_bytes(&bytes).expect("no-INFO DJVU must parse");
2689        let layer = TextLayer {
2690            text: "hello".to_string(),
2691            zones: vec![TextZone {
2692                kind: TextZoneKind::Page,
2693                rect: Rect {
2694                    x: 0,
2695                    y: 0,
2696                    width: 10,
2697                    height: 10,
2698                },
2699                text: "hello".to_string(),
2700                children: vec![],
2701            }],
2702        };
2703        let err = doc.page_mut(0).unwrap().set_text_layer(&layer).unwrap_err();
2704        assert!(matches!(err, MutError::MissingPageInfo), "{err:?}");
2705    }
2706
2707    // ---- emit_patched_single_page / original_single_page_child_ranges -------
2708
2709    // Line 700: root is a Leaf → emit_patched_single_page returns None immediately.
2710    #[test]
2711    fn emit_patched_leaf_root_returns_none() {
2712        let leaf = Chunk::Leaf {
2713            id: *b"INFO",
2714            data: vec![0u8; 4],
2715        };
2716        assert!(emit_patched_single_page(&leaf, &[]).is_none());
2717    }
2718
2719    // Line 725: DJVU FORM with a nested Form child → returns None.
2720    #[test]
2721    fn emit_patched_form_child_in_djvu_returns_none() {
2722        // Build minimal valid AT&T+FORM:DJVU bytes with one INFO leaf so that
2723        // original_single_page_child_ranges succeeds (1 child, no FORM inside).
2724        let original = iff::partial_emit(
2725            *b"DJVU",
2726            &[iff::EmitPart::Chunk(&Chunk::Leaf {
2727                id: *b"INFO",
2728                data: vec![0u8; 4],
2729            })],
2730        )
2731        .unwrap();
2732
2733        // In-memory tree: same DJVU root but child is a Form instead of the Leaf.
2734        let root = Chunk::Form {
2735            secondary_id: *b"DJVU",
2736            length: 0,
2737            children: vec![Chunk::Form {
2738                secondary_id: *b"INFO",
2739                length: 0,
2740                children: vec![],
2741            }],
2742        };
2743        assert!(emit_patched_single_page(&root, &original).is_none());
2744    }
2745
2746    // Line 734: slice shorter than 16 bytes → original_single_page_child_ranges returns None.
2747    #[test]
2748    fn original_child_ranges_too_short_returns_none() {
2749        assert!(original_single_page_child_ranges(b"AT&TFORM").is_none());
2750    }
2751
2752    // Line 739: secondary_id is not DJVU → returns None.
2753    #[test]
2754    fn original_child_ranges_not_djvu_returns_none() {
2755        let bytes = iff::partial_emit(*b"DJVI", &[]).unwrap();
2756        assert!(original_single_page_child_ranges(&bytes).is_none());
2757    }
2758
2759    // Line 753: DJVU body contains a chunk whose id is b"FORM" → returns None.
2760    #[test]
2761    fn original_child_ranges_nested_form_tag_returns_none() {
2762        // Build AT&T FORM:DJVU with one child whose id bytes are literally "FORM".
2763        // Data length = 4 so header+data = 12 bytes, body = DJVU(4)+12 = 16.
2764        // Use Verbatim so the chunk-id bytes spell "FORM" inside a slice literal,
2765        // routing around the raw-framing seam.
2766        let inner: &[u8] = b"FORM\x00\x00\x00\x04\x00\x00\x00\x00";
2767        let bytes = iff::partial_emit(*b"DJVU", &[iff::EmitPart::Verbatim(inner)]).unwrap();
2768        assert!(original_single_page_child_ranges(&bytes).is_none());
2769    }
2770
2771    // Line 761: last chunk has odd length and is exactly at body_end (no room for pad) → returns None.
2772    #[test]
2773    fn original_child_ranges_odd_length_at_body_end_returns_none() {
2774        // DJVU body = DJVU(4) + INFO header(8) + 3 bytes data = 15 bytes.
2775        // next = 16+8+3 = 27 = body_end → odd tail with no pad room.
2776        // partial_emit would add padding, so build the deliberately odd-body bytes manually.
2777        let form_tag: [u8; 4] = *b"FORM";
2778        let mut bytes: Vec<u8> = Vec::new();
2779        bytes.extend_from_slice(&iff::MAGIC);
2780        bytes.extend_from_slice(&form_tag);
2781        bytes.extend_from_slice(&15u32.to_be_bytes()); // body = 4+8+3 = 15
2782        bytes.extend_from_slice(b"DJVU");
2783        bytes.extend_from_slice(b"INFO");
2784        bytes.extend_from_slice(&3u32.to_be_bytes());
2785        bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
2786        assert!(original_single_page_child_ranges(&bytes).is_none());
2787    }
2788
2789    // Line 776: chunks don't tile the body exactly (1 extra trailing byte) → returns None.
2790    #[test]
2791    fn original_child_ranges_short_tail_returns_none() {
2792        // DJVU body = DJVU(4) + INFO hdr+data (12) + 1 extra byte = 17.
2793        // partial_emit cannot produce a non-tiling body, so build manually.
2794        let form_tag: [u8; 4] = *b"FORM";
2795        let mut bytes: Vec<u8> = Vec::new();
2796        bytes.extend_from_slice(&iff::MAGIC);
2797        bytes.extend_from_slice(&form_tag);
2798        bytes.extend_from_slice(&17u32.to_be_bytes()); // 17 = 4 + 8 + 4 + 1
2799        bytes.extend_from_slice(b"DJVU");
2800        bytes.extend_from_slice(b"INFO");
2801        bytes.extend_from_slice(&4u32.to_be_bytes());
2802        bytes.extend_from_slice(&[0u8; 4]);
2803        bytes.push(0x00); // extra trailing byte
2804        assert!(original_single_page_child_ranges(&bytes).is_none());
2805    }
2806
2807    // ---- recompute_dirm_offsets edge cases ----------------------------------
2808
2809    // Line 798: root is a Leaf → returns Ok immediately.
2810    #[test]
2811    fn recompute_dirm_offsets_leaf_root_is_noop() {
2812        let mut leaf = Chunk::Leaf {
2813            id: *b"INFO",
2814            data: vec![0u8; 4],
2815        };
2816        assert!(recompute_dirm_offsets(&mut leaf).is_ok());
2817    }
2818
2819    // Line 834: DJVM with FORM:DJVU child but no DIRM leaf → returns Ok.
2820    #[test]
2821    fn recompute_dirm_offsets_djvm_no_dirm_is_noop() {
2822        let mut root = Chunk::Form {
2823            secondary_id: *b"DJVM",
2824            length: 0,
2825            children: vec![Chunk::Form {
2826                secondary_id: *b"DJVU",
2827                length: 0,
2828                children: vec![],
2829            }],
2830        };
2831        assert!(recompute_dirm_offsets(&mut root).is_ok());
2832    }
2833
2834    // Lines 851-853: nfiles in DIRM != number of FORM:DJVU children → DirmComponentCountMismatch.
2835    #[test]
2836    fn recompute_dirm_offsets_count_mismatch_errors() {
2837        // DIRM payload with nfiles=2 but bundled, then supply only 1 FORM:DJVU.
2838        let dirm_payload = DirmPayload::build_bundled(
2839            2,
2840            &[0x01, 0x01],
2841            &["p1.djvu".to_string(), "p2.djvu".to_string()],
2842        );
2843        let dirm_data = dirm_payload.encode();
2844        let mut root = Chunk::Form {
2845            secondary_id: *b"DJVM",
2846            length: 0,
2847            children: vec![
2848                Chunk::Leaf {
2849                    id: *b"DIRM",
2850                    data: dirm_data,
2851                },
2852                Chunk::Form {
2853                    secondary_id: *b"DJVU",
2854                    length: 0,
2855                    children: vec![],
2856                },
2857                // Only 1 FORM:DJVU but DIRM says nfiles=2 → mismatch
2858            ],
2859        };
2860        let err = recompute_dirm_offsets(&mut root).unwrap_err();
2861        assert!(
2862            matches!(err, MutError::DirmComponentCountMismatch { .. }),
2863            "{err:?}"
2864        );
2865    }
2866}