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